etmtk-3.2.22/0000755000076500000240000000000012617420125012602 5ustar dagstaff00000000000000etmtk-3.2.22/etm0000755000076500000240000000457112544017023013322 0ustar dagstaff00000000000000#!/usr/bin/env python3 import os import sys lib_path = os.path.relpath('etmTk/') sys.path.append(lib_path) # from etmTk import view from etmTk.data import setup_logging import codecs import yaml import locale import gettext import platform import logging import logging.config logger = logging.getLogger() log_levels = [str(x) for x in range(1, 6)] from locale import getpreferredencoding from sys import stdout try: dterm_encoding = stdout.term_encoding except AttributeError: dterm_encoding = None if not dterm_encoding: dterm_encoding = getpreferredencoding() term_encoding = dterm_encoding = dfile_encoding = codecs.lookup(dterm_encoding).name if __name__ == "__main__": etmdir = language = use_locale = '' loglevel = '3' trans = gettext.NullTranslations() help = False argstr = " ".join(sys.argv) etm = sys.argv[0] if len(sys.argv) > 1 and sys.argv[1] in log_levels: loglevel = sys.argv.pop(1) if len(sys.argv) > 1 and os.path.isdir(sys.argv[1]): temp = sys.argv.pop(1) logger.debug("got directory: {0}".format(temp)) oldpath = os.path.join(temp, 'etm.cfg') newpath = os.path.join(temp, 'etmtk.cfg') if os.path.isfile(newpath) or os.path.isfile(oldpath): etmdir = temp locale_cfg = os.path.normpath(os.path.join(etmdir, 'locale.cfg')) if os.path.isfile(locale_cfg): fo = codecs.open(locale_cfg, 'r', dfile_encoding) use_locale = yaml.load(fo) fo.close() if use_locale: locale.setlocale(locale.LC_ALL, map(str, use_locale[0])) lcl = locale.getlocale() lang = use_locale[0][0] localedir = os.path.join(etmdir, "languages") trans = gettext.translation(lang, localedir=localedir, languages=[lang], fallback=True) else: lcl = locale.getdefaultlocale() uu = (platform.python_version() < '3') if uu: trans.install(unicode=True) else: trans.install() setup_logging(loglevel, etmdir=etmdir) if len(sys.argv) > 1: logger.debug("calling data.main with etmdir: {0}, argv: {1}".format(etmdir, sys.argv)) import etmTk.data as data data.main(etmdir, sys.argv) else: from etmTk import view logger.debug("calling view.main with etmdir: {0}".format(etmdir)) view.main(dir=etmdir) sys.exit() etmtk-3.2.22/etmTk/0000755000076500000240000000000012617420125013666 5ustar dagstaff00000000000000etmtk-3.2.22/etmTk/__init__.py0000666000076500000240000000000012116363642015775 0ustar dagstaff00000000000000etmtk-3.2.22/etmTk/CHANGES0000644000076500000240000004141012617417731014672 0ustar dagstaff00000000000000# Recent changes as of Sat Nov 7 10:55:52 EST 2015: - 60 minutes ago (HEAD -> master, tag: 3.2.22) Dan Graham 29c9630 2015-11-07 09:55:57 -0500 Added CHANGES and etm.1 to repository to satisfy setup.py. Tagged version 3.2.22 [2015-11-07 09:55:57 -0500]. - 20 hours ago Dan Graham 3412ab3 2015-11-06 14:44:23 -0500 Snooze message modification. Tagged version 3.2.21 [2015-11-06 14:44:23 -0500]. - 20 hours ago Dan Graham 39ba0f5 2015-11-06 14:44:23 -0500 Snooze message modification. - 24 hours ago Dan Graham a6bfc81 2015-11-06 10:39:05 -0500 Change snooze behavior to wait from the time the snooze button is pressed with seconds are rounded off to the nearest minute. Fixed typo: tmp_copy to tmp_cpy in view.py. - 2 weeks ago (tag: 3.2.20) Dan Graham 6bbd054 2015-10-23 11:39:44 -0400 Added EXTRAS_REQUIRES. Tagged version 3.2.19 [2015-10-23 11:39:44 -0400]. Tagged version [2015-10-23 11:39:44 -0400]. Tagged version [2015-10-23 11:39:44 -0400]. Tagged version [2015-10-23 11:39:44 -0400]. Tagged version 3.2.19p1 [2015-10-23 11:39:44 -0400]. Tagged version 3.2.19p2 [2015-10-23 11:39:44 -0400]. Tagged version 3.2.19p3 [2015-10-23 11:39:44 -0400]. Tagged version 3.2.19p4 [2015-10-23 11:39:44 -0400]. Tagged version 3.2.19p5 [2015-10-23 11:39:44 -0400]. Tagged version 3.2.20 [2015-10-23 11:39:44 -0400]. - 2 weeks ago Dan Graham 0afa353 2015-10-22 07:00:45 -0400 Changed REQUIRES in setup.py to eliminate the python requirement. - 3 weeks ago (tag: 3.2.18) Dan Graham 73d73ca 2015-10-18 16:39:53 -0400 Merge branch 'master' of https://github.com/dagraham/etm-tk Tagged version 3.2.18 [2015-10-18 16:39:53 -0400]. - 3 weeks ago dagraham d890da8 2015-10-18 16:33:21 -0400 Merge pull request #49 from jeanCarloMachado/master Added solarized dark colorscheme - 3 weeks ago Jean Carlo Machado 8768a13 2015-10-18 17:31:13 -0200 added solarized dark colorscheme - 3 weeks ago Dan Graham 9e99caf 2015-10-18 10:37:03 -0400 Changed error message for integer required. - 3 weeks ago Dan Graham 7e22189 2015-10-15 22:41:05 -0400 Make @q an option for all item types. - 4 weeks ago Dan Graham 5c2035a 2015-10-09 11:06:15 -0400 If 'q' but not 'z' is in hsh, then add hsh['z'] using local_timezone. - 4 weeks ago Dan Graham 278a723 2015-10-09 06:30:15 -0400 Added Python 3.5 to the setup.py classifiers. - 5 weeks ago Dan Graham 11fd77f 2015-10-03 12:47:31 -0400 Fixed bug when copying an item in monthly view. Tagged version [2015-10-03 12:47:31 -0400]. Tagged version ..p1 [2015-10-03 12:47:31 -0400]. Tagged version ..p2 [2015-10-03 12:47:31 -0400]. Tagged version ..p3 [2015-10-03 12:47:31 -0400]. Tagged version 3.2.17p4 [2015-10-03 12:47:31 -0400]. - 5 weeks ago Dan Graham 99477f9 2015-10-03 12:47:31 -0400 Fixed bug when copying an item in monthly view. - 5 weeks ago Dan Graham 03da1c8 2015-10-02 11:00:58 -0400 Updated REQUIRES and EXTRAS in setup.py. - 5 weeks ago (tag: 3.2.17) Dan Graham 16b846a 2015-10-01 07:44:08 -0400 Added def for _() to data. Some refactoring and optimizations, e.g., removed unused lines2Items. Changed "start time" to "starting time". Reduced min sizes in view. Tagged version 3.2.17 [2015-10-01 07:44:08 -0400]. - 7 weeks ago Dan Graham 3bf408a 2015-09-21 11:33:23 -0400 Add today to datetimes if there is a begin by for today. - 7 weeks ago Dan Graham f6253ff 2015-09-18 10:52:38 -0400 Changed platform information from platform.system() to platform.platform(terse=1). - 8 weeks ago (tag: 3.2.16) Dan Graham d5eca4c 2015-09-11 13:57:23 -0400 Added 'agenda_omit' to options. Accepts a list of types from 'ac', 'by', 'fn', 'ns', 'oc' to hide from the agenda day view. Tagged version 3.2.16 [2015-09-11 13:57:23 -0400]. - 2 months ago (tag: 3.2.15) Dan Graham 0fe0e4b 2015-08-29 07:57:43 -0400 Use naive datetime in setting 'bef' in updateClock. Tagged version 3.2.14p6 [2015-08-29 07:57:43 -0400]. Tagged version 3.2.15 [2015-08-29 07:57:43 -0400]. - 2 months ago (tag: 3.2.13p2) Dan Graham a6c3304 2015-08-28 06:27:11 -0400 Fixed bug in updateClock. Tagged version 3.2.14 [2015-08-28 06:27:11 -0400]. - 2 months ago Dan Graham 3a6a388 2015-08-28 06:27:11 -0400 Fixed bug in updateClock. - 2 months ago (tag: 3.2.13) Dan Graham 2dab897 2015-08-26 12:07:13 -0400 Set options['bef'] in get_options and update on new_day in update_clock. Refactored oneminute, onehour, oneday, oneweek as all uppercase to be consistent. Tagged version 3.2.13 [2015-08-26 12:07:13 -0400]. - 3 months ago (tag: 3.2.12) Dan Graham 5a76ce8 2015-08-24 17:44:04 -0400 Changed the options for when/if to close the alert message box to message_next and message_last. Tagged version 3.2.12 [2015-08-24 17:44:04 -0400]. - 3 months ago Dan Graham 0588774 2015-08-24 12:57:07 -0400 Added 'next' and 'next_alert' to template expansions. Added auto close to message box dialogs using options 'message_alert_seconds' for next message boxes and 'message_snooze_seconds' for last message boxes. - 3 months ago (tag: 3.2.11) Dan Graham 4d01318 2015-08-21 17:01:13 -0400 Cancel snooze if item is rescheduled, edited or deleted. Tagged version 3.2.11 [2015-08-21 17:01:13 -0400]. - 3 months ago (tag: 3.2.10) Dan Graham 83af7b5 2015-08-21 11:25:03 -0400 Check that @n is only used with item types "-" and "%" and only contains values from "d", "k" and "t". Tagged version 3.2.10 [2015-08-21 11:25:03 -0400]. - 3 months ago Dan Graham 1c681ff 2015-08-21 10:42:34 -0400 Check that @n is only used with item types "-" and "%" and only contains values from "d", "k" and "t". - 3 months ago (tag: 3.2.9) Dan Graham e119efe 2015-08-20 13:04:31 -0400 Changed sort order for @n. Tagged version 3.2.9 [2015-08-20 13:04:31 -0400]. - 3 months ago Dan Graham 49b11c8 2015-08-20 12:58:52 -0400 Added @n (no show) for itemtype "-" only. Takes a list of views from a)genda, d)ay (week and month), t)ag, k)eyword. E.g. with "@n a, d" the task would be omitted from both agenda and day (week and month) views but would appear in tag and keyword views. Note that it is not possible to exclude the item from path view. - 3 months ago (tag: 3.2.8, hidden) Dan Graham afcf5ab 2015-08-19 18:11:40 -0400 image updates Tagged version 3.2.8 [2015-08-19 18:11:40 -0400]. - 3 months ago Dan Graham d8ba751 2015-08-19 17:58:28 -0400 In onFinish, skip alertId for items without starting times. - 3 months ago Dan Graham fac82ae 2015-08-19 17:53:46 -0400 Trap and report errors in parse_period. - 3 months ago (tag: 3.2.7) Dan Graham a546170 2015-08-19 12:39:48 -0400 added alert_list image Tagged version 3.2.7 [2015-08-19 12:39:48 -0400]. - 3 months ago Dan Graham dc16e1e 2015-08-19 12:32:23 -0400 Removed unused variables. Show alert trigger time in snooze dialog. Make snooze trigger at least 40 seconds after closing dialog. Use '~' as show alerts label when none remain. - 3 months ago Dan Graham 1b7b32c 2015-08-19 09:37:51 -0400 Final tweaks for the show alerts button label. - 3 months ago Dan Graham abb9bbe 2015-08-19 09:11:28 -0400 Second pass at snoozing multiple items. Since uuid changes with file updates, e.g., finishing a task, use (summary, s) as alertId. Snooze for the chosen minutes beyond the alert time, not the current time. When finishing a task, use the (summary, s) id to find the correct alert_id to cancel the snooze. - 3 months ago Dan Graham cc3ba53 2015-08-18 14:45:37 -0400 First pass at snoozing multiple items. Added option seconds (False) to fmt_time to show seconds when True. Added longreprtimefmt to reprtimefmt in options to show seconds. - 3 months ago (tag: 3.2.6) Dan Graham 67607eb 2015-08-18 07:12:59 -0400 When a task is finished that has a snooze alert running, cancel the alert. Tagged version 3.2.6 [2015-08-18 07:12:59 -0400]. - 3 months ago Dan Graham 59b90b6 2015-08-17 17:53:23 -0400 Changed binding for onFinish in simpleEditor from Ctrl-w to Ctrl-s to avoid conflicts. Changed button label from Finish to Save and Close. - 3 months ago Dan Graham ca71856 2015-08-17 15:13:55 -0400 More KP_Enter bindings. Changed logic for setting master in simpleEditor. - 3 months ago Dan Graham 67fe85a 2015-08-17 12:54:41 -0400 Only try export_ical if has_calendar is True. Add KP_Enter to Return bindings. - 3 months ago Dan Graham 8f82a3c 2015-08-16 14:04:46 -0400 updated alert images - 3 months ago (tag: 3.2.5) Dan Graham ce4fd88 2015-08-16 12:14:42 -0400 Fixed bug is displaying next starting time. Tagged version 3.2.5 [2015-08-16 12:14:42 -0400]. - 3 months ago (tag: 3.2.4) Dan Graham a9286a5 2015-08-16 11:36:40 -0400 When next is "none", show "at the starting time" instead of "none before starting time" in the message alert. Tagged version 3.2.4 [2015-08-16 11:36:40 -0400]. - 3 months ago Dan Graham 3fcc6cd 2015-08-16 10:54:57 -0400 updated readme screenshots - 3 months ago (tag: 3.2.3) Dan Graham 9f2ec94 2015-08-16 10:42:42 -0400 renamed alert images Tagged version 3.2.3 [2015-08-16 10:42:42 -0400]. - 3 months ago Dan Graham a0efc30 2015-08-16 10:42:15 -0400 Added GetRepeat as subclass of GetInteger with Repeat and Close buttons instead of OK and Cancel. - 3 months ago Dan Graham bf192d6 2015-08-16 09:54:13 -0400 Message tweaks. - 3 months ago Dan Graham 5138126 2015-08-16 09:48:54 -0400 Show next alert in non-snooze alert and current time in title. - 3 months ago (tag: 3.2.2) Dan Graham 09ecb48 2015-08-15 18:27:55 -0400 Added snooze, no-snooze images Tagged version 3.2.2 [2015-08-15 18:27:55 -0400]. - 3 months ago Dan Graham af056ec 2015-08-15 17:10:19 -0400 Message tweaks for last alert. - 3 months ago Dan Graham 4b9c07c 2015-08-15 16:21:39 -0400 Add the timedelta for next scheduled alert or None if this is the last to the hash. Only show the snooze message alert if it is the last. - 3 months ago (tag: 3.2.1) Dan Graham 2e1323b 2015-08-15 07:46:45 -0400 Use the last snooze minutes as the default for the next. Changed the prompt for countdown to mirror the snooze question. Changed "Snooze/Countdown" to "Countdown". Tagged version 3.2.1 [2015-08-15 07:46:45 -0400]. - 3 months ago Dan Graham a2e0e13 2015-08-14 15:25:09 -0400 Merge branch 'countdown' - 3 months ago (tag: 3.2.0, origin/countdown) Dan Graham 30774fd 2015-08-14 14:53:03 -0400 Label and shortcut menu fixes. Tagged version 3.2.0 [2015-08-14 14:53:03 -0400]. - 3 months ago (tag: 3.1.61) Dan Graham 44471c2 2015-08-14 14:44:27 -0400 added snooze screen shot Tagged version 3.1.61 [2015-08-14 14:44:27 -0400]. - 3 months ago Dan Graham 68ca92a 2015-08-14 13:06:06 -0400 Removed smtp_to from options. - 3 months ago Dan Graham a891ff7 2015-08-14 07:02:43 -0400 Added snooze to the internal message box alert. In expand template, replace multiple blank lines with a single blank line. Added snooze_command and snooze_minutes to options. - 3 months ago Dan Graham 38891c4 2015-08-13 10:55:52 -0400 Added snooze for message alerts. Display the time of the next countdown or snooze in the status bar. - 3 months ago Dan Graham 6ab3eed 2015-08-12 15:13:39 -0400 Trap and report errors parsing periods in str2hsh. - 3 months ago (tag: 3.1.60) Dan Graham 892e228 2015-08-11 20:58:00 -0400 Changed label for countdown timer to snooze/countdown. Bound to shortcut key "z". Tagged version 3.1.60 [2015-08-11 20:58:00 -0400]. - 3 months ago Dan Graham 89f381c 2015-08-11 19:27:57 -0400 Added countdown screenshots - 3 months ago Dan Graham e3c74c7 2015-08-11 19:27:15 -0400 Added a countdown timer. Fixed colors in MessageWindow. - 3 months ago (tag: 3.1.59) Dan Graham ae31902 2015-08-08 15:43:36 -0400 Add 'invitees' from @i entries to the list of recipients for email alerts. Tagged version 3.1.59 [2015-08-08 15:43:36 -0400]. - 3 months ago Dan Graham 06bed7d 2015-08-08 14:27:46 -0400 Fixed email import. Fixed font color for report combobox. - 3 months ago (tag: 3.1.58) Dan Graham 2c8e83e 2015-08-05 16:40:25 -0400 Allow the CLI report option -w to be zero and, if it is, do not truncate report lines. Fixed bug in setting width1. Tagged version 3.1.58 [2015-08-05 16:40:25 -0400]. - 3 months ago (tag: 3.1.57) Dan Graham 6efad9e 2015-08-04 10:02:37 -0400 Found and replaced parse_datetime commands. tagged version 3.1.57 [2015-08-04 10:02:37 -0400] - 3 months ago (tag: 3.1.56) Dan Graham 8e87bb6 2015-08-04 06:49:44 -0400 tagged version 3.1.56 [2015-08-04 06:49:44 -0400] - 3 months ago (tag: 3.1.55) Dan Graham 55c01f2 2015-07-31 16:24:03 -0400 tagged version 3.1.55 [2015-07-31 16:24:03 -0400] - 3 months ago Dan Graham 600511a 2015-07-31 16:11:21 -0400 Fixed bug in period_string_regex. When parsing + and - @keys, use zoneinfo from @z and replace with tzinfo=None. - 3 months ago (tag: 3.1.54) Dan Graham b9783ba 2015-07-31 14:37:48 -0400 tagged version 3.1.54 [2015-07-31 14:37:48 -0400] - 3 months ago Dan Graham 46746ea 2015-07-31 14:15:19 -0400 Replaced parse_dt with parse_str. Replaced some calls to parse(parse_dtstr(...)) with parse_str. - 3 months ago Dan Graham db082e6 2015-07-31 14:08:00 -0400 Replaced parse_datetime and parse_dtstr with parse_str which returns a string if a datetime format specified and otherwise a datetime object is returned. Error generated using parse are trapped and reported. Call filterView after reschedule and scheduleNewItem. - 3 months ago Dan Graham 7975c65 2015-07-31 11:49:16 -0400 first pass at parse_str replacement for parse_dtstr and parse_datetime - 3 months ago Dan Graham 18ff503 2015-07-31 06:45:33 -0400 Merge remote-tracking branch 'origin/master' - 3 months ago Dan Graham 2996bad 2015-07-30 18:20:36 -0400 Merge remote-tracking branch 'origin/master' - 3 months ago Dan Graham 137afe5 2015-07-30 18:20:36 -0400 Merge remote-tracking branch 'origin/master' - 3 months ago Dan Graham 904fc73 2015-07-30 18:19:31 -0400 Updated version information - 3 months ago Dan Graham 8ac28f4 2015-07-30 18:19:31 -0400 version info - 3 months ago (tag: 3.1.53) Dan Graham 1491d33 2015-07-30 17:01:28 -0400 tagged version 3.1.53 [2015-07-30 17:01:28 -0400] - 3 months ago Dan Graham aca3cb4 2015-07-30 17:30:38 -0400 version number update - 3 months ago Dan Graham fe9e15e 2015-07-30 17:01:28 -0400 version number fix - 3 months ago Dan Graham 0d02427 2015-07-30 16:58:41 -0400 updated version info - 3 months ago Dan Graham 8856c57 2015-07-30 16:17:16 -0400 Tagged version 3.1.49 - 3 months ago Dan Graham 2a86da4 2015-07-30 16:17:16 -0400 Tagged version 3.1.49 - 3 months ago (tag: 3.1.46p6) Dan Graham 14c3c33 2015-07-30 16:17:16 -0400 Fixed logic for delete all previous tasks with @f entries. - 3 months ago Dan Graham 2d8ba7f 2015-07-30 12:23:19 -0400 Tagged version 3.1.47 - 3 months ago Dan Graham 1759887 2015-07-30 12:06:26 -0400 updated verson info - 3 months ago (tag: 3.1.46p3) Dan Graham ce4773f 2015-07-30 11:58:33 -0400 Fixed logic for edit/delete tasks with @f and/or @- entries. Warn but do not raise error when rrule generates no repetitions. Fixed binding for setFilter. Keep filter active after edit, delete and finish. - 3 months ago Dan Graham 52b686c 2015-07-29 17:02:42 -0400 Change the sort datetime to 23:59 but leave dtl 00:00 unchanged in midnight tasks. When @- is empty as a result of deletions, then remove @- itself. - 3 months ago Dan Graham 996e767 2015-07-26 13:41:36 -0400 Updated version info. Added this. And this. - 3 months ago (tag: 3.1.46, tag: 3.1.45p3) Dan Graham 676ecb8 2015-07-26 11:39:09 -0400 Removed global color declarations in Dialog. - 3 months ago Dan Graham d1f251c 2015-07-26 09:58:31 -0400 Updated documentation for HEADER links and README samples. - 4 months ago Dan Graham 5762d53 2015-07-25 17:47:20 -0400 Color fixes for dialog.py. - 4 months ago (tag: 3.1.45) Dan Graham bee920f 2015-07-25 11:28:06 -0400 tagged version 3.1.45 - 4 months ago Dan Graham caea58c 2015-07-25 11:26:52 -0400 When an action timer is deleted, add it's time to idle time. - 4 months ago (tag: 3.1.44) Dan Graham 9335900 2015-07-25 10:56:23 -0400 tagged version 3.1.44 - 4 months ago (tag: 3.1.43p2) Dan Graham 54e9dd6 2015-07-24 17:48:00 -0400 Dialog formatting and view typo fixes. etmtk-3.2.22/etmTk/data.py0000644000076500000240000100056012612140726015154 0ustar dagstaff00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import (absolute_import, division, print_function, unicode_literals) import os import os.path # import pwd from copy import deepcopy from textwrap import wrap import platform import logging import logging.config logger = logging.getLogger() this_dir, this_filename = os.path.split(__file__) LANGUAGES = os.path.normpath(os.path.join(this_dir, "locale")) BGCOLOR = HLCOLOR = FGCOLOR = CALENDAR_COLORS = None def _(x): return(x) def setup_logging(level, etmdir=None): """ Setup logging configuration. Override root:level in logging.yaml with default_level. """ if etmdir: etmdir = os.path.normpath(etmdir) else: etmdir = os.path.normpath(os.path.join(os.path.expanduser("~/.etm"))) log_levels = { '1': logging.DEBUG, '2': logging.INFO, '3': logging.WARN, '4': logging.ERROR, '5': logging.CRITICAL } if level in log_levels: loglevel = log_levels[level] else: loglevel = log_levels['3'] if os.path.isdir(etmdir): logfile = os.path.normpath(os.path.abspath(os.path.join(etmdir, "etmtk_log.txt"))) if not os.path.isfile(logfile): open(logfile, 'a').close() config = {'disable_existing_loggers': False, 'formatters': {'simple': { 'format': '--- %(asctime)s - %(levelname)s - %(module)s.%(funcName)s\n %(message)s'}}, 'handlers': {'console': {'class': 'logging.StreamHandler', 'formatter': 'simple', 'level': loglevel, 'stream': 'ext://sys.stdout'}, 'file': {'backupCount': 5, 'class': 'logging.handlers.RotatingFileHandler', 'encoding': 'utf8', 'filename': logfile, 'formatter': 'simple', 'level': 'WARN', 'maxBytes': 1048576}}, 'loggers': {'etmtk': {'handlers': ['console'], 'level': 'DEBUG', 'propagate': False}}, 'root': {'handlers': ['console', 'file'], 'level': 'DEBUG'}, 'version': 1} logging.config.dictConfig(config) logger.info('logging at level: {0}\n writing exceptions to: {1}'.format(loglevel, logfile)) else: # no etmdir - first use config = {'disable_existing_loggers': False, 'formatters': {'simple': { 'format': '--- %(asctime)s - %(levelname)s - %(module)s.%(funcName)s\n %(message)s'}}, 'handlers': {'console': {'class': 'logging.StreamHandler', 'formatter': 'simple', 'level': loglevel, 'stream': 'ext://sys.stdout'}}, 'loggers': {'etmtk': {'handlers': ['console'], 'level': 'DEBUG', 'propagate': False}}, 'root': {'handlers': ['console'], 'level': 'DEBUG'}, 'version': 1} logging.config.dictConfig(config) logger.info('logging at level: {0}'.format(loglevel)) import subprocess # setup gettext in get_options once locale is known import gettext if platform.python_version() >= '3': python_version = 3 python_version2 = False from io import StringIO unicode = str u = lambda x: x raw_input = input from urllib.parse import quote else: python_version = 2 python_version2 = True from cStringIO import StringIO from urllib2 import quote def s2or3(s): if python_version == 2: if type(s) is unicode: return s elif type(s) is str: try: return unicode(s, term_encoding) except ValueError: logger.error('s2or3 exception: {0}'.format(s)) else: return s else: return s from random import random from math import log class Node(object): __slots__ = 'value', 'next', 'width' def __init__(self, value, next, width): self.value, self.next, self.width = value, next, width class End(object): """ Sentinel object that always compares greater than another object. Replaced __cmp__ to work with python3.x """ def __eq__(self, other): return 0 def __ne__(self, other): return 1 def __gt__(self, other): return 1 def __ge__(self, other): return 1 def __le__(self, other): return 0 def __lt__(self, other): return 0 # Singleton terminator node NIL = Node(End(), [], []) class IndexableSkiplist: """Sorted collection supporting O(lg n) insertion, removal, and lookup by rank.""" def __init__(self, expected_size=100, type=""): self.size = 0 self.type = type self.maxlevels = int(1 + log(expected_size, 2)) self.head = Node('HEAD', [NIL] * self.maxlevels, [1] * self.maxlevels) def __len__(self): return self.size def __getitem__(self, i): node = self.head i += 1 for level in reversed(range(self.maxlevels)): while node.width[level] <= i: i -= node.width[level] node = node.next[level] return node.value def insert(self, value): # find first node on each level where node.next[levels].value > value chain = [None] * self.maxlevels steps_at_level = [0] * self.maxlevels node = self.head for level in reversed(range(self.maxlevels)): try: while node.next[level].value <= value: steps_at_level[level] += node.width[level] node = node.next[level] chain[level] = node except: logger.exception('Error comparing {0}:\n {1}\n with the value to be inserted\n {2}'.format(self.type, node.next[level].value, value)) return # insert a link to the newnode at each level d = min(self.maxlevels, 1 - int(log(random(), 2.0))) newnode = Node(value, [None] * d, [None] * d) steps = 0 for level in range(d): prevnode = chain[level] newnode.next[level] = prevnode.next[level] prevnode.next[level] = newnode newnode.width[level] = prevnode.width[level] - steps prevnode.width[level] = steps + 1 steps += steps_at_level[level] for level in range(d, self.maxlevels): chain[level].width[level] += 1 self.size += 1 def remove(self, value): # find first node on each level where node.next[levels].value >= value chain = [None] * self.maxlevels node = self.head for level in reversed(range(self.maxlevels)): try: while node.next[level].value < value: node = node.next[level] chain[level] = node except: logger.exception('Error comparing {0}:\n {1}\n with the value to be removed\n {2}'.format(self.type, node.next[level].value, value)) if value != chain[0].next[0].value: raise KeyError('Not Found') # remove one link at each level d = len(chain[0].next[0].next) for level in range(d): prevnode = chain[level] prevnode.width[level] += prevnode.next[level].width[level] - 1 prevnode.next[level] = prevnode.next[level].next[level] for level in range(d, self.maxlevels): chain[level].width[level] -= 1 self.size -= 1 def __iter__(self): 'Iterate over values in sorted order' node = self.head.next[0] while node is not NIL: yield node.value node = node.next[0] # initial instances itemsSL = IndexableSkiplist(5000, "items") alertsSL = IndexableSkiplist(100, "alerts") datetimesSL = IndexableSkiplist(1000, "datetimes") datesSL = IndexableSkiplist(1000, "dates") busytimesSL = {} occasionsSL = {} items = [] alerts = [] datetimes = [] busytimes = {} occasions = {} file2uuids = {} uuid2hash = {} file2data = {} name2list = { "items": items, "alerts": alerts, "datetimes": datetimes } name2SL = { "items": itemsSL, "alerts": alertsSL, "datetimes": datetimesSL } def clear_all_data(): global itemsSL, alertsSL, datetimesSL, datesSL, busytimesSL, occasionsSL, items, alerts, datetimes, busytimes, occasions, file2uuids, uuid2hash, file2data, name2list, name2SL itemsSL = IndexableSkiplist(5000, "items") alertsSL = IndexableSkiplist(100, "alerts") datetimesSL = IndexableSkiplist(1000, "datetimes") datesSL = IndexableSkiplist(1000, "dates") busytimesSL = {} occasionsSL = {} items = [] alerts = [] datetimes = [] busytimes = {} occasions = {} file2uuids = {} uuid2hash = {} file2data = {} name2list = { "items": items, "alerts": alerts, "datetimes": datetimes } name2SL = { "items": itemsSL, "alerts": alertsSL, "datetimes": datetimesSL } dayfirst = False yearfirst = True IGNORE = """\ syntax: glob .* """ from datetime import datetime, timedelta, time from dateutil.tz import (tzlocal, tzutc) from dateutil.easter import easter def get_current_time(): return datetime.now(tzlocal()) # this task will be created for first time users SAMPLE = """\ # Sample entries - edit or delete at your pleasure = @t sample, tasks ? lose weight and exercise more - milk and eggs @c errands - reservation for Saturday dinner @c phone - hair cut @s -1 @r w &i 2 &o r @c errands - put out trash @s 1 @r w &w MO @o s = @t sample, occasions ^ etm's !2009! birthday @s 2010-02-27 @r y @d initial release 2009-02-27 ^ payday @s 1/1 @r m &w MO, TU, WE, TH, FR &m -1, -2, -3 &s -1 @d the last weekday of each month = @t sample, events * sales meeting @s +7 9a @e 1h @a 5 @a 2d: e; who@when.com, what@where.org @u jsmith * stationary bike @s 1 5:30p @e 30 @r d @a 0 * Tête-à-têtes @s 1 3p @e 90 @r w &w fri @l conference room @t meetings * Book club @s -1/1 7pm @e 2h @z US/Eastern @r w &w TH * Tennis @s -1/1 9am @e 1h30m @z US/Eastern @r w &w SA * Dinner @s -1/1 7:30pm @e 2h30m @z US/Eastern @a 1h, 40m: m @u dag @r w &w SA * Appt with Dr Burns @s 2014-05-15 10am @e 1h @r m &i 9 &w 1TU &t 2 """ HOLIDAYS = """\ ^ Martin Luther King Day @s 2010-01-18 @r y &w 3MO &M 1 ^ Valentine's Day @s 2010-02-14 @r y &M 2 &m 14 ^ President's Day @s 2010-02-15 @c holiday @r y &w 3MO &M 2 ^ Daylight saving time begins @s 2010-03-14 @r y &w 2SU &M 3 ^ St Patrick's Day @s 2010-03-17 @r y &M 3 &m 17 ^ Easter Sunday @s 2010-01-01 @r y &E 0 ^ Mother's Day @s 2010-05-09 @r y &w 2SU &M 5 ^ Memorial Day @s 2010-05-31 @r y &w -1MO &M 5 ^ Father's Day @s 2010-06-20 @r y &w 3SU &M 6 ^ The !1776! Independence Day @s 2010-07-04 @r y &M 7 &m 4 ^ Labor Day @s 2010-09-06 @r y &w 1MO &M 9 ^ Daylight saving time ends @s 2010-11-01 @r y &w 1SU &M 11 ^ Thanksgiving @s 2010-11-26 @r y &w 4TH &M 11 ^ Christmas @s 2010-12-25 @r y &M 12 &m 25 ^ Presidential election day @s 2004-11-01 12am @r y &i 4 &m 2, 3, 4, 5, 6, 7, 8 &M 11 &w TU """ JOIN = "- join the etm discussion group @s +14 @b 10 @c computer @g http://groups.google.com/group/eventandtaskmanager/topics" COMPETIONS = """\ # put completion phrases here, one per line. E.g.: @z US/Eastern @z US/Central @z US/Mountain @z US/Pacific @c errands @c phone @c home @c office # empty lines and lines that begin with '#' are ignored. """ USERS = """\ jsmith: - Smith, John - jsmth@whatever.com - wife Rebecca - children Tom, Dick and Harry """ REPORTS = """\ # put report specifications here, one per line. E.g.: # scheduled items this week: c ddd, MMM dd yyyy -b mon - 7d -e +7 # this and next week: c ddd, MMM dd yyyy -b mon - 7d -e +14 # this month: c ddd, MMM dd yyyy -b 1 -e +1/1 # this and next month: c ddd, MMM dd yyyy -b 1 -e +2/1 # last month's actions: a MMM yyyy; u; k[0]; k[1:] -b -1/1 -e 1 # this month's actions: a MMM yyyy; u; k[0]; k[1:] -b 1 -e +1/1 # this week's actions: a w; u; k[0]; k[1:] -b sun - 6d -e sun # all items by folder: c f # all items by keyword: c k # all items by tag: c t # all items by user: c u # empty lines and lines that begin with '#' are ignored. """ # command line usage USAGE = """\ Usage: etm [logging level] [path] [?] [acmsv] With no arguments, etm will set logging level 3 (warn), use settings from the configuration file ~/.etm/etmtk.cfg, and open the GUI. If the first argument is an integer not less than 1 (debug) and not greater than 5 (critical), then set that logging level and remove the argument. If the first (remaining) argument is the path to a directory that contains a file named etmtk.cfg, then use that configuration file and remove the argument. If the first (remaining) argument is one of the commands listed below, then execute the remaining arguments without opening the GUI. a ARG display the agenda view using ARG, if given, as a filter. c ARGS display a custom view using the remaining arguments as the specification. (Enclose ARGS in single quotes to prevent shell expansion.) d ARG display the day view using ARG, if given, as a filter. k ARG display the keywords view using ARG, if given, as a filter. m INT display a report using the remaining argument, which must be a positive integer, to display a report using the corresponding entry from the file given by report_specifications in etmtk.cfg. Use ? m to display the numbered list of entries from this file. n ARG display the notes view using ARG, if given, as a filter. N ARGS Create a new item using the remaining arguments as the item specification. (Enclose ARGS in single quotes to prevent shell expansion.) p ARG display the path view using ARG, if given, as a filter. t ARG display the tags view using ARG, if given, as a filter. v display information about etm and the operating system. ? ARG display (this) command line help information if ARGS = '' or, if ARGS = X where X is one of the above commands, then display details about command X. 'X ?' is equivalent to '? X'.\ """ import re import sys import locale # term_encoding = locale.getdefaultlocale()[1] term_locale = locale.getdefaultlocale()[0] qt2dt = [ ('a', '%p'), ('dddd', '%A'), ('ddd', '%a'), ('dd', '%d'), ('MMMM', '%B'), ('MMM', '%b'), ('MM', '%m'), ('yyyy', '%Y'), ('yy', '%y'), ('hh', '%H'), ('h', '%I'), ('mm', '%M'), ('w', 'WEEK') ] def commandShortcut(s): """ Produce label, command pairs from s based on Command for OSX and Control otherwise. """ if s.upper() == s and s.lower() != s: shift = "Shift-" else: shift = "" if mac: # return "{0}Cmd-{1}".format(shift, s), "<{0}Command-{1}>".format(shift, s) return "{0}Ctrl-{1}".format(shift, s.upper()), "<{0}Control-{1}>".format(shift, s) else: return "{0}Ctrl-{1}".format(shift, s.upper()), "<{0}Control-{1}>".format(shift, s) def optionShortcut(s): """ Produce label, command pairs from s based on Command for OSX and Control otherwise. """ if s.upper() == s and s.lower() != s: shift = "Shift-" else: shift = "" if mac: return "{0}Alt-{1}".format(shift, s.upper()), "<{0}Option-{1}>".format(shift, s) else: return "{0}Alt-{1}".format(shift, s.upper()), "<{0}Alt-{1}>".format(shift, s) def d_to_str(d, s): for key, val in qt2dt: s = s.replace(key, val) ret = s2or3(d.strftime(s)) if 'WEEK' in ret: theweek = get_week(d) ret = ret.replace('WEEK', theweek) return ret def dt_to_str(dt, s): for key, val in qt2dt: s = s.replace(key, val) ret = s2or3(dt.strftime(s)) if 'WEEK' in ret: theweek = get_week(dt) ret = ret.replace('WEEK', theweek) return ret def get_week(dt): yn, wn, dn = dt.isocalendar() if dn > 1: days = dn - 1 else: days = 0 weekbeg = dt - days * ONEDAY weekend = dt + (6 - days) * ONEDAY ybeg = weekbeg.year yend = weekend.year mbeg = weekbeg.month mend = weekend.month if mbeg == mend: header = "{0} - {1}".format( fmt_dt(weekbeg, '%b %d'), fmt_dt(weekend, '%d')) elif ybeg == yend: header = "{0} - {1}".format( fmt_dt(weekbeg, '%b %d'), fmt_dt(weekend, '%b %d')) else: header = "{0} - {1}".format( fmt_dt(weekbeg, '%b %d, %Y'), fmt_dt(weekend, '%b %d, %Y')) header = leadingzero.sub('', header) theweek = "{0} {1}: {2}".format(_("Week"), "{0:02d}".format(wn), header) return theweek from etmTk.v import version from etmTk.version import version as fullversion last_version = version from re import split as rsplit sys_platform = platform.system() if sys_platform in ('Windows', 'Microsoft'): windoz = True from time import clock as timer else: windoz = False from time import time as timer if sys.platform == 'darwin': mac = True CMD = "Command" default_style = 'aqua' else: mac = False CMD = "Control" default_style = 'default' # used in hack to prevent dialog from hanging under os x if mac: AFTER = 200 else: AFTER = 1 class TimeIt(object): def __init__(self, loglevel=1, label=""): self.loglevel = loglevel self.label = label msg = "{0} timer started".format(self.label) if self.loglevel == 1: logger.debug(msg) elif self.loglevel == 2: logger.info(msg) self.start = timer() def stop(self, *args): self.end = timer() self.secs = self.end - self.start self.msecs = self.secs * 1000 # millisecs msg = "{0} timer stopped; elapsed time: {1} milliseconds".format(self.label, self.msecs) if self.loglevel == 1: logger.debug(msg) elif self.loglevel == 2: logger.info(msg) has_icalendar = False try: from icalendar import Calendar, Event, Todo, Journal from icalendar.caselessdict import CaselessDict from icalendar.prop import vDate, vDatetime has_icalendar = True import pytz except ImportError: if has_icalendar: logger.info('Could not import pytz') else: logger.info('Could not import icalendar and/or pytz') has_icalendar = False from time import sleep import dateutil.rrule as dtR from dateutil.parser import parse as dparse from dateutil import __version__ as dateutil_version # noinspection PyPep8Naming from dateutil.tz import gettz as getTz def memoize(fn): memo = {} def memoizer(*param_tuple, **kwds_dict): if kwds_dict: memoizer.namedargs += 1 return fn(*param_tuple, **kwds_dict) try: memoizer.cacheable += 1 try: return memo[param_tuple] except KeyError: memoizer.misses += 1 memo[param_tuple] = result = fn(*param_tuple) return result except TypeError: memoizer.cacheable -= 1 memoizer.noncacheable += 1 return fn(*param_tuple) memoizer.namedargs = memoizer.cacheable = memoizer.noncacheable = 0 memoizer.misses = 0 return memoizer @memoize def gettz(z=None): return getTz(z) import calendar import yaml from itertools import groupby # from dateutil.rrule import * from dateutil.rrule import (DAILY, rrule) import bisect import uuid import codecs import shutil import fnmatch def term_print(s): if python_version2: try: print(unicode(s).encode(term_encoding)) except Exception: logger.exception("error printing: '{0}', {1}".format(s, type(s))) else: print(s) parse = None def setup_parse(day_first, year_first): global parse # noinspection PyRedeclaration def parse(s): """ Return a datetime object """ try: res = dparse(str(s), dayfirst=day_first, yearfirst=year_first) except: return 'Could not parse: {0}'.format(s) return res try: from os.path import relpath except ImportError: # python < 2.6 from os.path import curdir, abspath, sep, commonprefix, pardir, join def relpath(path, start=curdir): """Return a relative version of a path""" if not path: raise ValueError("no path specified") start_list = abspath(start).split(sep) path_list = abspath(path).split(sep) # Work out how much of the filepath is shared by start and path. i = len(commonprefix([start_list, path_list])) rel_list = [pardir] * (len(start_list) - i) + path_list[i:] if not rel_list: return curdir return join(*rel_list) cwd = os.getcwd() def pathSearch(filename): search_path = os.getenv('PATH').split(os.pathsep) for path in search_path: candidate = os.path.normpath(os.path.join(path, filename)) # logger.debug('checking for: {0}'.format(candidate)) if os.path.isfile(candidate): # return os.path.abspath(candidate) return candidate return '' def getMercurial(): if windoz: hg = pathSearch('hg.exe') else: hg = pathSearch('hg') if hg: logger.debug('found hg: {0}'.format(hg)) base_command = "hg -R {work}" history_command = 'hg log --style compact --template "{desc}\\n" -R {work} -p {numchanges} {file}' commit_command = 'hg commit -q -A -R {work} -m "{mesg}"' init = 'hg init {work}' init_command = "%s && %s" % (init, commit_command) logger.debug('hg base_command: {0}; history_command: {1}; commit_command: {2}; init_command: {3}'.format(base_command, history_command, commit_command, init_command)) else: logger.debug('could not find hg in path') base_command = history_command = commit_command = init_command = '' return base_command, history_command, commit_command, init_command def getGit(): if windoz: git = pathSearch('git.exe') else: git = pathSearch('git') if git: logger.debug('found git: {0}'.format(git)) base_command = "git --git-dir {repo} --work-tree {work}" history_command = "git --git-dir {repo} --work-tree {work} log --pretty=format:'- %ai %an: %s' -U0 {numchanges} {file}" init = 'git init {work}' add = 'git --git-dir {repo} --work-tree {work} add */\*.txt > /dev/null' commit = 'git --git-dir {repo} --work-tree {work} commit -a -m "{mesg}" > /dev/null' commit_command = '%s && %s' % (add, commit) init_command = '%s && %s && %s' % (init, add, commit) logger.debug('git base_command: {0}; history_command: {1}; commit_command: {2}; init_command: {3}'.format(base_command, history_command, commit_command, init_command)) else: logger.debug('could not find git in path') base_command = history_command = commit_command = init_command = '' return base_command, history_command, commit_command, init_command zonelist = [ 'Africa/Cairo', 'Africa/Casablanca', 'Africa/Johannesburg', 'Africa/Mogadishu', 'Africa/Nairobi', 'America/Belize', 'America/Buenos_Aires', 'America/Edmonton', 'America/Mexico_City', 'America/Monterrey', 'America/Montreal', 'America/Toronto', 'America/Vancouver', 'America/Winnipeg', 'Asia/Baghdad', 'Asia/Bahrain', 'Asia/Calcutta', 'Asia/Damascus', 'Asia/Dubai', 'Asia/Hong_Kong', 'Asia/Istanbul', 'Asia/Jakarta', 'Asia/Jerusalem', 'Asia/Katmandu', 'Asia/Kuwait', 'Asia/Macao', 'Asia/Pyongyang', 'Asia/Saigon', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Tehran', 'Asia/Tokyo', 'Asia/Vladivostok', 'Atlantic/Azores', 'Atlantic/Bermuda', 'Atlantic/Reykjavik', 'Australia/Sydney', 'Europe/Amsterdam', 'Europe/Berlin', 'Europe/Lisbon', 'Europe/London', 'Europe/Madrid', 'Europe/Minsk', 'Europe/Monaco', 'Europe/Moscow', 'Europe/Oslo', 'Europe/Paris', 'Europe/Prague', 'Europe/Rome', 'Europe/Stockholm', 'Europe/Vienna', 'Pacific/Auckland', 'Pacific/Fiji', 'Pacific/Samoa', 'Pacific/Tahiti', 'Turkey', 'US/Alaska', 'US/Aleutian', 'US/Arizona', 'US/Central', 'US/East-Indiana', 'US/Eastern', 'US/Hawaii', 'US/Indiana-Starke', 'US/Michigan', 'US/Mountain', 'US/Pacific'] def get_localtz(zones=zonelist): """ :param zones: list of timezone strings :return: timezone string """ linfo = gettz() now = get_current_time() # get the abbreviation for the local timezone, e.g, EDT possible = [] # try the zone list first unless windows system if not windoz: for i in range(len(zones)): z = zones[i] zinfo = gettz(z) if zinfo and zinfo == linfo: possible.append(i) break if not possible: for i in range(len(zones)): z = zones[i] zinfo = gettz(z) if zinfo and zinfo.utcoffset(now) == linfo.utcoffset(now): possible.append(i) if not possible: # the local zone needs to be added to timezones return [''] return [zonelist[i] for i in possible] def calyear(advance=0, options=None): """ """ if not options: options = {} lcl = options['lcl'] if 'sundayfirst' in options and options['sundayfirst']: week_begin = 6 else: week_begin = 0 # hack to set locale on darwin, windoz and linux try: if mac: # locale test c = calendar.LocaleTextCalendar(week_begin, lcl[0]) elif windoz: locale.setlocale(locale.LC_ALL, '') lcl = locale.getlocale() c = calendar.LocaleTextCalendar(week_begin, lcl) else: lcl = locale.getdefaultlocale() c = calendar.LocaleTextCalendar(week_begin, lcl) except: logger.exception('Could not set locale: {0}'.format(lcl)) c = calendar.LocaleTextCalendar(week_begin) cal = [] y = int(today.strftime("%Y")) m = 1 # d = 1 y += advance for i in range(12): cal.append(c.formatmonth(y, m).split('\n')) m += 1 if m > 12: y += 1 m = 1 ret = [] for r in range(0, 12, 3): l = max(len(cal[r]), len(cal[r + 1]), len(cal[r + 2])) for i in range(3): if len(cal[r + i]) < l: for j in range(len(cal[r + i]), l + 1): cal[r + i].append('') for j in range(l): if python_version2: ret.append(s2or3(u' %-20s %-20s %-20s' % (cal[r][j], cal[r + 1][j], cal[r + 2][j]))) else: ret.append((u' %-20s %-20s %-20s' % (cal[r][j], cal[r + 1][j], cal[r + 2][j]))) return ret def date_calculator(s, options=None): """ x [+-] y where x is a datetime and y is either a datetime or a timeperiod :param s: """ estr = estr_regex.search(s) if estr: y = estr.group(1) e = easter(int(y)) E = e.strftime("%Y-%m-%d") s = estr_regex.sub(E, s) m = date_calc_regex.match(s) if not m: return 'Could not parse "%s"' % s x, pm, y = [z.strip() for z in m.groups()] xzs = None nx = timezone_regex.match(x) if nx: x, xzs = nx.groups() yz = tzlocal() yzs = None ny = timezone_regex.match(y) if ny: y, yzs = ny.groups() yz = gettz(yzs) windoz_epoch = _("Warning: any timezone information in dates prior to 1970 is ignored under Windows.") warn = "" try: dt_x = parse_str(x, timezone=xzs) pmy = "%s%s" % (pm, y) if period_string_regex.match(pmy): dt = (dt_x + parse_period(pmy, minutes=False)) if windoz and (dt_x.year < 1970 or dt.year < 1970): warn = "\n\n{0}".format(windoz_epoch) else: dt.astimezone(yz) res = dt.strftime("%Y-%m-%d %H:%M%z") prompt = "{0}:\n\n{1}{2}".format(s.strip(), res.strip(), warn) return prompt else: dt_y = parse_str(y, timezone=yzs) if windoz and (dt_x.year < 1970 or dt_y.year < 1970): warn = "\n\n{0}".format(windoz_epoch) dt_x = dt_x.replace(tzinfo=None) dt_y = dt_y.replace(tzinfo=None) if pm == '-': res = fmt_period(dt_x - dt_y) prompt = "{0}:\n\n{1}{2}".format(s.strip(), res.strip(), warn) return prompt else: return 'error: datetimes cannot be added' except ValueError: return 'error parsing "%s"' % s def mail_report(message, smtp_from=None, smtp_server=None, smtp_id=None, smtp_pw=None, smtp_to=None): """ """ import smtplib from email.MIMEMultipart import MIMEMultipart from email.MIMEText import MIMEText from email.Utils import COMMASPACE, formatdate # from email import Encoders assert type(smtp_to) == list msg = MIMEMultipart() msg['From'] = smtp_from msg['To'] = COMMASPACE.join(smtp_to) msg['Date'] = formatdate(localtime=True) msg['Subject'] = "etm agenda" msg.attach(MIMEText(message, 'html')) smtp = smtplib.SMTP_SSL(smtp_server) smtp.login(smtp_id, smtp_pw) smtp.sendmail(smtp_from, smtp_to, msg.as_string()) smtp.close() def send_mail(smtp_to, subject, message, files=None, smtp_from=None, smtp_server=None, smtp_id=None, smtp_pw=None): """ """ if not files: files = [] import smtplib from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email.mime.text import MIMEText from email.utils import COMMASPACE, formatdate from email import encoders as Encoders assert type(smtp_to) == list assert type(files) == list msg = MIMEMultipart() msg['From'] = smtp_from msg['To'] = COMMASPACE.join(smtp_to) msg['Date'] = formatdate(localtime=True) msg['Subject'] = subject msg.attach(MIMEText(message)) for f in files: part = MIMEBase('application', "octet-stream") part.set_payload(open(f, "rb").read()) Encoders.encode_base64(part) part.add_header( 'Content-Disposition', 'attachment; filename="%s"' % os.path.basename(f)) msg.attach(part) smtp = smtplib.SMTP_SSL(smtp_server) smtp.login(smtp_id, smtp_pw) smtp.sendmail(smtp_from, smtp_to, msg.as_string()) smtp.close() def send_text(sms_phone, subject, message, sms_from, sms_server, sms_pw): sms_phone = "%s" % sms_phone import smtplib from email.mime.text import MIMEText sms = smtplib.SMTP(sms_server) sms.starttls() sms.login(sms_from, sms_pw) for num in sms_phone.split(','): msg = MIMEText(message) msg["From"] = sms_from msg["Subject"] = subject msg['To'] = num sms.sendmail(sms_from, sms_phone, msg.as_string()) sms.quit() item_regex = re.compile(r'^([\$\^\*~!%\?#=\+\-])\s') email_regex = re.compile('([\w\-\.]+@(\w[\w\-]+\.)+[\w\-]+)') sign_regex = re.compile(r'(^\s*([+-])?)') week_regex = re.compile(r'[+-]?(\d+)w', flags=re.I) estr_regex = re.compile(r'easter\((\d{4,4})\)', flags=re.I) day_regex = re.compile(r'[+-]?(\d+)d', flags=re.I) hour_regex = re.compile(r'[+-]?(\d+)h', flags=re.I) minute_regex = re.compile(r'[+-]?(\d+)m', flags=re.I) date_calc_regex = re.compile(r'^\s*(.+)\s+([+-])\s+(.+)\s*$') period_string_regex = re.compile(r'^\s*([+-]?(\d+[wWdDhHmM])+\s*$)') timezone_regex = re.compile(r'^(.+)\s+([A-Za-z]+/[A-Za-z]+)$') int_regex = re.compile(r'^\s*([+-]?\d+)\s*$') leadingzero = re.compile(r'(? 59: options['update_minutes'] = default_options['update_minutes'] remove_keys = [] for key in options: if key not in default_options: remove_keys.append(key) changed = True for key in remove_keys: del options[key] action_keys = [x for x in options['action_keys']] if action_keys: for at_key in action_keys: if at_key not in key2type or "~" not in key2type[at_key]: action_keys.remove(at_key) changed = True if changed: options['action_keys'] = "".join(action_keys) # check freetimes for key in default_freetimes: if key not in options['freetimes']: options['freetimes'][key] = default_freetimes[key] logger.warn('A value was not provided for freetimes[{0}] - using the default value.'.format(key)) changed = True else: if type(options['freetimes'][key]) is not int: changed = True try: options['freetimes'][key] = int(eval(options['freetimes'][key])) except: logger.warn('The value provided for freetimes[{0}], "{1}", could not be converted to an integer - using the default value instead.'.format(key, options['freetimes'][key])) options['freetimes'][key] = default_freetimes[key] free_keys = [x for x in options['freetimes'].keys()] for key in free_keys: if key not in default_freetimes: del options['freetimes'][key] logger.warn('A value was provided for freetimes[{0}], but this is an invalid option and has been deleted.'.format(key)) changed = True if not os.path.isdir(options['datadir']): """ personal/ monthly/ sample/ completions.cfg reports.cfg sample.txt users.cfg shared/ holidays.txt etmtk.cfg calendars: - - personal - true - personal - - sample - true - sample - - shared - true - shared """ changed = True term_print('creating datadir: {0}'.format(options['datadir'])) # first use of this datadir - first use of new etm? os.makedirs(options['datadir']) # create one task for new users to join the etm discussion group currfile = ensureMonthly(options) with open(currfile, 'w') as fo: fo.write(JOIN) sample = os.path.normpath(os.path.join(options['datadir'], 'sample')) os.makedirs(sample) with codecs.open(os.path.join(sample, 'sample.txt'), 'w', dfile_encoding) as fo: fo.write(SAMPLE) holidays = os.path.normpath(os.path.join(options['datadir'], 'shared')) os.makedirs(holidays) with codecs.open(os.path.join(holidays, 'holidays.txt'), 'w', dfile_encoding) as fo: fo.write(HOLIDAYS) with codecs.open(os.path.join(options['datadir'], 'sample', 'completions.cfg'), 'w', dfile_encoding) as fo: fo.write(COMPETIONS) with codecs.open(os.path.join(options['datadir'], 'sample', 'reports.cfg'), 'w', dfile_encoding) as fo: fo.write(REPORTS) with codecs.open(os.path.join(options['datadir'], 'sample', 'users.cfg'), 'w', dfile_encoding) as fo: fo.write(USERS) if not options['calendars']: options['calendars'] = [['personal', True, 'personal'], ['sample', True, 'sample'], ['shared', True, 'shared']] logger.info('using datadir: {0}'.format(options['datadir'])) logger.debug('changed: {0}; user: {1}; options: {2}'.format(changed, (user_options != default_options), (options != default_options))) if changed or using_oldcfg: # save options to newconfig even if user options came from oldconfig logger.debug('Writing etmtk.cfg changes to {0}'.format(newconfig)) fo = codecs.open(newconfig, 'w', options['encoding']['file']) yaml.safe_dump(options, fo, default_flow_style=False) fo.close() # add derived options if options['vcs_system'] == 'git': if git_command: options['vcs'] = {'command': git_command, 'history': git_history, 'commit': git_commit, 'init': git_init, 'dir': '.git', 'limit': '-n', 'file': ""} repo = os.path.normpath(os.path.join(options['datadir'], options['vcs']['dir'])) work = options['datadir'] # logger.debug('{0} options: {1}'.format(options['vcs_system'], options['vcs'])) else: logger.warn('could not setup "git" vcs') options['vcs'] = {} options['vcs_system'] = '' elif options['vcs_system'] == 'mercurial': if hg_command: options['vcs'] = {'command': hg_command, 'history': hg_history, 'commit': hg_commit, 'init': hg_init, 'dir': '.hg', 'limit': '-l', 'file': ''} repo = os.path.normpath(os.path.join(options['datadir'], options['vcs']['dir'])) work = options['datadir'] # logger.debug('{0} options: {1}'.format(options['vcs_system'], options['vcs'])) else: logger.warn('could not setup "mercurial" vcs') options['vcs'] = {} options['vcs_system'] = '' else: options['vcs_system'] = '' options['vcs'] = {} # overrule the defaults if any custom settings are given if options['vcs_system']: if options['vcs_settings']: # update any settings with custom modifications for key in options['vcs_settings']: if options['vcs_settings'][key]: options['vcs'][key] = options['vcs_settings'][key] # add the derived options options['vcs']['repo'] = repo options['vcs']['work'] = work if options['vcs']: vcs_lst = [] keys = [x for x in options['vcs'].keys()] keys.sort() for key in keys: vcs_lst.append("{0}: {1}".format(key, options['vcs'][key])) vcs_str = "\n ".join(vcs_lst) else: vcs_str = "" logger.info('using vcs {0}; options:\n {1}'.format(options['vcs_system'], vcs_str)) (options['daybegin_fmt'], options['dayend_fmt'], options['reprtimefmt'], options['longreprtimefmt'], options['reprdatetimefmt'], options['etmdatetimefmt'], options['rfmt'], options['efmt']) = get_fmts(options) options['config'] = newconfig options['scratchpad'] = os.path.normpath(os.path.join(options['etmdir'], _("scratchpad"))) options['colors'] = os.path.normpath(os.path.join(options['etmdir'], "colors.cfg")) if options['action_minutes'] not in [1, 6, 12, 15, 30, 60]: term_print( "Invalid action_minutes setting: %s. Reset to 1." % options['action_minutes']) options['action_minutes'] = 1 setConfig(options) z = gettz(options['local_timezone']) if z is None: term_print( "Error: bad entry for local_timezone in etmtk.cfg: '%s'" % options['local_timezone']) options['local_timezone'] = '' if 'vcs_system' in options and options['vcs_system']: logger.debug('vcs_system: {0}'.format(options['vcs_system'])) f = '' if options['vcs_system'] == 'mercurial': f = os.path.normpath(os.path.join(options['datadir'], '.hgignore')) elif options['vcs_system'] == 'git': f = os.path.normpath(os.path.join(options['datadir'], '.gitignore')) if f and not os.path.isfile(f): fo = open(f, 'w') fo.write(IGNORE) fo.close() logger.info('created: {0}'.format(f)) logger.debug('checking for {0}'.format(options['vcs']['repo'])) if not os.path.isdir(options['vcs']['repo']): init = options['vcs']['init'] # work = (options['vcs']['work']) command = init.format(work=options['vcs']['work'], repo=options['vcs']['repo'], mesg="initial commit") logger.debug('initializing vcs: {0}'.format(command)) # run_cmd(command) subprocess.call(command, shell=True) if options['current_icsfolder']: if not os.path.isdir(options['current_icsfolder']): os.makedirs(options['current_icsfolder']) options['lcl'] = lcl logger.info('using lcl: {0}'.format(lcl)) options['hide_finished'] = False # define parse using dayfirst and yearfirst setup_parse(options['dayfirst'], options['yearfirst']) term_encoding = options['encoding']['term'] file_encoding = options['encoding']['file'] gui_encoding = options['encoding']['gui'] local_timezone = options['local_timezone'] options['background_color'] = BGCOLOR options['highlight_color'] = HLCOLOR options['foreground_color'] = FGCOLOR options['calendar_colors'] = CALENDAR_COLORS # set 'bef' here and update on newday using a naive datetime now = datetime.now() year, wn, dn = now.isocalendar() weeks_after = options['weeks_after'] if dn > 1: days = dn - 1 else: days = 0 week_beg = now - days * ONEDAY bef = (week_beg + (7 * (weeks_after + 1)) * ONEDAY) options['bef'] = bef logger.debug("ending get_options") return user_options, options, use_locale def get_fmts(options): global rfmt, efmt df = "%x" ef = "%a %b %d" if 'ampm' in options and options['ampm']: reprtimefmt = "%I:%M%p" longreprtimefmt = "%I:%M:%S%p" daybegin_fmt = "12am" dayend_fmt = "11:59pm" rfmt = "{0} %I:%M%p %z".format(df) efmt = "%I:%M%p {0}".format(ef) else: reprtimefmt = "%H:%M" longreprtimefmt = "%H:%M:%S" daybegin_fmt = "0:00" dayend_fmt = "23:59" rfmt = "{0} %H:%M%z".format(df) efmt = "%H:%M {0}".format(ef) reprdatetimefmt = "%s %s %%Z" % (reprdatefmt, reprtimefmt) etmdatetimefmt = "%s %s" % (etmdatefmt, reprtimefmt) return (daybegin_fmt, dayend_fmt, reprtimefmt, longreprtimefmt, reprdatetimefmt, etmdatetimefmt, rfmt, efmt) def checkForNewerVersion(): global python_version2 import socket timeout = 10 socket.setdefaulttimeout(timeout) if platform.python_version() >= '3': python_version2 = False from urllib.request import urlopen from urllib.error import URLError # from urllib.parse import urlencode else: python_version2 = True from urllib2 import urlopen, URLError url = "http://people.duke.edu/~dgraham/etmtk/version.txt" try: response = urlopen(url) except URLError as e: if hasattr(e, 'reason'): msg = """\ The latest version could not be determined. Reason: %s.""" % e.reason elif hasattr(e, 'code'): msg = """\ The server couldn\'t fulfill the request. Error code: %s.""" % e.code return 0, msg else: # everything is fine if python_version2: res = response.read() vstr = rsplit('\s+', res)[0] else: res = response.read().decode(term_encoding) vstr = rsplit('\s+', res)[0] if version < vstr: return (1, """\ A newer version of etm, %s, is available at \ people.duke.edu/~dgraham/etmtk.""" % vstr) else: return 1, 'You are using the latest version.' type_keys = [x for x in '=^*-+%~$?!#'] type2Str = { '$': "ib", '^': "oc", '*': "ev", '~': "ac", '!': "nu", # undated only appear in folders '-': "un", # for next view '+': "un", # for next view '%': "du", '?': "so", '#': "dl"} id2Type = { # TStr TNum Forground Color Icon view "ac": '~', "av": '-', "by": '>', "cs": '+', # job "cu": '+', # job with unfinished prereqs "dl": '#', "ds": '%', "du": '%', "ev": '*', "fn": u"\u2713", "ib": '$', "ns": '!', "nu": '!', "oc": '^', "pc": '+', # job pastdue "pu": '+', # job pastdue with unfinished prereqs "pd": '%', "pt": '-', "rm": '*', "so": '?', "un": '-', } # the named colors are listed in colors.py. # the contents of colors_light.cfg: colors_light = """\ base: foreground: 'black' # default font color highlight: '#B2B2AF' # default highlight color background: '#FEFEFC' # default background color item: # font colors for items in tree views ac: 'darkorchid' # action av: 'RoyalBlue3' # scheduled, available task by: 'DarkGoldenRod3' # begin by cs: 'RoyalBlue3' # scheduled job cu: 'gray65' # scheduled job with unfinished prereqs dl: 'gray70' # hidden (folder view) ds: 'darkslategray' # scheduled, delegated task du: 'darkslategray' # unscheduled, delegated task ev: 'springgreen4' # event fn: 'gray70' # finished task ib: 'coral2' # inbox ns: 'saddlebrown' # note nu: 'saddlebrown' # unscheduled noted oc: 'peachpuff4' # occasion pc: 'firebrick1' # pastdue job pu: 'firebrick1' # pastdue job with unfinished prereqs pd: 'firebrick1' # pastdue, delegated task pt: 'firebrick1' # pastdue task rm: 'seagreen' # reminder so: 'SteelBlue3' # someday un: 'RoyalBlue3' # unscheduled task (next) calendar: date: 'RoyalBlue3' # week/month calendar dates grid: 'gray85' # week/month calendar grid lines busybar: 'RoyalBlue3' # week/month busy bars current: '#DCEAFC' # current date calendar background active: '#FCFCD9' # active/selected date background occasion: 'gray92' # occasion background conflict: '#FF3300' # conflict flag year_past: 'springgreen4' # calendar, past years font color year_current: 'black' # calendar, current year font color year_future: 'RoyalBlue3' # calendar, future years font color """ # The contents of colors_light should duplicate the default # colors below. # default colors BASE_COLORS = { 'foreground': "black", 'highlight': "#B2B2AF", 'background': "#FEFEFC" } ITEM_COLORS = { "ac": "darkorchid", "av": "RoyalBlue3", "by": "DarkGoldenRod3", "cs": "RoyalBlue3", "cu": "gray65", "dl": "gray70", "ds": "darkslategray", "du": "darkslategray", "ev": "springgreen4", "fn": "gray70", "ib": "coral2", "ns": "saddlebrown", "nu": "saddlebrown", "oc": "peachpuff4", "pc": "firebrick1", "pu": "firebrick1", "pd": "firebrick1", "pt": "firebrick1", "rm": "seagreen", "so": "SteelBlue3", "un": "RoyalBlue3", } CALENDAR_COLORS = { "date": "RoyalBlue3", "grid": "gray85", "busybar": "RoyalBlue3", "current": "#DCEAFC", "active": "#FCFCD9", "occasion": "gray92", "conflict": "#FF3300", "year_past": "springgreen4", "year_current": 'black', "year_future": 'RoyalBlue3', } # type string to Sort Color Icon. The color will be added in # get_options either from colors.cfg or from the above defaults tstr2SCI = { # TStr TNum Forground Color Icon view "ac": [23, "", "action", "day"], "av": [16, "", "task", "day"], "by": [19, "", "beginby", "now"], "cs": [18, "", "child", "day"], "cu": [22, "", "child", "day"], "dl": [28, "", "delete", "folder"], "ds": [17, "", "delegated", "day"], "du": [21, "", "delegated", "day"], "ev": [12, "", "event", "day"], "fn": [27, "", "finished", "day"], "ib": [10, "", "inbox", "now"], "ns": [24, "", "note", "day"], "nu": [25, "", "note", "day"], "oc": [11, "", "occasion", "day"], "pc": [15, "", "child", "now"], "pu": [15, "", "child", "now"], "pd": [14, "", "delegated", "now"], "pt": [13, "", "task", "now"], "rm": [12, "", "reminder", "day"], "so": [26, "", "someday", "now"], "un": [20, "", "task", "next"], } def fmt_period(td, parent=None, short=False): if type(td) is not timedelta: return td if td < ONEMINUTE * 0: return '0m' if td == ONEMINUTE * 0: return '0m' until = [] td_days = td.days td_hours = td.seconds // (60 * 60) td_minutes = (td.seconds % (60 * 60)) // 60 if short: if td_days > 1: if td_minutes > 30: td_hours += 1 td_minutes = 0 if td_days > 7: if td_hours > 12: td_days += 1 td_hours = 0 if td_days: until.append("%dd" % td_days) if td_hours: until.append("%dh" % td_hours) if td_minutes: until.append("%dm" % td_minutes) if not until: until = "0m" return "".join(until) def fmt_time(dt, omitMidnight=False, seconds=False, options=None): # fmt time, omit leading zeros and, if ampm, convert to lowercase # and omit trailing m's if not options: options = {} if omitMidnight and dt.hour == 0 and dt.minute == 0: return u'' # logger.debug('dt before fmt: {0}'.format(dt)) if seconds: dt_fmt = dt.strftime(options['longreprtimefmt']) else: dt_fmt = dt.strftime(options['reprtimefmt']) # logger.debug('dt dt_fmt: {0}'.format(dt_fmt)) if dt_fmt[0] == "0": dt_fmt = dt_fmt[1:] # The 3rd test is for Poland where am, pm = '' if 'ampm' in options and options['ampm'] and not dt_fmt[-1].isdigit(): # dt_fmt = dt_fmt.lower()[:-1] dt_fmt = dt_fmt.lower() dt_fmt = leadingzero.sub('', dt_fmt) dt_fmt = trailingzeros.sub('', dt_fmt) return s2or3(dt_fmt) def fmt_date(dt, short=False): if type(dt) in [str, unicode]: return unicode(dt) if short: tdy = datetime.today() if type(dt) == datetime: dt = dt.date() if dt == tdy.date(): dt_fmt = "%s" % TODAY elif dt == tdy.date() - ONEDAY: dt_fmt = "%s" % YESTERDAY elif dt == tdy.date() + ONEDAY: dt_fmt = "%s" % TOMORROW elif dt.year == tdy.year: dt_fmt = dt.strftime(shortyearlessfmt) else: dt_fmt = dt.strftime(shortdatefmt) else: if python_version2: dt_fmt = unicode(dt.strftime(reprdatefmt), term_encoding) else: dt_fmt = dt.strftime(reprdatefmt) dt_fmt = leadingzero.sub('', s2or3(dt_fmt)) return dt_fmt def fmt_shortdatetime(dt, options=None): if not options: options = {} if type(dt) in [str, unicode]: return unicode(dt) tdy = datetime.today() if dt.date() == tdy.date(): dt_fmt = "%s %s" % (fmt_time(dt, options=options), TODAY) elif dt.date() == tdy.date() - ONEDAY: dt_fmt = "%s %s" % (fmt_time(dt, options=options), YESTERDAY) elif dt.date() == tdy.date() + ONEDAY: dt_fmt = "%s %s" % (fmt_time(dt, options=options), TOMORROW) elif dt.year == tdy.year: try: x1 = unicode(fmt_time(dt, options=options)) x2 = unicode(dt.strftime(shortyearlessfmt)) dt_fmt = "%s %s" % (x1, x2) except: dt_fmt = dt.strftime("%X %x") else: try: dt_fmt = dt.strftime(shortdatefmt) dt_fmt = leadingzero.sub('', dt_fmt) except: dt_fmt = dt.strftime("%X %x") return s2or3(dt_fmt) def fmt_datetime(dt, options=None): if not options: options = {} t_fmt = fmt_time(dt, options=options) dt_fmt = "%s %s" % (dt.strftime(etmdatefmt), t_fmt) return s2or3(dt_fmt) def fmt_weekday(dt): return fmt_dt(dt, weekdayfmt) def fmt_dt(dt, f): dt_fmt = dt.strftime(f) return s2or3(dt_fmt) rrule_hsh = { 'f': 'FREQUENCY', # unicode 'i': 'INTERVAL', # positive integer 't': 'COUNT', # total count positive integer 's': 'BYSETPOS', # integer 'u': 'UNTIL', # unicode 'M': 'BYMONTH', # integer 1...12 'm': 'BYMONTHDAY', # positive integer 'W': 'BYWEEKNO', # positive integer 'w': 'BYWEEKDAY', # integer 0 (SU) ... 6 (SA) 'h': 'BYHOUR', # positive integer 'n': 'BYMINUTE', # positive integer 'E': 'BYEASTER', # non-negative integer number of days after easter } # for icalendar export we need BYDAY instead of BYWEEKDAY ical_hsh = deepcopy(rrule_hsh) ical_hsh['w'] = 'BYDAY' ical_hsh['f'] = 'FREQ' ical_rrule_hsh = { 'FREQ': 'r', # unicode 'INTERVAL': 'i', # positive integer 'COUNT': 't', # total count positive integer 'BYSETPOS': 's', # integer 'UNTIL': 'u', # unicode 'BYMONTH': 'M', # integer 1...12 'BYMONTHDAY': 'm', # positive integer 'BYWEEKNO': 'W', # positive integer 'BYDAY': 'w', # integer 0 (SU) ... 6 (SA) # 'BYWEEKDAY': 'w', # integer 0 (SU) ... 6 (SA) 'BYHOUR': 'h', # positive integer 'BYMINUTE': 'n', # positive integer 'BYEASTER': 'E', # non negative integer number of days after easter } # don't add f and u - they require special processing in get_rrulestr rrule_keys = ['i', 'm', 'M', 'w', 'W', 'h', 'n', 't', 's', 'E'] ical_rrule_keys = ['f', 'i', 'm', 'M', 'w', 'W', 'h', 'n', 't', 's', 'u'] # ^ Presidential election day @s 2004-11-01 12am # @r y &i 4 &m 2, 3, 4, 5, 6, 7, 8 &M 11 &w TU # don't add l (list) - handeled separately freq_hsh = { 'y': 'YEARLY', 'm': 'MONTHLY', 'w': 'WEEKLY', 'd': 'DAILY', 'h': 'HOURLY', 'n': 'MINUTELY', 'E': 'EASTERLY', } ical_freq_hsh = { 'YEARLY': 'y', 'MONTHLY': 'm', 'WEEKLY': 'w', 'DAILY': 'd', 'HOURLY': 'h', 'MINUTELY': 'n', # 'EASTERLY': 'e' } amp_hsh = { 'r': 'f', # the starting value for an @r entry is frequency 'a': 't' # the starting value for an @a entry is *triggers* } at_keys = [ 's', # start datetime 'e', # extent time spent 'x', # expense money spent 'a', # alert 'b', # begin 'c', # context 'k', # keyword 't', # tags 'l', # location 'n', # noshow, tasks only. list of views in a, d, k, t. 'u', # user 'f', # finish date 'h', # history (task group) 'i', # invitees 'g', # goto 'j', # job 'p', # priority 'q', # queue 'r', # repetition rule '+', # include '-', # exclude 'o', # overdue 'd', # description 'm', # memo 'z', # time zone 'I', # id', 'v', # action rate key 'w', # expense markup key ] all_keys = at_keys + ['entry', 'fileinfo', 'itemtype', 'rrule', '_summary', '_group_summary', '_a', '_j', '_p', '_r', 'prereqs'] all_types = [u'=', u'^', u'*', u'-', u'+', u'%', u'~', u'$', u'?', u'!', u'#'] # job_types = [u'-', u'+', u'%', u'$', u'?', u'#'] job_types = [u'-', u'+', u'%'] any_types = [u'=', u'$', u'?', u'#'] # @key to item types - used to check for valid key usage key2type = { u'+': all_types, u'-': all_types, u'a': all_types, u'b': all_types, u'c': all_types, u'd': all_types, u'e': all_types, u'f': job_types + any_types, u'g': all_types + any_types, u'h': [u'+'] + any_types, u'i': [u'*', u'^'] + any_types, u'I': all_types, u'j': [u'+'] + any_types, u'k': all_types, u'l': all_types, u'm': all_types, u'o': job_types + any_types, u'n': [u'-', u'%'] + any_types, u'p': job_types + any_types, u'q': all_types, u'r': all_types, u's': all_types, u't': all_types, u'u': all_types, u'v': [u'~'] + any_types, u'w': [u'~'] + any_types, u'x': [u'~'] + any_types, u'z': all_types, } label_keys = [ # 'f', # finish date '_a', # alert 'b', # begin 'c', # context 'd', # description 'g', # goto 'i', # invitees 'k', # keyword 'l', # location 'm', # memo 'p', # priority '_r', # repetition rule 't', # tags 'u', # user ] amp_keys = { 'r': [ u'f', # r frequency u'i', # r interval u'm', # r monthday u'M', # r month u'w', # r weekday u'W', # r week u'h', # r hour u'n', # r minute u'E', # r easter u't', # r total (dateutil COUNT) (c is context in j) u'u', # r until u's'], # r set position 'j': [ u'j', # j job summary u'b', # j beginby u'c', # j context u'd', # j description u'e', # e extent u'f', # j finish u'h', # h history (task group jobs) u'p', # j priority u'u', # user u'q'], # j queue position } @memoize def makeTree(tree_rows, view=None, calendars=None, sort=True, fltr=None, hide_finished=False): """ e.g. row: [('now', (1, 13), datetime.datetime(2015, 8, 20, 0, 0), 10, 'Call Saul', 'personal/dag/monthly/2015/08.txt'), 'Now', 'Available', ('e2d85baae43140d5966f63ccabe455dcetm', 'pt', 'Call Saul', '-38d', datetime.datetime(2015, 8, 20, 0, 0))] """ tree = {} lofl = [] root = '_' empty = True cal_regex = None if calendars: cal_pattern = r'^%s' % '|'.join([x[2] for x in calendars if x[1]]) cal_regex = re.compile(cal_pattern) if fltr is not None: mtch = True if fltr[0] == '!': mtch = False fltr = fltr[1:] filter_regex = re.compile(r'{0}'.format(fltr), re.IGNORECASE) logger.debug('filter: {0} ({1})'.format(fltr, mtch)) else: filter_regex = None root_key = tuple(["", root]) tree.setdefault(root_key, []) for pc in tree_rows: if hide_finished and pc[-1][1] == 'fn': continue if cal_regex and not cal_regex.match(pc[0][-1]): continue if view and pc[0][0] != view: continue if filter_regex is not None: s = "{0} {1}".format(pc[-1][2], " ".join(pc[1:-1])) # logger.debug('looking in "{0}"'.format(s)) m = filter_regex.search(s) if not ((mtch and m) or (not mtch and not m)): continue if sort: pc.pop(0) empty = False key = tuple([root, pc[0]]) if key not in tree[root_key]: tree[root_key].append(key) # logger.debug('key: {0}'.format(key)) lofl.append(pc) for i in range(len(pc) - 1): if pc[:i]: parent_key = tuple([":".join(pc[:i]), pc[i]]) else: parent_key = tuple([root, pc[i]]) child_key = tuple([":".join(pc[:i + 1]), pc[i + 1]]) # logger.debug('parent: {0}; child: {1}'.format(parent_key, child_key)) if pc[:i + 1] not in lofl: lofl.append(pc[:i + 1]) tree.setdefault(parent_key, []) if child_key not in tree[parent_key]: tree[parent_key].append(child_key) if empty: return {} return tree def truncate(s, l): if l > 0 and len(s) > l: if re.search(' ~ ', s): s = s.split(' ~ ')[0] s = "%s.." % s[:l - 2] return s def tree2Html(tree, indent=2, width1=54, width2=20, colors=2): global html_lst html_lst = [] if colors: e_c = "" else: e_c = "" tab = " " * indent def t2H(tree_hsh, node=('', '_'), level=0): if type(node) == tuple: if type(node[1]) == tuple: t = id2Type[node[1][1]] col2 = "{0:^{width}}".format( truncate(node[1][3], width2), width=width2) if colors == 2: s_c = '' % tstr2SCI[node[1][1]][1] elif colors == 1: if node[1][1][0] == 'p': # past due s_c = '' % tstr2SCI[node[1][1]][1] else: s_c = '' else: s_c = '' if width1 > 0: rmlft = width1 - indent * level else: rmlft = 0 s = "%s%s%s %-*s %s%s" % (tab * level, s_c, unicode(t), rmlft, unicode(truncate(node[1][2], rmlft)), col2, e_c) html_lst.append(s) else: html_lst.append("%s%s" % (tab * level, node[1])) else: html_lst.append("%s%s" % (tab * level, node)) if node not in tree_hsh: return () level += 1 nodes = tree_hsh[node] for n in nodes: t2H(tree_hsh, n, level) t2H(tree) return [x[indent:] for x in html_lst] def tree2Rst(tree, indent=2, width1=54, width2=14, colors=0, number=False, count=0, count2id=None): global text_lst args = [count, count2id] text_lst = [] if colors: e_c = "" else: e_c = "" tab = " " * indent def t2H(tree_hsh, node=('', '_'), level=0): if args[1] is None: args[1] = {} if type(node) == tuple: if type(node[1]) == tuple: args[0] += 1 # join the uuid and the datetime of the instance args[1][args[0]] = "{0}::{1}".format(node[-1][0], node[-1][-1]) t = id2Type[node[1][1]] s_c = '' col2 = "{0:^{width}}".format( truncate(node[1][3], width2), width=width2) if number: rmlft = width1 - indent * level - 2 - len(str(args[0])) s = "%s\%s%s [%s] %-*s %s%s" % ( tab * (level - 1), s_c, unicode(t), args[0], rmlft, unicode(truncate(node[1][2], rmlft)), col2, e_c) else: rmlft = width1 - indent * level s = "%s\%s%s %-*s %s%s" % (tab * (level - 1), s_c, unicode(t), rmlft, unicode(truncate(node[1][2], rmlft)), col2, e_c) text_lst.append(s) else: if node[1].strip() != '_': text_lst.append("%s[b]%s[/b]" % (tab * (level - 1), node[1])) else: text_lst.append("%s%s" % (tab * (level - 1), node)) if node not in tree_hsh: return () level += 1 nodes = tree_hsh[node] for n in nodes: t2H(tree_hsh, n, level) t2H(tree) return [x for x in text_lst], args[0], args[1] def tree2Text(tree, indent=4, width1=43, width2=20, colors=0, number=False, count=0, count2id=None, depth=0): global text_lst logger.debug("data.tree2Text: width1={0}, width2={1}, colors={2}".format(width1, width2, colors)) args = [count, count2id] text_lst = [] if colors: e_c = "" else: e_c = "" tab = " " * indent def t2H(tree_hsh, node=('', '_'), level=0): if depth and level > depth: return if args[1] is None: args[1] = {} if type(node) == tuple: if type(node[1]) == tuple: args[0] += 1 # join the uuid and the datetime of the instance args[1][args[0]] = "{0}::{1}".format(node[-1][0], node[-1][-1]) t = id2Type[node[1][1]] s_c = '' # logger.debug("node13: {0}; width2: {1}".format(node[1][3], width2)) if node[1][3]: col2 = "{0:^{width}}".format( truncate(node[1][3], width2), width=width2) else: col2 = "" if number: if width1 > 0: rmlft = width1 - indent * level - 2 - len(str(args[0])) else: rmlft = 0 s = u"{0:s}{1:s}{2:s} [{3:s}] {4:<*s} {5:s}{6:s}".format( tab * level, s_c, unicode(t), args[0], rmlft, unicode(truncate(node[1][2], rmlft)), col2, e_c) else: if width1 > 0: rmlft = width1 - indent * level else: rmlft = 0 s = "%s%s%s %-*s %s%s" % (tab * level, s_c, unicode(t), rmlft, unicode(truncate(node[1][2], rmlft)), col2, e_c) text_lst.append(s) else: aug = "%s%s" % (tab * level, node[1]) text_lst.append(aug.split('!!')[0]) else: text_lst.append("%s%s" % (tab * level, node)) if node not in tree_hsh: return () level += 1 nodes = tree_hsh[node] for n in nodes: t2H(tree_hsh, n, level) t2H(tree) return [x[indent:] for x in text_lst], args[0], args[1] lst = None rows = None row = None def tallyByGroup(list_of_tuples, max_level=0, indnt=3, options=None, export=False): """ list_of_tuples should already be sorted and the last component in each tuple should be a tuple (minutes, value, expense, charge) to be tallied. ('Scotland', 'Glasgow', 'North', 'summary sgn', (306, 10, 20.00, 30.00)), ('Scotland', 'Glasgow', 'South', 'summary sgs', (960, 10, 45.00, 60.00)), ('Wales', 'Cardiff', 'summary wc', (396, 10, 22.50, 30.00)), ('Wales', 'Bangor', 'summary wb', (126, 10, 37.00, 37.00)), Recursively process groups and accumulate the totals. """ if not options: options = {} if not max_level: max_level = len(list_of_tuples[0]) - 1 level = -1 global lst global head global auglst head = [] auglst = [] lst = [] if 'action_template' in options: action_template = options['action_template'] else: action_template = "!hours! $!value!) !label! (!count!)" action_template = "!indent!%s" % action_template if 'action_minutes' in options and options['action_minutes'] in [6, 12, 15, 30, 60]: # floating point hours m = options['action_minutes'] tab = " " * indnt global rows, row rows = [] row = ['' for i in range(max_level + 1)] def doLeaf(tup, lvl): global row, rows, head, auglst if len(tup) < 2: rows.append(deepcopy(row)) return () k = tup[0] g = tup[1:] t = tup[-1] lvl += 1 row[lvl] = k row[-1] = t hsh = {} if max_level and lvl > max_level - 1: rows.append(deepcopy(row)) return () indent = " " * indnt hsh['indent'] = indent * lvl hsh['count'] = 1 hsh['minutes'] = t[0] hsh['value'] = "%.2f" % t[1] # only 2 digits after the decimal point hsh['expense'] = t[2] hsh['charge'] = t[3] hsh['total'] = t[1] + t[3] if options['action_minutes'] in [6, 12, 15, 30, 60]: # floating point hours hsh['hours'] = "{0:n}".format( ((t[0] // m + (t[0] % m > 0)) * m) / 60.0) else: # hours and minutes hsh['hours'] = "%d:%02d" % (t[0] // 60, t[0] % 60) hsh['label'] = k lst.append(expand_template(action_template, hsh, complain=True)) head.append(lst[-1].lstrip()) auglst.append(head) if len(g) >= 1: doLeaf(g, lvl) def doGroups(tuple_list, lvl): global row, rows, head, auglst hsh = {} lvl += 1 if max_level and lvl > max_level - 1: rows.append(deepcopy(row)) return hsh['indent'] = tab * lvl for k, g, t in group_sort(tuple_list): head = head[:lvl] row[lvl] = k[-1] row[-1] = t hsh['count'] = len(g) hsh['minutes'] = t[0] # only 2 digits after the decimal point hsh['value'] = "%.2f" % t[1] hsh['expense'] = t[2] hsh['charge'] = t[3] hsh['total'] = t[1] + t[3] if options['action_minutes'] in [6, 12, 15, 30, 60]: # hours and tenths hsh['hours'] = "{0:n}".format( ((t[0] // m + (t[0] % m > 0)) * m) / 60.0) else: # hours and minutes hsh['hours'] = "%d:%02d" % (t[0] // 60, t[0] % 60) hsh['label'] = k[-1] lst.append(expand_template(action_template, hsh, complain=True)) head.append(lst[-1].lstrip()) if len(head) == max_level: auglst.append(head) if len(g) > 1: doGroups(g, lvl) else: doLeaf(g[0], lvl) doGroups(list_of_tuples, level) for i in range(len(auglst)): if type(auglst[i][-1]) in [str, unicode]: res = auglst[i][-1].split('!!') if len(res) > 1: summary = res[0] uid = res[1] auglst[i][-1] = tuple((uid, 'ac', summary, '')) res = makeTree(auglst, sort=False) if export: for i in range(len(rows)): # remove the uuid from the summary summary = rows[i][-2].split('!!')[0] rows[i][-2] = summary return rows else: return res def group_sort(row_lst): # last element of each list component is a (minutes, value, # expense, charge) tuple. # next to last element is a summary string. key = lambda cols: [cols[0]] for k, group in groupby(row_lst, key): t = [] g = [] for x in group: t.append(x[-1]) g.append(x[1:]) s = tupleSum(t) yield k, g, s def uniqueId(): # return unicode(thistime.strftime("%Y%m%dT%H%M%S@etmtk")) return unicode("{0}etm".format(uuid.uuid4().hex)) def nowAsUTC(): return datetime.now(tzlocal()).astimezone(tzutc()).replace(tzinfo=None) def datetime2minutes(dt): if type(dt) != datetime: return () t = dt.time() return t.hour * 60 + t.minute def parse_str(dt, timezone=None, fmt=None): """ E.g., ('2/5/12', 'US/Pacific', rfmt) => "20120205T0000-0800" Return datetime object if fmt is None Return """ if type(dt) in [str, unicode]: if dt == 'now': if timezone is None: dt = datetime.now() else: dt = datetime.now().replace( tzinfo=tzlocal()).astimezone( gettz(timezone)).replace(tzinfo=None) elif period_string_regex.match(dt): dt = datetime.now() + parse_period(dt, minutes=False) else: now = datetime.now() estr = estr_regex.search(dt) rel_mnth = rel_month_regex.search(dt) rel_date = rel_date_regex.search(dt) if estr: y = estr.group(1) e = easter(int(y)) E = e.strftime("%Y-%m-%d") dt = estr_regex.sub(E, dt) if rel_mnth: new_y = now.year now_m = now.month mnth, day = map(int, rel_mnth.groups()) new_m = now_m + mnth new_d = day if new_m <= 0: new_y -= 1 new_m += 12 elif new_m > 12: new_y += 1 new_m -= 12 new_date = "%s-%02d-%02d" % (new_y, new_m, new_d) dt = rel_month_regex.sub(new_date, dt) elif rel_date: days = int(rel_date.group(0)) new_date = (now + days * ONEDAY).strftime("%Y-%m-%d") dt = rel_date_regex.sub(new_date, dt) dt = parse(dt) if type(dt) is not datetime: # we have a problem, return the error message return dt else: # dt is a datetime if dt.utcoffset() is None: dt = dt.replace(tzinfo=tzlocal()) if timezone is None: dtz = dt.replace(tzinfo=tzlocal()) else: dtz = dt.replace(tzinfo=gettz(timezone)) if windoz and dtz.year < 1970: y = dtz.year m = dtz.month d = dtz.day H = dtz.hour M = dtz.minute dtz = datetime(y, m, d, H, M, 0, 0) epoch = datetime(1970, 1, 1, 0, 0, 0, 0) # dtz.replace(tzinfo=None) td = epoch - dtz seconds = td.days * 24 * 60 * 60 + td.seconds dtz = epoch - timedelta(seconds=seconds) if fmt is None: return dtz else: return dtz.strftime(fmt) def parse_date_period(s): """ fuzzy_date [ (+|-) period string] e.g. mon + 7d: the 2nd Monday on or after today used in reports to handle begin and end options """ parts = [x.strip() for x in rsplit(' [+-] ', s)] try: dt = parse_str(parts[0]) except Exception: return 'error: could not parse date "{0}"'.format(parts[0]) if len(parts) > 1: try: pr = parse_period(parts[1]) except Exception: return 'error: could not parse period "{0}"'.format(parts[1]) if ' + ' in s: return dt + pr else: return dt - pr else: return dt def parse_period(s, minutes=True): """\ Take a case-insensitive period string and return a corresponding timedelta. Examples: parse_period('-2W3D4H5M')= -timedelta(weeks=2,days=3,hours=4,minutes=5) parse_period('1h30m') = timedelta(hours=1, minutes=30) parse_period('-10') = timedelta(minutes= 10) where: W or w: weeks D or d: days H or h: hours M or m: minutes If an integer is passed or a string that can be converted to an integer, then return a timedelta corresponding to this number of minutes if 'minutes = True', and this number of days otherwise. Minutes will be True for alerts and False for beginbys. """ td = timedelta(seconds=0) if minutes: unitperiod = ONEMINUTE else: unitperiod = ONEDAY try: m = int(s) return m * unitperiod except Exception: m = int_regex.match(s) if m: return td + int(m.group(1)) * unitperiod, "" # if we get here we should have a period string m = period_string_regex.match(s) if not m: logger.error("Invalid period string: '{0}'".format(s)) return "Invalid period string: '{0}'".format(s) m = week_regex.search(s) if m: td += int(m.group(1)) * ONEWEEK m = day_regex.search(s) if m: td += int(m.group(1)) * ONEDAY m = hour_regex.search(s) if m: td += int(m.group(1)) * ONEHOUR m = minute_regex.search(s) if m: td += int(m.group(1)) * ONEMINUTE if type(td) is not timedelta: return "Could not parse {0}".format(s) m = sign_regex.match(s) if m and m.group(1) == '-': return -1 * td else: return td def year2string(startyear, endyear): """compute difference and append suffix""" diff = int(endyear) - int(startyear) suffix = 'th' if diff < 4 or diff > 20: if diff % 10 == 1: suffix = 'st' elif diff % 10 == 2: suffix = 'nd' elif diff % 10 == 3: suffix = 'rd' return "%d%s" % (diff, suffix) def lst2str(l): if type(l) != list: return l tmp = [] for item in l: if type(item) in [datetime]: tmp.append(parse_str(item, fmt=zfmt)) elif type(item) in [timedelta]: tmp.append(timedelta2Str(item)) else: # type(i) in [unicode, str]: tmp.append(str(item)) return ", ".join(tmp) def hsh2str(hsh, options=None, include_uid=False): """ For editing one or more, but not all, instances of an item. Needed: 1. Add @+ datetime to orig and make copy sans all repeating info and with @s datetime. 2. Add &r datetime - ONEMINUTE to each _r in orig and make copy with @s datetime 3. Add &f datetime to selected job. """ if not options: options = {} msg = [] if '_summary' not in hsh: hsh['_summary'] = '' if '_group_summary' in hsh: sl = ["%s %s" % (hsh['itemtype'], hsh['_group_summary'])] if 'I' in hsh: # fix the item index hsh['I'] = hsh['I'].split(':')[0] else: sl = ["%s %s" % (hsh['itemtype'], hsh['_summary'])] if 'I' not in hsh or not hsh['I']: hsh['I'] = uniqueId() bad_keys = [x for x in hsh.keys() if x not in all_keys] if bad_keys: omitted = [] for key in bad_keys: omitted.append('@{0} {1}'.format(key, hsh[key])) msg.append("unrecognized entries: {0}".format(", ".join(omitted))) for key in at_keys: amp_key = None if hsh['itemtype'] == "=": prefix = "" elif 'prefix_uses' in options and key in options['prefix_uses']: prefix = options['prefix'] else: prefix = "" if key == 'a' and '_a' in hsh: alerts = [] for alert in hsh["_a"]: triggers, acts, arguments = alert _ = "@a %s" % ", ".join([fmt_period(x) for x in triggers]) if acts: _ += ": %s" % ", ".join(acts) if arguments: arg_strings = [] for arg in arguments: arg_strings.append(", ".join(arg)) _ += "; %s" % "; ".join(arg_strings) alerts.append(_) sl.extend(alerts) elif key in ['r', 'j']: at_key = key keys = amp_keys[key] key = "_%s" % key elif key in ['+', '-']: keys = [] elif key in ['t', 'l', 'd']: keys = [] else: keys = [] if key in hsh and hsh[key] is not None: # since r and j can repeat, value will be a list value = hsh[key] if keys: # @r or @j --- value will be a list of hashes or # possibly, in the case of @a, a list of lists. f # will be the first key for @r and t will be the # first for @a omitted = [] for v in value: for k in v.keys(): if k not in keys: omitted.append('&{0} {1}'.format(k, v[k])) if omitted: msg.append("unrecognized entries: {0}".format(", ".join(omitted))) tmp = [] for h in value: if unicode(keys[0]) not in h: logger.warning("{0} not in {1}".format(keys[0], h)) continue tmp.append('%s@%s %s' % (prefix, at_key, lst2str(h[unicode(keys[0])]))) for amp_key in keys[1:]: if amp_key in h: if at_key == 'j' and amp_key == 'f': pairs = [] for pair in h['f']: pairs.append(";".join([ x.strftime(zfmt) for x in pair if x])) v = (', '.join(pairs)) elif at_key == 'j' and amp_key == 'h': pairs = [] for pair in h['h']: pairs.append(";".join([ x.strftime(zfmt) for x in pair if x])) v = (', '.join(pairs)) elif amp_key == 'e': try: v = fmt_period(h['e']) except Exception: v = h['e'] logger.error( "error: could not parse h['e']: '{0}'".format( h['e'])) else: v = lst2str(h[amp_key]) tmp.append('&%s %s' % (amp_key, v)) if tmp: sl.append(" ".join(tmp)) elif key == 's': try: sl.append("%s@%s %s" % (prefix, key, fmt_datetime(value, options=options))) except: msg.append("problem with @{0}: {1}".format(key, value)) elif key == 'q': # Added this for abused women to record place in a queue - value should be the datetime the person entered the queue. Entering "now" would record the current datetime. if type(value) is datetime: sl.append("%s@%s %s" % ( prefix, key, value.strftime(zfmt), )) else: sl.append("%s@%s %s" % (prefix, key, value)) elif key == 'e': try: sl.append("%s@%s %s" % (prefix, key, fmt_period(value))) except: msg.append("problem with @{0}: {1}".format(key, value)) elif key == 'f': tmp = [] for pair in hsh['f']: tmp.append(";".join([x.strftime(zfmt) for x in pair if x])) sl.append("%s@f %s" % (prefix, ", {0}".format(prefix).join(tmp))) elif key == 'I': if include_uid and hsh['itemtype'] != "=": sl.append("prefix@i {0}".format(prefix, value)) elif key == 'h': tmp = [] for pair in hsh['h']: tmp.append(";".join([x.strftime(zfmt) for x in pair if x])) sl.append("%s@h %s" % (prefix, ", {0}".format(prefix).join(tmp))) else: sl.append("%s@%s %s" % (prefix, key, lst2str(value))) return " ".join(sl), msg def process_all_datafiles(options): prefix, filelist = getFiles(options['datadir']) return process_data_file_list(filelist, options=options) def process_data_file_list(filelist, options=None): if not options: options = {} messages = [] file2lastmodified = {} bad_datafiles = {} file2uuids = {} uuid2hashes = {} uuid2labels = {} for f, r in filelist: file2lastmodified[(f, r)] = os.path.getmtime(f) msg, hashes, u2l = process_one_file(f, r, options) uuid2labels.update(u2l) if msg: messages.append("errors loading %s:" % r) messages.extend(msg) try: for hsh in hashes: if hsh['itemtype'] == '=': continue uid = hsh['I'] uuid2hashes[uid] = hsh file2uuids.setdefault(r, []).append(uid) except Exception: fio = StringIO() msg = fio.getvalue() bad_datafiles[r] = msg logger.error('Error processing: {0}\n{1}'.format(r, msg)) return uuid2hashes, uuid2labels, file2uuids, file2lastmodified, bad_datafiles, messages def process_one_file(full_filename, rel_filename, options=None): if not options: options = {} file_items = getFileItems(full_filename, rel_filename) return items2Hashes(file_items, options) def getFiles(root, include=r'*.txt', exclude=r'.*', other=[]): """ Return the common prefix and a list of full paths from root :param root: directory :return: common prefix of files and a list of full file paths """ # includes = r'*.txt' # excludes = r'.*' paths = [root] filelist = [] other.sort() for path in other: paths.append(path) common_prefix = os.path.commonprefix(paths) for path in other: rel_path = relpath(path, common_prefix) filelist.append((path, rel_path)) for path, dirs, files in os.walk(root): # exclude dirs dirs[:] = [os.path.join(path, d) for d in dirs if not fnmatch.fnmatch(d, exclude)] # exclude/include files files = [os.path.join(path, f) for f in files if not fnmatch.fnmatch(f, exclude)] files = [os.path.normpath(f) for f in files if fnmatch.fnmatch(f, include)] for fname in files: rel_path = relpath(fname, common_prefix) filelist.append((fname, rel_path)) return common_prefix, filelist def getAllFiles(root, include=r'*', exclude=r'.*', other=[]): """ Return the common prefix and a list of full paths from root :param root: directory :return: common prefix of files and a list of full file paths """ paths = [root] filelist = [] for path in other: paths.append(path) other.sort() common_prefix = os.path.commonprefix(paths) for path in other: rel_path = relpath(path, common_prefix) filelist.append((path, rel_path)) for path, dirs, files in os.walk(root): # exclude dirs dirs[:] = [os.path.join(path, d) for d in dirs if not fnmatch.fnmatch(d, exclude)] # exclude/include files files = [os.path.join(path, f) for f in files if not fnmatch.fnmatch(f, exclude)] files = [os.path.normpath(f) for f in files if fnmatch.fnmatch(f, include)] for fname in files: rel_path = relpath(fname, common_prefix) filelist.append((fname, rel_path)) if not (dirs or files): # empty rel_path = relpath(path, common_prefix) filelist.append((path, rel_path)) return common_prefix, filelist def getFileTuples(root, include=r'*.txt', exclude=r'.*', all=False, other=[]): """ Used in view to get config files """ if all: common_prefix, filelist = getAllFiles(root, include, exclude, other=other) else: common_prefix, filelist = getFiles(root, include, exclude, other=other) lst = [] prior = [] for fp, rp in filelist: drive, tup = os_path_splitall(rp) for i in range(0, len(tup)): if len(prior) > i and tup[i] == prior[i]: continue prior = tup[:i] disable = (i < len(tup) - 1) or os.path.isdir(fp) lst.append(("{0}{1}".format(" " * 6 * i, tup[i]), rp, disable)) return common_prefix, lst def os_path_splitall(path, debug=False): parts = [] drive, path = os.path.splitdrive(path) while True: newpath, tail = os.path.split(path) if newpath == path: assert not tail if path: parts.append(path) break parts.append(tail) path = newpath parts.reverse() return drive, parts def getFileItems(full_name, rel_name, append_newline=True): """ Group the lines in file f into logical items and return them. :param full_name: including datadir :param rel_name: from datadir :param append_newline: bool, default True """ fo = codecs.open(full_name, 'r', file_encoding) lines = fo.readlines() fo.close() # make sure we have a trailing new-line. Yes, we really need this. if append_newline: lines.append('\n') linenum = 0 linenums = [] logical_line = [] for line in lines: linenums.append(linenum) linenum += 1 # preserve new lines and leading whitespace within logical lines stripped = line.rstrip() m = item_regex.match(stripped) if m is not None or stripped == '=': if logical_line: yield (''.join(logical_line), rel_name, linenums) logical_line = [] linenums = [] logical_line.append("%s\n" % line.rstrip()) elif stripped: # a line which does not continue, end of logical line logical_line.append("%s\n" % line.rstrip()) elif logical_line: # preserve interior empty lines logical_line.append("\n") if logical_line: # end of sequence implies end of last logical line yield (''.join(logical_line), rel_name, linenums) def items2Hashes(list_of_items, options=None): """ Return a list of messages and a list of hashes corresponding to items in list_of_items. """ if not options: options = {} messages = [] hashes = [] uuid2labels = {} defaults = {} # in_task_group = False for item, rel_name, linenums in list_of_items: hsh, msg = str2hsh(item, options=options) logger.debug("items2Hashes:\n item='{0}' hsh={1}\n msg={2}".format(item, hsh, msg)) if item.strip() == "=": # reset defaults defaults = {} tmp_hsh = {} tmp_hsh.update(defaults) tmp_hsh.update(hsh) hsh = tmp_hsh try: hsh['fileinfo'] = (rel_name, linenums[0], linenums[-1]) except: raise ValueError("exception in fileinfo:", rel_name, linenums, "\n", hsh) if msg: lines = [] item = item.strip() if len(item) > 56: lines.extend(wrap(item, 56)) else: lines.append(item) for line in lines: messages.append("{0}".format(line)) for m in msg: messages.append('{0}'.format(m)) msg.append(' {0}'.format(hsh['fileinfo'])) # put the bad item in the inbox for repairs hsh['_summary'] = "{0} {1}".format(hsh['itemtype'], hsh['_summary']) hsh['itemtype'] = "$" hsh['I'] = uniqueId() hsh['errors'] = "\n ".join(msg) logger.warn("{0}".format(hsh['errors'])) # no more processing # ('hsh:', hsh) hashes.append(hsh) continue itemtype = hsh['itemtype'] if itemtype == '$': # inbasket item hashes.append(hsh) elif itemtype == '#': # deleted item # yield this so that hidden entries are in file2uuids hashes.append(hsh) elif itemtype == '=': # set group defaults # hashes.append(this so that default entries are in file2uuids logger.debug("items2Hashes defaults: {0}".format(hsh)) defaults = hsh hashes.append(hsh) elif itemtype == '+': # needed for task group: # the original hsh with the summary adjusted to show # the number of tasks and type changed to '-' and the # date updated to refect the due (keep) due date # a non-repeating hash with type '+' for each job # with current due date for unfinished jobs and # otherwise the finished date. These will appear # in days but not folders # '+' items will be not be added to folders # Finishing a group task should be handled separately # when the last job is finished and 'f' is updated. # Here we assume that one or more jobs are unfinished. queue_hsh = {} tmp_hsh = {} for at_key in defaults: if at_key in key2type and itemtype in key2type[at_key]: tmp_hsh[at_key] = defaults[at_key] # tmp_hsh.update(defaults) tmp_hsh.update(hsh) group_defaults = tmp_hsh group_task = deepcopy(group_defaults) done, due, following = getDoneAndTwo(group_task) if 'f' in group_defaults and due: del group_defaults['f'] group_defaults['s'] = due if 'rrule' in group_defaults: del group_defaults['rrule'] prereqs = [] last_level = 1 uid = hsh['I'] summary = hsh['_summary'] if 'j' not in hsh: continue job_num = 0 jobs = [x for x in hsh['j']] completed = [] num_jobs = len(jobs) del group_defaults['j'] if following: del group_task['j'] # group_task['s'] = following group_task['s'] = following group_task['_summary'] = "%s [%s jobs]" % ( summary, len(jobs)) hashes.append(group_task) for job in jobs: tmp_hsh = {} tmp_hsh.update(group_defaults) tmp_hsh.update(job) job = tmp_hsh job['itemtype'] = '+' job_num += 1 current_id = "%s:%02d" % (uid, job_num) if 'f' in job: # this will be a done:due pair with the due # of the current group task completed.append(current_id) job["_summary"] = "%s %d/%d: %s" % ( summary, job_num, num_jobs, job['j']) del job['j'] if 'q' not in job: logger.warn('error: q missing from job') continue try: current_level = int(job['q']) except: logger.warn('error: bad value for q', job['q']) continue job['I'] = current_id queue_hsh.setdefault(current_level, set([])).add(current_id) if current_level < last_level: prereqs = [] for k in queue_hsh: if k > current_level: queue_hsh[k] = set([]) for k in queue_hsh: if k < current_level: prereqs.extend(list(queue_hsh[k])) job['prereqs'] = [x for x in prereqs if x not in completed] last_level = current_level try: job['fileinfo'] = (rel_name, linenums[0], linenums[-1]) except: logger.exception("fileinfo: {0}.{1}".format(rel_name, linenums)) logger.debug('appending job: {0}'.format(job)) hashes.append(job) else: tmp_hsh = {} for at_key in defaults: if at_key in key2type and itemtype in key2type[at_key]: tmp_hsh[at_key] = defaults[at_key] # tmp_hsh.update(defaults) tmp_hsh.update(hsh) hsh = tmp_hsh try: hsh['fileinfo'] = (rel_name, linenums[0], linenums[-1]) except: raise ValueError("exception in fileinfo:", rel_name, linenums, "\n", hsh) hashes.append(hsh) if itemtype not in ['=', '$']: tmp = [' '] for key in label_keys: if key in hsh and hsh[key]: # dump the '_' key = key[-1] tmp.append(key) # else: # tmp.append(' ') uuid2labels[hsh['I']] = "".join(tmp) return messages, hashes, uuid2labels def get_reps(bef, hsh): if hsh['itemtype'] in ['+', '-', '%']: done, due, following = getDoneAndTwo(hsh) if hsh['itemtype'] == '+': if done and following: start = following elif due: start = due elif due: start = due else: start = done else: start = hsh['s'].replace(tzinfo=None) tmp = [] if not start: return False, [] for hsh_r in hsh['_r']: tests = [ u'f' in hsh_r and hsh_r['f'] == 'l', u't' in hsh_r, u'u' in hsh_r ] for test in tests: passed = False if test: passed = True break if not passed: break if passed: # finite, get instances after start try: tmp.extend([x for x in hsh['rrule'] if x >= start]) except: logger.exception('done: {0}; due: {1}; following: {2}; start: {3}; rrule: {4}'.format(done, due, following, start, hsh['rrule'])) else: tmp.extend(list(hsh['rrule'].between(start, bef, inc=True))) tmp.append(hsh['rrule'].after(bef, inc=False)) if windoz: ret = [] epoch = datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=None) for i in tmp: if not i: continue # i.replace(tzinfo=gettz(hsh['z'])) if i.year < 1970: # i.replace(tzinfo=gettz(hsh['z'])) td = epoch - i i = epoch - td else: i.replace(tzinfo=gettz(hsh['z'])).astimezone(tzlocal()).replace(tzinfo=None) ret.append(i) return passed, ret return passed, [j.replace(tzinfo=gettz(hsh['z'])).astimezone(tzlocal()).replace(tzinfo=None) for j in tmp if j] def get_rrulestr(hsh, key_hsh=rrule_hsh): """ Parse the rrule relevant information in hsh and return a corresponding RRULE string. First pass - replace hsh['r'] with an equivalent rrulestr. """ if 'r' not in hsh: return () try: lofh = hsh['r'] except: raise ValueError("Could not load rrule:", hsh['r']) ret = [] l = [] if type(lofh) == dict: lofh = [lofh] for h in lofh: if 'f' in h and h['f'] == 'l': # list only l = [] else: try: l = ["RRULE:FREQ=%s" % freq_hsh[h['f']]] except: logger.exception("bad rrule: {0}, {1}, {2}\n{3}".format(rrule, "\nh:", h, hsh)) for k in rrule_keys: if k in h and h[k]: v = h[k] if type(v) == list: v = ",".join(map(str, v)) if k == 'w': # make weekdays upper case v = v.upper() m = threeday_regex.search(v) while m: v = threeday_regex.sub("%s" % m.group(1)[:2], v, count=1) m = threeday_regex.search(v) l.append("%s=%s" % (rrule_hsh[k], v)) if 'u' in h: dt = parse_str(h['u'], hsh['z']).replace(tzinfo=None) l.append("UNTIL=%s" % dt.strftime(sfmt)) ret.append(";".join(l)) return "\n".join(ret) def get_rrule(hsh): """ Used to process the rulestr entry. Dates and times in *rstr* will be datetimes with offsets. Parameters *aft* and *bef* are UTC datetimes. Datetimes from *rule* will be returned as local times. Second pass - use the hsh['r'] rrulestr entry """ rlst = [] warn = [] if 'z' not in hsh: hsh['z'] = local_timezone if 'o' in hsh and hsh['o'] == 'r' and 'f' in hsh: dtstart = hsh['f'][-1][0].replace(tzinfo=gettz(hsh['z'])) elif 's' in hsh: dtstart = parse_str(hsh['s'], hsh['z']).replace(tzinfo=None) else: dtstart = datetime.now() if 'r' in hsh: if hsh['r']: rlst.append(hsh['r']) if dtstart: rlst.insert(0, "DTSTART:%s" % dtstart.strftime(sfmt)) if '+' in hsh: parts = hsh['+'] if type(parts) != list: parts = [parts] if parts: for part in map(str, parts): # rlst.append("RDATE:%s" % parse(part).strftime(sfmt)) rlst.append("RDATE:%s" % parse_str(part, fmt=sfmt)) if '-' in hsh: tmprule = dtR.rrulestr("\n".join(rlst)) parts = hsh['-'] if type(parts) != list: parts = [parts] if parts: for part in map(str, parts): thisdatetime = parse_str(part, hsh['z']).replace(tzinfo=None) beforedatetime = tmprule.before(thisdatetime, inc=True) if beforedatetime != thisdatetime: warn.append(_( "{0} is listed in @- but doesn't match any datetimes generated by @r.").format( thisdatetime.strftime(rfmt))) rlst.append("EXDATE:%s" % parse_str(part, fmt=sfmt)) rulestr = "\n".join(rlst) try: rule = dtR.rrulestr(rulestr) except ValueError as e: rule = None warn.append("{0}".format(e)) # raise ValueError("could not create rule from", rulestr) return rulestr, rule, warn def checkhsh(hsh): messages = [] if hsh['itemtype'] in ['*', '~', '^'] and 's' not in hsh: messages.append( "An entry for @s is required for events, actions and occasions.") elif hsh['itemtype'] in ['~'] and 'e' not in hsh and 'x' not in hsh: messages.append("An entry for either @e or @x is required for actions.") if ('a' in hsh or 'r' in hsh) and 's' not in hsh: messages.append( "An entry for @s is required for items with either @a or @r entries.") if ('+' in hsh or '-' in hsh) and 'r' not in hsh: messages.extend( ["An entry for @r is required for items with", "either @+ or @- entries."]) if ('n' in hsh and hsh['n']): n_views = ['d', 't', 'k'] bad = [] for v in hsh['n']: if v not in n_views: bad.append(v) if bad: messages.extend( ["Not allowed in @n: {0}. Only values from".format(', '.join(bad)), "{0} are allowed.".format(", ".join(n_views)), ]) return messages def str2opts(s, options=None, cli=True): if not options: options = {} filters = {} if 'calendars' in options: cal_pattern = r'^%s' % '|'.join( [x[2] for x in options['calendars'] if x[1]]) filters['cal_regex'] = re.compile(cal_pattern) s = s2or3(s) op_str = s.split('#')[0] parts = minus_regex.split(op_str) head = parts.pop(0) report = head[0] groupbystr = head[1:].strip() if not report or report not in ['c', 'a'] or not groupbystr: return {} grpby = {'report': report} filters['dates'] = False dated = {'grpby': False} filters['report'] = unicode(report) filters['omit'] = [True, []] filters['neg_fields'] = [] filters['pos_fields'] = [] groupbylst = [unicode(x.strip()) for x in groupbystr.split(';')] grpby['lst'] = groupbylst for part in groupbylst: if groupdate_regex.search(part): dated['grpby'] = True filters['dates'] = True elif part not in ['c', 'u', 'l'] and part[0] not in ['k', 'f', 't']: term_print( str(_('Ignoring invalid grpby part: "{0}"'.format(part)))) groupbylst.remove(part) if not groupbylst: return '', '', '' # we'll split cols on :: after applying fmts to the string grpby['cols'] = "::".join(["{%d}" % i for i in range(len(groupbylst))]) grpby['fmts'] = [] grpby['tuples'] = [] filters['grpby'] = ['_summary'] filters['missing'] = False # include = {'y', 'm', 'w', 'd'} include = {'y', 'm', 'd'} for group in groupbylst: d_lst = [] if groupdate_regex.search(group): if 'w' in group: # groupby week or some other date spec, not both group = "w" d_lst.append('w') include.discard('w') if 'y' in group: include.discard('y') if 'M' in group: include.discard('m') if 'd' in group: include.discard('d') else: if 'y' in group: d_lst.append('yyyy') include.discard('y') if 'M' in group: d_lst.append('MM') include.discard('m') if 'd' in group: d_lst.append('dd') include.discard('d') grpby['tuples'].append(" ".join(d_lst)) grpby['fmts'].append( "d_to_str(tup[-3], '%s')" % group) elif '[' in group: if group[0] == 'f': if ':' in group: grpby['fmts'].append( "'/'.join(rsplit('/', hsh['fileinfo'][0])%s)" % (group[1:])) grpby['tuples'].append( "'/'.join(rsplit('/', hsh['fileinfo'][0])%s)" % (group[1:])) else: grpby['fmts'].append( "rsplit('/', hsh['fileinfo'][0])%s" % (group[1:])) grpby['tuples'].append( "rsplit('/', hsh['fileinfo'][0])%s" % (group[1:])) elif group[0] == 'k': if ':' in group: grpby['fmts'].append( "':'.join(rsplit(':', hsh['%s'])%s)" % (group[0], group[1:])) grpby['tuples'].append( "':'.join(rsplit(':', hsh['%s'])%s)" % (group[0], group[1:])) else: grpby['fmts'].append( "rsplit(':', hsh['%s'])%s" % (group[0], group[1:])) grpby['tuples'].append( "rsplit(':', hsh['%s'])%s" % (group[0], group[1:])) filters['grpby'].append(group[0]) else: if 'f' in group: grpby['fmts'].append("hsh['fileinfo'][0]") grpby['tuples'].append("hsh['fileinfo'][0]") else: grpby['fmts'].append("hsh['%s']" % group.strip()) grpby['tuples'].append("hsh['%s']" % group.strip()) filters['grpby'].append(group[0]) if include: if include == {'y', 'm', 'd'}: grpby['include'] = "yyyy-MM-dd" elif include == {'m', 'd'}: grpby['include'] = "MMM d" elif include == {'y', 'd'}: grpby['include'] = "yyyy-MM-dd" elif include == set(['y', 'w']): groupby['include'] = "w" elif include == {'d'}: grpby['include'] = "MMM dd" elif include == set(['w']): grpby['include'] = "w" else: grpby['include'] = "" else: grpby['include'] = "" logger.debug('grpby final: {0}'.format(grpby)) for part in parts: key = unicode(part[0]) if key in ['b', 'e']: dt = parse_date_period(part[1:]) dated[key] = dt.replace(tzinfo=None) elif key == 'm': value = unicode(part[1:].strip()) if value == '1': filters['missing'] = True elif key == 'f': value = unicode(part[1:].strip()) if value[0] == '!': filters['folder'] = (False, re.compile(r'%s' % value[1:], re.IGNORECASE)) else: filters['folder'] = (True, re.compile(r'%s' % value, re.IGNORECASE)) elif key == 's': value = unicode(part[1:].strip()) if value[0] == '!': filters['search'] = (False, re.compile(r'%s' % value[1:], re.IGNORECASE)) else: filters['search'] = (True, re.compile(r'%s' % value, re.IGNORECASE)) elif key == 'S': value = unicode(part[1:].strip()) if value[0] == '!': filters['search-all'] = (False, re.compile(r'%s' % value[1:], re.IGNORECASE | re.DOTALL)) else: filters['search-all'] = (True, re.compile(r'%s' % value, re.IGNORECASE | re.DOTALL)) elif key == 'd': if cli: if grpby['report'] == 'a': d = int(part[1:]) if d: d += 1 grpby['depth'] = d else: pass elif key == 't': value = [x.strip() for x in part[1:].split(',')] for t in value: if t[0] == '!': filters['neg_fields'].append(( 't', re.compile(r'%s' % t[1:], re.IGNORECASE))) else: filters['pos_fields'].append(( 't', re.compile(r'%s' % t, re.IGNORECASE))) elif key == 'o': value = unicode(part[1:].strip()) if value[0] == '!': filters['omit'][0] = False filters['omit'][1] = [x for x in value[1:]] else: filters['omit'][0] = True filters['omit'][1] = [x for x in value] elif key == 'h': grpby['colors'] = int(part[1:]) elif key == 'w': grpby['width1'] = int(part[1:]) elif key == 'W': grpby['width2'] = int(part[1:]) else: value = unicode(part[1:].strip()) if value[0] == '!': filters['neg_fields'].append(( key, re.compile(r'%s' % value[1:], re.IGNORECASE))) else: filters['pos_fields'].append(( key, re.compile(r'%s' % value, re.IGNORECASE))) if 'b' not in dated: dated['b'] = parse_str(options['report_begin']).replace(tzinfo=None) if 'e' not in dated: dated['e'] = parse_str(options['report_end']).replace(tzinfo=None) if 'colors' not in grpby or grpby['colors'] not in [0, 1, 2]: grpby['colors'] = options['report_colors'] if 'width1' not in grpby: grpby['width1'] = options['report_width1'] if 'width2' not in grpby: grpby['width2'] = options['report_width2'] grpby['lst'].append(u'summary') logger.debug('grpby: {0}; dated: {1}; filters: {2}'.format(grpby, dated, filters)) return grpby, dated, filters def applyFilters(file2uuids, uuid2hash, filters): """ Apply all filters except begin and end and return a list of the uid's of the passing hashes. TODO: memoize? """ typeHsh = { 'a': '~', 'd': '%', 'e': '*', 'g': '+', 'o': '^', 'n': '!', 't': '-', 's': '?', } uuids = [] omit = None if 'omit' in filters: omit, omit_types = filters['omit'] omit_chars = [typeHsh[x] for x in omit_types] for f in file2uuids: if 'cal_regex' in filters and not filters['cal_regex'].match(f): continue if 'folder' in filters: tf, folder_regex = filters['folder'] if tf and not folder_regex.search(f): continue if not tf and folder_regex.search(f): continue for uid in file2uuids[f]: hsh = uuid2hash[uid] skip = False type_char = hsh['itemtype'] if type_char in ['=', '#', '$']: # omit defaults, hidden, inbox and someday continue if filters['dates'] and 's' not in hsh: # groupby includes a date specification and this item is undated continue if filters['report'] == 'a' and type_char != '~': continue if filters['report'] == 'c' and omit is not None: if omit and type_char in omit_chars: # we're omitting this type continue if not omit and type_char not in omit_chars: # we're not showing this type continue if 'search' in filters: tf, rx = filters['search'] l = [] for g in filters['grpby']: # search over the leaf summary and the branch for t in ['_summary', u'c', u'k', u'f', u'u']: if t not in g: continue if t == 'f': v = hsh['fileinfo'][0] elif t in hsh: v = hsh[t] else: continue # add v to l l.append(v) s = ' '.join(l) res = rx.search(s) if tf and not res: skip = True if not tf and res: skip = True if 'search-all' in filters: tf, rx = filters['search-all'] # search over the entire entry and the file path l = [hsh['entry'], hsh['fileinfo'][0]] s = ' '.join(l) res = rx.search(s) if tf and not res: skip = True if not tf and res: skip = True for t in ['c', 'k', 'u', 'l']: if t in filters['grpby']: if filters['missing']: if t not in hsh: hsh[t] = NONE else: if t in hsh and hsh[t] == NONE: # we added this on an earlier report del hsh[t] if t not in hsh: skip = True break if skip: # try the next uid continue for flt, rgx in filters['pos_fields']: if flt == 't': if 't' not in hsh or not rgx.search(" ".join(hsh['t'])): skip = True break elif flt not in hsh or not rgx.search(hsh[flt]): skip = True break if skip: # try the next uid continue for flt, rgx in filters['neg_fields']: if flt == 't': if 't' in hsh and rgx.search(" ".join(hsh['t'])): skip = True break elif flt in hsh and rgx.search(hsh[flt]): skip = True break if skip: # try the next uid continue # passed all tests uuids.append(uid) return uuids def reportDT(dt, include, options=None): # include will be something like "MMM d yyyy" if not options: options = {} res = '' if dt.hour == 0 and dt.minute == 0: if not include: return '' return d_to_str(dt, "yyyy-MM-dd") else: if options['ampm']: if include: res = dt_to_str(dt, "%s h:mma" % include) else: res = dt_to_str(dt, "h:mma") else: if include: res = dt_to_str(dt, "%s hh:mm" % include) else: res = dt_to_str(dt, "hh:mm") return leadingzero.sub('', res.lower()) # noinspection PyChainedComparisons def makeReportTuples(uuids, uuid2hash, grpby, dated, options=None): """ Using filtered uuids, and dates: grpby, b and e, return a sorted list of tuples (sort1, sort2, ... typenum, dt or '', uid) using dt takes care of time when need or date and time when grpby has no date specification """ if not options: options = {} today_datetime = datetime.now().replace( hour=0, minute=0, second=0, microsecond=0) today_date = datetime.now().date() tups = [] for uid in uuids: try: hsh = {} for k, v in uuid2hash[uid].items(): hsh[k] = v # we'll make anniversary subs to a copy later hsh['summary'] = hsh['_summary'] tchr = hsh['itemtype'] tstr = type2Str[tchr] if 't' not in hsh: hsh['t'] = [] if dated['grpby']: dates = [] if 'f' in hsh and hsh['f']: next = getDoneAndTwo(hsh)[1] if next: start = next else: start = parse_str(hsh['s'], hsh['z']).astimezone(tzlocal()).replace(tzinfo=None) if 'rrule' in hsh: if dated['b'] > start: start = dated['b'] for date in hsh['rrule'].between(start, dated['e'], inc=True): # on or after start but before 'e' if date < dated['e']: bisect.insort(dates, date) elif 's' in hsh and hsh['s'] and 'f' not in hsh: if hsh['s'] < dated['e'] and hsh['s'] >= dated['b']: bisect.insort(dates, start) # datesSL.insert(start) if 'f' in hsh and hsh['f']: dt = parse_str( hsh['f'][-1][0], hsh['z']).astimezone( tzlocal()).replace(tzinfo=None) if dt <= dated['e'] and dt >= dated['b']: bisect.insort(dates, dt) for dt in dates: item = [] # ('dt', type(dt), dt) for g in grpby['tuples']: if groupdate_regex.search(g): item.append(d_to_str(dt, g)) elif g in ['c', 'u']: item.append(hsh[g]) else: # should be f or k item.append(eval(g)) item.extend([ tstr2SCI[tstr][0], tstr, dt, reportDT(dt, grpby['include'], options), uid]) bisect.insort(tups, tuple(item)) else: # no date spec in grpby item = [] dt = '' if hsh['itemtype'] in [u'+', u'-', u'%']: # task type done, due, following = getDoneAndTwo(hsh) if due: # add a due entry if due.date() < today_date: if tchr == '+': tstr = 'pc' elif tchr == '-': tstr = 'pt' elif tchr == '%': tstr = 'pd' dt = due elif done: dt = done else: # not a task type if 's' in hsh: if 'rrule' in hsh: if tchr in ['^', '*', '~']: dt = (hsh['rrule'].after(today_datetime, inc=True) or hsh['rrule'].before(today_datetime, inc=True)) if dt is None: logger.warning('No valid datetimes for {0}, {1}'.format(hsh['_summary'], hsh['fileinfo'])) continue else: dt = hsh['rrule'].after(hsh['s'], inc=True) else: dt = parse_str(hsh['s'], hsh['z']).replace(tzinfo=None) else: # undated dt = '' for g in grpby['tuples']: if groupdate_regex.search(g): item.append(dt_to_str(dt, g)) else: try: res = eval(g) item.append(res) except: pass if type(dt) == datetime: dtstr = reportDT(dt, grpby['include'], options) dt = dt.strftime(etmdatefmt) else: dtstr = dt item.extend([ tstr2SCI[tstr][0], tstr, dt, dtstr, uid]) bisect.insort(tups, tuple(item)) except: logger.exception('Error processing: {0}, {1}'.format(hsh['_summary'], hsh['fileinfo'])) return tups def getAgenda(allrows, colors=2, days=4, indent=2, width1=54, width2=14, calendars=None, omit=[], mode='html', fltr=None): if not calendars: calendars = [] items = deepcopy(allrows) day = [] inbasket = [] now = [] next = [] someday = [] if colors and mode == 'html': bb = "" eb = "" else: bb = "" eb = "" # show day items starting with beg and ending with lst beg = datetime.today() tom = beg + ONEDAY lst = beg + (days - 1)*ONEDAY beg_fmt = beg.strftime("%Y%m%d") tom_fmt = tom.strftime("%Y%m%d") lst_fmt = lst.strftime("%Y%m%d") if not items: return {} for item in items: if item[0][0] == 'day': if item[0][1] >= beg_fmt and item[0][1] <= lst_fmt: # if item[2][1] in ['fn', 'ac', 'ns']: if omit and item[2][1] in omit: # skip omitted items continue if item[0][1] == beg_fmt: item[1] = TODAY elif item[0][1] == tom_fmt: item[1] = TOMORROW day.append(item) elif item[0][0] == 'inbasket': item.insert(1, "{0}{1}{2}".format(bb, _("In Basket"), eb)) inbasket.append(item) elif item[0][0] == 'now': item.insert(1, "{0}{1}{2}".format(bb, _("Now"), eb)) now.append(item) elif item[0][0] == 'next': item.insert(1, "{0}{1}{2}".format(bb, _("Next"), eb)) next.append(item) elif item[0][0] == 'someday': item.insert(1, "{0}{1}{2}".format(bb, _("Someday"), eb)) someday.append(item) tree = {} nv = 0 for l in [day, inbasket, now, next, someday]: if l: nv += 1 update = makeTree(l, calendars=calendars, fltr=fltr) for key in update.keys(): tree.setdefault(key, []).extend(update[key]) logger.debug("called makeTree for {0} views".format(nv)) return tree # @memoize def getReportData(s, file2uuids, uuid2hash, options=None, export=False, colors=None, cli=True): """ getViewData returns items with the format: [(view, (sort)), node1, node2, ..., (uuid, typestr, summary, col_2, dt_sort_str) ] pop item[0] after sort leaving [node1, node2, ... (xxx) ] for actions (tallyByGroup) we need (node1, node2, ... (minutes, value, expense, charge)) """ if not options: options = {} try: grpby, dated, filters = str2opts(s, options, cli) except: e = "{0}: {1}".format(_("Could not process"), s) logger.exception(e) return e if not grpby: return ["{0}: grpby".format(_('invalid setting'))] uuids = applyFilters(file2uuids, uuid2hash, filters) tups = makeReportTuples(uuids, uuid2hash, grpby, dated, options) items = [] cols = grpby['cols'] fmts = grpby['fmts'] for tup in tups: uuid = tup[-1] hsh = uuid2hash[tup[-1]] # for eval we need to be sure that t is in hsh if 't' not in hsh: hsh['t'] = [] try: # for eval: {} is the global namespace # and {'tup' ... dt_to_str} is the local namespace eval_fmts = [ eval(x, {}, {'tup': tup, 'hsh': hsh, 'rsplit': rsplit, 'd_to_str': d_to_str, 'dt_to_str': dt_to_str}) for x in fmts] except Exception: logger.exception('fmts: {0}'.format(fmts)) continue if filters['dates']: dt = reportDT(tup[-3], grpby['include'], options) if dt == '00:00': dt = '' dtl = None else: dtl = tup[-3] else: # the datetime (sort string) will be in tup[-3], # the display string in tup[-2] dt = tup[-2] dtl = tup[-3] if dtl: etmdt = parse_str(dtl, hsh['z'], fmt=rfmt) if etmdt is None: etmdt = "" else: etmdt = '' try: item = (cols.format(*eval_fmts)).split('::') except: logger.exception("eval_fmts: {0}".format(*eval_fmts)) if grpby['report'] == 'c': if fmts.count(u"hsh['t']"): position = fmts.index(u"hsh['t']") for tag in hsh['t']: rowcpy = deepcopy(item) rowcpy[position] = tag rowcpy.append( (tup[-1], tup[-4], setSummary(hsh, parse(dtl)), dt, etmdt)) items.append(rowcpy) else: item.append((tup[-1], tup[-4], setSummary(hsh, parse(dtl)), dt, etmdt)) items.append(item) else: # action report summary = format(setSummary(hsh, parse(dt))) item.append("{0}!!{1}!!".format(summary, uuid)) temp = [] temp.extend(timeValue(hsh, options)) temp.extend(expenseCharge(hsh, options)) item.append(temp) items.append(item) if grpby['report'] == 'c' and not export: tree = makeTree(items, sort=False) return tree else: if grpby['report'] == 'a' and 'depth' in grpby and grpby['depth']: depth = min(grpby['depth']-1, len(grpby['lst'])) else: depth = len(grpby['lst']) logger.debug('using depth: {0}'.format(depth)) if export: data = [] head = [x for x in grpby['lst'][:depth]] logger.debug('head: {0}\nlst: {1}\ndepth: {2}'.format(head, grpby['lst'], depth)) if grpby['report'] == 'c': for row in items: tup = ['"{0}"'.format(x) for x in row.pop(-1)[2:6]] row.extend(tup) data.append(row) else: head.extend(['minutes', 'value', 'expense', 'charge']) data.append(head) lst = tallyByGroup( items, max_level=depth, options=options, export=True) for row in lst: tup = [x for x in list(row.pop(-1))] row.extend(tup) data.append(row) return data else: res = tallyByGroup(items, max_level=depth, options=options) return res def str2hsh(s, uid=None, options=None): if not options: options = {} msg = [] try: hsh = {} alerts = [] at_parts = at_regex.split(s) # logger.debug('at_parts: {0}'.format(at_parts)) head = at_parts.pop(0).strip() if head and head[0] in type_keys: itemtype = unicode(head[0]) summary = head[1:].strip() else: # in basket itemtype = u'$' summary = head hsh['itemtype'] = itemtype hsh['_summary'] = summary if uid: hsh['I'] = uid if itemtype == u'+': hsh['_group_summary'] = summary hsh['entry'] = s for at_part in at_parts: at_key = unicode(at_part[0]) at_val = at_part[1:].strip() if itemtype not in key2type[at_key]: msg.append("An entry for @{0} is not allowed in items of type '{1}'.".format(at_key, itemtype)) continue if at_key == 'a': actns = options['alert_default'] arguments = [] # alert_parts = at_val.split(':', maxsplit=1) alert_parts = re.split(':', at_val, maxsplit=1) t_lst = alert_parts.pop(0).split(',') periods = [] for x in t_lst: p = parse_period(x) if type(p) is timedelta: periods.append(p) else: msg.append(p) periods = tuple(periods) triggers = [x for x in periods] if alert_parts: action_parts = [ x.strip() for x in alert_parts[0].split(';')] actns = [ unicode(x.strip()) for x in action_parts.pop(0).split(',')] if action_parts: arguments = [] for action_part in action_parts: tmp = action_part.split(',') arguments.append(tmp) alerts.append([triggers, actns, arguments]) elif at_key in ['+', '-', 'i', 'n']: parts = comma_regex.split(at_val) tmp = [] for part in parts: tmp.append(part) hsh[at_key] = tmp elif at_key in ['r', 'j']: amp_parts = amp_regex.split(at_val) part_hsh = {} this_key = unicode(amp_hsh.get(at_key, at_key)) amp_0 = amp_parts.pop(0) part_hsh[this_key] = amp_0 for amp_part in amp_parts: amp_key = unicode(amp_part[0]) amp_val = amp_part[1:].strip() if amp_key in ['q', 'i', 't']: try: part_hsh[amp_key] = int(amp_val) except ValueError: msg.append('"&{0} {1}" is invalid - a positive integer is required.'.format(amp_key, amp_val)) logger.exception('Bad entry "{0}" given for "&{1}" in "{2}". An integer is required.'.format(amp_val, amp_key, hsh['entry'])) else: if part_hsh[amp_key] < 1: msg.append('"&{0} {1}" is invalid - a positive integer is required.'.format(amp_key, amp_val)) elif amp_key == 'e': p = parse_period(amp_val) if type(p) is timedelta: part_hsh['e'] = p else: msg.append(p) else: m = range_regex.search(amp_val) if m: if m.group(3): part_hsh[amp_key] = [ x for x in range( int(m.group(1)), int(m.group(3)))] else: part_hsh[amp_key] = range(int(m.group(1))) # value will be a scalar or list elif comma_regex.search(amp_val): part_hsh[amp_key] = comma_regex.split(amp_val) else: part_hsh[amp_key] = amp_val try: hsh.setdefault("%s" % at_key, []).append(part_hsh) except: msg.append("error appending '%s' to hsh[%s]" % (part_hsh, at_key)) else: # value will be a scalar or list if at_key in ['a', 't']: if comma_regex.search(at_val): hsh[at_key] = [ x for x in comma_regex.split(at_val) if x] else: hsh[at_key] = [at_val] elif at_key == 's': # we'll parse this after we get the timezone hsh['s'] = at_val elif at_key == 'k': hsh['k'] = ":".join([x.strip() for x in at_val.split(':')]) elif at_key == 'e': p = parse_period(at_val) if type(p) is timedelta: hsh['e'] = p else: msg.append(p) elif at_key == 'p': hsh['p'] = int(at_val) if hsh['p'] <= 0 or hsh['p'] >= 10: hsh['p'] = 10 else: hsh[at_key] = at_val if alerts: hsh['_a'] = alerts if 'z' not in hsh: if 's' in hsh or 'f' in hsh or 'q' in hsh: hsh['z'] = options['local_timezone'] if 'z' in hsh: z = gettz(hsh['z']) if z is None: msg.append("error: bad timezone: '%s'" % hsh['z']) hsh['z'] = '' if 's' in hsh: dt = parse_str(hsh['s'], hsh['z']) if type(dt) is datetime: hsh['s'] = dt.replace(tzinfo=None) else: msg.append(dt) if 'q' in hsh: try: hsh['q'] = parse_str(hsh['q'], hsh['z']).replace(tzinfo=None) except: err = "error: could not parse '@q {0}'".format(hsh['q']) msg.append(err) if '+' in hsh: tmp = [] for part in hsh['+']: r = parse_str(part, hsh['z']).replace(tzinfo=None) tmp.append(r) hsh['+'] = tmp if '-' in hsh: tmp = [] for part in hsh['-']: r = parse_str(part, hsh['z']).replace(tzinfo=None) tmp.append(r) hsh['-'] = tmp if 'b' in hsh: try: hsh['b'] = int(hsh['b']) except: msg.append( '"@b {0}" is invalid - a positive integer is required'.format(hsh['b'])) else: if hsh['b'] < 1: msg.append( '"@b {0}" is invalid - a positive integer is required'.format(hsh['b'])) if 'f' in hsh: # this will be a list of done:due pairs # 20120201T1325;20120202T1400, ... # logger.debug('hsh["f"]: {0}'.format(hsh['f'])) pairs = [x.strip() for x in hsh['f'].split(',') if x.strip()] # logger.debug('pairs: {0}'.format(pairs)) hsh['f'] = [] for pair in pairs: pair = pair.split(';') done = parse_str( pair[0], hsh['z']).replace(tzinfo=None) if len(pair) > 1: due = parse_str(pair[1], hsh['z']).replace(tzinfo=None) else: due = done # logger.debug("appending {0} to {1}".format(done, hsh['entry'])) hsh['f'].append((done, due)) if 'h' in hsh: # this will be a list of done:due pairs # 20120201T1325;20120202T1400, ... # logger.debug('hsh["f"]: {0}'.format(hsh['f'])) pairs = [x.strip() for x in hsh['h'].split(',') if x.strip()] # logger.debug('pairs: {0}'.format(pairs)) hsh['h'] = [] for pair in pairs: pair = pair.split(';') done = parse_str( pair[0], hsh['z']).replace(tzinfo=None) if len(pair) > 1: due = parse_str( pair[1], hsh['z']).replace(tzinfo=None) else: due = done # logger.debug("appending {0} to {1}".format(done, hsh['entry'])) hsh['h'].append((done, due)) if 'j' in hsh: for i in range(len(hsh['j'])): job = hsh['j'][i] if 'q' not in job: msg.append("@j: %s" % job['j']) msg.append("an &q entry is required for jobs") if 'f' in job: if 'z' not in hsh: hsh['z'] = options['local_timezone'] pair = job['f'].split(';') done = parse_str( pair[0], hsh['z']).replace(tzinfo=None) if len(pair) > 1: due = parse_str( pair[1], hsh['z']).replace(tzinfo=None) else: due = '' job['f'] = [(done, due)] if 'h' in job: # this will be a list of done:due pairs # 20120201T1325;20120202T1400, ... logger.debug("job['h']: {0}, {1}".format(job['h'], type(job['h']))) if type(job['h']) is str: pairs = job['h'].split(',') else: pairs = job['h'] logger.debug('starting pairs: {0}, {1}'.format(pairs, type(pairs))) job['h'] = [] # if type(pairs) in [unicode, str]: if type(pairs) not in [list]: pairs = [pairs] for pair in pairs: logger.debug('splitting pair: {0}'.format(pair)) pair = pair.split(';') logger.debug('processing done, due: {0}'.format(pair)) done = parse_str( pair[0], hsh['z']).replace(tzinfo=None) if len(pair) > 1: logger.debug('parsing due: {0}, {1}'.format(pair[1], type(pair[1]))) due = parse_str( pair[1], hsh['z']).replace(tzinfo=None) else: due = done logger.debug("appending ({0}, {1}) to {2} ".format(done, due, job['j'])) job['h'].append((done, due)) logger.debug("job['h']: {0}".format(job['h'])) # put the modified job back in the hash hsh['j'][i] = job for k, v in hsh.items(): if type(v) in [datetime, timedelta]: pass elif k == 's': pass elif type(v) in [list, int, tuple]: hsh[k] = v else: hsh[k] = v.strip() if 'r' in hsh: if hsh['r'] == 'l': # list only with no '&' fields hsh['r'] = {'f': 'l'} # skip one time and handle with finished, begin and pastdue msg.extend(checkhsh(hsh)) if msg: return hsh, msg if 'p' in hsh: hsh['_p'] = hsh['p'] else: hsh['_p'] = 10 if 'a' in hsh: hsh['_a'] = hsh['a'] if 'j' in hsh: hsh['_j'] = hsh['j'] if 'r' in hsh: hsh['_r'] = hsh['r'] try: hsh['r'] = get_rrulestr(hsh) except: msg.append("exception processing rulestring: %s" % hsh['_r']) try: hsh['r'], hsh['rrule'], warn = get_rrule(hsh) if warn: msg.extend(warn) except: logger.exception("exception processing rrule: {0}".format(hsh['_r'])) if 'I' not in hsh: hsh['I'] = uniqueId() except: logger.exception('exception processing "{0}"'.format(s)) msg.append('exception processing "{0}"'.format(s)) return hsh, msg def expand_template(template, hsh, lbls=None, complain=False): if not lbls: lbls = {} marker = '!' if hsh and "_summary" in hsh and "summary" not in hsh: hsh["summary"] = hsh["_summary"] def lookup(w): if w == '': return marker l1, l2 = lbls.get(w, ('', '')) v = hsh.get(w, None) if template.startswith("mailto"): v = quote(v) if v is None: if complain: return w else: return '' if type(v) in [str, unicode]: return "%s%s%s" % (l1, v, l2) if type(v) == datetime: return "%s%s%s" % (l1, v.strftime("%a %b %d, %Y %H:%M"), l2) return "%s%s%s" % (l1, repr(v), l2) parts = template.split(marker) parts[1::2] = map(lookup, parts[1::2]) s = ''.join(parts).strip() return blank_lines_regex.sub('', s) def getToday(): return datetime.today().strftime(sortdatefmt) def getCurrentDate(): return datetime.today().strftime(reprdatefmt) last_added = None def add2list(l, item, expand=True): """Add item to l if not already present using bisect to maintain order.""" global last_added if expand and len(item) == 3 and type(item[1]) is tuple: # this is a tree entry, so we need to expand the middle tuple # for makeTree try: entry = [item[0]] entry.extend(list(item[1])) entry.append(item[2]) item = entry except: logger.exception('error expanding: {0}'.formt(item)) return () try: # i = bisect.bisect_left(name2list[l], item) name2SL[l].insert(item) except: logger.exception("error adding:\n{0}\n\n last added:\n{1}".format(item, last_added)) return () return True def removeFromlist(l, item, expand=True): """Add item to l if not already present using bisect to maintain order.""" global last_added if expand and len(item) == 3 and type(item[1]) is tuple: # this is a tree entry, so we need to expand the middle tuple # for makeTree try: entry = [item[0]] entry.extend(list(item[1])) entry.append(item[2]) item = entry except: logger.exception('error expanding: {0}'.formt(item)) return () try: name2SL[l].remove(item) except: logger.exception("error adding:\n{0}\n\n last added:\n{1}".format(item, last_added)) return () return True def getPrevNext(l, cal_regex): result = [] seen = [] # remove duplicates for xx in l: if cal_regex and not cal_regex.match(xx[1]): continue x = xx[0].date() i = bisect.bisect_left(seen, x) if i == len(seen) or seen[i] != x: seen.insert(i, x) result.append(x) l = result prevnext = {} if not l: return {} aft = l[0] bef = l[-1] d = aft prev = 0 nxt = len(l) - 1 last_prev = 0 while d <= bef: i = bisect.bisect_left(l, d) j = bisect.bisect_right(l, d) if i != len(l) and l[i] == d: # d is in the list last_prev = i curr = i prev = max(0, i - 1) nxt = min(len(l) - 1, j) else: # d is not in the list curr = last_prev prev = last_prev prevnext[d] = [l[prev], l[curr], l[nxt]] d += ONEDAY return prevnext def get_changes(options, file2lastmodified): new = [] deleted = [] modified = [] prefix, filelist = getFiles(options['datadir']) for f, r in filelist: if (f, r) not in file2lastmodified: new.append((f, r)) elif os.path.getmtime(f) != file2lastmodified[(f, r)]: logger.debug('mtime: {0}; lastmodified: {1}'.format(os.path.getmtime(f), file2lastmodified[(f, r)])) modified.append((f, r)) for (f, r) in file2lastmodified: if (f, r) not in filelist: deleted.append((f, r)) return new, modified, deleted def get_data(options=None): if not options: options = {} bad_datafiles = [] (uuid2hash, uuid2labels, file2uuids, file2lastmodified, bad_datafiles, messages) = process_all_datafiles(options) if bad_datafiles: logger.warn("bad data files: {0}".format(bad_datafiles)) return uuid2hash, uuid2labels, file2uuids, file2lastmodified, bad_datafiles, messages def expandPath(path): path, ext = os.path.splitext(path) folders = [] while 1: path, folder = os.path.split(path) if folder != "": folders.append(folder) else: if path != "": folders.append(path) break folders.reverse() return folders # noinspection PyArgumentList def getDoneAndTwo(hsh, keep=False): if hsh['itemtype'] not in ['+', '-', '%']: return done = None nxt = None following = None if 'z' in hsh: today_datetime = datetime.now(gettz(hsh['z'])).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) else: today_datetime = get_current_time() if 'f' in hsh and hsh['f']: if type(hsh['f']) in [str, unicode]: parts = str(hsh['f']).split(';') done = parse_str(parts[0], hsh['z']).replace(tzinfo=None) if len(parts) < 2: due = done else: due = parse_str(parts[1], hsh['z']).replace(tzinfo=None) elif type(hsh['f'][-1]) in [list, tuple]: done, due = hsh['f'][-1] else: done = hsh['f'][-1] due = done k_aft = due k_inc = False r_aft = done r_inc = False if due and due < today_datetime: s_aft = today_datetime s_inc = True else: s_aft = due s_inc = False else: if 's' in hsh: k_aft = r_aft = hsh['s'] else: k_aft = r_aft = today_datetime k_inc = r_inc = True s_aft = today_datetime s_inc = True if 'rrule' in hsh: nxt = None if keep or 'o' not in hsh or hsh['o'] == 'k': # keep if k_aft: nxt = hsh['rrule'].after(k_aft, k_inc) elif hsh['o'] == 'r': # restart if r_aft: nxt = hsh['rrule'].after(r_aft, r_inc) elif hsh['o'] == 's': # skip if s_aft: nxt = hsh['rrule'].after(s_aft, s_inc) if nxt: following = hsh['rrule'].after(nxt, False) elif 's' in hsh and hsh['s']: if 'f' in hsh: nxt = None else: nxt = parse_str(hsh['s'], hsh['z']).replace(tzinfo=None) return done, nxt, following def timeValue(hsh, options): """ Return rounded integer minutes and float value. """ minutes = value = 0 if 'e' not in hsh or hsh['e'] <= ONEMINUTE * 0: return 0, 0.0 td_minutes = hsh['e'].seconds // 60 + (hsh['e'].seconds % 60 > 0) a_m = int(options['action_minutes']) if a_m not in [1, 6, 12, 15, 30, 60]: a_m = 1 minutes = ((td_minutes // a_m + (td_minutes % a_m > 0)) * a_m) if 'action_rates' in options: if 'v' in hsh and hsh['v'] in options['action_rates']: rate = float(options['action_rates'][hsh['v']]) elif 'default' in options['action_rates']: rate = float(options['action_rates']['default']) else: rate = 0.0 value = rate * (minutes / 60.0) else: value = 0.0 return minutes, value def expenseCharge(hsh, options): expense = charge = 0.0 rate = 1.0 if 'x' in hsh: expense = charge = float(hsh['x']) if 'action_markups' in options: if 'w' in hsh and hsh['w'] in options['action_markups']: rate = float(options['action_markups'][hsh['w']]) elif 'default' in options['action_markups']: rate = float(options['action_markups']['default']) else: rate = 1.0 charge = rate * expense return expense, charge def timedelta2Str(td, short=False): """ """ if td <= ONEMINUTE * 0: return 'none' until = [] td_days = td.days td_hours = td.seconds // (60 * 60) td_minutes = (td.seconds % (60 * 60)) // 60 if short: # drop the seconds part return "+%s" % str(td)[:-3] if td_days: if td_days == 1: days = _("day") else: days = _("days") until.append("%d %s" % (td_days, days)) if td_hours: if td_hours == 1: hours = _("hour") else: hours = _("hours") until.append("%d %s" % (td_hours, hours)) if td_minutes: if td_minutes == 1: minutes = _("minute") else: minutes = _("minutes") until.append("%d %s" % (td_minutes, minutes)) return " ".join(until) def timedelta2Sentence(td): string = timedelta2Str(td) if string == 'none': return _("now") else: return _("{0} from now").format(string) def add_busytime(key, sm, em, evnt_summary, uid, rpth): """ key = (year, weeknum, weekdaynum with Monday=1, Sunday=7) value = [minute_total, list of (uid, start_minute, end_minute)] """ # key = tuple(sd.isocalendar()) # year, weeknum, weekdaynum entry = (sm, em, evnt_summary, uid, rpth) # logger.debug("adding busytime: {0}; {1}".format(key, evnt_summary)) busytimesSL.setdefault(key, IndexableSkiplist(2000, "busytimes")).insert(entry) def remove_busytime(key, bt): """ key = (year, weeknum, weekdaynum with Monday=1, Sunday=7) value = [minute_total, list of (uid, start_minute, end_minute)] """ busytimesSL[key].remove(bt) def add_occasion(key, evnt_summary, uid, f): # key = tuple(sd.isocalendar()) # year, weeknum, weekdaynum # logger.debug("adding occasion: {0}; {1}".format(key, evnt_summary)) occasionsSL.setdefault(key, IndexableSkiplist(1000, "occasions")).insert((evnt_summary, uid, f)) def remove_occasion(key, oc): # sd, evnt_summary, uid, f): # logger.debug("removing occasion: {0}, {1}".format(key, oc)) occasionsSL[key].remove(oc) def setSummary(hsh, dt): if not dt: return hsh['_summary'] # logger.debug("dt: {0}".format(dt)) mtch = anniversary_regex.search(hsh['_summary']) retval = hsh['_summary'] if mtch: startyear = mtch.group(1) numyrs = year2string(startyear, dt.year) retval = anniversary_regex.sub(numyrs, hsh['_summary']) return retval def setItemPeriod(hsh, start, end, short=False, options=None): if not options: options = {} sy = start.year ey = end.year sm = start.month em = end.month sd = start.day ed = end.day if start == end: # same time - zero extent if short: period = "%s" % fmt_time(start, options=options) else: period = "%s %s" % ( fmt_time(start, options=options), fmt_date(start, True)) elif (sy, sm, sd) == (ey, em, ed): # same day if short: period = "%s - %s" % ( fmt_time(start, options=options), fmt_time(end, options=options)) else: period = "%s - %s %s" % ( fmt_time(start, options=options), fmt_time(end, options=options), fmt_date(end, True)) else: period = "%s %s - %s %s" % ( fmt_time(start, options=options), fmt_date(start, True), fmt_time(end, options=options), fmt_date(end, True)) return period def getDataFromFile(f, file2data, bef, file2uuids=None, uuid2hash=None, options=None): if not options: options = {} if file2data is None: file2data = {} if not file2uuids: file2uuids = {} if not uuid2hash: uuid2hash = {} today_datetime = datetime.now().replace( hour=0, minute=0, second=0, microsecond=0) today_date = datetime.now().date() yearnum, weeknum, daynum = today_date.isocalendar() items = [] # [(view, sort(3|4), fn), (branches), (leaf)] datetimes = [] busytimes = [] occasions = [] alerts = [] alert_minutes = {} folders = expandPath(f) pastduerepeating = [] for uid in file2uuids[f]: # this will give the items in file order! if uuid2hash[uid]['itemtype'] in ['=']: continue sdt = "" hsh = {} for k, v in uuid2hash[uid].items(): hsh[k] = v # we'll make anniversary subs to a copy later hsh['summary'] = hsh['_summary'] typ = type2Str[hsh['itemtype']] # we need a context for due view and a keyword for keyword view if hsh['itemtype'] != '#': if 'c' not in hsh: if 's' not in hsh and hsh['itemtype'] in [u'+', u'-', u'%']: # undated task hsh['c'] = NONE else: hsh['c'] = NONE if 'k' not in hsh: hsh['k'] = NONE if 't' not in hsh: hsh['t'] = [NONE] # make task entries for day, keyword and folder view if hsh['itemtype'] in [u'+', u'-', u'%']: done, due, following = getDoneAndTwo(hsh) hist_key = 'f' if hsh['itemtype'] == '+' and 'h' in hsh: hist_key = 'h' if done: # add the last show_finished completions to day and keywords # dts = done.strftime(sortdatefmt) # sdt = fmt_date(hsh['f'][-1][0], True) sdt = fmt_date(hsh['f'][-1][0], True) typ = 'fn' # add a finished entry to day view # only show the last 'show_finished' completions for d0, d1 in hsh[hist_key][-options['show_finished']:]: if 'n' not in hsh or 'd' not in hsh['n']: item = [ ('day', d0.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], '', f), (fmt_date(d0, short=True), ), (uid, typ, setSummary(hsh, d0), '', d0)] items.append(item) datetimes.append((d0, f)) if 'k' in hsh: if 'n' not in hsh or 'k' not in hsh['n']: keywords = [x.strip() for x in hsh['k'].split(':')] item = [ ('keyword', (hsh['k'], tstr2SCI[typ][0]), d0, hsh['_summary'], f), tuple(keywords), (uid, typ, setSummary(hsh, d0), fmt_date(d0, True), d0)] items.append(item) if not due: # add the last completion to folder view item = [ ('folder', (f, tstr2SCI[typ][0]), done, hsh['_summary'], f), tuple(folders), (uid, typ, setSummary(hsh, done), sdt, done)] items.append(item) if due: # add a due entry to folder view dtl = due # dts = due.strftime(sortdatefmt) sdt = fmt_date(due, True) time_diff = (due - today_datetime).days if time_diff >= 0: time_str = sdt pastdue = False else: if time_diff > -99: time_str = '%s: %dd' % (sdt, time_diff) else: time_str = sdt pastdue = True time_str = leadingzero.sub('', time_str) if hsh['itemtype'] == '%': if pastdue: typ = 'pd' else: typ = 'ds' elif hsh['itemtype'] == '-': if pastdue: typ = 'pt' else: typ = 'av' else: # group if 'prereqs' in hsh and hsh['prereqs']: if pastdue: typ = 'pu' else: typ = 'cu' else: if pastdue: typ = 'pc' else: typ = 'cs' item = [ ('folder', (f, tstr2SCI[typ][0]), due, hsh['_summary'], f), tuple(folders), (uid, typ, setSummary(hsh, due), time_str, dtl)] items.append(item) if 'k' in hsh and hsh['itemtype'] != '#': if 'n' not in hsh or 'k' not in hsh['n']: keywords = [x.strip() for x in hsh['k'].split(':')] item = [ ('keyword', (hsh['k'], tstr2SCI[typ][0]), due, hsh['_summary'], f), tuple(keywords), (uid, typ, setSummary(hsh, due), time_str, dtl)] items.append(item) if 't' in hsh and hsh['itemtype'] != "#": if 'n' not in hsh or 't' not in hsh['n']: for tag in hsh['t']: item = [ ('tag', (tag, tstr2SCI[typ][0]), due, hsh['_summary'], f), (tag,), (uid, typ, setSummary(hsh, due), time_str, dtl)] items.append(item) if not due and not done: # undated # dts = "none" dtl = today_datetime extstr = "" exttd = "" if 'q' in hsh and type(hsh['q']) is datetime: # extstr = exttd = fmt_datetime(hsh['q'], options=options) dtl = hsh['q'] exttd = hsh['q'] - datetime.now() extstr = fmt_period(abs(exttd), short=True) item = [ ('folder', (f, tstr2SCI[typ][0]), '', hsh['_summary'], f), tuple(folders), (uid, typ, setSummary(hsh, ''), extstr)] items.append(item) if 'k' in hsh and hsh['itemtype'] != "#": if 'n' not in hsh or 'k' not in hsh['n']: keywords = [x.strip() for x in hsh['k'].split(':')] item = [ ('keyword', (hsh['k'], tstr2SCI[typ][0]), dtl, hsh['_summary'], f), tuple(keywords), (uid, typ, setSummary(hsh, ''), extstr, dtl)] items.append(item) if 't' in hsh and hsh['itemtype'] != "#": if 'n' not in hsh or 't' not in hsh['n']: for tag in hsh['t']: item = [ ('tag', (tag, tstr2SCI[typ][0]), dtl, hsh['_summary'], f), (tag,), (uid, typ, setSummary(hsh, ''), extstr, dtl)] items.append(item) else: # not a task type if 's' in hsh: if 'rrule' in hsh: if hsh['itemtype'] in [u'^', u'*', u'~']: dt = ( hsh['rrule'].after(today_datetime, inc=True) or hsh['rrule'].before( today_datetime, inc=True)) else: dt = hsh['rrule'].after(hsh['s'], inc=True) else: dt = parse_str(hsh['s'], hsh['z']).replace(tzinfo=None) # dt = hsh['s'].replace(tzinfo=None) else: dt = None # dts = "none" sdt = "" if dt: if hsh['itemtype'] == '*': sdt = fmt_shortdatetime(dt, options=options) elif hsh['itemtype'] == '~': if 'e' in hsh: sd = fmt_date(dt, True) sd = leadingzero.sub('', sd) sdt = "%s: %s" % ( sd, fmt_period(hsh['e']) ) else: sdt = "" else: # sdt = fmt_date(dt, True) # sdt = leadingzero.sub('', fmt_date(dt, True)), sdt = fmt_date(dt, True) sdt = leadingzero.sub('', sdt) else: dt = today_datetime if hsh['itemtype'] == '*': if 'e' in hsh and hsh['e']: typ = 'ev' else: typ = 'rm' else: typ = type2Str[hsh['itemtype']] item = [ ('folder', (f, tstr2SCI[typ][0]), dt, hsh['_summary'], f), tuple(folders), (uid, typ, setSummary(hsh, dt), sdt, dt)] items.append(item) if 'k' in hsh and hsh['itemtype'] != "#": keywords = [x.strip() for x in hsh['k'].split(':')] item = [ ('keyword', (hsh['k'], tstr2SCI[typ][0]), dt, hsh['_summary'], f), tuple(keywords), (uid, typ, setSummary(hsh, dt), sdt, dt)] items.append(item) if 't' in hsh and hsh['itemtype'] != "#": for tag in hsh['t']: item = [ ('tag', (tag, tstr2SCI[typ][0]), dt, hsh['_summary'], f), (tag,), (uid, typ, setSummary(hsh, dt), sdt, dt)] items.append(item) if hsh['itemtype'] == '#': # don't include hidden items in any other views continue # could be anything # make in basket and someday entries # # sort numbers for now view --- we'll add the typ num to if hsh['itemtype'] == '$': item = [ ('inbasket', (0, tstr2SCI['ib'][0]), dt, hsh['_summary'], f), (uid, 'ib', setSummary(hsh, dt), sdt, dt)] items.append(item) continue if hsh['itemtype'] == '?': item = [ ('someday', 2, (tstr2SCI['so'][0]), dt, hsh['_summary'], f), (uid, 'so', setSummary(hsh, dt), sdt, dt)] items.append(item) continue if hsh['itemtype'] == '!': if not ('k' in hsh and hsh['k']): hsh['k'] = _("none") keywords = [x.strip() for x in hsh['k'].split(':')] item = [ ('note', (hsh['k'], tstr2SCI[typ][0]), '', hsh['_summary'], f), tuple(keywords), (uid, typ, setSummary(hsh, ''), '', dt)] items.append(item) # make entry for next view if 's' not in hsh and hsh['itemtype'] in [u'+', u'-', u'%']: if 'f' in hsh: continue if 'q' in hsh and type(hsh['q']) is datetime: # extstr = exttd = fmt_datetime(hsh['q'], options=options) exttd = hsh['q'] - datetime.now() extstr = fmt_period(abs(exttd), short=True) # extstr = exttd = hsh['q'].strftime(zfmt) elif 'e' in hsh and hsh['e'] is not None: extstr = fmt_period(hsh['e']) exttd = hsh['e'] else: extstr = '' exttd = 0 * ONEDAY if hsh['itemtype'] == '+': if 'prereqs' in hsh and hsh['prereqs']: typ = 'cu' else: typ = 'cs' elif hsh['itemtype'] == '%': typ = 'du' else: typ = type2Str[hsh['itemtype']] if 'n' not in hsh or 'd' not in hsh['n']: item = [ ('next', (1, hsh['c'], hsh['_p'], exttd), tstr2SCI[typ][0], hsh['_p'], hsh['_summary'], f), (hsh['c'],), (uid, typ, hsh['_summary'], extstr)] items.append(item) continue # make entries for day view and friends dates = [] if 'rrule' in hsh: gotall, dates = get_reps(bef, hsh) for date in dates: # add2list("datetimes", (date, f)) datetimes.append((date, f)) elif 's' in hsh and hsh['s'] and 'f' not in hsh: thisdate = parse_str(hsh['s'], hsh['z']).astimezone( tzlocal()).replace(tzinfo=None) dates.append(thisdate) # add2list("datetimes", (thisdate, f)) datetimes.append((thisdate, f)) for dt in dates: dtl = dt sd = dtl.date() st = dtl.time() if typ == 'oc': st_fmt = '' else: st_fmt = fmt_time(st, options=options) alertId = (hsh['_summary'], hsh['s']) summary = setSummary(hsh, dtl) tmpl_hsh = {'alertId': alertId, 'I': uid, 'summary': summary, 'start_date': fmt_date(dtl, True), 'start_time': fmt_time(dtl, True, options=options)} if 't' in hsh: tmpl_hsh['t'] = ', '.join(hsh['t']) else: tmpl_hsh['t'] = '' if 'e' in hsh: try: tmpl_hsh['e'] = fmt_period(hsh['e']) etl = (dtl + hsh['e']) except: logger.exception("Could not fmt hsh['e']=%s" % hsh['e']) else: tmpl_hsh['e'] = '' etl = dtl tmpl_hsh['time_span'] = setItemPeriod( hsh, dtl, etl, options=options) tmpl_hsh['busy_span'] = setItemPeriod( hsh, dtl, etl, True, options=options) for k in ['c', 'd', 'i', 'k', 'l', 'm', 'uid', 'z']: if k in hsh: tmpl_hsh[k] = hsh[k] else: tmpl_hsh[k] = '' if '_a' in hsh and hsh['_a']: for alert in hsh['_a']: time_deltas, acts, arguments = alert if not acts: acts = options['alert_default'] tmpl_hsh['alert_email'] = tmpl_hsh['alert_process'] = '' tmpl_hsh["_alert_action"] = acts tmpl_hsh["_alert_argument"] = arguments num_deltas = len(time_deltas) for i in range(num_deltas): td = time_deltas[i] adt = dtl - td if adt.date() == today_date: this_hsh = deepcopy(tmpl_hsh) if i == num_deltas - 1: this_hsh['next_alert'] = _("This is the last alert.") this_hsh['next'] = None else: nxt = timedelta2Str(time_deltas[i+1]) strt = _("starting time") if nxt == 'none': this_hsh['next'] = _("at the {0}".format(strt)) this_hsh['next_alert'] = _("The next alert is at the {0}.".format(strt)) else: this_hsh['next'] = _("{0} before the {1}".format(nxt, strt)) this_hsh['next_alert'] = _("The next alert is {0} before the {1}.".format(nxt, strt)) this_hsh['td'] = td this_hsh['at'] = adt this_hsh['alert_time'] = fmt_time( adt, True, options=options) this_hsh['time_left'] = timedelta2Str(td) this_hsh['when'] = timedelta2Sentence(td) if adt.date() != dtl.date(): this_hsh['_event_time'] = fmt_period(td) else: this_hsh['_event_time'] = fmt_time( dtl, True, options=options) amn = adt.hour * 60 + adt.minute # we don't want ties in amn else add2list will try to sort on the hash and fail if amn in alert_minutes: # add 6 seconds to avoid the tie alert_minutes[amn] += .1 else: alert_minutes[amn] = amn alerts.append((alert_minutes[amn], this_hsh['I'], this_hsh, f)) if (hsh['itemtype'] in ['+', '-', '%'] and dtl < today_datetime): time_diff = (dtl - today_datetime).days if time_diff == 0: time_str = fmt_period(hsh['e']) pastdue = False else: time_str = '%dd' % time_diff pastdue = True if hsh['itemtype'] == '%': if pastdue: typ = 'pd' else: typ = 'ds' cat = _('Delegated') sn = (2, tstr2SCI[typ][0]) elif hsh['itemtype'] == '-': if pastdue: typ = 'pt' else: typ = 'av' cat = _('Available') sn = (1, tstr2SCI[typ][0]) else: # group if 'prereqs' in hsh and hsh['prereqs']: if pastdue: typ = 'pu' else: typ = 'cu' cat = _('Waiting') sn = (2, tstr2SCI[typ][0]) else: if pastdue: typ = 'pc' else: typ = 'cs' cat = _('Available') sn = (1, tstr2SCI[typ][0]) if 'f' in hsh and 'rrule' not in hsh: continue else: if 'n' not in hsh or 'd' not in hsh['n']: if 'rrule' in hsh and 'o' in hsh and hsh['o'] == 'r': # only nag about the oldest instance if uid in pastduerepeating: continue pastduerepeating.append(uid) item = [ ('now', sn, dtl, hsh['_p'], summary, f), (cat,), (uid, typ, summary, time_str, dtl)] items.append(item) if 'b' in hsh: time_diff = (dtl - today_datetime).days if time_diff > 0 and time_diff <= hsh['b']: if 'n' not in hsh or 'd' not in hsh['n']: extstr = '%dd' % time_diff exttd = 0 * ONEDAY item = [('day', today_datetime.strftime(sortdatefmt), tstr2SCI['by'][0], # tstr2SCI[typ][0], time_diff, hsh['_p'], f), (fmt_date(today_datetime, short=True),), (uid, 'by', summary, extstr, dtl)] items.append(item) datetimes.append((today_datetime, f)) if hsh['itemtype'] == '!': typ = 'ns' item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], '', f), (fmt_date(dt, short=True),), (uid, typ, summary, '', dtl)] items.append(item) continue if hsh['itemtype'] == '^': typ = 'oc' item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], '', f), (fmt_date(dt, short=True),), (uid, typ, summary, '', dtl)] items.append(item) occasions.append([sd, summary, uid, f]) continue if hsh['itemtype'] == '~': typ = 'ac' if 'e' in hsh: sdt = fmt_period(hsh['e']) else: sdt = "" item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], '', f), (fmt_date(dt, short=True),), (uid, 'ac', summary, sdt, dtl)] items.append(item) continue if hsh['itemtype'] == '*': sm = st.hour * 60 + st.minute ed = etl.date() et = etl.time() em = et.hour * 60 + et.minute evnt_summary = "%s: %s" % (tmpl_hsh['summary'], tmpl_hsh['busy_span']) if et != st: et_fmt = " ~ %s" % fmt_time(et, options=options) else: et_fmt = '' if ed > sd: # this event overlaps more than one day # first_min = 24*60 - sm # last_min = em # the first day tuple item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], st.strftime(sorttimefmt), f), (fmt_date(sd, short=True),), (uid, typ, summary, '%s ~ %s' % (st_fmt, options['dayend_fmt']), dtl)] items.append(item) busytimes.append([sd, sm, day_end_minutes, evnt_summary, uid, f]) sd += ONEDAY i = 0 item_copy = [] while sd < ed: item_copy.append([x for x in item]) item_copy[i][0] = list(item_copy[i][0]) item_copy[i][1] = list(item_copy[i][1]) item_copy[i][2] = list(item_copy[i][2]) item_copy[i][0][1] = sd.strftime(sortdatefmt) item_copy[i][1][0] = fmt_date(sd, short=True) item_copy[i][2][3] = '%s ~ %s' % ( options['daybegin_fmt'], options['dayend_fmt']) item_copy[i][0] = tuple(item_copy[i][0]) item_copy[i][1] = tuple(item_copy[i][1]) item_copy[i][2] = tuple(item_copy[i][2]) # add2list("items", item_copy[i]) items.append(item_copy[i]) busytimes.append([sd, 0, day_end_minutes, evnt_summary, uid, f]) sd += ONEDAY i += 1 # the last day tuple if em: item_copy.append([x for x in item]) item_copy[i][0] = list(item_copy[i][0]) item_copy[i][1] = list(item_copy[i][1]) item_copy[i][2] = list(item_copy[i][2]) item_copy[i][0][1] = sd.strftime(sortdatefmt) item_copy[i][1][0] = fmt_date(sd, short=True) item_copy[i][2][3] = '%s%s' % ( options['daybegin_fmt'], et_fmt) item_copy[i][0] = tuple(item_copy[i][0]) item_copy[i][1] = tuple(item_copy[i][1]) item_copy[i][2] = tuple(item_copy[i][2]) # add2list("items", item_copy[i]) items.append(item_copy[i]) busytimes.append([sd, 0, em, evnt_summary, uid, f]) else: # single day event or reminder item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], st.strftime(sorttimefmt), f), (fmt_date(sd, short=True),), (uid, typ, summary, '%s%s' % ( st_fmt, et_fmt), dtl)] items.append(item) busytimes.append([sd, sm, em, evnt_summary, uid, f]) continue # other dated items if (hsh['itemtype'] in ['-', '%'] and ('n' not in hsh or 'd' not in hsh['n'])) or hsh['itemtype'] in ['+']: if 'f' in hsh and hsh['f'] and hsh['f'][-1][1] == dtl: typ = 'fn' else: if hsh['itemtype'] == '%': typ = 'ds' elif hsh['itemtype'] == '+': if 'prereqs' in hsh and hsh['prereqs']: typ = 'cu' else: typ = 'cs' else: typ = 'av' sm = st.hour * 60 + st.minute if sm != 0: ed = etl.date() et = etl.time() em = et.hour * 60 + et.minute # make tasks with set starting times highest priority hsh['_p'] = 0 evnt_summary = "%s: %s" % (tmpl_hsh['summary'], tmpl_hsh['busy_span']) if et != st: et_fmt = " ~ %s" % fmt_time(et, options=options) else: et_fmt = '' if ed > sd: # this task overlaps more than one day # first_min = 24*60 - sm # last_min = em # the first day tuple item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], st.strftime(sorttimefmt), f), (fmt_date(sd, short=True),), (uid, typ, summary, '%s ~ %s' % (st_fmt, options['dayend_fmt']), dtl)] items.append(item) busytimes.append([sd, sm, day_end_minutes, evnt_summary, uid, f]) sd += ONEDAY i = 0 item_copy = [] while sd < ed: item_copy.append([x for x in item]) item_copy[i][0] = list(item_copy[i][0]) item_copy[i][1] = list(item_copy[i][1]) item_copy[i][2] = list(item_copy[i][2]) item_copy[i][0][1] = sd.strftime(sortdatefmt) item_copy[i][1][0] = fmt_date(sd) item_copy[i][2][3] = '%s ~ %s' % ( options['daybegin_fmt'], options['dayend_fmt']) item_copy[i][0] = tuple(item_copy[i][0]) item_copy[i][1] = tuple(item_copy[i][1]) item_copy[i][2] = tuple(item_copy[i][2]) # add2list("items", item_copy[i]) items.append(item_copy[i]) busytimes.append([sd, 0, day_end_minutes, evnt_summary, uid, f]) sd += ONEDAY i += 1 # the last day tuple if em: item_copy.append([x for x in item]) item_copy[i][0] = list(item_copy[i][0]) item_copy[i][1] = list(item_copy[i][1]) item_copy[i][2] = list(item_copy[i][2]) item_copy[i][0][1] = sd.strftime(sortdatefmt) item_copy[i][1][0] = fmt_date(sd) item_copy[i][2][3] = '%s%s' % ( options['daybegin_fmt'], et_fmt) item_copy[i][0] = tuple(item_copy[i][0]) item_copy[i][1] = tuple(item_copy[i][1]) item_copy[i][2] = tuple(item_copy[i][2]) # add2list("items", item_copy[i]) items.append(item_copy[i]) busytimes.append([sd, 0, em, evnt_summary, uid, f]) else: # single day task item = [ ('day', sd.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], st.strftime(sorttimefmt), f), (fmt_date(sd, short=True),), (uid, typ, summary, '%s%s' % ( st_fmt, et_fmt), dtl)] items.append(item) busytimes.append([sd, sm, em, evnt_summary, uid, f]) continue else: # sm == 0 # midnight task - show extent only # use 11:59pm as the sorting datetime dtm = dtl + 1439 * ONEMINUTE item = [ ('day', dtm.strftime(sortdatefmt), tstr2SCI[typ][0], hsh['_p'], '', f), (fmt_date(dt, short=True),), (uid, typ, summary, tmpl_hsh['e'], dtl)] items.append(item) continue file2data[f] = [items, alerts, busytimes, datetimes, occasions] # noinspection PyChainedComparisons def getViewData(bef, file2uuids=None, uuid2hash=None, options=None, file2data=None): """ Collect data on all items, apply filters later """ tt = TimeIt(loglevel=2, label="getViewData") if not file2uuids: file2uuids = {} if not uuid2hash: uuid2hash = {} if not options: options = {} file2data = {} clear_all_data() logger.debug('calling getDataFromFile for {0} files'.format(len(file2uuids.keys()))) for f in file2uuids: getDataFromFile(f, file2data, bef, file2uuids, uuid2hash, options) logger.debug('calling updateViewFromFile for {0} files'.format(len(file2uuids.keys()))) for f in file2data: updateViewFromFile(f, file2data) numfiles = len(file2uuids.keys()) numitems = len(uuid2hash.keys()) logger.info("files: {0}\n file items: {1}\n view items: {2}\n datetimes: {3}\n alerts: {4}\n busytimes: {5}\n occasions: {6}".format(numfiles, numitems, len(list(itemsSL)), len(list(datetimesSL)), len(list(alertsSL)), len(busytimesSL.keys()), len(occasionsSL.keys()))) tt.stop() return file2data def updateViewFromFile(f, file2data): if not file2data: file2data = {} if f not in file2data: file2data[f] = [[], [], [], [], []] _items, _alerts, _busytimes, _datetimes, _occasions = file2data[f] # logger.debug('file: {0}'.format(f)) for item in _items: # logger.debug('adding item: {0}'.format(item)) add2list("items", item) for alert in _alerts: # logger.debug('adding alert: {0}'.format(alert)) add2list("alerts", alert) for dt in _datetimes: # logger.debug('adding datetime: {0}'.format(dt)) add2list("datetimes", dt) for bt in _busytimes: # logger.debug('adding busytime: {0}'.format(bt)) sd, sm, em, evnt_summary, uid, rpth = bt key = sd.isocalendar() add_busytime(key, sm, em, evnt_summary, uid, rpth) for oc in _occasions: # logger.debug('adding occasion: {0}'.format(oc)) sd, evnt_summary, uid, f = oc key = sd.isocalendar() add_occasion(key, evnt_summary, uid, f) def updateViewData(f, bef, file2uuids=None, uuid2hash=None, options=None, file2data=None): tt = TimeIt(loglevel=2, label="updateViewData") if not file2uuids: file2uuids = {} if not uuid2hash: uuid2hash = {} if not options: options = {} if file2data is None: file2data = {} # clear data for this file _items = _alerts = _busytimes = _datetimes = _occasions = [] if file2data is not None and f in file2data: _items, _alerts, _busytimes, _datetimes, _occasions = file2data[f] if _items: for item in _items: # logger.debug('removing item: {0}'.format(item)) removeFromlist("items", item) # itemsSL.remove(item) for alert in _alerts: # logger.debug('removing alert: {0}'.format(alert)) removeFromlist("alerts", alert) # alertsSL.remove(alert) for dt in _datetimes: # logger.debug('removing datetime: {0}'.format(datetime)) removeFromlist("datetimes", dt) # datetimesSL.remove(datetime) for bt in _busytimes: bt = list(bt) sd = bt.pop(0) bt = tuple(bt) key = sd.isocalendar() # logger.debug('removing busytime: {0}: {1}'.format(key, bt)) remove_busytime(key, bt) for oc in _occasions: oc = list(oc) sd = oc.pop(0) oc = tuple(oc) key = sd.isocalendar() # logger.debug('removing occasion: {0}: {1}'.format(key, oc)) remove_occasion(key, oc) # remove the old entry for f in file2data del file2data[f] # update file2data getDataFromFile(f, file2data, bef, file2uuids, uuid2hash, options) # update itemsSL, ... updateViewFromFile(f, file2data) rows = list(itemsSL) alerts = list(alertsSL) datetimes = list(datetimesSL) busytimes = {} for key in busytimesSL: busytimes[key] = list(busytimesSL[key]) occasions = {} for key in occasionsSL: occasions[key] = list(occasionsSL[key]) numitems = len(file2uuids[f]) logger.info("file: {0}\n file items: {1}\n view items: {2}\n datetimes: {3}\n alerts: {4}\n busytimes: {5}\n occasions: {5}".format(f, numitems, len(_items), len(_datetimes), len(_alerts), len(_busytimes), len(_occasions))) tt.stop() return rows, alerts, busytimes, datetimes, occasions, file2data def updateCurrentFiles(allrows, file2uuids, uuid2hash, options): logger.debug("updateCurrent") # logger.debug(('options: {0}'.format(options))) res = True if options['current_textfile']: if 'current_opts' in options and options['current_opts']: txt, count2id = getReportData( options['current_opts'], file2uuids, uuid2hash, options, colors=0) else: tree = getAgenda( allrows, colors=options['agenda_colors'], days=options['agenda_days'], indent=options['current_indent'], width1=options['current_width1'], width2=options['current_width2'], calendars=options['calendars'], omit=options['agenda_omit'], mode='text' ) # logger.debug('text colors: {0}'.format(options['agenda_colors'])) txt, args0, args1 = tree2Text(tree, colors=options['agenda_colors'], indent=options['current_indent'], width1=options['current_width1'], width2=options['current_width2']) # logger.debug('text: {0}'.format(txt)) if txt and not txt[0].strip(): txt.pop(0) fo = codecs.open(options['current_textfile'], 'w', file_encoding) fo.writelines("\n".join(txt)) fo.close() if options['current_htmlfile']: if 'current_opts' in options and options['current_opts']: html, count2id = getReportData( options['current_opts'], file2uuids, uuid2hash, options) else: tree = getAgenda( allrows, colors=options['agenda_colors'], days=options['agenda_days'], indent=options['current_indent'], width1=options['current_width1'], width2=options['current_width2'], calendars=options['calendars'], omit=options['agenda_omit'], mode='html') txt = tree2Html(tree, colors=options['agenda_colors'], indent=options['current_indent'], width1=options['current_width1'], width2=options['current_width2']) if not txt[0].strip(): txt.pop(0) fo = codecs.open(options['current_htmlfile'], 'w', file_encoding) fo.writelines(' \
%s
' % "\n".join(txt)) fo.close() if has_icalendar and options['current_icsfolder']: res = export_ical(file2uuids, uuid2hash, options['current_icsfolder'], options['calendars']) return res def availableDates(s): """ start; end; busy Return dates between start and end that are not in busy where busy is a comma separated list of dates and date intervals, e.g. 'jul 3, jul 7 - jul 15, jul 8, jul 6 - jul 10, jul 23 - aug 8'. """ start_date, end_date, busy_dates = s.split(';') set = dtR.rruleset() set.rrule(rrule(DAILY, dtstart=parse(start_date), until=parse(end_date))) parts = busy_dates.split(',') for part in parts: interval = part.split('-') if len(interval) == 1: set.exdate(parse(interval[0])) if len(interval) == 2: set.exrule(rrule(DAILY, dtstart=parse(interval[0]), until=parse(interval[1]))) res = "\n ".join(x.strftime("%a %b %d") for x in list(set)) prompt = "between {0} and {1}\nbut not in {2}:\n\n {3}".format(start_date.strip(), end_date.strip(), busy_dates.strip(), res) return prompt def tupleSum(list_of_tuples): # get the common length of the tuples l = len(list_of_tuples[0]) res = [] for i in range(l): res.append(sum([x[i] for x in list_of_tuples])) return res def hsh2ical(hsh): """ Convert hsh to ical object and return tuple (Success, object) """ summary = hsh['_summary'] if hsh['itemtype'] in ['*', '^']: element = Event() elif hsh['itemtype'] in ['-', '%', '+']: element = Todo() elif hsh['itemtype'] in ['!', '~']: element = Journal() else: return False, 'Cannot export item type "%s"' % hsh['itemtype'] element.add('uid', hsh[u'I']) if 'z' in hsh: # pytz is required to get the proper tzid into datetimes tz = pytz.timezone(hsh['z']) else: tz = None if 's' in hsh: dt = hsh[u's'] dz = dt.replace(tzinfo=tz) tzinfo = dz.tzinfo dt = dz dd = dz.date() else: dt = None tzinfo = None # tzname = None if u'_r' in hsh: # repeating rlst = hsh[u'_r'] for r in rlst: if r['f'] == 'l': if '+' not in hsh: logger.warn("An entry for '@+' is required but missing.") continue # list only kludge: make it repeat daily for a count of 1 # using the first element from @+ as the starting datetime dz = parse_str(hsh['+'].pop(0), hsh['z']).replace(tzinfo=tzinfo) dt = dz dd = dz.date() r['f'] = 'd' r['t'] = 1 rhsh = {} for k in ical_rrule_keys: if k in r: if k == 'f': rhsh[ical_hsh[k]] = freq_hsh[r[k]] elif k == 'w': if type(r[k]) == list: rhsh[ical_hsh[k]] = [x.upper() for x in r[k]] else: rhsh[ical_hsh[k]] = r[k].upper() elif k == 'u': uz = parse_str(r[k], hsh['z']).replace(tzinfo=tzinfo) rhsh[ical_hsh[k]] = uz else: rhsh[ical_hsh[k]] = r[k] chsh = CaselessDict(rhsh) element.add('rrule', chsh) if '+' in hsh: for pd in hsh['+']: element.add('rdate', pd) if '-' in hsh: for md in hsh['-']: element.add('exdate', md) element.add('summary', summary) if 'q' in hsh: element.add('priority', hsh['_p']) if 'l' in hsh: element.add('location', hsh['l']) if 't' in hsh: element.add('categories', hsh['t']) if 'd' in hsh: element.add('description', hsh['d']) if 'm' in hsh: element.add('comment', hsh['m']) if 'u' in hsh: element.add('organizer', hsh['u']) if 'i' in hsh: for x in hsh['i']: element.add('attendee', "MAILTO:{0}".format(x)) if hsh['itemtype'] in ['-', '+', '%']: done, due, following = getDoneAndTwo(hsh) if 's' in hsh: element.add('dtstart', dt) if done: finz = done.replace(tzinfo=tzinfo) fint = vDatetime(finz) element.add('completed', fint) if due: duez = due.replace(tzinfo=tzinfo) dued = vDate(duez) element.add('due', dued) elif hsh['itemtype'] == '^': element.add('dtstart', dd) elif dt: try: element.add('dtstart', dt) except: logger.exception('exception adding dtstart: {0}'.format(dt)) if hsh['itemtype'] == '*': if 'e' in hsh and hsh['e']: ez = dz + hsh['e'] else: ez = dz try: element.add('dtend', ez) except: logger.exception('exception adding dtend: {0}, {1}'.format(ez, tz)) elif hsh['itemtype'] == '~': if 'e' in hsh and hsh['e']: element.add('comment', timedelta2Str(hsh['e'])) return True, element def export_ical_item(hsh, vcal_file): """ Export a single item in iCalendar format """ if not has_icalendar: logger.error("Could not import icalendar") return False cal = Calendar() cal.add('prodid', '-//etm_tk %s//dgraham.us//' % version) cal.add('version', '2.0') ok, element = hsh2ical(hsh) if not ok: return False cal.add_component(element) (name, ext) = os.path.splitext(vcal_file) pname = "%s.ics" % name try: cal_str = cal.to_ical() except Exception: logger.exception("could not serialize the calendar") return False try: fo = open(pname, 'wb') except: logger.exception("Could not open {0}".format(pname)) return False try: fo.write(cal_str) except Exception: logger.exception("Could not write to {0}".format(pname)) finally: fo.close() return True def export_ical_active(file2uuids, uuid2hash, vcal_file, calendars=None): """ Export items from active calendars to an ics file with the same name in vcal_folder. """ if not has_icalendar: logger.error('Could not import icalendar') return False logger.debug("vcal_file: {0}; calendars: {1}".format(vcal_file, calendars)) calendar = Calendar() calendar.add('prodid', '-//etm_tk {0}//dgraham.us//'.format(version)) calendar.add('version', '2.0') cal_tuples = [] if calendars: for cal in calendars: logger.debug('processing cal: {0}'.format(cal)) if not cal[1]: continue name = cal[0] regex = re.compile(r'^{0}'.format(cal[2])) cal_tuples.append((name, regex)) else: logger.debug('processing cal: all') regex = re.compile(r'^.*') cal_tuples.append(('all', regex)) if not cal_tuples: return logger.debug('using cal_tuples: {0}'.format(cal_tuples)) for rp in file2uuids: match = False for name, regex in cal_tuples: if regex.match(rp): for uid in file2uuids[rp]: this_hsh = uuid2hash[uid] ok, element = hsh2ical(this_hsh) if ok: calendar.add_component(element) break if not match: logger.debug('skipping {0} - no match in calendars'.format(rp)) try: cal_str = calendar.to_ical() except Exception: logger.exception("Could not serialize the calendar: {0}".format(calendar)) return False try: fo = open(vcal_file, 'wb') except: logger.exception("Could not open {0}".format(vcal_file)) return False try: fo.write(cal_str) except Exception: logger.exception("Could not write to {0}" .format(vcal_file)) return False finally: fo.close() return True def export_ical(file2uuids, uuid2hash, vcal_folder, calendars=None): """ Export items from each calendar to an ics file with the same name in vcal_folder. """ if not has_icalendar: logger.error('Could not import icalendar') return False logger.debug("vcal_folder: {0}; calendars: {1}".format(vcal_folder, calendars)) cal_tuples = [] calfiles = [] if calendars: for cal in calendars: logger.debug('processing cal: {0}'.format(cal)) name = cal[0] regex = re.compile(r'^{0}'.format(cal[2])) calendar = Calendar() calendar.add('prodid', '-//etm_tk {0}//dgraham.us//'.format(version)) calendar.add('version', '2.0') cal_tuples.append((name, regex, calendar)) else: logger.debug('processing cal: all') all = Calendar() all.add('prodid', '-//etm_tk {0}//dgraham.us//'.format(version)) all.add('version', '2.0') regex = re.compile(r'^.*') cal_tuples.append(('all', regex, all)) if not cal_tuples: return logger.debug('using cal_tuples: {0}'.format(cal_tuples)) for rp in file2uuids: this_calendar = None this_file = None this_lst = [] # for error logging for name, regex, calendar in cal_tuples: if regex.match(rp): this_calendar = calendar this_file = os.path.join(vcal_folder, "{0}.ics".format(name)) for uid in file2uuids[rp]: this_hsh = uuid2hash[uid] ok, element = hsh2ical(this_hsh) if ok: this_lst.append(element) this_calendar.add_component(element) calfiles.append([this_calendar, this_file, this_hsh, this_lst]) break if not this_calendar: logger.debug('skipping {0} - no match in calendars'.format(rp)) for this_calendar, this_file, this_hsh, this_lst in calfiles: try: cal_str = this_calendar.to_ical() except Exception: logger.exception("Could not serialize the calendar: {0}; {1}\n {2}\n {3}".format(this_calendar, this_file, this_lst, this_hsh)) return False try: fo = open(this_file, 'wb') except: logger.exception("Could not open {0}".format(this_file)) return False try: fo.write(cal_str) except Exception: logger.exception("Could not write to {0}" .format(this_file)) return False finally: fo.close() return True def txt2ical(file2uuids, uuid2hash, datadir, txt_rp, ics_rp): """ Export items from txtfile to icsfile. """ if not has_icalendar: logger.error('Could not import icalendar') return False if txt_rp not in file2uuids: return cal = Calendar() cal.add('prodid', '-//etm_tk {0}//dgraham.us//'.format(version)) cal.add('version', '2.0') for uid in file2uuids[txt_rp]: hsh = uuid2hash[uid] ok, element = hsh2ical(hsh) if ok: cal.add_component(element) try: cal_str = cal.to_ical() except Exception: logger.exception("Could not serialize the calendar") return False ics = os.path.join(datadir, ics_rp) try: fo = open(ics, 'wb') except: logger.exception("Could not open {0}".format(ics)) return False try: fo.write(cal_str) except Exception: logger.exception("Could not write to {0}" .format(ics)) return False finally: fo.close() return True def update_subscription(url, txt): if python_version2: import urllib2 as request else: from urllib import request res = False u = request.urlopen(url) vcal = u.read() if vcal: res = import_ical(vcal=vcal.decode('utf-8'), txt=txt.decode('utf-8')) return res def import_ical(ics="", txt="", vcal=""): if not has_icalendar: logger.error("Could not import icalendar") return False logger.debug("ics: {0}, txt: {1}, vcal:{2}".format(ics, txt, vcal)) if vcal: cal = Calendar.from_ical(vcal) else: g = open(ics, 'rb') cal = Calendar.from_ical(g.read()) g.close() ilst = [] for comp in cal.walk(): clst = [] # dated = False start = None t = '' # item type s = '' # @s e = '' # @e f = '' # @f tzid = comp.get('tzid') if comp.name == "VEVENT": t = '*' start = comp.get('dtstart') if start: s = start.to_ical().decode()[:16] # dated = True end = comp.get('dtend') if end: e = end.to_ical().decode()[:16] logger.debug('start: {0}, s: {1}, end: {2}, e: {3}'.format(start, s, end, e)) extent = parse(e) - parse(s) e = fmt_period(extent) else: t = '^' elif comp.name == "VTODO": t = '-' tmp = comp.get('completed') if tmp: f = tmp.to_ical().decode()[:16] due = comp.get('due') start = comp.get('dtstart') if due: s = due.to_ical().decode() elif start: s = start.to_ical().decode() elif comp.name == "VJOURNAL": t = u'!' tmp = comp.get('dtstart') if tmp: s = tmp.to_ical().decode()[:16] else: continue summary = comp.get('summary') clst = [t, summary] if start: if 'TZID' in start.params: logger.debug("TZID: {0}".format(start.params['TZID'])) clst.append('@z %s' % start.params['TZID']) if s: clst.append("@s %s" % s) if e: clst.append("@e %s" % e) if f: clst.append("@f %s" % f) tzid = comp.get('tzid') if tzid: clst.append("@z %s" % tzid.to_ical().decode()) logger.debug("Using tzid: {0}".format(tzid.to_ical().decode())) else: logger.debug("Using tzid: {0}".format(local_timezone)) clst.append("@z {0}".format(local_timezone)) tmp = comp.get('description') if tmp: clst.append("@d %s" % tmp.to_ical().decode('utf-8')) rule = comp.get('rrule') if rule: rlst = [] keys = rule.sorted_keys() for key in keys: if key == 'FREQ': rlst.append(ical_freq_hsh[rule.get('FREQ')[0].to_ical().decode()]) elif key in ical_rrule_hsh: rlst.append("&%s %s" % ( ical_rrule_hsh[key], ", ".join(map(str, rule.get(key))))) clst.append("@r %s" % " ".join(rlst)) tags = comp.get('categories') if tags: if type(tags) is list: tags = [x.to_ical().decode() for x in tags] clst.append("@t %s" % u', '.join(tags)) else: clst.append("@t %s" % tags) invitees = comp.get('attendee') if invitees: if type(invitees) is list: invitees = [x.to_ical().decode() for x in invitees] ilst = [] for x in invitees: if x.startswith("MAILTO:"): x = x[7:] ilst.append(x) clst.append("@i %s" % u', '.join(ilst)) else: clst.append("@i %s" % invitee) tmp = comp.get('organizer') if tmp: clst.append("@u %s" % tmp.to_ical().decode()) item = u' '.join(clst) ilst.append(item) if ilst: if txt: if os.path.isfile(txt): tmpfile = "{0}.tmp".format(os.path.splitext(txt)[0]) shutil.copy2(txt, tmpfile) fo = codecs.open(txt, 'w', file_encoding) fo.write("\n".join(ilst)) fo.close() elif vcal: return "\n".join(ilst) return True def syncTxt(file2uuids, uuid2hash, datadir, relpath): root = os.path.splitext(relpath)[0] ics_rp = "{0}.ics".format(root) txt_rp = "{0}.txt".format(root) logger.debug('txt_rp: {0}, ics_rp: {1}'.format(txt_rp, ics_rp)) sync_ics = os.path.join(datadir, ics_rp) sync_txt = os.path.join(datadir, txt_rp) logger.debug('sync_txt: {0}, sync_ics: {1}'.format(sync_txt, sync_ics)) mode = 0 # do nothing if os.path.isfile(sync_txt) and not os.path.isfile(sync_ics): mode = 1 # to ics elif os.path.isfile(sync_ics) and not os.path.isfile(sync_txt): mode = 2 # to txt elif os.path.isfile(sync_ics) and os.path.isfile(sync_txt): mod_ics = os.path.getmtime(sync_ics) mod_txt = os.path.getmtime(sync_txt) if mod_ics < mod_txt: logger.debug('mode 1, to ics: {0} < {1}'.format(mod_ics, mod_txt)) mode = 1 # to ics elif mod_txt < mod_ics: logger.debug('mode 2, to txt: {0} > {1}'.format(mod_ics, mod_txt)) mode = 2 # to txt else: logger.debug('sync_txt and sync_ics have the same mtime: {0}'.format(mod_txt)) if not mode: return if mode == 1: # to ics logger.debug('calling txt2ical: {0}, {1}, {2}'.format(datadir, txt_rp, ics_rp)) res = txt2ical(file2uuids, uuid2hash, datadir, txt_rp, ics_rp) if not res: return seconds = os.path.getmtime(sync_txt) elif mode == 2: # to txt res = import_ical(ics=sync_ics, txt=sync_txt) if not res: return seconds = os.path.getmtime(sync_ics) # update times logger.debug('updating mtimes using seconds: {0}'.format(seconds)) os.utime(sync_ics, times=(seconds, seconds)) os.utime(sync_txt, times=(seconds, seconds)) def ensureMonthly(options, date=None): """ """ retval = None if ('monthly' in options and options['monthly']): monthly = os.path.normpath(os.path.join( options['datadir'], options['monthly'])) if not os.path.isdir(monthly): os.makedirs(monthly) sleep(0.5) if date is None: date = datetime.now().date() yr = date.year mn = date.month curryear = os.path.normpath(os.path.join(monthly, "%s" % yr)) if not os.path.isdir(curryear): os.makedirs(curryear) sleep(0.5) currfile = os.path.normpath(os.path.join(curryear, "%02d.txt" % mn)) if not os.path.isfile(currfile): fo = codecs.open(currfile, 'w', options['encoding']['file']) fo.write("") fo.close() if os.path.isfile(currfile): retval = currfile return retval class ETMCmd(): """ Data handling commands """ def __init__(self, options=None, parent=None): if not options: options = {} self.options = options self.calendars = deepcopy(options['calendars']) self.cal_regex = None self.messages = [] self.cmdDict = { '?': self.do_help, 'a': self.do_a, 'd': self.do_d, 'n': self.do_n, 'k': self.do_k, 'm': self.do_m, 'N': self.do_N, 'p': self.do_p, 'c': self.do_c, 't': self.do_t, 'v': self.do_v, } self.helpDict = { 'help': self.help_help, 'a': self.help_a, 'd': self.help_d, 'n': self.help_n, 'k': self.help_k, 'm': self.help_m, 'N': self.help_N, 'p': self.help_p, 'c': self.help_c, 't': self.help_t, 'v': self.help_v, } self.do_update = False self.ruler = '-' # self.rows = [] self.file2uuids = {} self.file2lastmodified = {} self.uuid2hash = {} self.loop = False self.number = True self.count2id = {} self.uuid2labels = {} self.last_rep = "" self.item_hsh = {} self.output = 'text' self.tkversion = '' self.tkstyle = '' self.rows = None self.busytimes = None self.busydays = None self.alerts = None self.occasions = None self.file2data = None self.prevnext = None self.line_length = self.options['agenda_indent'] + self.options['agenda_width1'] + self.options['agenda_width2'] self.currfile = '' # ensureMonthly(options) if 'edit_cmd' in self.options and self.options['edit_cmd']: self.editcmd = self.options['edit_cmd'] else: self.editcmd = '' self.tmpfile = os.path.normpath(os.path.join(self.options['etmdir'], '.temp.txt')) def do_command(self, s): logger.debug('processing command: {0}'.format(s)) args = s.split(' ') cmd = args.pop(0) if args: arg_str = " ".join(args) else: arg_str = '' if cmd not in self.cmdDict: return _('"{0}" is an unrecognized command.').format(cmd) logger.debug('do_command: {0}, {1}'.format(cmd, arg_str)) res = self.cmdDict[cmd](arg_str) return res def do_help(self, cmd): if cmd: return self.helpDict[cmd]() else: return self.help_help() def mk_rep(self, arg_str): logger.debug("arg_str: {0}".format(arg_str)) # we need to return the output string rather than print it self.last_rep = arg_str cmd = arg_str[0] ret = [] views = { # everything but agenda, week and month 'd': 'day', 'p': 'folder', 't': 'tag', 'n': 'note', 'k': 'keyword' } try: if cmd == 'a': if len(arg_str) > 2: f = arg_str[1:].strip() else: f = None return (getAgenda( self.rows, colors=self.options['agenda_colors'], days=self.options['agenda_days'], indent=self.options['agenda_indent'], width1=self.options['agenda_width1'], width2=self.options['agenda_width2'], omit=self.options['agenda_omit'], calendars=self.calendars, mode=self.output, fltr=f)) logger.debug('calling getAgenda') elif cmd in views: view = views[cmd] if len(arg_str) > 2: f = arg_str[1:].strip() else: f = None if not self.rows: return {} rows = deepcopy(self.rows) return (makeTree(rows, view=view, calendars=self.calendars, fltr=f, hide_finished=self.options['hide_finished'])) else: res = getReportData( arg_str, self.file2uuids, self.uuid2hash, self.options) return res except: logger.exception("could not process '{0}'".format(arg_str)) s = str(_('Could not process "{0}".')).format(arg_str) # p = str(_('Enter ? r or ? t for help.')) ret.append(s) return '\n'.join(ret) def loadData(self, e=None): self.count2id = {} now = datetime.now() bef = self.options['bef'] self.file2data = {} logger.debug('calling get_data') uuid2hash, uuid2labels, file2uuids, self.file2lastmodified, bad_datafiles, messages = get_data(options=self.options) self.file2uuids = file2uuids self.uuid2hash = uuid2hash self.uuid2labels = uuid2labels logger.debug('calling getViewData') self.file2data = getViewData(bef, file2uuids, uuid2hash, self.options) self.rows = tuple(itemsSL) self.alerts = list(alertsSL) self.datetimes = list(datetimesSL) self.busytimes = {} for key in busytimesSL: self.busytimes[key] = list(busytimesSL[key]) self.occasions = {} for key in occasionsSL: self.occasions[key] = list(occasionsSL[key]) self.do_update = True self.currfile = ensureMonthly(self.options, now) if self.last_rep: logger.debug('calling mk_rep with {0}'.format(self.last_rep)) return self.mk_rep(self.last_rep) def updateDataFromFile(self, fp, rp): """ Called from safe_save. Calls process_one_file to produce hashes for the items in the file """ logger.debug('starting updateDataFromFile: {0}; {1}'.format(fp, rp)) self.count2id = {} now = datetime.now() bef = self.options['bef'] if rp in self.file2uuids: ids = self.file2uuids[rp] else: ids = [] logger.debug('rp: {0}; ids: {1}'.format(rp, ids)) # remove the old logger.debug('removing the relevant entries in uuid2hash') for id in ids: if id in self.uuid2hash: del self.uuid2hash[id] if id in self.uuid2labels: logger.debug('removing uuid2label[{0}] = {1}'.format(id, self.uuid2labels[id])) del self.uuid2labels[id] logger.debug('removing the relevant entry in file2uuids') self.file2uuids[rp] = [] msg, hashes, u2l = process_one_file(fp, rp, self.options) logger.debug('update labels: {0}'.format(u2l)) self.uuid2labels.update(u2l) loh = [x for x in hashes if x] for hsh in loh: if hsh['itemtype'] == '=': continue logger.debug('adding: {0}, {1}'.format(hsh['I'], hsh['_summary'])) id = hsh['I'] self.uuid2hash[id] = hsh self.file2uuids.setdefault(rp, []).append(id) mtime = os.path.getmtime(fp) self.file2lastmodified[(fp, rp)] = mtime (self.rows, self.alerts, self.busytimes, self.datetimes, self.occasions, self.file2data) = updateViewData(rp, bef, self.file2uuids, self.uuid2hash, self.options, self.file2data) logger.debug('ended updateDataFromFile') def edit_tmp(self): if not self.editcmd: term_print("""\ Either ITEM must be provided or edit_cmd must be specified in etmtk.cfg. """) return [], {} hsh = {'file': self.tmpfile, 'line': 1} cmd = expand_template(self.editcmd, hsh) msg = True while msg: subprocess.call(cmd, shell=True) # check the item fo = codecs.open(self.tmpfile, 'r', file_encoding) lines = [unicode(u'%s') % x.rstrip() for x in fo.readlines()] fo.close() if len(lines) >= 1: while len(lines) >= 1 and not lines[-1]: lines.pop(-1) if not lines: term_print(_('canceled')) return False item = "\n".join(lines) new_hsh, msg = str2hsh(item, options=self.options) if msg: term_print('Error messages:') term_print("\n".join(msg)) rep = raw_input('Correct item? [Yn] ') if rep.lower() == 'n': term_print(_('canceled')) return [], {} item = unicode(u"{0}".format(hsh2str(new_hsh, self.options)[0])) lines = item.split('\n') return lines, new_hsh def commit(self, file, mode=""): if self.options['vcs_system']: mesg = u"{0}".format(mode) if python_version == 2 and type(mesg) == unicode: # hack to avoid unicode in .format() for python 2 cmd = self.options['vcs']['commit'].format( repo=self.options['vcs']['repo'], work=self.options['vcs']['work'], mesg="XXX") cmd = cmd.replace("XXX", mesg) else: cmd = self.options['vcs']['commit'].format( repo=self.options['vcs']['repo'], work=self.options['vcs']['work'], mesg=mesg) subprocess.call(cmd, shell=True) logger.debug("executed vcs commit command:\n {0}".format(cmd)) return True def safe_save(self, file, s, mode="", cli=False): """ Try writing the s to tmpfile and then, if it succeeds, copy tmpfile to file. """ if not mode: mode = "Edited file" logger.debug('starting safe_save. file: {0}, mode: {1}, cli: {2},\n file_encoding: {3}, type(s): {4}, term_encoding: {5}'.format(file, mode, cli, file_encoding, type(s), term_encoding)) try: fo = codecs.open(self.tmpfile, 'w', file_encoding) # add a trailing newline to make diff happy fo.write("{0}\n".format(s.rstrip())) fo.close() except: return 'error writing to file - aborted' shutil.copy2(self.tmpfile, file) logger.debug("modified file: '{0}'".format(file)) pathname, ext = os.path.splitext(file) if not cli and ext == ".txt": # this is a data file fp = file rp = relpath(fp, self.options['datadir']) # this will update self.uuid2hash, ... self.updateDataFromFile(fp, rp) return self.commit(file, mode) def get_itemhash(self, arg_str): try: count = int(arg_str) except: return _('an integer argument is required') if count not in self.count2id: return _('Item number {0} not found').format(count) uid, dtstr = self.count2id[count].split('::') hsh = self.uuid2hash[uid] if dtstr: hsh['_dt'] = parse_str(dtstr, hsh['z']) return hsh def do_a(self, arg_str): return self.mk_rep('a {0}'.format(arg_str)) def help_a(self): return ("""\ Usage: etm a Generate an agenda including dated items for the next {0} days (agenda_days from etmtk.cfg) together with any now and next items.\ """.format(self.options['agenda_days'])) def cmd_do_delete(self, choice): if not choice: return False try: choice = int(choice) except: return False if choice in [1, 2, 4]: hsh = self.item_hsh dt = parse( hsh['_dt']).replace( tzinfo=tzlocal()).astimezone( gettz(hsh['z'])) dtn = dt.replace(tzinfo=None) hsh_rev = deepcopy(hsh) if choice == 1: # delete this instance only by removing it from @+ # or adding it to @- if 'f' in hsh_rev: for i in range(len(hsh_rev['f'])): d = hsh_rev['f'][i][0] if d == dtn: hsh_rev['f'].pop(i) break if '+' in hsh_rev and dtn in hsh_rev['+']: hsh_rev['+'].remove(dtn) if not hsh_rev['+'] and hsh_rev['r'] == 'l': del hsh_rev['r'] del hsh_rev['_r'] else: hsh_rev.setdefault('-', []).append(dt) # newstr = hsh2str(hsh_rev, self.options) self.replace_item(hsh_rev) elif choice == 2: # delete this and all subsequent instances by adding # this instance - one minute to &u for each @r if 'f' in hsh_rev: for i in range(len(hsh_rev['f'])): d = hsh_rev['f'][i][0] if d >= dtn: hsh_rev['f'].pop(i) tmp = [] for h in hsh_rev['_r']: if 'f' in h and h['f'] != u'l': h['u'] = dtn - ONEMINUTE tmp.append(h) hsh_rev['_r'] = tmp if u'+' in hsh: tmp_rev = [] for d in hsh_rev['+']: if d < dtn: tmp_rev.append(d) if tmp_rev: hsh_rev['+'] = tmp_rev else: del hsh_rev['+'] if u'-' in hsh: tmp_rev = [] for d in hsh_rev['-']: if d < dtn: tmp_rev.append(d) if tmp_rev: hsh_rev['-'] = tmp_rev else: del hsh_rev['-'] self.replace_item(hsh_rev) elif choice == 4: # delete all previous instances if 'f' in hsh_rev: for i in range(len(hsh_rev['f']), 0, -1): d = hsh_rev['f'][i-1][1] if d < dtn: hsh_rev['f'].pop(i-1) if not hsh_rev['f']: del hsh_rev['f'] if u'+' in hsh: logger.debug('starting @+: {0}'.format(hsh['+'])) tmp_rev = [] for d in hsh_rev['+']: if d >= dtn: tmp_rev.append(d) if tmp_rev: hsh_rev['+'] = tmp_rev logger.debug('ending @+: {0}'.format(hsh['+'])) else: del hsh_rev['+'] logger.debug('removed @+') if u'-' in hsh: logger.debug('starting @-: {0}'.format(hsh['-'])) tmp_rev = [] for d in hsh_rev['-']: if d >= dtn: tmp_rev.append(d) if tmp_rev: hsh_rev['-'] = tmp_rev logger.debug('ending @-: {0}'.format(hsh['-'])) else: del hsh_rev['-'] logger.debug('removed @-') hsh_rev['s'] = dtn self.replace_item(hsh_rev) else: self.delete_item() def cmd_do_reschedule(self, new_dtn): # new_dtn = new_dt.astimezone(gettz(self.item_hsh['z'])).replace(tzinfo=None) hsh_rev = deepcopy(self.item_hsh) if self.old_dt: # old_dtn = self.old_dt.astimezone(gettz(self.item_hsh['z'])).replace(tzinfo=None) old_dtn = self.old_dt if 'r' in hsh_rev: if '+' in hsh_rev and old_dtn in hsh_rev['+']: hsh_rev['+'].remove(old_dtn) if not hsh_rev['+'] and hsh_rev['r'] == 'l': del hsh_rev['r'] del hsh_rev['_r'] else: hsh_rev.setdefault('-', []).append(old_dtn) hsh_rev.setdefault('+', []).append(new_dtn) # check starting time if new_dtn < hsh_rev['s']: d = (hsh_rev['s'] - new_dtn).days hsh_rev['s'] -= (d + 1) * ONEDAY else: # dated but not repeating hsh_rev['s'] = new_dtn else: # either undated or not repeating hsh_rev['s'] = new_dtn logger.debug(('replacement: {0}'.format(hsh_rev))) self.replace_item(hsh_rev) def cmd_do_schedulenew(self, new_dtn): # new_dtn = new_dt.astimezone(gettz(self.item_hsh['z'])).replace(tzinfo=None) hsh_rev = deepcopy(self.item_hsh) if self.old_dt: # old_dtn = self.old_dt.astimezone(gettz(self.item_hsh['z'])).replace(tzinfo=None) if 'r' in hsh_rev: if '+' in hsh_rev and new_dtn in hsh_rev['+']: return if '-' in hsh_rev and new_dtn in hsh_rev['-']: hsh_rev['-'].remove(new_dtn) else: hsh_rev.setdefault('+', []).append(new_dtn) # check starting time if new_dtn < hsh_rev['s']: d = (hsh_rev['s'] - new_dtn).days hsh_rev['s'] -= (d + 1) * ONEDAY else: # dated but not repeating if hsh_rev['s'] == new_dtn: return hsh_rev['r'] = 'l' hsh_rev.setdefault('+', []).append(new_dtn) else: # either undated or not repeating hsh_rev['s'] = new_dtn logger.debug(('replacement: {0}'.format(hsh_rev))) self.replace_item(hsh_rev) def delete_item(self): f, begline, endline = self.item_hsh['fileinfo'] fp = os.path.normpath(os.path.join(self.options['datadir'], f)) fo = codecs.open(fp, 'r', file_encoding) lines = fo.readlines() fo.close() self.replace_lines(fp, lines, begline, endline, []) return True def replace_item(self, new_hsh): new_item, msg = hsh2str(new_hsh, self.options) logger.debug(new_item) newlines = new_item.split('\n') f, begline, endline = new_hsh['fileinfo'] fp = os.path.normpath(os.path.join(self.options['datadir'], f)) fo = codecs.open(fp, 'r', file_encoding) lines = fo.readlines() fo.close() self.replace_lines(fp, lines, begline, endline, newlines) # self.loadData() return True def append_item(self, new_hsh, file, cli=False): """ """ # new_item, msg = hsh2str(new_hsh, self.options, include_uid=True) new_item, msg = hsh2str(new_hsh, self.options) old_items = getFileItems(file, self.options['datadir'], False) items = [u'%s' % x[0].rstrip() for x in old_items if x[0].strip()] items.append(new_item) itemstr = "\n".join(items) mode = _("added item") logger.debug('saving {0} to {1}, mode: {2}'.format(itemstr, file, mode)) self.safe_save(file, itemstr, mode=mode, cli=cli) # self.loadData() return "break" def cmd_do_finish(self, dt, options={}): """ Called by do_f to process the finish datetime and add it to the file. """ hsh = self.item_hsh done, due, following = getDoneAndTwo(hsh) if 'z' not in hsh: hsh['z'] = options['local_timezone'] if due: # undated tasks won't have a due date ddn = due.replace( tzinfo=tzlocal()).astimezone( gettz(hsh['z'])).replace(tzinfo=None) else: ddn = '' if 's' in hsh and 'o' in hsh and hsh['o'] == 'r': hours = hsh['s'].hour minutes = hsh['s'].minute dt = dt.replace(hour=hours, minute=minutes, second=0, microsecond=0) if hsh['itemtype'] == u'+': m = group_regex.match(hsh['_summary']) if m: group, num, tot, job = m.groups() hsh['_j'][int(num) - 1]['f'] = [ (dt.replace(tzinfo=None), ddn)] finished = True # check to see if all jobs are finished for job in hsh['_j']: if 'f' not in job: finished = False break if finished: # move the finish dates from the jobs to the history for j in range(len(hsh['_j'])): job = hsh['_j'][j] job.setdefault('h', []).append(job['f'][0]) del job['f'] hsh['_j'][j] = job # and add the last finish date (this one) to the group completion = (dt.replace(tzinfo=None), ddn) hsh['f'] = [completion] else: dtz = dt.replace(tzinfo=tzlocal()).astimezone(gettz(hsh['z'])).replace(tzinfo=None) if not ddn: ddn = dtz hsh.setdefault('f', []).append((dtz, ddn)) logger.debug('finish hsh: {0}'.format(hsh)) self.replace_item(hsh) def do_k(self, arg_str): # self.prevnext = getPrevNext(self.dates) return self.mk_rep('k {0}'.format(arg_str)) @staticmethod def help_k(): return ("""\ Usage: etm k [FILTER] Show items grouped and sorted by keyword optionally limited to those containing a case insenstive match for the regex FILTER.\ """) def do_m(self, arg_str): lines = self.options['reports'] try: n = int(arg_str) if n < 1 or n > len(lines): return _('report {0} does not exist'.format(n)) except: return self.help_m() rep_spec = "{0}".format(lines[n - 1].strip().split('#')[0]) logger.debug(('rep_spec: {0}'.format(rep_spec))) tree = getReportData( rep_spec, self.file2uuids, self.uuid2hash, self.options) return(tree) def help_m(self): res = [] lines = self.options['reports'] if lines: res.append(_("""\ Usage: etm m N where N is the number of a report specification:\n """)) for i in range(len(lines)): res.append("{0:>2}. {1}".format(i + 1, lines[i].strip())) return "\n".join(res) # return(res) def do_n(self, arg_str): return self.mk_rep('n {0}'.format(arg_str)) @staticmethod def help_n(): return ("""\ Usage: etm N [FILTER] Show notes grouped and sorted by keyword optionally limited to those containing a case insenstive match for the regex FILTER.\ """) def do_N(self, arg_str='', itemstr=""): logger.debug('arg_str: {0}, type(arg_str): {1}'.format(arg_str, type(arg_str))) if arg_str: new_item = s2or3(arg_str) new_hsh, msg = str2hsh(new_item, options=self.options) logger.debug('new_hsh: {0}'.format(new_hsh)) if msg: return "\n".join(msg) if 's' not in new_hsh: new_hsh['s'] = None res = self.append_item(new_hsh, self.currfile, cli=True) if res: return _("item saved") @staticmethod def help_N(): return _("""\ Usage: etm n ITEM Create a new item from ITEM. E.g., etm n '* meeting @s +0 4p @e 1h30m' The item will be appended to the monthly file for the current month.\ """) @staticmethod def do_q(line): sys.exit() @staticmethod def help_q(): return '{0}\n'.format(_("quit")) def do_c(self, arg): logger.debug('custom spec: {0}, {1}'.format(arg, type(arg))) """report (non actions) specification""" if not arg: return self.help_c() res = getReportData( arg, self.file2uuids, self.uuid2hash, self.options) return res @staticmethod def help_c(): return _("""\ Usage: etm c [options] Generate a custom view where type is either 'a' (action) or 'c' (composite). Groupby can include *semicolon* separated date specifications and elements from: c context f file path k keyword t tag u user A *date specification* is either w: week number or a combination of one or more of the following: yy: 2-digit year yyyy: 4-digit year MM: month: 01 - 12 MMM: locale specific abbreviated month name: Jan - Dec MMMM: locale specific month name: January - December dd: month day: 01 - 31 ddd: locale specific abbreviated week day: Mon - Sun dddd: locale specific week day: Monday - Sunday Options include: -b begin date -c context regex -d depth (CLI a reports only) -e end date -f file regex -k keyword regex -l location regex -o omit (r reports only) -s summary regex -S search regex -t tags regex -u user regex -w column 1 width -W column 2 width Example: etm c 'c ddd, MMM dd yyyy -b 1 -e +1/1' """) def do_d(self, arg_str): if self.calendars: cal_pattern = r'^%s' % '|'.join( [x[2] for x in self.calendars if x[1]]) self.cal_regex = re.compile(cal_pattern) logger.debug("cal_pattern: {0}".format(cal_pattern)) self.prevnext = getPrevNext(self.datetimes, self.cal_regex) return self.mk_rep('d {0}'.format(arg_str)) @staticmethod def help_d(): return ("""\ Usage: etm d [FILTER] Show the day view with dated items grouped and sorted by date and type, optionally limited to those containing a case insensitive match for the regex FILTER.\ """) def do_p(self, arg_str): return self.mk_rep('p {0}'.format(arg_str)) @staticmethod def help_p(): return ("""\ Usage: etm p [FILTER] Show items grouped and sorted by file path, optionally limited to those containing a case insensitive match for the regex FILTER.\ """) def do_t(self, arg_str): return self.mk_rep('t {0}'.format(arg_str)) @staticmethod def help_t(): return ("""\ Usage: etm t [FILTER] Show items grouped and sorted by tag, optionally limited to those containing a case insensitive match for the regex FILTER.\ """) def do_v(self, arg_str): d = { 'copyright': '2009-%s' % datetime.today().strftime("%Y"), 'home': 'www.duke.edu/~dgraham/etmtk', 'dev': 'daniel.graham@duke.edu', 'group': "groups.google.com/group/eventandtaskmanager", 'gpl': 'www.gnu.org/licenses/gpl.html', 'etmversion': fullversion, 'platform': platform.platform(terse=1), 'python': platform.python_version(), 'dateutil': dateutil_version, 'pyyaml': yaml.__version__, 'tkversion': self.tkversion, 'tkstyle': self.tkstyle, 'file_encoding': file_encoding, 'gui_encoding': gui_encoding, 'term_encoding': term_encoding, 'github': 'https://github.com/dagraham/etm-tk', } if not d['tkversion']: # command line d['tkversion'] = 'NA' if not d['tkstyle']: # command line d['tkstyle'] = 'NA' return _("""\ Event and Task Manager etmtk {0[etmversion]} This application provides a format for using plain text files to store events, tasks and other items and a Tk based GUI for creating and modifying items as well as viewing them. System Information: Platform: {0[platform]} Python: {0[python]} Dateutil: {0[dateutil]} PyYaml: {0[pyyaml]} Tk/Tcl Version: {0[tkversion]} Style: {0[tkstyle]} Encodings File: {0[file_encoding]} GUI: {0[gui_encoding]} Term: {0[term_encoding]} ETM Information: Homepage: {0[home]} Discussion: {0[group]} GitHub: {0[github]} Developer: {0[dev]} GPL License: {0[gpl]} Copyright {0[copyright]} {0[dev]}. All rights reserved. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.\ """.format(d)) @staticmethod def help_v(): return _("""\ Display information about etm and the operating system.""") @staticmethod def help_help(): return (USAGE) def replace_lines(self, fp, oldlines, begline, endline, newlines): lines = oldlines del lines[begline - 1:endline] newlines.reverse() for x in newlines: lines.insert(begline - 1, x) itemstr = "\n".join([unicode(u'%s') % x.rstrip() for x in lines if x.strip()]) if newlines: mode = _("replaced item") else: mode = _("removed item") self.safe_save(fp, itemstr, mode=mode) return "break" def main(etmdir='', argv=[]): global lang, trans lang = trans = None logger.debug("data.main etmdir: {0}, argv: {1}".format(etmdir, argv)) use_locale = () (user_options, options, use_locale) = get_options(etmdir) ARGS = ['a', 'k', 'm', 'n', 'N', 'p', 'c', 'd', 't', 'v'] QUESTION = s2or3("?") if len(argv) > 1: for i in range(len(argv)-1): j = i+1 argv[j] = s2or3(argv[j]) c = ETMCmd(options) c.loop = False c.number = False args = [] if len(argv) == 2 and argv[1] == QUESTION: term_print(USAGE) elif len(argv) == 2 and argv[1] == 'v': term_print(c.do_v("")) elif len(argv) == 3 and QUESTION in argv: if argv[1] == QUESTION: args = [QUESTION, argv[2]] else: args = [QUESTION, argv[1]] if args[1] not in ARGS: term_print(USAGE) else: argstr = ' '.join(args) res = c.do_command(argstr) term_print(res) elif argv[1] in ARGS: for x in argv[1:]: x = s2or3(x) args.append(x) argstr = ' '.join(args) opts = {} if len(args) > 1: try: tmp = str2opts(" ".join(args[1:]), options) except: logger.exception('Could not process" {0}'.format(args[1:])) return if len(tmp) == 3: opts = str2opts(" ".join(args[1:]), options)[0] tt = TimeIt(loglevel=2, label="cmd '{0}'".format(argstr)) c.loadData() res = c.do_command(argstr) width1 = 43 if opts and 'width1' in opts: width1 = opts['width1'] elif options: if 'report_width1' in options: width1 = options['report_width1'] elif 'agenda_width1' in options: width1 = options['agenda_width1'] width2 = 20 if opts and 'width2' in opts: width2 = opts['width2'] elif options: if 'report_width2' in options: width2 = options['report_width2'] elif 'agenda_width2' in options: width2 = options['agenda_width2'] indent = 4 if options: if 'report_indent' in options: indent = options['report_indent'] elif 'agenda_indent' in options: indent = options['agenda_indent'] colors = 0 if options: if 'report_colors' in options: colors = options['report_colors'] elif 'agenda_colors' in options: colors = options['agenda_colors'] if type(res) is dict: logger.debug("data.main res is dict; calling tree2Text width1={0}, width2={1}".format(width1, width2)) lines = tree2Text(res, indent=indent, width1=width1, width2=width2, colors=colors)[0] if lines and not lines[0]: lines.pop(0) res = "\n".join(lines) tt.stop() term_print(res) else: logger.warn("argv: {0}".format(argv)) if __name__ == "__main__": etmdir = '' if len(sys.argv) > 1: if sys.argv[1] not in ['a', 'c']: etmdir = sys.argv.pop(1) main(etmdir, sys.argv) etmtk-3.2.22/etmTk/dialog.py0000644000076500000240000022771312567341202015515 0ustar dagstaff00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import (absolute_import, division, print_function, unicode_literals) import logging import logging.config import uuid import os import os.path logger = logging.getLogger() import codecs import yaml import platform if platform.python_version() >= '3': import tkinter from tkinter import ( ACTIVE, BooleanVar, BOTH, BROWSE, Button, Checkbutton, END, Entry, FLAT, Frame, INSERT, IntVar, Label, LEFT, Listbox, Radiobutton, RAISED, RIGHT, Scrollbar, StringVar, TclError, Text, Toplevel, W, X, ) from tkinter import ttk from tkinter import font as tkFont unicode = str else: import Tkinter as tkinter from Tkinter import ( ACTIVE, BooleanVar, BOTH, BROWSE, Button, Checkbutton, END, Entry, FLAT, Frame, INSERT, IntVar, Label, LEFT, Listbox, Radiobutton, RAISED, RIGHT, Scrollbar, StringVar, TclError, Text, Toplevel, W, X, ) import ttk import tkFont from datetime import datetime, timedelta from etmTk.data import ( BGCOLOR, FGCOLOR, commandShortcut, completion_regex, ensureMonthly, fmt_date, fmt_period, fmt_shortdatetime, fmt_time, get_current_time, get_reps, getFileTuples, hsh2str, import_ical, parse_str, parse_period, relpath, rrulefmt, s2or3, str2hsh, uniqueId, ) SOMEREPS = _('selected repetitions') ALLREPS = _('all repetitions') MESSAGES = _('Error messages') VALID = _("Valid entry") FOUND = "found" # for found text marking FINISH = _("Save and Close") # MAKE = _("Make") PRINT = _("Print") EXPORTTEXT = _("Export report in text format ...") EXPORTCSV = _("Export report in CSV format ...") SAVESPECS = _("Save changes to report specifications") CLOSE = _("Close") # VALID = _("Valid {0}").format(u"\u2714") SAVEANDEXIT = _("Save changes and exit?") UNCHANGEDEXIT = _("Item is unchanged. Exit?") CREATENEW = _("creating a new item") EDITEXISTING = _("editing an existing item") type2Text = { '$': _("In Basket"), '^': _("Occasion"), '*': _("Event"), '~': _("Action"), '!': _("Note"), # undated only appear in folders '-': _("Task"), # for next view '+': _("Task Group"), # for next view '%': _("Delegated Task"), '?': _("Someday"), '#': _("Hidden") } def sanitize_id(id): if type(id) == str: return id.strip().replace(" ", "") else: return id (_ADD, _DELETE, _INSERT) = range(3) (_ROOT, _DEPTH, _WIDTH) = range(3) ONEMINUTE = timedelta(minutes=1) ONEHOUR = timedelta(hours=1) ONEDAY = timedelta(days=1) ONEWEEK = timedelta(weeks=1) STOPPED = _('stopped') PAUSED = _('paused') RUNNING = _('running') FOUND = "found" class SimpleEditor(Toplevel): def __init__(self, parent=None, master=None, file=None, line=None, newhsh=None, rephsh=None, options=None, title=None, start=None, modified=False): """ If file is given, open file for editing. Otherwise, we are creating a new item and/or replacing an item mode: 1: new: edit newhsh, replace none 2: replace: edit and replace rephsh 3: new and replace: edit newhsh, replace rephsh :param parent: :param file: path to file to be edited """ # self.frame = frame = Frame(parent) if master is None: master = parent self.master = master Toplevel.__init__(self, master) self.minsize(400, 300) self.geometry('500x200') self.transient(parent) self.parent = parent self.loop = parent.loop BGCOLOR = self.loop.options['background_color'] self.BGCOLOR = BGCOLOR HLCOLOR = self.loop.options['highlight_color'] self.HLCOLOR = HLCOLOR FGCOLOR = self.loop.options['foreground_color'] self.FGCOLOR = FGCOLOR self.configure(background=BGCOLOR, highlightcolor=HLCOLOR) self.messages = self.loop.messages self.messages = [] self.mode = None self.changed = False self.scrollbar = None self.listbox = None self.autocompletewindow = None self.line = None self.match = None self.file = file self.initfile = None self.fileinfo = None self.repinfo = None self.title = title self.edithsh = {} self.newhsh = newhsh self.rephsh = rephsh self.value = '' self.options = options self.tkfixedfont = tkFont.nametofont("TkFixedFont") self.tkfixedfont.configure(size=self.options['fontsize_fixed']) # self.text_value.trace_variable("w", self.setSaveStatus) frame = Frame(self, bd=0) frame.pack(side="bottom", fill=X, padx=4, pady=0) frame.configure(background=BGCOLOR, highlightcolor=HLCOLOR, highlightbackground=BGCOLOR) qb = ttk.Button(frame, text=_("Cancel"), style="bg.TButton", command=self.quit) qb.pack(side=LEFT, padx=4) self.bind("", self.quit) l, c = commandShortcut('q') self.bind(c, self.quit) self.bind("", self.cancel) # finish will evaluate the item entry and, if repeating, show reps finish = ttk.Button(frame, text=FINISH, style="bg.TButton", command=self.onFinish) finish.pack(side=RIGHT, padx=4) self.bind("", self.onFinish) # find xb = ttk.Button(frame, text='\u2716', command=self.clearFind, style="bg.TButton", width=0) xb.pack(side=LEFT, padx=0) self.find_text = StringVar(frame) self.e = Entry(frame, textvariable=self.find_text, width=10, highlightcolor=HLCOLOR, background=BGCOLOR, highlightbackground=BGCOLOR, highlightthickness=2, bd=2, foreground=FGCOLOR) self.e.pack(side=LEFT, padx=2, expand=1, fill=X) self.e.bind("", self.onFind) nb = ttk.Button(frame, text='\u279c', command=self.onFind, style="bg.TButton", width=0) nb.pack(side=LEFT, padx=0) text = Text(self, wrap="word", bd=2, relief="sunken", padx=3, pady=2, font=self.tkfixedfont, undo=True, width=70) text.configure(highlightthickness=0, background=BGCOLOR, foreground=FGCOLOR, insertbackground=FGCOLOR) text.tag_configure(FOUND, background=FGCOLOR, foreground=BGCOLOR) text.pack(side="bottom", padx=4, pady=3, expand=1, fill=BOTH) self.text = text self.completions = self.loop.options['completions'] if start is not None: # we have the starting text but will need a new uid text = start if self.rephsh is None: self.edithsh = {} self.mode = 1 self.title = CREATENEW else: self.edithsh = self.rephsh self.mode = 2 self.title = EDITEXISTING elif file is not None: # we're editing a file - if it's a data file we will add uid's # as necessary when saving self.mode = 'file' if not os.path.isfile(file): logger.warn('could not open: {0}'.format(file)) text = "" else: with codecs.open(file, 'r', self.options['encoding']['file']) as f: text = f.read() else: # we are creating a new item and/or replacing an item # mode: # 1: new # 2: replace # 3: new and replace initfile = ensureMonthly(options=self.options, date=datetime.now()) # set the mode if newhsh is None and rephsh is None: # we are creating a new item from scratch and will need # a new uid self.mode = 1 self.title = CREATENEW self.edithsh = {} self.edithsh['I'] = uniqueId() text = '' elif rephsh is None: # newhsh is not None # we are creating a new item as a copy and will need # a new uid self.mode = 1 self.title = CREATENEW self.edithsh = self.newhsh self.edithsh['I'] = uniqueId() if ('fileinfo' in newhsh and newhsh['fileinfo']): initfile = newhsh['fileinfo'][0] text, msg = hsh2str(self.edithsh, self.options) elif newhsh is None: # we are editing and replacing rephsh - no file prompt # using existing uid self.title = EDITEXISTING self.mode = 2 # self.repinfo = rephsh['fileinfo'] self.edithsh = self.rephsh text, msg = hsh2str(self.edithsh, self.options) else: # neither is None # we are changing some instances of a repeating item # we will be writing but not editing rephsh using its fileinfo # and its existing uid # we will be editing and saving newhsh using self.initfile # we will need a new uid for newhsh self.mode = 3 self.title = CREATENEW self.edithsh = self.newhsh self.edithsh['I'] = uniqueId() if 'fileinfo' in newhsh and newhsh['fileinfo'][0]: initfile = self.newhsh['fileinfo'][0] text, msg = hsh2str(self.edithsh, self.options) self.initfile = initfile logger.debug('mode: {0}; initfile: {1}; edit: {2}'.format(self.mode, self.initfile, self.edithsh)) if self.title is not None: self.wm_title(self.title) self.settext(text) # clear the undo buffer if not modified: self.text.edit_reset() self.setmodified(False) self.text.bind('<>', self.updateSaveStatus) self.text.focus_set() self.protocol("WM_DELETE_WINDOW", self.quit) if parent: self.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50)) self.configure(background=BGCOLOR) l, c = commandShortcut('f') self.bind(c, lambda e: self.e.focus_set()) l, c = commandShortcut('g') self.bind(c, lambda e: self.onFind()) if start: # self.text.tag_add("sel", "1.1", "1.{0}".format(len(start))) self.text.mark_set(INSERT, 0.0) elif line: self.text.mark_set(INSERT, "{0}.0".format(line)) else: self.text.mark_set(INSERT, END) self.text.see(INSERT) # l, c = commandShortcut('/') logger.debug("/: {0}, {1}".format(l, c)) self.text.bind("", self.showCompletions) self.grab_set() self.wait_window(self) def settext(self, text=''): self.text.delete('1.0', END) self.text.insert(INSERT, text) self.text.mark_set(INSERT, '1.0') self.text.focus() logger.debug("modified: {0}".format(self.checkmodified())) def gettext(self): return self.text.get('1.0', END + '-1c') def setCompletions(self, *args): match = self.filterValue.get() self.matches = matches = [x for x in self.completions if x and x.lower().startswith(match.lower())] self.listbox.delete(0, END) for item in matches: self.listbox.insert(END, item) self.listbox.select_set(0) self.listbox.see(0) self.fltr.focus_set() def showCompletions(self, e=None): if not self.completions: return "break" if self.autocompletewindow: return "break" line = self.text.get("insert linestart", INSERT) m = completion_regex.search(line) if not m: logger.debug("no match in {0}".format(line)) return "break" # set self.match here since it determines the characters to be replaced self.match = match = m.groups()[0] logger.debug("found match '{0}' in line '{1}'".format(match, line)) self.autocompletewindow = acw = Toplevel(master=self.text) acw.geometry("+%d+%d" % (self.text.winfo_rootx() + 50, self.text.winfo_rooty() + 50)) self.autocompletewindow.wm_attributes("-topmost", 1) self.filterValue = StringVar(self) self.filterValue.set(match) self.filterValue.trace_variable("w", self.setCompletions) self.fltr = Entry(acw, textvariable=self.filterValue) self.fltr.pack(side="top", fill="x") self.fltr.icursor(END) self.listbox = listbox = Listbox(acw, exportselection=False, width=self.loop.options['completions_width']) listbox.pack(side="bottom", fill=BOTH, expand=True) self.autocompletewindow.bind("", self.completionSelected) self.autocompletewindow.bind("", self.completionSelected) self.autocompletewindow.bind("", self.hideCompletions) self.autocompletewindow.bind("", self.cursorUp) self.autocompletewindow.bind("", self.cursorDown) self.fltr.bind("", self.cursorUp) self.fltr.bind("", self.cursorDown) self.setCompletions() def is_active(self): return self.autocompletewindow is not None def hideCompletions(self, e=None): if not self.is_active(): return # destroy widgets self.listbox.destroy() self.listbox = None self.autocompletewindow.destroy() self.autocompletewindow = None def completionSelected(self, event): # Put the selected completion in the text, and close the list modified = False if self.matches: cursel = self.matches[int(self.listbox.curselection()[0])] else: cursel = self.filterValue.get() modified = True start = "insert-{0}c".format(len(self.match)) end = "insert-1c wordend" logger.debug("cursel: {0}; match: {1}; start: {2}; insert: {3}".format( cursel, self.match, start, INSERT)) self.text.delete(start, end) self.text.insert(INSERT, cursel) self.hideCompletions() if modified: file = FileChoice(self, "append completion to file", prefix=self.loop.options['etmdir'], list=self.loop.options['completion_files']).returnValue() if (file and os.path.isfile(file)): with codecs.open(file, 'r', self.loop.options['encoding']['file']) as fo: lines = fo.readlines() lines.append(cursel) lines.sort() content = "\n".join([x.strip() for x in lines if x.strip()]) with codecs.open(file, 'w', self.loop.options['encoding']['file']) as fo: fo.write(content) self.completions.append(cursel) self.completions.sort() def cursorUp(self, event=None): cursel = int(self.listbox.curselection()[0]) # newsel = max(0, cursel=1) newsel = max(0, cursel - 1) self.listbox.select_clear(cursel) self.listbox.select_set(newsel) self.listbox.see(newsel) return "break" def cursorDown(self, event=None): cursel = int(self.listbox.curselection()[0]) newsel = min(len(self.matches) - 1, cursel + 1) self.listbox.select_clear(cursel) self.listbox.select_set(newsel) self.listbox.see(newsel) return "break" def setmodified(self, bool): if bool is not None: self.text.edit_modified(bool) def checkmodified(self): return self.text.edit_modified() def updateSaveStatus(self, event=None): # Called by <> if self.checkmodified(): self.wm_title("{0} (modified)".format(self.title)) else: self.wm_title("{0}".format(self.title)) def onFinish(self, e=None): if self.mode == 'file': self.onSave() else: self.onCheck() def onSave(self, e=None, v=0): if not self.checkmodified(): self.quit() elif self.file is not None: # we are editing a file alltext = self.gettext() self.loop.safe_save(self.file, alltext) self.setmodified(False) self.changed = True self.quit() else: # we are editing an item if self.mode in [1, 3]: # new dir = self.options['datadir'] if 's' in self.edithsh and self.edithsh['s']: dt = self.edithsh['s'] file = ensureMonthly(self.options, dt.date()) else: dt = None file = ensureMonthly(self.options) dir, initfile = os.path.split(file) # we need a filename for the new item # make datadir the root prefix, tuples = getFileTuples(self.options['datadir'], include=r'*.txt') if v == 2: filename = file else: ret = FileChoice(self, "etm data files", prefix=prefix, list=tuples, start=file).returnValue() if not ret: return False filename = os.path.join(prefix, ret) if not os.path.isfile(filename): return False filename = os.path.normpath(filename) logger.debug('saving to: {0}'.format(filename)) self.text.focus_set() logger.debug('edithsh: {0}'.format(self.edithsh)) if self.mode == 1: if self.loop.append_item(self.edithsh, filename): logger.debug('append mode: {0}'.format(self.mode)) elif self.mode == 2: if self.loop.replace_item(self.edithsh): logger.debug('replace mode: {0}'.format(self.mode)) else: # self.mode == 3 if self.loop.append_item(self.edithsh, filename): logger.debug('append mode: {0}'.format(self.mode)) if self.loop.replace_item(self.rephsh): logger.debug('replace mode: {0}'.format(self.mode)) # update the return value so that when it is not null then modified # is false and when modified is true then it is null self.setmodified(False) self.changed = True self.quit() return "break" def onCheck(self, event=None, showreps=True, showres=True): # only called when editing an item and finish is pressed self.loop.messages = [] text = self.gettext() msg = [] reps = [] if text.startswith("BEGIN:VCALENDAR"): text = import_ical(vcal=text) logger.debug("text: {0} '{01}'".format(type(text), text)) if self.edithsh and 'i' in self.edithsh: uid = self.edithsh['i'] else: uid = None hsh, msg = str2hsh(text, options=self.options, uid=uid) if not msg: # we have a good hsh pre = post = warn = "" if 'r' in hsh: pre = _("Repeating ") elif 's' in hsh: dt = hsh['s'] if hsh['itemtype'] in ['*', '~']: if self.options['early_hour'] and dt.hour < self.options['early_hour']: warn = _("Is {0} the starting time you intended?".format(fmt_time(dt, options=self.options))) dtfmt = fmt_shortdatetime(hsh['s'], self.options) else: if not dt.hour and not dt.minute: dtfmt = fmt_date(dt, short=True) else: dtfmt = fmt_shortdatetime(hsh['s'], self.options) post = _(" starting {0}.").format(dtfmt) else: # unscheduled pre = _("Unscheduled ") prompt = "{0}{1}{2}".format(pre, type2Text[hsh['itemtype']], post) if warn: prompt = prompt + "\n\n" + warn if self.edithsh and 'fileinfo' in self.edithsh: fileinfo = self.edithsh['fileinfo'] self.edithsh = hsh self.edithsh['fileinfo'] = fileinfo else: # we have a new item without fileinfo self.edithsh = hsh # update missing fields logger.debug('calling hsh2str with {0}'.format(hsh)) str, msg = hsh2str(hsh, options=self.options) self.loop.messages.extend(msg) if self.loop.messages: messages = "{0}".format("\n".join(self.loop.messages)) logger.debug("messages: {0}".format(messages)) self.messageWindow(MESSAGES, messages, opts=self.options) return False logger.debug("back from hsh2str with: {0}".format(str)) if 'r' in hsh: showing_all, reps = get_reps(self.loop.options['bef'], hsh) if reps: if showreps: try: repsfmt = [unicode(x.strftime(rrulefmt)) for x in reps] except: repsfmt = [unicode(x.strftime("%X %x")) for x in reps] logger.debug("{0}: {1}".format(showing_all, repsfmt)) if showing_all: reps = ALLREPS else: reps = SOMEREPS prompt = "{0}, {1}:\n\n {2}".format(prompt, reps, "\n ".join(repsfmt)) else: warn = "No repetitions were generated.\nConsider removing the @r entry." prompt = prompt + "\n\n" + warn if self.loop.messages: messages = "{0}".format("\n".join(self.loop.messages)) logger.debug("messages: {0}".format(messages)) self.messageWindow(MESSAGES, messages, opts=self.options) return False if self.checkmodified(): prompt += "\n\n{0}".format(SAVEANDEXIT) else: prompt += "\n\n{0}".format(UNCHANGEDEXIT) if str != text: self.settext(str) ans, value = OptionsDialog(parent=self, title=self.title, prompt=prompt, yesno=False, list=True).getValue() if ans: self.onSave(v=value) return def clearFind(self, *args): self.text.tag_remove(FOUND, "0.0", END) self.find_text.set("") def onFind(self, *args): target = self.find_text.get() logger.debug('target: {0}'.format(target)) if target: where = self.text.search(target, INSERT, nocase=1) if where: pastit = where + ('+%dc' % len(target)) self.text.tag_add(FOUND, where, pastit) self.text.mark_set(INSERT, pastit) self.text.see(INSERT) self.text.focus() def cancel(self, e=None): t = self.find_text.get() if t.strip(): self.clearFind() return "break" if self.autocompletewindow: self.hideCompletions() return "break" if self.text.tag_ranges("sel"): self.text.tag_remove('sel', "1.0", END) return logger.debug(('calling quit')) self.quit() def quit(self, e=None): if self.checkmodified(): ans = self.confirm(parent=self, title=_('Quit'), prompt=_("There are unsaved changes.\nDo you really want to quit?")) else: ans = True if ans: if self.master: logger.debug('setting focus') self.master.focus() self.master.focus_set() logger.debug('focus set') self.destroy() logger.debug('done') def messageWindow(self, title, prompt, opts=None, height=8, width=52): win = Toplevel(self, highlightcolor=self.HLCOLOR, background=self.BGCOLOR, highlightbackground=self.BGCOLOR) win.title(title) win.geometry("+%d+%d" % (self.text.winfo_rootx() + 50, self.text.winfo_rooty() + 50)) f = Frame(win) # pack the button first so that it doesn't disappear with resizing b = ttk.Button(win, text=_('OK'),style="bg.TButton", command=win.destroy) b.pack(side='bottom', fill=tkinter.NONE, expand=0, pady=0) win.bind('', (lambda e, b=b: b.invoke())) win.bind('', (lambda e, b=b: b.invoke())) win.bind('', (lambda e, b=b: b.invoke())) tkfixedfont = tkFont.nametofont("TkFixedFont") if 'fontsize_fixed' in self.loop.options and self.loop.options['fontsize_fixed']: tkfixedfont.configure(size=self.loop.options['fontsize_fixed']) t = ReadOnlyText( f, wrap="word", padx=2, pady=2, bd=2, relief="sunken", font=tkfixedfont, height=height, background=self.BGCOLOR, highlightcolor=self.HLCOLOR, foreground=self.FGCOLOR, width=width, takefocus=False) t.insert("0.0", prompt) t.pack(side='left', fill=tkinter.BOTH, expand=1, padx=0, pady=0) if height > 1: ysb = ttk.Scrollbar(f, orient='vertical', command=t.yview) ysb.pack(side='right', fill=tkinter.Y, expand=0, padx=0, pady=0) t.configure(state="disabled", yscroll=ysb.set) t.configure(yscroll=ysb.set) f.pack(padx=2, pady=2, fill=tkinter.BOTH, expand=1) win.focus_set() win.grab_set() win.transient(self) win.wait_window(win) def confirm(self, parent=None, title="", prompt="", instance="xyz"): ok, value = OptionsDialog(parent=parent, title=_("confirm").format(instance), prompt=prompt).getValue() return ok class OriginalCommand: def __init__(self, redir, operation): self.redir = redir self.operation = operation self.tk = redir.tk self.orig = redir.orig self.tk_call = self.tk.call self.orig_and_operation = (self.orig, self.operation) def __repr__(self): return "OriginalCommand(%r, %r)" % (self.redir, self.operation) def __call__(self, *args): return self.tk_call(self.orig_and_operation + args) ######################################################## # WidgetRedirector and OriginalCommand are from idlelib ######################################################## class WidgetRedirector: """Support for redirecting arbitrary widget subcommands. Some Tk operations don't normally pass through Tkinter. For example, if a character is inserted into a Text widget by pressing a key, a default Tk binding to the widget's 'insert' operation is activated, and the Tk library processes the insert without calling back into Tkinter. Although a binding to could be made via Tkinter, what we really want to do is to hook the Tk 'insert' operation itself. When a widget is instantiated, a Tcl command is created whose name is the same as the pathname widget._w. This command is used to invoke the various widget operations, e.g. insert (for a Text widget). We are going to hook this command and provide a facility ('register') to intercept the widget operation. In IDLE, the function being registered provides access to the top of a Percolator chain. At the bottom of the chain is a call to the original Tk widget operation. """ def __init__(self, widget): self._operations = {} self.widget = widget # widget instance self.tk = tk = widget.tk # widget's root w = widget._w # widget's (full) Tk pathname self.orig = w + "_orig" # Rename the Tcl command within Tcl: tk.call("rename", w, self.orig) # Create a new Tcl command whose name is the widget's pathname, and # whose action is to dispatch on the operation passed to the widget: tk.createcommand(w, self.dispatch) def __repr__(self): return "WidgetRedirector(%s<%s>)" % (self.widget.__class__.__name__, self.widget._w) def close(self): for operation in list(self._operations): self.unregister(operation) widget = self.widget del self.widget orig = self.orig del self.orig tk = widget.tk w = widget._w tk.deletecommand(w) # restore the original widget Tcl command: tk.call("rename", orig, w) def register(self, operation, function): self._operations[operation] = function setattr(self.widget, operation, function) return OriginalCommand(self, operation) def unregister(self, operation): if operation in self._operations: function = self._operations[operation] del self._operations[operation] if hasattr(self.widget, operation): delattr(self.widget, operation) return function else: return None def dispatch(self, operation, *args): '''Callback from Tcl which runs when the widget is referenced. If an operation has been registered in self._operations, apply the associated function to the args passed into Tcl. Otherwise, pass the operation through to Tk via the original Tcl function. Note that if a registered function is called, the operation is not passed through to Tk. Apply the function returned by self.register() to *args to accomplish that. For an example, see ColorDelegator.py. ''' m = self._operations.get(operation) try: if m: return m(*args) else: return self.tk.call((self.orig, operation) + args) except TclError: return "" class Node: def __init__(self, name, identifier=None, expanded=True): self.__identifier = (uuid.uuid1()) if identifier is None else sanitize_id(s2or3(identifier)) self.name = name self.expanded = expanded self.__bpointer = None self.__fpointer = [] @property def identifier(self): return self.__identifier @property def fpointer(self): return self.__fpointer def update_fpointer(self, identifier, mode=_ADD): if mode is _ADD: self.__fpointer.append(sanitize_id(identifier)) elif mode is _DELETE: self.__fpointer.remove(sanitize_id(identifier)) elif mode is _INSERT: self.__fpointer = [sanitize_id(identifier)] class MenuTree: """ Used for the shortcuts menu """ def __init__(self): self.nodes = [] self.lst = [] def get_index(self, position): for index, node in enumerate(self.nodes): if node.identifier == position: break return index def create_node(self, name, identifier=None, parent=None): # logger.debug("name: {0}, identifier: {1}; parent: {2}".format(name, identifier, parent)) node = Node(name, identifier) self.nodes.append(node) self.__update_fpointer(parent, node.identifier, _ADD) node.bpointer = parent return node def showMenu(self, position, level=_ROOT): queue = self[position].fpointer if level == _ROOT: self.lst = [] else: name, key = self[position].name.split("::") name = "{0}{1}".format(" " * (level - 1), name.strip()) s = "{0:<48} {1:^12}".format(name, key.strip()) self.lst.append(s) logger.debug("position: {0}, level: {1}, name: {2}, key: {3}".format(position, level, name, key)) if self[position].expanded: level += 1 for element in queue: self.showMenu(element, level) # recursive call return "\n".join(self.lst) def __update_fpointer(self, position, identifier, mode): if position is None: return else: self[position].update_fpointer(identifier, mode) def __getitem__(self, key): return self.nodes[self.get_index(key)] class Timer(): def __init__(self, parent=None, options={}): """ Methods providing timers """ self.parent = parent self.options = options self.loop = parent.loop self.idleactive = False self.showIdle = self.loop.options['display_idletime'] BGCOLOR = self.loop.options['background_color'] HLCOLOR = self.loop.options['highlight_color'] FGCOLOR = self.loop.options['foreground_color'] self.BGCOLOR = BGCOLOR self.HLCOLOR = HLCOLOR self.FGCOLOR = FGCOLOR self.timermenu = parent.timermenu self.match = "" self.etmtimers = os.path.normpath(os.path.join(options['etmdir'], ".etmtimers")) self.dfile_encoding = options['encoding']['file'] self.resetTimers() def updateMenu(self, e=None): if self.activeTimers: self.timermenu.entryconfig(1, state="active") if self.currentTimer: self.timermenu.entryconfig(2, state="active") else: self.timermenu.entryconfig(2, state="disabled") self.timermenu.entryconfig(3, state="active") self.timermenu.entryconfig(4, state="active") self.timermenu.entryconfig(5, state="active") self.timermenu.entryconfig(6, state="active") elif self.idleactive: self.timermenu.entryconfig(1, state="disabled") self.timermenu.entryconfig(2, state="disabled") self.timermenu.entryconfig(3, state="disabled") self.timermenu.entryconfig(4, state="active") self.timermenu.entryconfig(5, state="active") self.timermenu.entryconfig(6, state="active") else: self.timermenu.entryconfig(1, state="disabled") self.timermenu.entryconfig(2, state="disabled") self.timermenu.entryconfig(3, state="disabled") self.timermenu.entryconfig(4, state="disabled") self.timermenu.entryconfig(5, state="disabled") self.timermenu.entryconfig(6, state="disabled") def resetTimers(self): try: self.loadTimers() logger.info("reloaded saved timer data") except: self.activeDate = datetime.now().date() self.activeTimers = {} # summary -> { total, start, stop } self.currentTimer = None # summary self.currentStatus = STOPPED self.currentMinutes = 0 self.idletime = 0 * ONEMINUTE self.idlestart = None logger.info("reset timer data") def clearIdle(self, e=None): # reset idle self.idletime = 0 * ONEMINUTE if self.activeTimers: if self.currentStatus == RUNNING: self.idlestart = None else: self.idlestart = datetime.now() else: self.idlestart = None self.idleactive = False self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() def toggleIdle(self, e=None): if not self.idleactive: return self.showIdle = not self.showIdle if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() def selectTimer(self, e=None, new=True, title=None, name=None): """ Combo box with list of active timer summaries and option to create a new, unique summary. """ self.selected = None self.new = new now = datetime.now() if not self.activeTimers: if not new: return False self.completions = [] if title is None: title = _("Create Timer") else: if title is None: title = _("Create or Choose Timer") self.completions = [] tmp = [(self.activeTimers[x]['stop'], x) for x in self.activeTimers] # put the most recently stopped timers at the top sort = sorted(tmp, reverse=True) self.completions = [] for x in sort: timer = x[1] h = self.activeTimers[timer] if timer == self.currentTimer and self.currentStatus == RUNNING: t = fmt_period(h['total'] + now - h['start']) s = '\u279c' else: t = fmt_period(h['total']) s = '\u2716' self.completions.append(" {0} {1} {2} {3} ".format(timer, '\u25aa', t, s)) # return the focus to the right place if self.parent.weekly or self.parent.monthly: master = self.parent.canvas else: master = self.parent.tree master=self.parent self.timerswindow = win = Toplevel(master=master, highlightcolor=self.HLCOLOR, background=self.BGCOLOR, highlightbackground=self.BGCOLOR, bd=4) self.timerswindow.title(title) self.filterValue = StringVar(master) self.timerswindow.geometry("+%d+%d" % (master.winfo_rootx() + 50, master.winfo_rooty() + 50)) self.timerswindow.minsize(240, 30) self.timerswindow.wm_attributes("-topmost", 1) self.filterValue = StringVar(master) self.filterValue.set("") self.filterValue.trace_variable("w", self.setCompletions) self.fltr = Entry(self.timerswindow, highlightcolor=self.HLCOLOR, highlightbackground=self.BGCOLOR, background=self.BGCOLOR, foreground=self.FGCOLOR, highlightthickness=0, selectbackground=self.FGCOLOR, selectforeground=self.BGCOLOR, textvariable=self.filterValue) self.fltr.pack(side="top", fill="x") self.fltr.icursor(END) self.listbox = listbox = Listbox(self.timerswindow, highlightcolor=self.HLCOLOR, highlightbackground=self.BGCOLOR, background=self.BGCOLOR, foreground=self.FGCOLOR, selectbackground=self.FGCOLOR, selectforeground=self.BGCOLOR, highlightthickness=0, exportselection=False) listbox.pack(side="bottom", fill=BOTH, expand=True) self.timerswindow.bind("", self.completionSelected) self.timerswindow.bind("", self.completionSelected) self.timerswindow.bind("", self.hideCompletions) self.timerswindow.bind("", self.cursorUp) self.timerswindow.bind("", self.cursorDown) self.fltr.bind("", self.cursorUp) self.fltr.bind("", self.cursorDown) if name is not None: self.filterValue.set(name) self.setCompletions() win.wait_window(win) def setCompletions(self, *args): match = self.filterValue.get() self.matches = matches = [x for x in self.completions if x and x.lower().startswith(match.lower())] self.listbox.delete(0, END) for item in matches: self.listbox.insert(END, item) self.listbox.select_set(0) self.listbox.see(0) self.fltr.focus_set() def is_active(self): return self.timerswindow is not None def hideCompletions(self, e=None): # destroy widgets if not self.is_active(): return self.fltr.destroy() self.fltr = None self.listbox.destroy() self.listbox = None self.timerswindow.destroy() self.timerswindow = None def completionSelected(self, event): # Put the selected completion in the text, and close the list cursel = None if self.matches: cursel = self.matches[int(self.listbox.curselection()[0])] else: tmp = self.filterValue.get() if tmp in self.activeTimers or self.new: cursel = tmp logger.debug("cursel: {0}; match: {1}".format(cursel, self.match)) self.hideCompletions(e=event) if cursel is not None: self.selected = cursel.split('\u25aa')[0].strip() if self.new: self.startTimer() def cursorUp(self, event=None): cursel = int(self.listbox.curselection()[0]) # newsel = max(0, cursel=1) newsel = max(0, cursel - 1) self.listbox.select_clear(cursel) self.listbox.select_set(newsel) self.listbox.see(newsel) return "break" def cursorDown(self, event=None): cursel = int(self.listbox.curselection()[0]) newsel = min(len(self.matches) - 1, cursel + 1) self.listbox.select_clear(cursel) self.listbox.select_set(newsel) self.listbox.see(newsel) return "break" def saveTimers(self): """ dump activeTimers, ... """ tmp = ( self.activeDate, self.activeTimers, self.currentTimer, self.currentStatus, self.currentMinutes, self.idlestart, self.idletime ) fo = codecs.open(self.etmtimers, 'w', self.dfile_encoding) yaml.dump(tmp, fo) fo.close() self.updateMenu() def loadTimers(self): """ load activeTimers """ fo = codecs.open(self.etmtimers, 'r', self.dfile_encoding) tmp = yaml.load(fo) fo.close() (self.activeDate, self.activeTimers, self.currentTimer, self.currentStatus, self.currentMinutes, self.idlestart, self.idletime) = tmp if self.idlestart or self.idletime: self.idleactive = True if self.activeDate != datetime.now().date(): self.newDay() self.updateMenu() def startTimer(self, e=None): self.pauseTimer() self.idleactive = True if not self.selected: return if self.currentStatus == PAUSED: if self.idlestart: self.idletime += datetime.now() - self.idlestart self.idlestart = None summary = self.selected if summary not in self.activeTimers: # new timer hsh = {} hsh['total'] = 0 * ONEMINUTE hsh['stop'] = datetime.now() else: hsh = self.activeTimers[summary] hsh['start'] = datetime.now() self.activeTimers[summary] = hsh self.currentTimer = summary self.currentStatus = RUNNING self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() def finishTimer(self, e=None): self.pauseTimer() self.selectTimer(new=False, title="Finish Timer") if not self.selected: return self.currentStatus = STOPPED self.currentTimer = None hsh = self.activeTimers[self.selected] hsh['summary'] = self.selected self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() return hsh def deleteTimer(self, e=None, timer=None): if timer is None: self.selectTimer(new=False, title=_("Delete Timer")) timer = self.selected if not timer or timer not in self.activeTimers: return self.pauseTimer() if self.currentTimer == timer: self.currentTimer = None self.currentMinutes = 0 self.currentStatus = STOPPED if self.idlestart: idle = (datetime.now() - self.idlestart) + self.idletime elif self.idletime: idle = self.idletime else: idle = 0 * ONEMINUTE self.idletime = idle + self.activeTimers[timer]['total'] del self.activeTimers[timer] self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() def newDay(self, e=None): now = datetime.now() self.activeDate = now.date() self.idlestart = None self.idletime = 0 * ONEMINUTE if not self.activeTimers: self.idleactive = False self.activeTimers = {} # summary -> { total, start, stop } self.currentTimer = None # summary self.currentStatus = STOPPED self.currentMinutes = 0 running = (self.currentTimer and self.currentStatus == RUNNING) curfile = ensureMonthly(self.options, date=now.date()) tmp = [] for timer in self.activeTimers: # create inbox entries thsh = self.activeTimers[timer] hsh = {"itemtype": "$", "_summary": timer, "s": thsh['start'], "e": thsh['total']} tmp.append([timer, hsh]) for timer, hsh in tmp: res = self.loop.append_item(hsh, curfile) if res: del self.activeTimers[timer] if running: # currentStatus == RUNNING hsh = {} hsh['total'] = 0 * ONEMINUTE hsh['start'] = hsh['stop'] = now self.activeTimers[self.currentTimer] = hsh else: self.currentTimer = None self.currentStatus = STOPPED self.currentMinutes = 0 self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() def toggleCurrent(self, e=None): """ """ if not self.activeTimers or not self.currentTimer: return hsh = self.activeTimers[self.currentTimer] if self.currentStatus == RUNNING: hsh['total'] += datetime.now() - hsh['start'] hsh['stop'] = datetime.now() self.idlestart = datetime.now() self.currentStatus = PAUSED elif self.currentStatus == PAUSED: hsh['start'] = datetime.now() if self.idlestart: self.idletime += datetime.now() - self.idlestart self.idlestart = None self.currentStatus = RUNNING self.activeTimers[self.currentTimer] = hsh self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() def pauseTimer(self): """ Pause the running timer """ if self.activeTimers and self.currentTimer and self.currentStatus == RUNNING: self.toggleCurrent() self.saveTimers() if self.parent: self.parent.updateTimerStatus() self.parent.update_idletasks() return False def getStatus(self): """ Return the status of the timers for the status bar """ if not self.activeTimers and not self.idlestart and not self.idletime: return "", "" idlestatus = "" now = datetime.now() if self.currentTimer and self.currentStatus: hsh = self.activeTimers[self.currentTimer] if self.currentStatus == RUNNING: status='\u279c' idlestatus='\u2716' hsh['total'] = hsh['total'] + (now - hsh['start']) hsh['start'] = now else: status='\u2716' idlestatus='\u279c' ret1 = "{0}: {1} {2}".format(self.currentTimer, fmt_period(hsh['total']), status) total = hsh['total'] self.currentMinutes = total.seconds // 60 elif self.activeTimers: ret1 = "{0}".format(_("all paused")) else: ret1 = "" if self.showIdle: if self.idlestart: idle = (now - self.idlestart) + self.idletime elif self.idletime: idle = self.idletime else: idle = 0 * ONEMINUTE ret2 = "{0}: {1} {2}".format(_("idle"), fmt_period(idle), idlestatus) else: ret2 = "" logger.debug("timer: {0} {1}".format(ret1, ret2)) return ret1, ret2 class ReadOnlyText(Text): # noinspection PyShadowingNames def __init__(self, *args, **kwargs): Text.__init__(self, *args, **kwargs) bg = kwargs.pop('background', None) self.redirector = WidgetRedirector(self) self.insert = self.redirector.register("insert", lambda *args, **kw: "break") self.delete = self.redirector.register("delete", lambda *args, **kw: "break") self.configure(highlightthickness=0, insertwidth=0, takefocus=0, wrap="word", background=bg) class MessageWindow(): # noinspection PyShadowingNames def __init__(self, parent, title, prompt, opts={}): self.loop = parent.loop BGCOLOR = self.loop.options['background_color'] HLCOLOR = self.loop.options['highlight_color'] FGCOLOR = self.loop.options['foreground_color'] self.win = Toplevel(parent, highlightcolor=HLCOLOR, background=BGCOLOR) self.win.protocol("WM_DELETE_WINDOW", self.cancel) self.parent = parent self.options = opts self.win.title(title) tkfixedfont = tkFont.nametofont("TkFixedFont") if 'fontsize_fixed' in self.options and self.options['fontsize_fixed']: tkfixedfont.configure(size=self.options['fontsize_fixed']) self.content = ReadOnlyText(self.win, wrap="word", padx=3, bd=2, height=10, relief="sunken", font=tkfixedfont, width=46, takefocus=False, background=BGCOLOR, highlightcolor=HLCOLOR, foreground=FGCOLOR) self.content.pack(fill=tkinter.BOTH, expand=1, padx=10, pady=10) self.content.insert("1.0", prompt) b = ttk.Button(self.win, text=_("OK"), style="bg.TButton", command=self.cancel) # b = Button(self.win, text=_('OK'), width=10, command=self.cancel, default='active', pady=2) b.pack() self.win.bind('', (lambda e, b=b: b.invoke())) self.win.bind('', (lambda e, b=b: b.invoke())) self.win.focus_set() self.win.grab_set() self.win.transient(parent) self.win.wait_window(self.win) return def cancel(self, event=None): # put focus back to the parent window self.parent.focus_set() self.win.destroy() class FileChoice(object): def __init__(self, parent, title=None, prefix=None, list=[], start='', ext="txt", new=False): self.parent = parent self.loop = parent.loop BGCOLOR = self.loop.options['background_color'] self.BGCOLOR = BGCOLOR HLCOLOR = self.loop.options['highlight_color'] self.HLCOLOR = HLCOLOR FGCOLOR = self.loop.options['foreground_color'] self.FGCOLOR = FGCOLOR self.value = None self.prefix = prefix self.list = list if prefix and start: self.start = relpath(start, prefix) else: self.start = start self.ext = ext self.new = new self.modalPane = Toplevel(self.parent, highlightcolor=HLCOLOR, background=BGCOLOR) logger.debug('winfo: {0}, {1}; {2}, {3}'.format(parent.winfo_rootx(), type(parent.winfo_rootx()), parent.winfo_rooty(), type(parent.winfo_rooty()))) self.modalPane.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50)) self.modalPane.transient(self.parent) self.modalPane.grab_set() self.modalPane.bind("", self._choose) self.modalPane.bind("", self._cancel) if title: self.modalPane.title(title) if new: nameFrame = Frame(self.modalPane, highlightcolor=HLCOLOR, background=BGCOLOR) nameFrame.pack(side="top", padx=18, pady=2, fill="x") nameLabel = Label(nameFrame, text=_("file:"), bd=1, relief="flat", anchor="w", padx=0, pady=0, highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR) nameLabel.pack(side="left") self.fileName = StringVar(self.modalPane) self.fileName.set("untitled.{0}".format(ext)) self.fileName.trace_variable("w", self.onSelect) self.fname = Entry(nameFrame, textvariable=self.fileName, bd=1, highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR) self.fname.pack(side="left", fill="x", expand=1, padx=0, pady=0) self.fname.icursor(END) self.fname.bind("", self.cursorUp) self.fname.bind("", self.cursorDown) filterFrame = Frame(self.modalPane, highlightcolor=HLCOLOR, background=BGCOLOR) filterFrame.pack(side="top", padx=18, pady=4, fill="x") filterLabel = Label(filterFrame, text=_("filter:"), bd=1, relief="flat", anchor="w", padx=0, pady=0, highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR) filterLabel.pack(side="left") self.filterValue = StringVar(self.modalPane) self.filterValue.set("") self.filterValue.trace_variable("w", self.setMatching) self.fltr = Entry(filterFrame, textvariable=self.filterValue, bd=1, highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR) self.fltr.pack(side="left", fill="x", expand=1, padx=0, pady=0) self.fltr.icursor(END) prefixFrame = Frame(self.modalPane, highlightcolor=HLCOLOR, background=BGCOLOR) prefixFrame.pack(side="top", padx=8, pady=2, fill="x") self.prefixLabel = Label(prefixFrame, text=_("{0}:").format(prefix), bd=1, highlightcolor=BGCOLOR, background=BGCOLOR, foreground=FGCOLOR) self.prefixLabel.pack(side="left", expand=0, padx=0, pady=0) buttonFrame = Frame(self.modalPane, highlightcolor=HLCOLOR, background=BGCOLOR) buttonFrame.pack(side="bottom", padx=10, pady=2) chooseButton = ttk.Button(buttonFrame, text="Choose", style="bg.TButton", command=self._choose) chooseButton.pack(side="right", padx=10) cancelButton = ttk.Button(buttonFrame, text="Cancel", style="bg.TButton", command=self._cancel, ) cancelButton.pack(side="left") selectionFrame = Frame(self.modalPane, highlightcolor=HLCOLOR, background=BGCOLOR) selectionFrame.pack(side="bottom", padx=8, pady=2, fill="x") self.selectionValue = StringVar(self.modalPane) self.selectionValue.set("") self.selection = Label(selectionFrame, textvariable=self.selectionValue, bd=1, highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR) self.selection.pack(side="left", fill="x", expand=1, padx=0, pady=0) listFrame = Frame(self.modalPane, highlightcolor=HLCOLOR, background=BGCOLOR, width=40) listFrame.pack(side="top", fill="both", expand=1, padx=5, pady=2) scrollBar = Scrollbar(listFrame, width=8) scrollBar.pack(side="right", fill="y") self.listBox = Listbox(listFrame, selectmode=BROWSE, width=36, foreground=FGCOLOR, background=BGCOLOR, selectbackground=FGCOLOR, selectforeground=BGCOLOR) self.listBox.pack(side="left", fill="both", expand=1, ipadx=4, padx=2, pady=0) self.listBox.bind('<>', self.onSelect) self.listBox.bind("", self._choose) self.modalPane.bind("", self._choose) self.modalPane.bind("", self._cancel) # self.modalPane.bind("", self.cursorUp) # self.modalPane.bind("", self.cursorDown) self.fltr.bind("", self.cursorUp) self.fltr.bind("", self.cursorDown) scrollBar.config(command=self.listBox.yview) self.listBox.config(yscrollcommand=scrollBar.set) self.setMatching() def ignore(self, e=None): return "break" def onSelect(self, *args): # Note here that Tkinter passes an event object to onselect() if self.listBox.curselection(): firstIndex = self.listBox.curselection()[0] value = self.matches[int(firstIndex)] r = value[1] p = os.path.join(self.prefix, r) if self.new: if os.path.isfile(p): p = os.path.split(p)[0] r = os.path.split(r)[0] f = self.fileName.get() r = os.path.join(r, f) p = os.path.join(p, f) self.selectionValue.set(r) self.value = p return "break" def cursorUp(self, event=None): cursel = int(self.listBox.curselection()[0]) newsel = max(0, cursel - 1) self.listBox.select_clear(cursel) self.listBox.select_set(newsel) self.listBox.see(newsel) self.onSelect() return "break" def cursorDown(self, event=None): cursel = int(self.listBox.curselection()[0]) newsel = min(len(self.list) - 1, cursel + 1) self.listBox.select_clear(cursel) self.listBox.select_set(newsel) self.listBox.see(newsel) self.onSelect() return "break" def setMatching(self, *args): # disabled = "#BADEC3" # disabled = "#91CC9E" disabled = "#62B374" match = self.filterValue.get() if match: self.matches = matches = [x for x in self.list if x and match.lower() in x[1].lower()] else: self.matches = matches = self.list self.listBox.delete(0, END) index = 0 init_index = 0 for item in matches: if type(item) is tuple: # only show the label self.listBox.insert(END, item[0]) if self.new: if not item[-1]: self.listBox.itemconfig(index, fg=disabled) else: self.listBox.itemconfig(index, fg=self.FGCOLOR) if self.start and item[1] == self.start: init_index = index else: if item[-1]: self.listBox.itemconfig(index, fg=disabled) else: self.listBox.itemconfig(index, fg=self.FGCOLOR) if self.start and item[1] == self.start: init_index = index # elif files: else: self.listBox.insert(END, item) index += 1 self.listBox.select_set(init_index) self.listBox.see(init_index) self.fltr.focus_set() self.onSelect() def _choose(self, event=None): try: if self.listBox.curselection(): firstIndex = self.listBox.curselection()[0] if self.new: if not self.value or os.path.isfile(self.value): return else: tup = self.matches[int(firstIndex)] if tup[-1]: return self.value = os.path.join(self.prefix, tup[1]) else: return except IndexError: self.value = None self.modalPane.destroy() def _cancel(self, event=None): self.value = None self.modalPane.destroy() def returnValue(self): if self.parent is not None: self.parent.wait_window(self.modalPane) return self.value class Dialog(Toplevel): def __init__(self, parent, title=None, prompt=None, opts=None, default=None, modal=True, xoffset=50, yoffset=50, event=None, process=None, font=None, close=0): # global BGCOLOR, HLCOLOR, FGCOLOR self.parent = parent self.loop = parent.loop BGCOLOR = self.loop.options['background_color'] self.BGCOLOR = BGCOLOR HLCOLOR = self.loop.options['highlight_color'] self.HLCOLOR = HLCOLOR FGCOLOR = self.loop.options['foreground_color'] self.FGCOLOR = FGCOLOR Toplevel.__init__(self, parent, highlightcolor=HLCOLOR, background=BGCOLOR) self.protocol("WM_DELETE_WINDOW", self.quit) if modal: logger.debug('modal') self.transient(parent) else: logger.debug('non modal') if title: self.title(title) self.event = event logger.debug("parent: {0}".format(self.parent)) self.prompt = prompt self.options = opts self.font = font self.default = default self.value = "" self.process = process self.error_message = None # self.buttonbox() body = Frame(self, highlightcolor=HLCOLOR, background=BGCOLOR) # self.initial_focus = self.body(body) self.body(body).focus_set() self.buttonbox() # don't expand body or it will fill below the actual content body.pack(side="top", fill=tkinter.BOTH, padx=0, pady=0, expand=1) self.protocol("WM_DELETE_WINDOW", self.quit) if parent: self.geometry("+%d+%d" % (parent.winfo_rootx() + xoffset, parent.winfo_rooty() + yoffset)) if close: self.after(close, lambda: self.cancel()) if modal: self.grab_set() self.wait_window(self) def body(self, master): # create dialog body. return widget that should have # initial focus. this method should be overridden pass def buttonbox(self): # add standard button box. override if you don't want the # standard buttons box = Frame(self, background=self.BGCOLOR, highlightcolor=self.HLCOLOR) w = ttk.Button(box, text="OK", style="bg.TButton", command=self.ok, default=ACTIVE) w.pack(side="right", padx=5, pady=2) w = ttk.Button(box, text="Cancel", style="bg.TButton", command=self.cancel) w.pack(side="right", padx=5, pady=2) self.bind("", self.ok) self.bind("", self.ok) self.bind("", self.cancel) box.pack(side='bottom') # standard button semantics def ok(self, event=None): res = self.validate() logger.debug('validate: {0}, value: "{1}"'.format(res, self.value)) if not res: if self.error_message: self.messageWindow('error', self.error_message) # self.initial_focus.focus_set() # put focus back return "break" self.withdraw() self.update_idletasks() self.apply() self.quit() def cancel(self, event=None): # return the focus to the tree view in the main window self.value = None logger.debug('value: "{0}"'.format(self.value)) self.quit() def quit(self, event=None): if self.parent: logger.debug("returning focus to parent: {0}".format(self.parent)) self.parent.focus() # self.parent.tree.focus_set() if self.parent.weekly or self.parent.monthly: self.parent.canvas.focus_set() else: self.parent.tree.focus_set() else: logger.debug("returning focus, no parent") self.destroy() # command hooks def validate(self): return 1 # override def apply(self): pass # override def messageWindow(self, title, prompt): MessageWindow(self.parent, title, prompt) class TextVariableWindow(Dialog): def body(self, master): if 'textvariable' not in self.options: return self.entry = Entry(master, textvariable=self.options['textvariable']) self.entry.pack(side="bottom", padx=5, pady=5) Label(master, text=self.prompt, justify='left', highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR).pack(side="top", fill=tkinter.BOTH, expand=1, padx=10, pady=5) self.entry.focus_set() self.entry.bind('', self.entry.delete(0, END)) return self.entry def buttonbox(self): # add standard button box. override if you don't want the # standard buttons box = Frame(self, highlightcolor=self.HLCOLOR, background=self.BGCOLOR) w = ttk.Button(box, text=CLOSE, style="bg.TButton", command=self.ok, default=ACTIVE) w.pack(side=LEFT, padx=5, pady=5) self.bind("", self.ok) self.bind("", self.ok) self.bind("", self.ok) box.pack(side='bottom') def quit(self, event=None): if self.parent: logger.debug("returning focus to parent: {0}".format(self.parent)) self.parent.focus() self.parent.focus_set() else: logger.debug("returning focus, no parent") self.entry.delete(0, END) self.options['textvariable'].set("") self.destroy() class DialogWindow(Dialog): # master will be a frame in Dialog def body(self, master): self.entry = Entry(master, highlightthickness=0, highlightcolor=self.HLCOLOR, highlightbackground=self.BGCOLOR, selectbackground=self.FGCOLOR, selectforeground=self.BGCOLOR, background=self.BGCOLOR, foreground=self.FGCOLOR) self.entry.pack(side="bottom", padx=5, pady=2, fill=X) tkfixedfont = self.font lines = self.prompt.split('\n') height = min(20, len(lines) + 1) lengths = [len(line) for line in lines] width = min(70, max(lengths) + 2) self.text = ReadOnlyText( master, wrap="word", padx=2, pady=2, bd=2, relief="sunken", font=tkfixedfont, height=height, width=width, background=self.BGCOLOR, highlightcolor=self.HLCOLOR, highlightbackground=self.BGCOLOR, foreground=self.FGCOLOR, takefocus=False) self.text.insert("1.1", self.prompt) self.text.pack(side="top", fill=tkinter.BOTH, expand=1, padx=6, pady=2) if self.default is not None: self.entry.insert(0, self.default) self.entry.select_range(0, END) return self.entry class TextDialog(Dialog): def body(self, master): tkfixedfont = self.font lines = self.prompt.split('\n') height = min(25, len(lines) + 1) lengths = [len(line) for line in lines] width = min(70, max(lengths) + 2) self.text = ReadOnlyText( master, wrap="word", padx=2, pady=2, bd=2, relief="sunken", # font=tkFont.Font(family="Lucida Sans Typewriter"), font=tkfixedfont, height=height, width=width, background=self.BGCOLOR, highlightbackground=self.BGCOLOR, highlightcolor=self.HLCOLOR, foreground=self.FGCOLOR, takefocus=False) self.text.insert("1.1", self.prompt) self.text.pack(side='left', fill=tkinter.BOTH, expand=1, padx=5, pady=2) return self.text def buttonbox(self): # add standard button box. override if you don't want the # standard buttons box = Frame(self, highlightcolor=self.HLCOLOR, background=self.BGCOLOR) w = ttk.Button(box, text="OK", style="bg.TButton", command=self.cancel, default=ACTIVE) w.pack(side=LEFT, padx=5, pady=0) self.bind("", self.ok) self.bind("", self.ok) self.bind("", self.ok) box.pack(side='bottom') class OptionsDialog(): def __init__(self, parent, master=None, title="", prompt="", opts=None, radio=True, yesno=True, list=False): if not opts: opts = [] self.parent = parent self.loop = parent.loop BGCOLOR = self.loop.options['background_color'] self.BGCOLOR = BGCOLOR HLCOLOR = self.loop.options['highlight_color'] self.HLCOLOR = HLCOLOR FGCOLOR = self.loop.options['foreground_color'] self.FGCOLOR = FGCOLOR self.win = Toplevel(parent, background=BGCOLOR, highlightcolor=HLCOLOR) self.win.protocol("WM_DELETE_WINDOW", self.quit) if parent: self.win.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50)) # self.parent = parent self.master = master self.options = opts self.radio = radio self.win.title(title) if list: self.win.configure(bg=BGCOLOR) tkfixedfont = tkFont.nametofont("TkFixedFont") if 'fontsize_fixed' in self.parent.options and self.parent.options['fontsize_fixed']: tkfixedfont.configure(size=self.parent.options['fontsize_fixed']) self.content = ReadOnlyText(self.win, wrap="word", padx=3, bd=2, height=10, relief="sunken", font=tkfixedfont, background=BGCOLOR, highlightcolor=HLCOLOR, foreground=FGCOLOR, width=46, takefocus=False) self.content.pack(fill=tkinter.BOTH, expand=1, padx=10, pady=5) self.content.insert("1.0", prompt) else: Label(self.win, text=prompt, justify='left', highlightcolor=HLCOLOR, background=BGCOLOR, foreground=FGCOLOR).pack(fill=tkinter.BOTH, expand=1, padx=10, pady=5) self.sv = StringVar(parent) self.sv = IntVar(parent) self.sv.set(1) if self.options: if radio: self.value = opts[0] for i in range(min(9, len(self.options))): txt = self.options[i] val = i + 1 # bind keyboard numbers 1-9 (at most) to options selection, i.e., press 1 to select option 1, 2 to select 2, etc. self.win.bind(str(val), (lambda e, x=val: self.sv.set(x))) Radiobutton(self.win, text="{0}: {1}".format(val, txt), padx=20, indicatoron=True, variable=self.sv, background=BGCOLOR, highlightcolor=HLCOLOR, foreground=FGCOLOR, command=self.getValue, value=val).pack(padx=10, anchor=W) else: self.check_values = {} # show 0, check 1, return 2 for i in range(min(9, len(self.options))): txt = self.options[i][0] self.check_values[i] = BooleanVar(self.parent) self.check_values[i].set(self.options[i][1]) Checkbutton(self.win, text=self.options[i][0], padx=20, variable=self.check_values[i]).pack(padx=10, anchor=W) box = Frame(self.win) box.configure(bg=BGCOLOR, highlightcolor=HLCOLOR) # if list: # box.configure(bg=BGCOLOR) if yesno: YES = _("Yes") NO = _("No") else: YES = _("Ok") NO = _("Cancel") c = ttk.Button(box, text=NO, style="bg.TButton", command=self.cancel) c.pack(side=LEFT, padx=5, pady=5) o = ttk.Button(box, text=YES, style="bg.TButton", default='active', command=self.ok) o.pack(side=LEFT, padx=5, pady=5) box.pack() self.win.bind('', (lambda e, o=o: o.invoke())) self.win.bind('', (lambda e, o=o: o.invoke())) self.win.bind('', self.Ok) self.win.bind('', (lambda e, c=c: c.invoke())) logger.debug('parent: {0}'.format(parent)) self.win.focus_set() self.win.transient(parent) self.win.wait_window(self.win) def getValue(self, e=None): v = self.sv.get() logger.debug("sv: {0}".format(v)) if self.options: if self.radio: if v - 1 in range(len(self.options)): o = self.options[v - 1] logger.debug( 'OptionsDialog returning {0}: {1}'.format(v, o)) return v, o # return o, v else: logger.debug( 'OptionsDialog returning {0}: {1}'.format(v, None)) return 0, None else: # checkbutton values = [] for i in range(len(self.options)): bool = self.check_values[i].get() > 0 values.append([self.options[i][0], bool, self.options[i][2]]) return values else: # askokcancel type dialog logger.debug( 'OptionsDialog returning {0}: {1}'.format(v, None)) return v, v def ok(self, event=None): # self.parent.update_idletasks() self.quit() def Ok(self, event=None): # self.parent.update_idletasks() self.sv.set(2) self.quit() def cancel(self, event=None): self.sv.set(0) self.quit() def quit(self, event=None): # put focus back to the parent window if self.master: self.master.focus_set() elif self.parent: self.parent.focus_set() self.win.destroy() class GetInteger(DialogWindow): def validate(self): minvalue = maxvalue = None if len(self.options) > 0: minvalue = self.options[0] if len(self.options) > 1: maxvalue = self.options[1] res = self.entry.get() try: val = int(res) ok = (minvalue is None or val >= minvalue) and ( maxvalue is None or val <= maxvalue) except: val = None ok = False if ok: self.value = val return True else: self.value = None msg = [_('an integer')] conj = "" if minvalue is not None: msg.append(_("no less than {0}".format(minvalue))) conj = _("and ") if maxvalue is not None: msg.append(_("{0}no greater than {1}").format(conj, maxvalue)) msg.append(_("is required")) self.error_message = "\n".join(msg) return False class GetRepeat(GetInteger): def buttonbox(self): # add standard button box. override if you don't want the # standard buttons box = Frame(self, background=self.BGCOLOR, highlightcolor=self.HLCOLOR) w = ttk.Button(box, text=_("Repeat"), style="bg.TButton", command=self.ok, default=ACTIVE) w.pack(side="right", padx=5, pady=2) w = ttk.Button(box, text=_("Close"), style="bg.TButton", command=self.cancel) w.pack(side="right", padx=5, pady=2) self.bind("", self.ok) self.bind("", self.ok) self.bind("", self.cancel) box.pack(side='bottom') class GetDateTime(DialogWindow): def validate(self): res = self.entry.get() logger.debug('res: {0}'.format(res)) ok = False if not res.strip(): # return the current date if ok is pressed with no entry # val = get_current_time().replace(hour=0, minute=0, second=0, microsecond=0) val = get_current_time() ok = True else: try: val = parse_str(res) ok = True except: val = None if ok: self.value = val return True else: self.value = False self.error_message = _('could not parse "{0}"').format(res) return False class GetString(DialogWindow): def validate(self): nullok = False if 'nullok' in self.options and self.options['nullok']: nullok = True # an entry is required val = self.entry.get() if val.strip(): self.value = val return True elif nullok: # null and null is ok if self.value is None: return False else: self.value = val return True else: self.error_message = _('an entry is required') return False def ok(self, event=None): res = self.validate() logger.debug('validate: {0}, value: "{1}"'.format(res, self.value)) if not res: if self.error_message: self.messageWindow('error', self.error_message) return "break" if self.process: res = self.process(self.value) self.text.delete("1.0", END) self.text.insert("1.0", res) else: self.withdraw() self.update_idletasks() self.apply() self.quit() etmtk-3.2.22/etmTk/etm.10000644000076500000240000001401012617417731014542 0ustar dagstaff00000000000000.\" Text automatically generated by txt2man .TH etm 1 "07 November 2015" "version 3.2.22" "Unix user's manual" .SH NAME \fBetm \fP- manage events and tasks using simple text files .SH SYNOPSIS .nf .fam C \fBetm\fP [\fIlogging\fP \fIlevel\fP] [\fIpath\fP] [?] [\fIacmsv\fP] .fam T .fi .fam T .fi .SH DESCRIPTION With no arguments, \fBetm\fP will use settings from the configuration file ~/.etm/etmtk.cfg, set \fIlogging\fP \fIlevel\fP 3 (warn) and open the GUI. .PP if the first argument is an integer not less than 1 (debug) and not greater than 5 (critical), then \fBetm\fP will use that \fIlogging\fP \fIlevel\fP and remove the argument. .PP If the first (remaining) argument is the \fIpath\fP to a directory which contains a file named etm.cfg, then \fBetm\fP will use that configuration file and remove the argument. .PP If the first (remaining) argument is one of the commands listed below, then \fBetm\fP will execute the remaining arguments without opening the GUI. .PP .nf .fam C a ARG display the agenda view using ARG, if given, as a filter. k ARG display the keywords view using ARG, if given, as a filter. n ARGS Create a new item using the remaining arguments as the item specification. m INT display a report using the remaining argument, which must be a positive integer, to display a report using the corresponding entry from the file given by report_specifications in etmtk.cfg. Use ? m to display the numbered list of entries from this file. p ARG display the path view using ARG, if given, as a filter. r ARGS display a report using the remaining arguments as the report specification. s ARG display the schedule view using ARG, if given, as a filter. t ARG display the tags view using ARG, if given, as a filter. v display information about etm and the operating system. ? ARGS display (this) command line help information if ARGS = '' or, if ARGS = X where X is one of the above commands, then display details about command X. 'X ?' is equivalent to '? X'.\ .fam T .fi .SH EXAMPLES .SS COMMAND LINE Group items by year, month and day together .PP .nf .fam C etm r c ddd, MMM d yyyy .fam T .fi Output: .PP .nf .fam C Fri, Apr 1 2011 items for April 1 Sat, Apr 2 2011 items for April 2 \.\.\. .fam T .fi .SS DATA FILES Data items begin with a data type character and continue on one or more lines either until the end of the file is reached or another line is found that begins with a type character. Data type characters and the associated data types: .TP .B \%~ Action: a record of time and/or money spent. .TP .B \%* Event: happens on a particular date and time. .TP .B \%^ Occasion: happens on a particular date, e.g., a holiday, anniversary or birthday. .TP .B \%! Note: a record of some useful information. .TP .B \%\- Task: something that needs to be done. .TP .B \%% Delegated task: a task assigned to someone else. .TP .B \%+ Task group: a group of related tasks, some of which may be prerequisites for others. .TP .B \%$ Inbasket: quick entry to be edited later when time permits. .TP .B \%? Someday maybe: remember but don't show in the common views. .TP .B \%# Hidden: remember but hide from all \fBetm\fP views except \fIpath\fP view. .TP .B \%= Defaults: set default options for subsequent entries in the same data file. .PP The beginning data type character for each item is followed by the item summary and then, perhaps, by one or more '@key value' option pairs. Examples: .IP \(bu 3 A sales meeting (an event) a week from today from 9:00am until 10:00am with a 5 minute early warning alert: .PP .nf .fam C \%* sales meeting @s +7 9a @e 1h @a 5 .fam T .fi .IP \(bu 3 Prepare a report (a task) for the meeting beginning 3 days early: .PP .nf .fam C \%\- prepare report @s +7 @b 3 .fam T .fi .IP \(bu 3 A 35 minute period (an action) spent working on the report yesterday: .PP .nf .fam C \%~ report preparation @s \-1 @e 35 .fam T .fi .IP \(bu 3 Get a haircut (a task) on the 24th of the current month and then [r]epeatedly at (d)aily [i]ntervals of 14 days and, [o]n completion, (r)estart from the completion date: .PP .nf .fam C \%\- get haircut @s 24 @r d &i 14 @o r .fam T .fi .IP \(bu 3 Do the jobs in the following task group in 'q' order to finish the dog house project: .PP .nf .fam C \%+ dog house @j pickup lumber and paint &q 1 @j cut pieces &q 2 @j assemble &q 3 @j paint &q 4 .fam T .fi .IP \(bu 3 Payday (an occassion) on the last week day of each month. The '&s' part of the entry extracts the last date which is both a weekday and falls within the last three days of the month.): .PP .nf .fam C \%^ payday @s 1/1 @r m &w (MO, TU, WE, TH, FR) &m (\-1, \-2, \-3) &s \-1 .fam T .fi .IP \(bu 3 Take a prescribed medication daily (a reminder) for the next three days at 10am, 2pm, 6pm and 10pm and trigger the default alert zero minutes before each event: .PP .nf .fam C \%* take Rx @s +0 @r d &h 10, 14, 18, 22 &u +4 @a 0 .fam T .fi .IP \(bu 3 Presidential election day (an occassion) every four years on the first Tuesday after a Monday in November: .PP .nf .fam C \%^ Presidential Election Day @s 2012-11-06 @r y &i 4 &M 11 &m range(2,9) &w TU .fam T .fi .IP \(bu 3 Join the \fBetm\fP discussion group (a task). Because of the @g (goto) link, pressing Ctrl-G when the details of this item are displayed in the gui would open the link using the system default application: .PP .nf .fam C \%\- join the etm discussion group @g http://groups.google.com/group/eventandtaskmanager/topics .fam T .fi .SH SEE ALSO Extensive documentation can be found in the folder: .PP .nf .fam C http://people.duke.edu/~dgraham/etmtk/help/ .fam T .fi .SH BUGS Please report bugs to the \fBetm\fP discussion group: .PP .nf .fam C http://groups.google.com/forum/#!forum/eventandtaskmanager .fam T .fi .SH AUTHOR Daniel A Graham .SH COPYRIGHT Copyright (c) 2009-2014 [Daniel Graham]. All rights reserved. etmtk-3.2.22/etmTk/etm.appdata.xml0000644000076500000240000000337212357501176016622 0ustar dagstaff00000000000000 etm.desktop CC0-1.0 GPL-2.0+ and GFDL-1.3 Event and task manager Manage events and tasks using simple text files

Examples:

  • A sales meeting (an event) [s]tarting seven days from today at 9:00am and [e]xtending for one hour with a default [a]lert 5 minutes before the start:
    * sales meeting @s +7 9a @e 1h @a 5
  • Get a haircut (a task) on the 24th of the current month and then [r]epeatedly at (d)aily [i]ntervals of (14) days and, [o]n completion, (r)estart from the completion date:
    - get haircut @s 24 @r d &i 14 @o r
  • Presidential election day (an occasion) every four years on the first Tuesday after a Monday in November:
    ^ Presidential Election Day @s 2012-11-06
    @r y &i 4 &M 11 &m 2, 3, 4, 5, 6, 7, 8 &w TU
http://people.duke.edu/~dgraham/etmtk/images/agenda.gif http://people.duke.edu/~dgraham/etmtk/images/day.gif http://people.duke.edu/~dgraham/etmtk/images/week.gif http://people.duke.edu/~dgraham/etmtk/images/monthly.gif http://people.duke.edu/~dgraham/etmtk/ daniel.graham_at_duke.edu
etmtk-3.2.22/etmTk/etm.desktop0000644000076500000240000000034512413130673016050 0ustar dagstaff00000000000000[Desktop Entry] Type=Application Name=Event and task manager Comment=Manage events and tasks using simple text files Exec=etm Icon=etm Categories=Office;Calendar;Clock; Keywords=Calendar;Task;Event;Alarm;Reminder;Todo;Time;Note; etmtk-3.2.22/etmTk/etm.xpm0000666000076500000240000004312412305076210015205 0ustar dagstaff00000000000000/* XPM */ static char *etmlogo____x___x__[] = { /* columns rows colors chars-per-pixel */ "128 128 66 1", " c #134157", ". c #13445A", "X c #154B64", "o c #164E68", "O c #17516B", "+ c #18536E", "@ c #195672", "# c #195875", "$ c #1A5C7B", "% c #1B607F", "& c #1C6282", "* c #1D6689", "= c #1F6B8E", "- c #1F6D91", "; c #206E93", ": c #207196", "> c #22759C", ", c #237BA3", "< c #247FA9", "1 c #2582AD", "2 c #2880A9", "3 c #2685B1", "4 c #2B86B0", "5 c #2789B6", "6 c #2A89B7", "7 c #288CBB", "8 c #2D90BE", "9 c #2B93C4", "0 c #2B97C9", "q c #2C99CC", "w c #3D9AC5", "e c #339BCB", "r c #2D9DD2", "t c #329FD1", "y c #2EA1D6", "u c #2FA3D9", "i c #35A1D5", "p c #3AA2D2", "a c #30A7DE", "s c #30A8DF", "d c #3DA9DC", "f c #31A9E1", "g c #3BADE2", "h c #40A6D6", "j c #49AEDD", "k c #4BB0DE", "l c #41AFE3", "z c #45B1E3", "x c #4CB4E4", "c c #51B6E5", "v c #56B8E6", "b c #5BBBE7", "n c #62BDE7", "m c #64BEE8", "M c #67C0E9", "N c #6BC1E9", "B c #73C4EA", "V c #79C7EB", "C c #7DC9EC", "Z c #83CBEC", "A c #8BCEEE", "S c #8ED0EE", "D c #92D2EE", "F c #98D4EF", "G c #96D3F0", "H c None", /* pixels */ "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHAZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZnHHHSZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZCbHHHAZZZZZZZZZZZVcHHHHHHHHHHDAZZZZZZZZZZZZZZZZZZZZZZZZmHH", "HBNvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvp1HHNbvvvvvvvvvvvvvvvvvvvvvvvvvvvvvceHHBNvvvvvvvvvvvxeHHHHHHHHHHVnvvvvvvvvvvvvvvvvvvvvvvvvp3H", "Hbxfffffffffffffffffffffffffffffffa7-HHcffffffffffffffffffffffffffffffa3HHbxfffffffffffa5HHHHHHHHHHbgfffffffffffffffffffffffs7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxffffffffffffffffffffffffffffffu3HHbxfffffffffffa7HHHHHHHHHHbgfffffffffffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxffffffffffffffffffffffffffffffu3HHbxfffffffffffa9,HHHHHHHHHvgfffffffffffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxffffffffffffffffffffffffffffffu3HHbxffffffffffff0,HHHHHHHHMcffffffffffffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxffffffffffffffffffffffffffffffu3HHbxffffffffffffrHHHHHHHbgffffffffffffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxffffffffffffffffffffffffffffffu3HHbxffffffffffffs9,HHHHHHHbgffffffffffffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxffffffffffffffffffffffffffffffu3HHbxfffffffffffffq,HHHHHHHvfffffffffffffffffffffffffa7-H", "Hbxffffffffffffaaaaaaaaaaaaaaaaaaau5=HHxsaaaaaaaaaaaffffffffffffffffffa3HHbxfffffffffffffrHHHHHnlfffffffffffffffffffffffffa7-H", "Hbxffffffffaq9HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHBzffa7-HHSZZZZZZZZZACbgfffffffffffffs9,HHHHHbgfffffffffffffffffffffffffa7-H", "HbxfffffffffauHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHAmgffa7-HHNbvvvvvvvvvzgfffffffffffffff0,HHHHHbgfffffffffffffffffffffffffa7-H", "HbxffffffffffsuHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHVzfffa7-HHcgffffffffffffffffffffffffffrHHHnzffffffffffffffffffffffffffa7-H", "HbxffffffffffffffaHHHHHAZVVVVVVVVVVVVVVVVBcHHHHHAmgffffffa7-HHcgffffffffffffffffffffffffffs9,HHHngfffffffffaafffffffffffffffa7-H", "HbxffffffffffffffsuHHHHcxzzzzzzzzzzzzzzzzd2HHHHHBzfffffffa7-HHcgfffffffffffffffaaffffffffff0,HHHbgffffffffayusffffffffffffffa7-H", "HbxfffffffffffffffauHHHrufffffffffffffffu3+HHHHCcffffffffa7-HHcgffffffffffffffauusfffffffffrqfffu,XHHHHBlfffffffffa7-HHcgffffffffffffffu0yffffffffffu3HHMcffffffffs07ggffffffffffffffa7-H", "HbxfffffffffffffffffsaHHHHHHHHHHHHHigfs9$HHHHCxffffffffffa7-HHcgffffffffffffffy9ygfffffffffu6HHmxffffffffa95ggffffffffffffffa7-H", "HbxffffffffffffffffffgbHHHHHHHHHHHHxlfy:.HHHAbgffffffffffa7-HHcgffffffffffffffr7tgfffffffffa7,Hmzffffffffa71zlffffffffffffffa7-H", "Hbxffffffffffffffffffgcnmmmmmmmmk0Hbza5@HHHANgfffffffffffa7-HHcgffffffffffffffr5Hggffffffffs9Hvzq*HHHHCxffffffffffffa7-HHcgffffffffffffffr1Hzgfffffffffq5HbgffffffffyHczffffffffffffffa7-H", "Hbxffffffffffffffffffffffffffffs7:Hvp%HHHANgfffffffffffffa7-HHcgffffffffffffffrHHbzffffffffffffffa7-H", "Hbxffffffffffffffffffffffffffffs7;HHHHHH0yfffffffffffffffa7-HHcgffffffffffffffr,HHxgffffffffffffffffffffq-HHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffff7;HHHHHHHquffffffffffffffa7-HHcgffffffffffffffr,HHxzffffffffffffffffffff9=HHbzffffffffffffffa7-H", "Hbxffffffffffffffffffffffssssssa7-HHHHHHH9rafffffffffffffa7-HHcgffffffffffffffr,HHHzffffffffffffffffffff7*HHbzffffffffffffffa7-H", "Hbxffffffffffffffffffsr711111111;$HHHHHHHH0yfffffffffffffa7-HHcgffffffffffffffr,HHHzfffffffffffffffffffa5&HHbzffffffffffffffa7-H", "Hbxffffffffffffffffffr,@XoooooooXHHHHHHHHHHquffffffffffffa7-HHcgffffffffffffffr,HHHzgffffffffffffffffffu1HHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffa1OHHHHHHHHHHHHHHHHHHHHHrafffffffffffa7-HHcgffffffffffffffr,HHHxgffffffffffffffffffu,HHHbzffffffffffffffa7-H", "Hbxffffffffffffffffs9$HHHHHHHHHHCCjHHHHHHHHH0yfffffffffffa7-HHcgffffffffffffffr,HHHxgffffffffffffffffffr>HHHbzffffffffffffffa7-H", "Hbxffffffffffffffffr-.HHHHHHHHHHBv9HHHHHHHHHHquffffffffffa7-HHcgffffffffffffffr,HHHxlffffffffffffffffffq;HHHbzffffffffffffffa7-H", "Hbxfffffffffffffffu1oHHHHHHHHHHHMz7HHHHHHHHHHHrafffffffffa7-HHcgffffffffffffffr,HHHxzffffffffffffffffff0=HHHbzffffffffffffffa7-H", "Hbxffffffffffffffa9#HHHHHHHHHHHHxi>HHHHHHHHHHH0ysffffffffa7-HHcgffffffffffffffr,HHHHzffffffffffffffffff7*HHHbzffffffffffffffa7-H", "Hbxffffffffffffffr= HHHHHHHHHHHHxt>HHHHHHHHHHHH0uffffffffa7-HHcgffffffffffffffr,HHHHzfffffffffffffffffa5&HHHbzffffffffffffffa7-H", "HbxfffffffffffffuHHHHbzffffffffffffffa7-H", "Hbxfffffffffffu,XHHHHHHHABxgffffffffffflxcHHHHHHHHqafffffa7-HHcgffffffffffffffr,HHHHxlffffffffffffffffr:HHHHbzffffffffffffffa7-H", "Hbxffffffffffa7@HHHHHHFZbgffffffffffffffglxHHHHHHH9raffffa7-HHcgffffffffffffffr,HHHHxzffffffffffffffff0-HHHHbzffffffffffffffa7-H", "Hbxffffffffffq*HHHHHHGVxffffffffffffffffffgzzHHHHHH0uffffa7-HHcgffffffffffffffr,HHHHHzffffffffffffffff9*HHHHbzffffffffffffffa7-H", "Hbxfffffffffu,XHHHHHCmhqqqqqqqqqqqqqqqqqqqqtitHHHHHHqafffa7-HHcgffffffffffffffr,HHHHHzfffffffffffffffs7*HHHHbzffffffffffffffa7-H", "Hbxffffffffa7#HHHHHjw>&&&&&&&&&&&&&&&&&&&&&&*:HHHHHH0ufffa7-HHcgffffffffffffffr,HHHHHxgffffffffffffffa3&HHHHbzffffffffffffffa7-H", "Hbxffffffffu<+HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHdgffa7-HHcgffffffffffffffr,HHHHHxgffffffffffffffuHHHHHbzffffffffffffffa7-H", "HbxffffffffffznVZZZZZZZZZZZZZZZZZZZmHHHSZZZZZZZZZZZAVvgffa7-HHcgffffffffffffffr,HHHHHxzffffffffffffffq;HHHHHbzffffffffffffffa7-H", "Hbxffffffffffgzcvvvvvvbbbbbbbbbbbbbh6HHNbvvvvvvvvvvvzffffa7-HHcgffffffffffffffr,HHHHHHzffffffffffffff9=HHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffs7-HHcfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHzffffffffffffff7*HHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6=HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHxgffffffffffffa5&HHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHxgffffffffffffa1$HHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHxgffffffffffffu,HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHxlffffffffffffr>HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHHzffffffffffffq:HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHHzffffffffffff0=HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHHzgfffffffffff9*HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHHxgffffffffffa6&HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHHxgffffffffffa1&HHHHHHbzffffffffffffffa7-H", "Hbxfffffffffffffffffffffffffffffffa6-HHxfffffffffffffffffa7-HHcgffffffffffffffr,HHHHHHHxgffffffffffuHHHHHHHbzffffffffffffffs7-H", "Hvzauuuuuuuuuuuuuuuuuuuuuuuuuuuuuuy3*HHxiyyyyyyyyyyyyyyyyy3*HHxauuuuuuuuuuuuuu0>HHHHHHHHgauuuuuuuuu0;HHHHHHHvguuuuuuuuuuuuuuy5=H", "Hi6:;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-$OHH6;===============-=$OHH8>;;;;;;;;;;;;;;*@HHHHHHHH6:;;;;;;;;;*OHHHHHHHe2;;;;;;;;;;;;;;-$OH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH" }; etmtk-3.2.22/etmTk/etmlogo.gif0000644000076500000240000001142012275716576016043 0ustar dagstaff00000000000000GIF87a@*U3&<)7*>,E.>.F /A/90C0@2@33 7I99:Q;M >Y@ @` B^G_KcNoOkOgRnUqUr X{Yv]|] `peffghi(i k)kn!op!rt"uw'yUy[y7z${$~@%&,'('*++7V2=8,F-H-3 8E/=1GJ/D0/1FTK1`5D^;UZBNm=LRGNTaRJ^\dy@M`h꿿_ze]kjsfyqtyfUmUժ! ! ICCRGBG1012 HLinomntrRGB XYZ  1acspMSFTIEC sRGB-HP cprtP3desclwtptbkptrXYZgXYZ,bXYZ@dmndTpdmddvuedLview$lumimeas $tech0 rTRC< gTRC< bTRC< textCopyright (c) 1998 Hewlett-Packard CompanydescsRGB IEC61966-2.1sRGB IEC61966-2.1XYZ QXYZ XYZ o8XYZ bXYZ $descIEC http://www.iec.chIEC http://www.iec.chdesc.IEC 61966-2.1 Default RGB colour space - sRGB.IEC 61966-2.1 Default RGB colour space - sRGBdesc,Reference Viewing Condition in IEC61966-2.1,Reference Viewing Condition in IEC61966-2.1view_. \XYZ L VPWmeassig CRT curv #(-27;@EJOTY^chmrw| %+28>ELRY`gnu| &/8AKT]gqz !-8COZfr~ -;HUcq~ +:IXgw'7HYj{+=Oat 2FZn  % : O d y  ' = T j " 9 Q i  * C \ u & @ Z t .Id %A^z &Ca~1Om&Ed#Cc'Ij4Vx&IlAe@e Ek*Qw;c*R{Gp@j>i  A l !!H!u!!!"'"U"""# #8#f###$$M$|$$% %8%h%%%&'&W&&&''I'z''( (?(q(())8)k))**5*h**++6+i++,,9,n,,- -A-v--..L.../$/Z///050l0011J1112*2c223 3F3334+4e4455M555676r667$7`7788P8899B999:6:t::;-;k;;<' >`>>?!?a??@#@d@@A)AjAAB0BrBBC:C}CDDGDDEEUEEF"FgFFG5G{GHHKHHIIcIIJ7J}JK KSKKL*LrLMMJMMN%NnNOOIOOP'PqPQQPQQR1R|RSS_SSTBTTU(UuUVV\VVWDWWX/X}XYYiYZZVZZ[E[[\5\\]']x]^^l^__a_``W``aOaabIbbcCccd@dde=eef=ffg=ggh?hhiCiijHjjkOkklWlmm`mnnknooxop+ppq:qqrKrss]sttptu(uuv>vvwVwxxnxy*yyzFz{{c{|!||}A}~~b~#G k͂0WGrׇ;iΉ3dʋ0cʍ1fΏ6n֑?zM _ɖ4 uL$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2TF[p(@Xr4Pm8Ww)Km,@*o H*T#FL$.BsѢ(#E\q3˗0]f4GnaA>e:&T-ZuӚE^=jkVf6ejU˜kR?t5ewL%PH,D%3"(H&9BNqO.9Zlq؅,itMڀ5 lfj @gZ ӵq;yӭ˷6_fe.6rY+ .ңM#0ޚtzqGx 2yT/=1^.E0 .tDKݲL.u!"tuDrPuWS 2"kptXwSdl^KqRh .SٕEgH8#6yaDd"KDc09wTHࣛX$cK@@Kz ebEGƢ.5@[fKB܂PqG-MZK vj?TzT(*/uQAIA);F+ zA\AgKk-ekGdPP [M.D%HA2h-RB %~ux1(1:dJTˆtq֎dZ/U!<,R~ Ygx!dE EP*'6?:53/52.81.7266 89443/52-:1.3, ./,0.+1/)6-* ـڹɾ۾Žs8mkӼ:c60+(~L{jPltބӑeݯ۪C͎jil32M]POPIRSOPHSSPL9hTOAD312.=612.>511*Q71+C201.=501-=400+L40*C20-+(5/++//,,)>500,BH2010*C20(2>,AIIL301->D1010*C2011'i>,F:85011-=A010110*C2010QMKLM@kH1,@201.<=0%110*C2010<90Y;01,@201.610;70/9010*C201100/*EC001,@201-820420/;010*C20110/.%:0011,@201-930|//<010*C2010)$'7 -011,@201-94011/0<010*C201+xE=.01,@201-76011.1<010*C21."dM>EJ1/0,@201-48011,5<010*C20&/N4*),0))1-@201-.:1++<<010*C20:bSOQJERWfA,@201-'<100*F<010*C205:;7D?::4,@201-!?200(T<010*C20-<40,@201-B300'_<010*B10-<40 ,?200,D40.$c;0)2'&$.)& #0'&&#9*&$J-&! uWZYYUg^Yx}dƣBvƿk*{roo¬}y{]gt πȼˎܻuxwxr~w̶еƽѥ˲ֳܸܽ߾ٸdžϲfԵԝоϴ޳ïб+ɸ¶ТИ߼ڀ Еݵ߀Δ֬|ж㹯l8mkĶpòsA;ďqcтؑ !  !+| YР742Ф`rУУaУp!УЂ]PУrzA"УU\w{?У>D95ң.0Tiee`HkaTӣ$"УУ`ҥAd{dp5ih32HylhfKukhiZWqhaDuhbDV?898,R=9 2>G8996+pN96)O60/'L50+:?0)fC0.%P7010'L501 +:?0110*a?01/%P70010(M61,:?01,"\;01/%P7010+*#@.*-00.-(8?01-$X801/%P701+$ *5.#'))'ZB01.%S501/%P701/. vB--k\[\T701/'O301/%P700100 Z4--G322101/(K101/%P7010}xu h?i<0.-E101010)dG01/%P701011/OB??@AB7"{H01.-E101 00110*VB010/101/%P7011011A/**&1-}W301.-E101 .1101+N?01.2401/%P70102GWWT@B'2h:011.-E101 -6201,I;01-8601/%P701220,=wF0011.-E101 +:301/D701+>701/%P701 001/)+]T201.-E101 +<50019201)D701/%P7010'J601.-E101 *;80011010'I701/%P70100/&-%.01.-E101 *8:0100110%M701/%P7010)$!I,/01.-E101+3=101/#P701/%P701 * IY9!-01.-E101+,?101.!R701/%P701. yA%*.011.-E101+#B201-R701/%P70110%|eGN^h,/01.-E101+D401+R701/%P7011)bD636>GL-/1.-E101+G601*Q701 /%P701-^K.)* .1))-0.-E101+J8010( Q701 /%P701+$; (O;--E101+N:010'P701/%P7002McdeghhfLviedd_<--E101 +U=1011/%P701 /%P700146676+P;641.-E101 +i?1011.#P701/%P7010'L501.-E101 +B3011-!P701/%P7010'L501.-E101 +D4011, P701/%P7010'M601.-F1 +G6011+Q801/%H2,-,%D1,-*)?- (D3,--&H2,-+#+ *&-",  ĿõA hvy}cXYXOy^Y znqqj ǰûκȳ ǀ žlɲ ||k g gƱ  p    ~~|l} ^$%l~vo,Т{qoHȿgf[ϾcX l4 d= laWXY amf  i kϯ{ lu ln mf} fYxkefeXie`^{f ]NrefeYClefcU ߼ݽڲ޶ ˻޿Wج޷ ̻ŋج߸̼ʝجÂƀ ҽϦجȄuwui}vwwעӭجԊӫسج؈Ԯ۹ ج Ԯ޾ج݀ ŽԮ جܱӐԮۀجԮ جܺȈԮ ج ܸԮ جܴ߂Ԯج ڲ߁Ԯݯج ߽Ԯƴڧج}/1Ԯƥ՞جԔ:ϣԮǔДجު`ԮljɈجyԮDžw جҐAʄ ԮdžS جȍstv Ԯlj޳ ج ԮǍ۫ج߻Ԯ Ǐףج޷Ԯ ǐқج޷Ԯ ǐ˒ج߷կ Ǒň٭҇ͪсäЀиԀwҀȡu}|hvYqh8mk PiRed|qgb=[:wP2e?rjXUVT:&smZVWR6%rjXUVM2%yvbUVT='\M41(_P6120&\K410'"m_>1(\L30(^O50/&[J30)"k[:0(\L3010(^O501/&\K3010*#jX7010(\L3010(^O501/&\K3010,$iS4010(\L3010(^O501/&\K301-%gO3010(\L3010(^O501/&\K301.& veJ1010(\L3010(^O501/&\K301/'!pcE1010(\L3010(^O501/&\K3010("naA0010(\L3010(^O501/&\K3010)"l^=0010(\L3010(^O501/&\K3010+#k[9010(\L3010(^O501/&\K3010,$jW6010(\L3010/']M4010&[K301-$hS4010(\L3010-'$#A2%$',0010/.-,#\K301.% gN2010(\L3010+)$-10-$`L301/'!teI1010(\L3010% &25110)nO2010(!pcD1010(\L301/& TA100(˗vH1010)"m`@0010(\L3010,*/ pD110(~[80010*#l]<010(\L30100// e9010(so\WTG81010,$kZ8010(\L301011/0 wG2010(^R8121101-$iV6010(\L3010110/2 U40010(]Q701.% hR4010(\L30101100f:00110(^Q701 /&!fM2010(\L301011/0ƐuE1010(^Q701 /(!seH1010(\L3010110/1vbS4010(^Q701 0)"obD0010(\L3010110/|zyzuR1c90010(^Q701 0*#m`?001010(\L301 011/0ZRKF ?(sC1010(^Q701010,#l];010./11010(\L301 0110/1)./0 /%Q3010(^Q7010//0101-$jY801/,/31010(\L30101100-,-/0110,a80010(^Q701/-.1101.% iU5010-*271010(\L3010110/6& ",1 /#qA1010(^Q701/+.4201/&!hQ3010,)8;1010(\L30126   5710*~N3010(^Q701.*/7301/(!fL2010*'?>1010(\L301012=^mEVO@1. ^70010(^Q701.(0;5010)"dG1010(&EA1010(\L30101:PbfeK.TYC0'o?1010(^Q701-'1?7010*$lbC001/&%KB1010(\L30101146+!UXC-|L2010(^Q701-&/B90010,&_^>001.$$PC1010(\L3010) UX@%\60010(^Q701-%+E<101-*WZ:01-"%SD1010(\L3010) UW8l>1010(^Q701-$%G?101..QS701+ (VD1010(\L301 0) XP+zJ2010(^Q701-#JA20104LI4010*1WD1010(\L301 0) S? Y50010(^Q701-#LD301006A=2010(FXD1010(\L301 0) :, j<1010(^Q701-#NF501013531010&uYD1010(\L301 0) '$njG2010(^Q701-#OH701/$YD1010(\L301 0) "%,I<3010(^Q701-#RI:00101."YD1010(\L3010) #+-010(^Q701-#XJ<101-!YD1010(\L3010) 1++/010(^Q701-#sK?101,YD1010(\L3010) "1+,/10(^Q701-#LB201*sYD1010(\L3010),+-010(^Q701-#LD4010)iYD1010(\L3010.)&% 6++.010(^Q701-#MF5010'eYD1010(\L301.#+BA+,/10(^Q701-#MH801/%dYD1010(\L3010&1k`@5,+-010(^Q701-#MI:001/#dYD1010(\L3010*n}{J..++.010(^Q701-#NJ=101.!dYD1010(\L301-tpX/#+,/010(^Q701-#PK@101, dYD1010(\L301/%ogH(".+-010(^Q701-#LB201+dYD1010(\L3010*ZO6"++.010(^Q701-#LE401 0)dYD1010(\L301- O3"+,/010(^Q701-#MG601 0'dYD1010(\L301/$ pLEartՄ/+-010(^Q701 -#MH8001 0&dYD1010(\L3010)xgYLCFUafgfz++.010(^Q701 -#MJ;001 /$dYD1010(\L3010- kP>521125;ERZ[Z+,/010(^Q701 -#NK=101 ."dYD1010(\L301/#pM81016@LRQ^+-010(^Q701 -#QK@201 - dYD1010(\L3010([<101017BJJK++.010(^Q701-#LC301 ,dYD1010(\L3010, zN613101/#dYD1010(\L3010/6`qC010(^Q701-#RKA201-!dYD1010(\L30103Cbzf@{V7010(^Q701-#LC301, dYD1010(\L301018FSWY\]^[@)tn[W TF61010(^Q701-#LE401+dYD1010(\L3010011232(_P612110 110(^Q701-#MG6010)dYD1010(\L3010(^O5010(^Q701-#MI90010' dYD1010(\L3010(^O5010(^Q701-#NJ<1010%dYD1010(\L3010(^O5010(^Q701-#OK>101/#dYD1010(\L3010(^O5010(^Q701-#TKA201."dYD1010(\L3010(^O5010(^Q701-#LD301- dYD1010(\L3010(^O5010(^Q701-#LF501+dYD1010(\L3010(^O5010(^Q701-#MH7010)dYD1010(\L3010(^O5010(^Q701-#MI90010(dYD1010(\L3010(^O5010(^Q701-#NJ<1010&dYD1010(\L3010(^O5010(^Q701-#OK?101/$dYD1010(\L4010(^P5010(^R701-#WLB21."eZE10)WF1/.&YI2./.&YL4./,!I?1/+ bT?/.'6* 8* 8-! 2)! C3& %(&%.#؝ٛ؈څؕ˜̚ʺˆƵΕºƽliu¼mliw mlizGmli|fmlinϿmliqſmlisþmliu½mliwmlizTmli|gmkimm~|}{gW| }~{cp˿mkNIJKJCElNJK N]|hdaSMrſmQ) 3cA>?=;@µuþmj 𷯩fƲw½m ñl̅ɺyPm ξm»|fm Ʋlmm ˸lpmοl rɿműl uľmԍρʷl wýmʍŴ;lyN¼m hİl|fm R=ɶlmmcAͽlpmpb`bw{K5ïlrm7U?<Am[>ɵltm̈́ĴɵqG.ͼlx׾mT;ïl}mthC ȴl~ympN7̻lwvmq`@l~zqvm qJ1dzl|kk}m ql=̺lzafm qc"ly\aκm p}fly\~^m ou~ly^x[mo8ly`ƳsWmnblyb䴮nS뺱mnulybiMҺmn0lybf<źmo[TlybbmyVLMNOJJlyb_mP4 ^¾lybz\m\<ȮlybuYmnD-ø|lyboVmM6ulybkRmZ<w`lyb gJmkB+ζvZlyb c1m~K5 ómlyb `mX;ƿăl yb |]mhA'l yb wZm{J4ôl yb qWmV:˺lyb mTmf@"ȵlyb hNmyI3ʽ lyb e=mX:sb`afpr{lybam~R gA<=>Kdy lyb^mu ѻlyby[mԂߌچðlybsYmǂˇ˿̆ȸlybnUmº lybjPmm lybfEmllybb#mllyb_mllyb{\mllybuZmllybpWmllybkSmllybgKmllybd5mllyb`mllyb}]mlmybw[mhhu_nVjpnm]Qoklk[PsnogUOrnoePHonm^Q`FDC>BeFCB>BiHD@?FfHD?=B{VDC>B؅үͬˮԱ޹ܱ۷߼޸۰ݽ ߻޸۰â_߻޸۰ʦ߻޸۰Ъ߻޸۰ծ߻޸۰ٳ߻޸۰ܹ߻޸۰޿߻޸۰Ţp߻޸۰˥߻ߏܶ߇ܱЩ߻εtȫ ӆ̤ծ߻ɏhacdbY[hcd h|ѩnfٳ߻ܫk7Cր߽WSTQNUܸ߻۰ ߸޾߻Ţ ߺġk߻ա ߺʥ߻ՠ ߺЩ߻Ӟ ߺխ߻֝ߺ ٲ߻՛ߺ ܸ߻Ӏߺ ޾߻׀ǀߺġh߻ ٩ߺʥߋ߻ڈ ݱmQߺ߆Щ߻ ʃ˄Wߺ߆խ߻ڤdFߺزʼ߻]tWSQWyRߺܸ´߻՗^=ߺԺ޾ܹ߻޷pNߺҵĩٰ߻ÚϊY ߺү˶է߻߾hIߺҫϟ߻߿Tߺҧɖ߻ ߿bBߺҥ߻ ߿ِQߺң߹߻ ߿޽-ߺҢ|ݰ߻ ߿ߺҡ{ۨ}߻ ߾ߺҡ~נy߻߾Kߌߺҡҙu߻߾݀ߺҡ˒p߻࿓ځߺҡČg߻޽Aފߺҡ༇Q߻Լyoߺҡ޴ ߻ԡseghibcߺҡܫ߻ܯjF sވߺҡأ{߻{Pރߺҡԛw߻ҒZ;ĥߺҡΔs߻۫gHṜކߺҡǎm߻xP՞߅ߺҡ ࿉b߻юX9Нyߺҡ ߷B߻ڧeF ߺҡ ݮ߻߾uN ߃ߺҡ ڦ|߻ϋW3ߺ ҡ ֞y߻٤cEߺ ҡ Зu߻޻sM߁ߺҡ ʑp߻͈V,ߺҡ ‹h߻ءaD̏ʁ ߺҡ ߺR߻߾uNř ߺҡޱ߻ۧm*ʋXPQSd߀ߺҡ۩~߻۶ ߺҡסz߻ ߺҡҙv߻ߺҡ̓r߻׷ ߺҡōk߻߻ ߺҡ཈]߻޸ߺҡ޴0߻޸ߺҡܬ߻޸ߺҡ٤{߻޸ߺҡԜw߻޸ߺҡϕs߻޸ߺҡȏn߻޸ߺҡd߻޸ߺҡ߸G߻޸ߺҡݯ߻޸ߺҡڧ}߻߹߻ҡןy߼ښֲٍղۊʛڅɓsى׵Ӷ|lԸzk׾riӸk`Ϊ}l]Z[YSX]YXRX`Z[VT]`Z[TQXݤr[ZSXt8mk@]gHMf qgo7fmV2fmyJfmdfm fmfm,fmGfmf"fm6fmOfm jfmgm/ġZ@jI ?GGGGGGGGGGGGfM?&(;;E T TaSp,S-M SFkSd &&&&&&&&&&&&&&&&&& 2SU&bS)#/wtS ? ;SY]$bS,u)k~SE ,///////0 fS-&fS-fS-nfS-QfS-w7fT-W!7mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm>'cccccccccccccccccccc;#kmmmmmmmmmmmmmmmmdimmmmmmmmmmm[ PmmmmmmmmmmmmmmmmmCic08 jP ftypjp2 jp2 Ojp2hihdrcolr"cdefjp2cOQ2d#Creator: JasPer Version 1.900.1R \@@HHPHHPHHPHHPHHP]@@HHPHHPHHPHHPHHP]@@HHPHHPHHPHHPHHP]@@HHPHHPHHPHHPHHP ߁/}]̴!MNzJw!q SA霢4S}; O_tq(?߂ xJ0^Y)oW8[ ~ C[ h-' dICc\')߂@ wJ]-(PݪKKQʲ>:qԸ߂8 Qq~ ,b<k>B ~)_ T6ŕ!V}^q xnCBX-i2ooUrN^)/[R%pt$G:^O~u)Ӧ RWtX\hi>ɒG mTEK _@RCUs6}JLd;vz+~7[\?DpvZz3\ohΙAŕ:P#=WfF.:ΕBTlߔc J|غM(ZDM:>gN`4 AxZ32.Eu%~Mf3ԟ;˲؈(0 k@ߙ't[\Pe?/V|nNH I'Uz/GQ᭷/+k>fkKkj$;cK>h#BѯUQppw xiۑ9;򈹉a@RgDQVr)P̀A7+GPS~SԲ쎪ߚO[\9z|DL)A~vjwpow庒dnQGb4ڽa=i0VEP&wɌ{G(ۯ&0vr ׻XE%X(>j+ClE/i.j<[SQGVyR[ݖ9E%8k1uSp ayB?_NGCGoٓ"·RPmx'.in1ls>Ͱ,}P~-ĝWi|ˋˌ.v\!/=yx`S̄@nezA>H}?b,]bʐ}Qx b0B%r$x y@oBi2^=ςS6fh•zD *'[]>)[Wӯ`hJ&MeG }|+Nѿ\ A96u\a0<5:swPXN33Zv+{C 2r.$ېPg0uBR^?S?RbO >0Dmp~Bi]F޶1}R+ט^W{(YDxy4~DAX3cEb"eܷk~S(Ȃns,VJs Oo+XqzV0 5"Bߧ毗owCTMRb S1GB>U /5lXKŦQ4'5+7>4ƿv2PCCM:u^f:Bu*5IUSG٘ކk U.ӂXsv؉rY?D6fVaոK٭@1Y8uޣp(L>۶a5W6kl*1t+a]yǼiħ"z $L Fݩ1<򜦴 (T#m*SMX.Vqȝ"#]29 3܁[}c5FG IFЖTtD/}Rd) p@*m$+ȵznJ6Qu֫R !HeyշLf ?BRG'Nف@hɝHhJI܏k[}0@R3H$sAw!۷NPfXS}fy ͟ESU >r1X8ե6{[-ڃs7n>r8gB ox,YOrcs<,33Rw _}'uQ9TEw|6O ɗQ_['۸ȝp_U_,iK\^mq>9Z<_ws_gHvݸ Ijخwc9ut5z`7еr({&4zl<,=V=' {\WL iJI'o}.;vНGeQR_4[ûXFr +7HwQۖh#]o'̤xlWy2Zg᧑_m{*塩$X{|>/k ZzFV^͆*keRVUg>"8 Ι5Ujȱ87Ŀ2-ɤsU.u+p U3q֣/=EhAd?#BK>0X_@^9~#>M:pv;q- sn'cŵwxKCma:ΉFiT̘-L 3<R2o̠v&ӿuݪ5:F ]L%ߍ؏IND}יBck s=/-|>$ĿΝ_ p?B1mFlȀ.CsFs%'(gF"*ZkEbZyutYLhakE3p,,?? qs{D׻CIN\`(4IDwϵTy j&FZo|nxѷj#lW#5S2xO\ow .ODhfRi<Tw '`5(oX o˜6 g/12bDd͜* Or`7MA` ^;HXZi6Y7buB5|7эL+f]Z Xv3e_1F+F)j"dhKoaB=6L DT]T 0,,n+яSt2l: Zk>`1y\2~d9-d.GmRC48,I.ҫƯ%. d$ZSOE!r?Cjz\-8>%V+6/@6ly^ό!T~-ZĿ>Hebl/zҫhᯱipz09{}{9s6EaVȦ,oI%*D=+ 9^AA.9DSUPo?zt1l^m2oX= 4eZ)w-o^@bH|Y9p%2 5Aa|Ctr"LY&#.zu{(ZG2jxrkm$їk}WބbԣN$%7 Ch)R9( XIeH݌PRfPlXƬL{jjϻl 4~W`&C.h[/Tm@fv4G3*JR)B1}a,|ӽK3ب;~Xd15|U3Є8GOI^@e0dd#U6g0{5ly wGgKҋק&ŹH(DaSJĽJ? A+%,WKr΅ГqbH:U4?{_${|81bO"e&?Bwux 9I UNhDs}=R33١TOSsJAj\ĘPqh ˞A 2]oR`McyS.7,W 'gzV!]o8}#'Da8)&wDC'GDnD(58D][u-Ѻџw?OSI~xQϨb ( Z`j㚟 sage|A]@g$04/g IabuKrZoyj*΁ԾE",`== S:˵X !3 Mv7ܩ1r IaȠW|P`@-q)sۿqy|dblW/ կ.Js%$y5_0_)n}~w$\F4wj'iȌcr)F<B+FgDY*Z!v!fE, QY3pdZBϴ]_m_ݚ%_-20^^T '& HDb<8#DTx^xB"^[BG=ƹAL  K&eTQ*^< ĴN=XRoGyO;xuUjY̜}C-e43]3Cl}nyEp V__Ws!wLDrʺ3.w,MVC-~7}!j+4Z pf~7JO`2}3~"s6nU?OnB*=y4gU]EŪ 4A !bXdb,#H1vmN2.vHcYרV4=ԗd{ (;Tũ*z(embɽ7+\ uF^ OIg Ӥ$Kg >XPĈx阎Tz=&  ?z]\ѽ?>!( D=.YO.xy/n)}X&=i̭< ͣu  -ѹ y8}YlU;N2'D0~3xge 'h-r.֐5Ҭv5Yrc/\KP/`1cPr_c[3p;-E&:ef80Zu~F ([ucbľD29΄,9&PgOC^KXBP)>Dw"xm7&L&wcd^;TaHxl2\lvDBkݛ#nX; 0>}($+ Rg(iK^ևx}Ͱ"^\SrJ#}BU{s|J-V7#]L{C`Vni6-8ß=niH+R1~Bl9F4MN-6KYX{pV9Z˹+Sζ4U $\e8pdhk͚Vߞߞᦀ]"+u.AFItqږ;lβ)}AeֱӮ%qu )HO E f >Oۙz+uo zě0Go 9fl'bb|`%*&C930*sc^h6_#eOaou(\PEA^##PrY.EA[F|/q(~FY^ 6-Ld6,S'a!1NсK~N]J va4&_4@Tkqݱ5b>04d$F' i_sHu蓄K.@bl &b5Cd {rc"k(%9;_f U4ص>x.pt)~f@ϭP}>vQѬ2 H;5n 7p]bTޡ?6gL^, <|N&Rcqc ZϥQՉ6) e(9Ɉ(c 虈TTksĽO}3fx"Q-v?ԏs ;/k)nꥲ C[+0 r Ux#ᥙOM \ >ۋVJ/ Y[U gv\N|>_Iǃ-+ ځpI (ϠඤeNkS,kqmȗ5<dXԹXȘJ(JCn4洝 zkzԋKui;(g 3MGXvFU+퍍A&*>e!ά6wxO!u/JBz!eudE+a wT!h 3ԶG74 "R#MAdaUO$2r8jFRvy(]+\( I+f"/ [Dndm7G|F@tХi5AY㯘d)LZ4}_а{dmp3/[آ}4~}"\i@aPkCA@U!&~{? 4zA&Fe,4.)Rߺ[CL+7KD#d>Ѻhba(yGs6V5Z܏qH%݊p#E ^h9z:ʃrHwL"3H/2MW$1a3W8dUӽ$ޑ6\PK b!BxJ"ЪcjZ"dNvVy3-Al^g6pL,蟰cpEDz}Ã8ȑENGrd&!.#'= }GwqlWz0j$4F[1cPN~e}1yBzCHg#!@ˉE-UkE\Wkk I{H;y|#@WNܨu5*&ݫBN7~;շNjsɿKq:9](L>Ef.]Lba_{x+N%{*PGv_ P󙞠(ÍY'j [)oe[.5ɰP]+HR4|@>r$!c_ܣj"2xڃFߣLdJ:E˵L^^(؏rBz>FQ=$M5cJ@WoGL'}jKJR gl. ;}38NڣeMOHϟ{ٱn߹Hжʾ*/l4Ldx=)4l@v3\ɷ7h]Ԧ 2̋:lQ#iylm"sRU;LUz4f6;TfPsLXw,vh3E5[tA!o:J-khy> Xd'z{?.x+p VG!fvƹ!RҶse..a[GtP*[ ?bO8}ʗBԈw5柠v'ի:na50ԭ}  ĪA.X~p% {tXov.XFvC[鎃6sjN CU!ү7Al1 :e'*3Dd4xR2IO(H*% ʋG4ujN/q9ܫ: ;A hO׎vx\~>J^r,F:p,j87@7Vsc5L_&Q&5oF 4Gt}3pXk]I1O.- 8,E!s(li)oDWqPMw$hlT1 ,NȮZJ̰HPؔdp7<Wq .l//Cbt?ú9vEJ'R)};r64;ػ;8 5Ad T +kJjs(%Ԍʄ(2@7,MabnH;Oi,aߦ'\;;")ٽwbj'KeVǣKI*M9|йڲt7.@8t< dvl}@phid7Du,hxۃE(hSڑK T\ƈ,UNwzH.9``K9Js|>%K &Tĉ^'.F<Ki∣bFlV^6yԂdBoYXUFI MnJͩ-3E]GKeg>8oiu=,ׯ|5݆RHR(xU-4s#\6r5OAqeŹVtVI1nQjn|wn䖷W ĀzVQ/+3a3t]M+ b>ceT!Hbh/}#]%wĻ5>Z˯tRSI~I+Ïd$cZ{^jFBXӢ(b>ͦU>^+'DW3āv&֬>Pmř2P;j5٩_K3).p2ios%ve!]UBBN o ]kQ}¿itO!`r` h{,T~יִOrm? dV9 }x7E cp"E]>O"=C.@3_?rgC>8= d1YfTύJa3I@;MrCd|BV6vT4HF 2T EuH Tz8WQ|3Z1?4?f"\_@\}}-`ݠʠ3C]8ErϮcqg.yWlyiU5UP :[ɹV5khx,kj4\HE[,Ca.z6~W+İ,K1F RQ|hy vc0~2k$U° C7IGA,a:tН&Kb >"|95K-"=pI[I{Pg'[g ?*K6vm6 OI_͙K!%,wY8vCD{{{|MSX+.Q0QmPŸX f|?{xktHn7Y `|L:4jSwZC% y:;9OI Cͺ5 \>n=^w(Uᛣ흧`<1+>XN2"$iS^GOpJh"c>DJ?X֍TX<x!R&!5G̋Dl|З;񐠰2hwR\BmK$G.Mf~ { >4klA.n{[3rR*y'F1 q36\4+PWy[LdG>=;!RI5@-afTNzf@qp1.ho 3DSa OE2$ .k'PL#mVALTR#ⰏY״NlW@U;ҲlPnbvtRMR "vTUg"CD\eCK:rx4ʴh.,f0)vEI< ၾ=/a0Glsn5xWj"tf@ JTtSӱØwFwb(ucDd-,eLaej|wrKyIsGQ=g_S]Q+t7mdyTMM^/}U>Ssն$`_\C (gQ?cI5qQie( cJDN=/~j bNI~窈 .BW9?qepUZѽp#zaVu*iZ˫_D;wUK6j]ך&#Ui^b)|AZMޜwgAg?Z[wIeWrK_"8$ۍ¢[i8m( Ks7LJ3S*3)>K  ę!>ClU[IFkrZBC}eߟx2? z/ ? X>uN"S^7S-D)rvص/fQmDgiXOj]`J9n RFNz(@__\M7H`m6 5XV ӢOc;*t}1ӯz"[6(rhTF«a8̙%Ϸ^9 y&k6~Y cV1m|>Shh_e6udۖ1Ɗ[2 gLyԴ aHp/J6t5̣1LH=FX{Y$%ꓲN"FXcyW`20 ͦW,<ߢ-l!qE;| xvA&{?D4`FG?mJ.+۝Bnf)$gBRFX[ɳGrg.ʶɽq%J[\t–(8au󩇁&މK7FH c.Eh5ЫciDrKJғ~;XV;#φq$"Y{"B=g LRU㔋i4kYuO/>* | ö' qX) F Icd`_ֺRx̢V^h;mR1-j& ξ"~ڴZnS~)H]i>,CbX">pa Ȗd4jNj@->/Nl ܮ}ַ!P!3n 9h`u(jHqYAޓ82<0(`9"s}h =3e{  ނ$Pc;br, Xiw2wёlP T%|>X^H hᔩ<]Dg^DwV;%#h!w[OuTFo+!7B6fxAP>3b"pEA8@z7np X? B)=k3A@ϦSYFzܸK^, c4cB#C4gmz+(lS,ۭ\.3Ta5Zf '܊o[ 츉(`}3v+wp}4n,q6_hocc&N&sfj舠,hZA%i.,֋B 1ҧ Nm_,3n:ZwqC#O)c:+z2^q&U) zʈjdab#W ܾcvb.؅"*9pz̡,5OQ#6}Hu YovU&Xp9 ˵kN @D/Ԁ!wߟr3?@39"gj~a:|bcXzIBkyq,gnw f!1e_(|ܷD& !|.B.0\TGљx:)W>ZMn1 fNc'%f# Ưj y@7wzT2pt_ɼ aW\Kbdut8tiN_ޟlj$_r":{@ij إN> K5|8 +>*:ԤO/Vi[ 6/a)һq`~ gˆ Q Fv v,Bb_&tJ99!J޲דU77PvPT(\t AМ1g$J7[UMezHps j(^ǕmO2QwC ȅAawqQ{(9sTI3͌9.*7sdu>X]B-f]Qtܒ,%U]kP#|7kuqFEMKl3>i'U $()hd>:mǰns2ʰz QғmBdSS0jBhgʃ!ʧn^Vpcfe3WnP|Q~DAX~l"wO.nA6bwڃO nמls3nTr.%^$$8p)ToS^ ;{ V8h,J? >G ~v,9Lg6Or}wNy]Op8KvA ߌ8AyBɅ֩#Pg*GYUTD %Oʽ jlyKLy\I.MQOy=q;51>`XxYu$xU9t!>UO[%1}G#R tO-AhlXZtuG%C:ZE@7Cojhcp}ke#7̈́)N:4yp]2=>H=g^pkID@pkRg<#^!3crU%xo˽߸c7 qwl6~[>t남_A^7S٭jj&(/>+F F>8{}mz"!╉%J>A$kšN|*ޑ̅\Wn:$1?r3ch I#0jXO)r q  >N2뿜uP̍P?goXԔ`2;ԙ$pL밯emx^D4}v$=Foc%CUBH>93S8h>3{nZkCJdf< uSuTS {Me[IgrZ#;\r:Elǂi2vԗQLb,(=E㳯Y-r>lYll8 }orF҄&uiUN]yLmNX5pbny#?b{_*!V~aiJB::E_]J!怍 !;VN4 qəz<-8Fs,.%6kvtFlu^uSRN-m]{/J`Sy1b}<6}B\fvk"151EzrEz"K^̷?CS(KE%@GcrRn]=x$Km9\ .once1H6Tɍl[Զ1m{w@@@V(X`ȗs1'7UW&|8́POl a !C['U>I?TK푀ō'A=7]8E}j['S'JH4A2%ֺv2]N]ăB{?bzzB>m$mmC]G;Uy'"e°T%[@ Tu懢IVRXkSCtקa`M{c;O)  c&_yg{5} ֮X8H,Z&Ψ4 LηޮT1PT`SZ{WTȖXя͏zvG gy!rKDxVJ Q.;mڕnl ')תP/ i#pvDS _:n" s%rA,@o^$тF5M;a}uٜFlm "y>2'B4PA*@Շgl,-rX gP)wvnO$➣v~3}X֓_%8tlݚ|8+lG yeC䇯ݟcNI_u"BFWQ/0?fW~dX싼SCw ^,nUTdrN1f'VNt#ܸ"Y Ia akY'Lt@gfě!R6pȜxe(ffs2% pS%7ؤ泻PwQu(kvPKpqq=lDr\%5k' &]878ei8H)Dj=2(oEki\ Q@UEc X4ʖlFFUq1 i#N.*p^ R D\ 7Ns<Lۨ+Ȓ) IiϢ;w06 [! X`G `XHˠ4;]k&H"n[nM4;|;%)m+cN_x;#` X>i]nu-NaR (`W2wOCIfA7qap;2Xe,f $Ӫc,t(߷[E@f] z7 t tnj%Z~IAPߓFRg ~qUUK]Cʺ3?es'BSoE'"QżS-^8Z-i5Ӧ)΄i_쇏vw"Q}|ifUrN/@/{F$fԔVUr/? , m0.< Q\vpDWB 4)KǭuQSoĎNCX~ѻlJS Pc ߲Yllߑ61?5Uo3R7C AQv.mIkn{iR?YɔҮ8!"QڰcxfԼM)iw+3,&]OIX~g|YUrrEQAbiVpr),Jc_Oq腞لӎ\)+5Ik`T,\JEh%I14yOvJږB7%k@h fpfųM r1HH =  @z+s È2 Eb8gܝX "i_K~7?Dc? 7<^GEkY as?OʅQ`j+bʷl ׆ɽX*IkŖc9a꠾r 0s[8?*Rkp'FN {^fP{\DnO>aNBm69g7YW7oh=Gp_X?lY@`ejrAĉՄ";gy0#TM\;`;d"bі:g;1Vc'v gWsE/=1=ʜ70Ʀl}voҏd_m(w*k,J?D ۵z վ @T /-#3tM\- ioHOv\ Q P|~Y#aj*iCކ XVf-8}."[`&|5œ} 4ƨ9G0bsB\OG`$a'`IN{~dsC?d#AcbO'P8z7b6IhZe Ou3Ayh5 @Vw5/q2@൑vjA%f@G'~[3\@ BEe~l9tZc_v$}P]5RrEi^¤Q'x?pt| AIݯ=Z29Qؽge@7Z]x <JvM1DrP9(o7j~ >vyCAHFOStnIݼL=dhݚcwXQ"DLRCp]!uNfĠK9@f3t@@Wrf.6}2d/u/^t@aYEk0Tv#]oGؿ9<;v a55eXﰠ>>ZEG,6ɵHQ}dKjB89+9\u[W{1\Ӯ`2G+eB6ṭY_"оxMz#pG nIj-β*^$^P,*dbur}Ed4{ۨKk \Y )C }kn ? ["U*GZ\f}.e0TZn9,\J|3E\ɋE⿫tin ǜ VN?mJoK)h*t*2C&L1 Dhû: 5%9"D1jMZ dEĜ[i[eXkaujcFҢO@Z[Cc+ +Rg9@ٕrbrn81y/-1oƂiiI6ls֠|N)Ft`"Wh XP|&GnxWgLEhoXj"]ir[~l`3tS^P2m[NR7(.1*  H^BQ:YқKC"T~X楖VpQZPJ:J{k@Aդ Y c88DDelH)JDoʺbޖӿpe͊> ށHˌi~9N &C0!@k9!꺆Ɗ xک2 6x }qy(".[s-Rh %K\+W)=M -ř#@0):-MB7`r).Վ2RW{pf 28`{S߱TtVc\I_5y2n ћQl&G΅^V)QคO mY/ <)TOG H*O<7 WQL!#:]\Qa$%D;z早~eICv'鎫p3fjJ#.w2jWT?6MWS,MH>Tw9՗w=Mh~$$=3GLCˁ8npT I'TjQݾ9%s*{lh j=|v|PY!<5wurW-HI ae%~3O=18kݯЩ/=ßX;:Կ8 _$Jygj |Nb}AYk*Cěz"7a}qd0ZhhfRe?}['|y=s[8n1(NdE ڎ9@;f*k̚ߦqﰄ S2(Yw;E7.(Ф+P 9b@,H%mo(}lR‰]Iʡ|e۠vz!)Vj>%W q5}>H8 )Bzӹ-d/׆j3[ۀSE5~9~YGF=EF)/Ӵ_=6w] ӔVvQopm+V6kXוQ^t2: _dӪZ% LlXɱA%*zՄ*bHeO@#N+\E`E$Tx"yHs,192:-".5jȟpcjX&$DY$7fFO:+Xᛎswdַ2-.bÏ؆تD/1nN҅}OKeɫP|~л³AA̦?IǁRʼK/@[@"|ZecN m8ZOxf(Zhɤړ>B8PC@a(6~ Q N0ap%M:5(Rjl{Q yA1p`~v &)1 ,~RLOl<;:MҖ\xwg<zdn&#EˆѪJnGw dį VS#TVV^w`K7]bS<-8\S6¤5.^v=*Q (_%X k9mȄ_KMu:;M1°ͷkoF>}ED^8{8W/|h\z۪Ⱥ؋嵣bPwރE "vjKl (VxQC|jag cߣ^ݝÇ'HcYQZJ0KAN9R֯2:q6 tk^.DbǹE08ݺ_nZ]yҹZuҪ@Q}1!Hz63ZL@wҨ]" A7 1B:55WP&18EW~H!1E|ոL49l\"f"םkl.xTlK w!R%ïW䞧kh8{@ly񺅓mf -{wr>4# JؙR6{8'[6 sWWg Tk%ImB,׀yqkX탓}ZC9v01,="$SѸUGwxRE§kTDa^>ZIדkK kaI-ɷ:7ڎ{tBۡ&)z'2ʬ!Q{,ֹd"JC$@Ӻ#qt.{-ˆjf{# QҦ!  2i8H&5Z#Zl*ӮFKE/6GVD$jTR2RZ0P/|/G_ʻWDǟF_ gBgt\uR(x^1\!OUFYt TCK67OTa_ӑcV$h=Gp_X.qXSk+1ǂORjDP#G>|CfL {IN{̿8XO$]0M-"Dz%Cm 2OGWZPjPn| R\ֈ9lWl[+cPڹ9/n,/=.sLSDER&)+CӡTxJzcP/YcYVt0wFFVE'dqaIHV#a=  RG#2r=H-KNr*:1m33i q 3> uDdV5 xgVEXv`̖DȠ;Y)o0GQ]ۏ.nCmf\/]ьVc2.fHB|ꖼ4wPppdq8RȠAxϗ#NM2Yh_SrqjMTP٬&, }-E Q s{g^M/f_\QڒG4$H4%#'x+IdqA|(Ɋ/?ݯڞ}p 6B WT9z0>-X Űa̿T+xp X^᧾ ..6\Z! Fd4n7u#!9 CE(q &$i)٫}KL0jEo#p;}'=R7OŷV }ĭ KrC$"Fl;*bKsHDc82+ 0ړ·0Vd?_Ë|`Ec:ȡ/欤(hwW˪޺뀵;"md q"[JQŘPr لnyZR0MPfF_}1@EƭķžIiw/iHNs`2I d-_mpS͜q@] Zmt!|*!i+=:Mi*i# Pg *| A)R'駫P)bvh=Gp_!['#u/8uѰ9#%Pt,_1e7Úm*u}, GE-!pz8!W<~gOGa}VY݀9S>\3FN?g4Qi-Ѩ[G¶}u Ҿu ~be|r`B[q=(` C Yz qL KT @L#@*\ ל,jUHl>Ê35d7.dh 5L`*4@JF[yzȇ-*H wiZG&uƣɍm@0Xj8GYK'3*}ojT+8՞ԸEq,<:>.q pǀOi_*jD4ee}N ٚ}uÁU~l2Ha1paړ>.Nv⣕z| j`_6J"d|mB`ѿbAs/NJLS. ۗ kTP_Ly/7k $]| `qU3ϮC7P֑ C3anNB"GCHTUdn<6-_H3abAi󵀝܌`.b{(Ck=mf_~g?3>dm_0G+sIP3!LiLz |z ӥzu7YTRKB@((YN95~O&\jg'%uhy=[c1gS]t,-"KJW*>A6[6 霗q8X 锲e-!gq) t 5dϻ$ _ E:LlBAZ7&ULNg5}ra+~f0 0ga=h/GF0x[xJt0al*Z($D.+`\=2i!vCXvr#|rz9-l Hoo%FS$;d:n /N/ ta浬DBO8.L>jsB5Q~R}1ڂE S)YSGٿOqK?h}g{(vn@Ϩcke! 2K§UM "#sM%3r{f(Ny^0o&rZ7?O)Hc "|bmG~Kڬp5K=clDYz/K:A KPz=F@a1 UNORDjs61G1ޙ16wqyK^ &3*'Ve)BC 3c_t۽,Qkt1xZeL˽aĵIJ& ˮI#&wN׋U9qP6LgΖ_'<7ڪNh*75G&|''fPebx''>bNDXb8?}Ap^zTHJ5abSp/_N4jeN1[ns. $4A5(9@.<>d]{(H0DDG 9m7mћij9QR\yLrv/bƾn5NX8eghҿǷaCۛI]Ҁ҆R^P;a.?M3?4J7q =Kr"*LRc`TWG#3P#f&ⴄ? !-X6021c/j9ƷF ҈spߦGjQoB*^qaP :0ij o~月?rQE-WKo;$Aa熕"c1*NC̋SЀb.ʷ`񠽙Z ON;}.%a[JTN?Ng2cEw}QtfD6!>.@OE~s[v<ΐY.di@۠ Uw: OqE :l'13jғU]!5KpEd"_<aIzukg-P"|FODex2dڗH`ic09, jP ftypjp2 jp2 Ojp2hihdrcolr"cdefjp2cOQ2d#Creator: JasPer Version 1.900.1R \@@HHPHHPHHPHHPHHP]@@HHPHHPHHPHHPHHP]@@HHPHHPHHPHHPHHP]@@HHPHHPHHPHHPHHP ߅,:xD tTת:䛼u&(u/c c06tܸ>L4f}=W#R rZ=f<E1\wGo\Di9-+(\ZmLNo4`ʄx@X{1g%ƽx ڀxiszǞ%)߅,=?5J*y3Q;+ 2(LՁ43_cPq\}i [$VIkEa+X׮-=; RaRT}]:d$4+J7k 0dAuԻ t&gm2[J%wR8 o5xdIZ9Pε,Ƴ%}{<5C4vlBK#~A|߅,03P"NhrPЯ/ɒ7-#Hp&+f ZU:sZNIvb%7ÕCYQ<# [<#@y/#! N۸b6HD_)ۛ.T+'vS ?߆,-;4k m gW:Ucc83 }еoRymc(j~Hv&9zKbH5|e=ҭ^GMž1HI  F{SȽ\-?_w=Rv]Hc. %0 b5DZ2U4~ *:+f3&^f`Vg./I)K96|ct{RrWKE~~Sclamo_iN4AnIƽ aE|x}*H/~SQL3юiNoLӋFŎ3j谶cWvᒎO}u}|}T ֞4H7PWIX,W~UŮwL`[ PޕH<|ɾc%̼T\f*jd`һ-By:?KOnY+Eq ~c(f ~YoX&B2[Bd,Kg$K'Eg4#%W¼3&o٩@cM臊܇M8_uX?8_dM~}U̸3"fpɇN'`FwB$&\!_V[É,>[yG֮;GyF)r(׉XAƹMxҗqM8/-'$Kƨ|}y:Ab6"/CºiZ6Be5F4E4VF%KAd]JI&G)ꚾީY/2!W6;T`; Y҆~dj} Vj!yayM,.73x͉ ߦnŔ+oUswgue`Jь Ty!6\D!#0q5D'/ٖvz{atvOw$Nb?40~ xER)+BmfӾ#o&L\P@Fdg Q Y* kI8nu.iO rJ #]&FdOORt:AqEKm;bWޫsVGMߝ? ~tXE;nz<ɮM%D,,aT>wѷnkn>4 }7?=2CxLy% BaJel*m:fT5kϲ",8 ik?NGdؚIޡzkZ_H /:Z͠1t̼2z2G%MAPpz<6kV0*r6x0!u[HJGB;x !{C)?}`5a;)}1!8{/9ʵp`}ylʋx1}++[mPR{M#VTr5j*S[ (~'OiPXkJ٧N5/pcig; ԃ1-mt33sۊl]GJBygkVps)yt "ΝJc) d$x$q |pv*%Q s^;'ݳ4K!.cd}!<)x$B Nk7ZL7چwom*U~x0dL4&AXIūya FYES+YE2لnJ(CH,>pTdNMܘPd7Q}g?5y`{U=EBJ[%Y [!U, \ԚmYtt3U dSh><^ h x>8ju ;t CRG Dc£ iC`e fO`PWrܞrF ^֐ 뙕+g39 áh-"Ie 'ઠ* ZU7.r',i Ir!復8xXmz1쭊ZhY>'ݝn\Tl,m%}@!VUGYM$ύ :ЭVyZ^ [(L*݌nA*$eXQTAS7̢wv0NR[]gX\ ) LY'|Pm7)ڿyz4 qQZZs]p'BܝmgcUeU&A 0wBI Ol[pFvħAܧWq/xVgVę0Y3:L?RHoD[Su> r*= JO\$Yd&\/G&[݆Z<x@Ϝ2cxb72nz!~LVA_m\CIçyg`֔g3rul)9/bJ]GhtlWMl[C:VaEYϧ wz}-&FY4a}J<7q @! ;6'z/ү"$c_]6T{MϦmOB ~lA""t}j jU>P[|l\:b!1ܮ6T(֨GuhO"B'G{3#hVASeP#%L<%2zoR, mȇ en?kN`oKaB8LxsA2╋Ϋ.n{9u#;U0ա2=Fw*_V w ո[@e!w &>:e6(j*H:1h9HhH?fĎTQ d2*t޼ 4sf^1 noKbqiPm*,%拳l= k0LcBIqx{m8ts.8{ ܗ/h1w)gzl|fo(LN/dW=ToAc:JT2*֚KZM;<|7,:*xwєшW⥻{obv3NWb0ras/I*n=l9?Нi;i25W 719*MTʼnrJN)GK"4,P_Wu^%w[%D˨PM 2ާ;A:^00e-+|%U1> +vЇ2Iz2 KOGN)@5MhJ|g3_vY>4fFD8~bT`(oV} <C]ybS6n@֔ef%:I">耪jJ[ҲFAmCi//dsGx#C?oľCt.[|uN{%om.o8+V3dwXH2 fcj N7f<_7T*zNn yxyq9ʄKal#6EbtӖS!'| 8nS 9\^5|nu^|-'.YԣvT1F5Ne!1V_HF[TQd`1LR|\ J!фp̣#hQa )ߞߞGᨀ'_4߲9ض!rd`gmǻQX2­,Q^Vfq)|I2m =K ^?e{#(HwqS=\QFU$(vħ1CR;5ZVY{>No6KE~ŏlk%<4ŝɄ$LvE.:0r;_?'i2by\^}WTj@9+7xݯ}aMXGmcDZs *c{LhaӰl1ϔ߰]%ud;\rtqU6!+ljDf S)e^IžcdːX쓪O(uÅXCExXUh[l]3@rOhNc :.5hQB-C+9 O(,~ZoCډE/蓄K.z/kuBi :56uM_i9DbsCb Z%Mz3ƚ>ޱL1chl|k6LW)=WԞ {X8pREjla1v9bKuV7 ERJGcX.H:nti;0t3k`fš]b*ؖ"1pЫ٭Fd͑►}+׈ǣq+_ev5Waڡ秘H gznw7R,KvL=ɓ!m%9* =ŗSrHӭ shp!ؓedGSvC %9Τ8J0ۡJwvyභM:HJo @G: iPNCl?Nkʰ ڔĢ~ِ*dME}Vi낲FB$)ّԞ ̫<j^ƎR:h8vwtGD=&8I1b& gr|@+z;B g.Tx?FK|Bۊ? Y;7 &6jS,HwUG-8CzW"Da>W^ V--A)t~1qrۦ%Rtoj3 L !zh,̖*  Gc7h4w|M'O+rudǥF!сa/3¨8\z4`ZQ̦P/[0 3a'.x3ْ!#=vD#MH(F+.WmP)@d6LhY{v*.SJtZow,aǻP"NQ[^ ZkO[vс%aU4\S'AR| bKd# YF1@N'[ue=Cl jlxρa>애W5 y*b15k%y*j$֕b~*4^MCsK~qָ d~&jkJ҂O$ Jw,e̔"?H5]ʻ 椖v;g*x2/hrs,^RZ}Vy'a57m6z.˲wWQyu9bcOa~\0"jhqZvؾJ2F,l$R%1vQ:2>Q`Ԇ_OQ?M j}xΤB78iv 5q;lmߺp Vo}nuEKyN$mOryr$/Q*Ao`To e%C`V-beRqt'x0?;Nmb0˼ւ-/d>7ua?^)FEu8^I^#Bv7ӣmO`ZGvOkPhBkT2$"h~=L0@(5Vrª>u:_Ɯi^nbxO@+iH HkIT7xâ7!QwNBcd؏> ~ 9{d|UԢ8= ^UgPy[UܥΡ$-T֜k7VaZdC x>}~-(crO "nj6'A|Ps{↸y] 2ic/EY^SfTn۹vhJZZtԜ WdFρv|9gfp9j󄔖3ibkiMe:qtޣưO J: Lc 䩢pUN 5 PB?T 0fE`-%ƻAbey']^7r@הkK9c<4"X +Ō 0 0D@E~ Mq? cלSqꗹ--wiǔWl0K OPv|wP(-\`eY#CK ^̹Á\\.DIb>@遬QUm#fP'2ʪsqAޙF;NoG`G(YS!Q-.Kݝ!RwWqLV"76<,BO%۵zMQcN S0Fみ83{^Dd]/I"[b* Fiy* \q 2lx?SkjZ(g%kgQr$%ʌ fBЧ=hU悻B;ڸ,7ar DbWqhm*kM2Jƻ1c,38s-tjifwJ.z d,MXP5ppo/AX+=9XkYpv/|VC3WFP};1IAY0N9&NxXù=<8@gҢ܀d \g LYheF JިpV{? S`0mSw(jLIǶʄ/43B͕Lj},D-^OF<hZϰEb&3;zL{R.GWWýf<+\?v+Cȃqdl2JVm+\DVbJ9ؠ+[u"#f?8IC8gwsSen?̦d7O^:on]댌e %CD?ZO TOBfm1.ΠU}SR4yv<ߝ? 8eW0_U@p SoItS-U5U3|ʣu0ަ`J<*8GxQK2~[bEbq/$`n~uGtg""QCa6xyb/'aeٞ՚a0}i%J Fme.gXȲݫ,a!#-vP=X$H; zazH@~8K9;$ bcC/}Sޕu+|x_Ee5 @VFr8Dz^_sY:0ɶe M-( !I~2>E p;4/:L='ɾ} ]lXrKUEh==rI3@hERKH-|&[MeN%!ۗ&ZpF?B$dO2O4tl}[Ԁ@?JD2Mn"uL¡fK"LBPS)\,UF鍵0'f^ YDu ٥kh3d]|ێR 2H;<'PXZm~-Pi4>_sxe2Eo 7TTDrn!WT\iz%-w߽Ԡtzh 7i=K10ǧ٪4?q<FjMDp,2s90&18q[rۘT䫳YAnG}'0;~k(0/@ήwx}kƽtH@z]`b 0f%W]}fRP]Svחl10}6ȷO4 1nTzQ=[ށg3b湶&]2|N+SjY#g 'ɰ1sع <Ix ?ƙЏ6фUͬ);-Z!/(.6g|30)oNaH?_,Ho`V,W+ E,czt7Tc=sP@~1dp  Ҵ5G~]R?{baUB0Px;Z)9 ( ? JY[9áztY$-ﱅG3}|F|&9ҏ=;>T[)P/4~ETbY" ǖ:G.X۳uj؃NK0p ߁D-tXmME^9#%̈́"F 籉A(D+g)Ӟ2=O6 aa[ۦy^x8\[.bY:nD6 eyS;uHy`yرu&Λ$֑CB9;`&+# HUE" 4禗RaZ̥6@s }n`y0<<)p+r- %^7CC3YNϒ.1+ $| LF͆OJZݯTA>@`q, À1G/ kF%~#ipBm7ZY ˧;. oCذ/L'v)Le ug&nOJBT"D߸ȁV?r1F^y LhGkya(PԘ$PU-hCP:bygZ@V70`S1$F4j㡒@ۊ3񐴖wSFg|\V`a^1!ȭ(VFslbrJMcj& H.ӣbBt.% 8fKĮvA@6P;A<ǃ7yDꤌN0KZ1٦ Gu D+$(T\(8]M'5j/1}4Io@(vW$W`Z^BL㩦pkwQo#jBtnQ~LI#N.C9'm0'dp&sLWxyը~¿ZRЊ0Ρ[XfKmЂ…Dk Fh'eRҥ<7\ďuoC!G> jzi7MV1,++Qo,Bx(ǭY|ipGS`>/|ovF8(,H4dSrH D&jN8t}tsGvTf%Y RtC[c  ]59Z(,g$Jrj#ꏕe:XraWt01 1y5tTEҽe۠L*GH̡F"o) 9OJ'rDb=M7*l3%z1HD c4yd'd]@ bVmMR{SXhO6{fRZ{?Yy>Zd]keR v]NYiȂӦOٵ@T_, +MuQ`˂RA+|W0U,>"ߙ>UZW0=̊$$RqqsHtEw)Vf`;kh "Ben7 zQ 2~*B۬\P#Ffpx GL`Sch\ ,ɛ,"cy׭١w#۩^֏˕?lHqAvLeq)6vz ږ=|#0㫨pfEf\GW6b8gzQk{&N~!^#0OY,B84h , lwOj6?nJ>}Sп᮳ꭀIx3'\'f:t1 om6vo쇠%i2Sؾ- I)[8.-;tk \-~뀔2#޼V󞥞pBrےUk}?b<5taX|HcGq薜K߱`:0 #]'V=L8 -Ks 1?L9<7Pz(u]ku>"rrʝ䕯`e?(xtrXr6ŁClt53R.+;%u2GL(g.cO4yYYIG {Q QNOpa9p4j݊Nۉ{sF`ø!ĸ1"\53z8Ue* =7Omw^@ 0a M^s U[-fV%xO=úYށB}a Op_ą!u_vlAL -l!Nӹ9Hm\e/%na|#CP|vZ]>ѯ3UqP0$Z}i*dsQkkB4 b:$ xME~*/!FvBWb*ñ>"_h;l :Ne3%=mc15?&AylOMxqݩdҝ%)9Ƴ|0@Yu_kK:Xs EVwu#ыXK~S#{(~RsCEj&yYx$|~n+!W ֹL!=^a"qu nІT6n>huIJ\J,iM[3zaͳ&U&4셚vX f!~J'^Ѷ( 3٦ TX?j_I3KF8&N"w'ѓv9RXĎh$o'' $ FN^if[ؖlIxN"MO,1dy:+xM3t[zstal\ry-q$TP_ TJ,ϴx3w{ߨn?+W)]44=bo gX5àb¨w݅0LިGp!AIPI.Z=pH=uYՓW>m;3eҾ%/J!@/o9lvYX.bsLx|Oʥ)l Rg,!%{l#x* Y2M,HaP^ ܲt:yefcP^^, Wݑ ge"ZqRԯ.4ٿJPdR5IHџNnT"aTHa:қN J{duNÅP@-9 A90# .?TTi2$J1)5 x;K\z[~<~t^-'U<2l9oX xx>>nD8ynZhŌ)@8Z}z_o'"%NH$<l6Ŧ9Pݬ.޻lhBm^l/3C>W5=2^on-_ZavQ&&`M*ӿ J /P/!r`&5#Jx^"Re#u<dC^w H-]D>X ``q$6Kx֣S#2Wֵ#VSHhm䂫{pQ;TtFom) ;S6Hv C/&?#IAT½xs=ɕtTe7JR%S{8!UN1ӵfzKoB:Q`Ҁ!MK>OtLY!JlKE&d)CdhmdIt=ġ%+c7pnFU[ZZ oAǯ|R=Fi|:m2}bPO",u>_XDmkQƜekHOU"o2tR )Ϩl-13Myףg[ʿm QiD1/\kE(i3gH j v4x ?d8~"V ƶ$- Vi-X;obVuYzk `3V%ԓ$_q#ꖅH0ѫߞM[#qoaN0{5z:hȱePg߳~1* `=H;fyMڞ3@7_) I5HafH@0y LzfB`rbFt lsgو@hpH q*R4]/hynRh/pY_7B,=l9+!Xqr%;dfÎ /A]4WNtb) pW0`߬A8˯X؊UAL dS\,.I܀IjZٖد%͞7VQ7f/#|nZf |KzTM@v%E:-`¹H-MΠ_n0I8`A\ B d;L se1`aï @M}^?dzF WDހEKL[x%f*Qك?P\'HXzoS'' )oO6Ӓn$bG cphZҭl\9~O</ٶ2BN WRWUhYɓӓixxِ1z\(ѴwGs7MN K+Ӱ{y=?O߀Em͎#bexAv7FݲYB<.Q  L׆?5h ՜з3usexpdžV f*0In o*u6V{:ՍwAӌdb@!d1^T\ɲ.Bނ|?ЗG;H9ý(J@7O4UT׻}v %^~CK\K{_3u~Qx}9iNzx:?^}Q`_tio+A<;`Aj4)M]d6~?QÐr BC8ѴrM H5 tقxjvD.-kOعk i x\*Li*x^'3һgsס%`iwybJj~Ci8 &l]Bnm4r3܁RT+'N:2@2r-Q-$hfG㓤!>[cQ]Yno`Ρc Gfm> zL%@nF3yt$yP7\uF*πQMcvf8\/u~EMRsKї%1#fOTGtSE̎xzcY͢[I x=} wEͤ}k %_1E8|~ZI'wfWm뇔DˇjÃc“h'ao%e;e x*-ud#UCkUt9m <(Ů#D-޽8~%2Hl7?j< 8讪&&Ìil+LED!SEtъN#Ο3̡rAvU!SY ! &r>!k/d'^/Z{ yBRMY*H,R0;JCw[rviq@mZ هՄȱNbއ/B]>îiڅj 2kLp+'RBljZA?3;AowB*޽27UsxB`IH1 #q dpm̲-T=`v3I2 1,("MS Y/x:fݨMZ[\AL1(:o5RA aw/* P _s&}IS\ bK œU9TO8ڹD͌L/z!+=1(Dp.C8޼, z8Mι6Vd0HȔvE?ᩃ5;'#u :OX.6M!ޭ8dl>\xgu3&da[!Gz o4>bgʇ7#U r:$jb \Ja,p8;w'ͮwҨ""EȟY4ނGHΡZ@i<&Df#%l[ӈ #y Slb&9|C‘mGA,olJf1X <'7b3Oo\rGV= q_D^E-gsP¢;!Ŀ採//;@9ڑW*EKdYa8xw9t&Y5N H<E@m!tUxHWkxSyy*hCtODnW*U:[IW2cǵStsQCmq:,A|u]=뜘ĵßkRyv_$s|j`O%qSu飅e Ra[=뗽ĺ )*c$9X$1*{5Zz%%2P#9|2B_RP=y(ec 9@j o|G4F] pu5m_.bbGr2Xf.oS^4q1φ,ދ`q^{gџʐa~ٹ`(Xw,٠x,lF.² Lh:gْ }'&ksTz,2T]1 *>p7a&P!r0NP 7]*P9w=<K#֒EŇ"~d+5o xf%>+9T OP7& Zߛd[K%ѓNL6w(\rnK:/75jQ5?#b3߫*'C0V= @E>"LF%$LȭbxzjNc@ #J(R+Qܬ=M_ꈨPXNH e/t˧p7v-_,# 2h~~{:q^+}o\>تԖ{_>5EK E/5Ύ}u sLٞU͙ vٖAv7gS#S&%+%:؃^Y%qp{ Ņ hg$Skd{sY5_lSϛhU/rBO,(ya濅xdٲI3eG_o?nK15vtmEހƾ +('O8uxbǿ`TKM^c1]DA }UcKx9!H*g,a=:2u`rs TP>0 \fp_`ӌF6?k+k5N@z$S^@YS3Xf 亿&\Q,K"Yքe4}.U_4W(G:U-! Ef $ /jwؿ >Qڣ{NuY␋e= =AIeQRHzYc3DTrJ r |Yߗ0`@zĢ<$.4^żܶMv R!T!حL]p^ n_oփtل/v#:V;Tg}<^Z֘~eD݂4什D $e#ݡ ^ FYƚ!B,)LC H@-&ԿM h"fKqܜQ/AYLϕR_uunh6sD45p]AcvҞf2sw4IY} *PM!\ǒLm3LOLo:t+'=4FQ,gzal}vJ#gĔ͎EhnusVb}N&ĕgwZ{'MH3زp}Za;^>Y.Bc mAqȏ D̎ۅM=^9ENz(C$]xa5zuEJ%Ml { Έb ,OHp!z4>< ය5#m#(R -^q`$cVL?C'kHJ>Mo+DfTI]&h&훶}nϩLupAЇ_;?tgsRB'ZPh5&S[*r{EKh ' )v8%X^-2ئI;PHd>҈v/)S֔LSNw(i  N$ LKӫr0aYVo=^/ss'j-ɜZF߮gZõL4F}@ٯx~ 1z_}@RyZ*v9sP~\!x+v| Qp.J;<i n~AFfR&6lx%JM~` "s4&J0,̶y]!J`KؚQ:èONґjX>LσxZT't`P"h];ܷ&@4eLF)329`,o?s;)8\(/ZQlָQ^c9!:Im{J4fj20lKNLNd kj`TVnḿd9~˯trls9syaQT_X@,cж#?sAZP>2΋]cTp#@"vl 8`τg 7=CkXDH,@O2kҟml}5-7ڃXNBFӈǛlrd{ o.muQOu eW XeFmj,6%z,XiUDx & ]([e%I bw]SW8p)<eYDňek&Ed}!jk6\/`srR 彡:no; W1%Z۝Q_)2:zqqW%yqCG2s#'!9%Lr7Xoȹ*BdtS'M? 7/^e4~y39g5ڔ=մ2*ڼoZN4(eQωpzK)ʝA:Idg,Xjh$MF"ZW\I wLh`JKy&rh-tذ R !,jIn@apr8 vDYԒ3tO-t4"  8ELMupQa FvXňY.S6ώWnd@xl%k2 "5^"G%L2U(kI_tpX鋏 nbIE X&-,;f:0c6&3IkHxyjO,ez,tbp,0rIv3cFzIˊLECwOR$SYxGEj͉3 ɩ %XHF ;%a[^{e$ G> ks|c|Q-dE.;MM3̇t{.e+$+A\ZDn@GADͶexZ*'7wsO8~xk 5 [8&XVzXwQ> cde%XlĤKB)̱: ȋ^yDJTY,yev#8p$ћ# {o5K7C3*Y*RM_#(j:y}Rnr+ ѢtV3guC(ql#NM|ۨOuVsu>pT9Bz̆p*T*|; E N~ SYρ]%#L} &X}2 +eG앇BU_E" [=Dj|1?K)&K=n44,QM~GNP BOo9jWsN{dt7ɭ k7\+V8=a]sDQV}GݻMH7*.5ibGhpnw+~l Hfx`.s6%sV5ɆsD ZI֗AN^جmɜh sϙIEq_ھ:|ЈH&e, &/S1Hv<Φv8R6[+e5?:96Y=Mz(?F-O-ʐٗ^Y/s7kl& L>0qߧ%9|-X|\c/k ʦ*0ЕseDm on ұl aGxkcern0XW-pwY/I OGSP5fωyB J<q?yM*0pe96͈,[q.NT VfHRabjK[9wlS ŰX*ޒmlo63{JE4MOx{O_j8??5* _ZX0a@ƭw1_[b|6@u(N*9f"DE.e;`ԕvyK'>,Qg``Iaf^4l \9;UC4ڋ0ܚAp@A0 6FBef(? (X64FxMW:ő$oa_w47t}?5j}U󵾮2 s 17\TEJ~@o,'^T,XcET&8D$/gHeiG]VŕD3[rP-0S0;v?^A]%#HI"S]гEE׈Q PPh܀Tr=愺0t}5T,T̶?H'T.\MGvǷ9x|,RgeBJ@c8+Z~ȯHUB!vWMSyF`_cb"{hX7y:{BOVn]F-`fJZNӅ"`iyDJw$IC g=pNIvM;')| 4hκg3IJM$D,с*>|-neaF 5}ؔNoN03a;Z ."e L3z$qH Z?a D {jvAgE#D$MrSZqj$Z #l<$d$i , k=UZvm5WtYyXO Ϝƚ1+ #7C\Rs HS!*;pU։d?=D3aCbY({AV9SK45I(ss}b.l/uFG|U{-<<%oxu ܌bLG5\ܲ- !rgz!|6?T95-Tnz|(̮_DT˰@M rp2wBmv]TQD6MYrb:F5_\ v:?%Je3 5t3Wwgy(Ic6SY}23)4!Pcz 'ZaGE' eVƜ -AѴ0>_q>*a8K /Ɂeª(0LBbno5Ù0~;O~8'cDKqX5Gd]88qif -uf%tU/dpo011#,GǏW=;rMgoQd(Di`$sB$r3r@faCܞy$弟HmC˟ ;g@}cا)oB@gʬ鱱B3grt?"CynIxKAP36v^FȶlTsB30 |:LFՆĽkhh4P]0F iyt >뭼X5PcF{}ҟ-!`urtجaDjP Dg)/!ɢ%& KAmU %4wX<ĢȀ.bkmJ&#PYV%ѴUQEHZJ|VpPc@ AwI$gO~awo޽r Ey N#)q96#J3 P>v; >@/& cZy!H+= wЊ?m՗Y{lNܦQw#:bl)i փ>M]#SIJ%,WL{/}{Ǯx;ĨSPdR',BHK6~>o/6j9 UopwR_Sjʕqѥr@1\кs-H N=>8"Li:'I`{JZ0+*P7ҠCaV:;Ƥ֧(@I[X1qraZG]AҘ(Ml~!<[#b i1+ nrgXB|NmA(:SSe]ќ7e{+\hl<ь.N Mt]( EC2kO1o܅ml iU T+X6ڸh{Lݯ׋Ħѡ@Yl=mcT KnI(ޘiP#74h;ZzR7-iO ,fBM aȥ6= XJfkCR~QqJ2 ;pgJ?lm&pA~ڬU}[kUڧp2Q0B?6x~ޭ(drj[(E-Rs2LMi.ftrD%"4h)< 9HY Gm6`n!j/ w|OA)|o1n)v5bilvVEԙ܀-D>k~M YV{"1|o)CmP`b|mSmش 7`dTlSժkf5]tv|Z1`j01T ǦDV&JJ0NsXix:;Lh4kF7_+ђ0j|:n3u_n*Un,]3s2YG.cя2 i{PfUCsJVPG909t(G+B/BW!?OZVmY B4m+ŧWI`2QW]Oj:T󜅜 LD9@/USJLN&QAeʇ*+Gus&Hiuhz=UIB]>/0_n*81CfN C%"hg5}y r-t57v$RJWEl᰺ҍ$$rVw1Dpo\\ 1HtvS _m/xȓ[PLFk[ }WڃkV6vN]O}N30}7ڟm [oLAFaAGΆ>z?zO__R5 Klm g% |Qd5JVl mHb׈0Iι&LOxŎk,Q9 A=kәP%f9nCJ05+apkU@kߞ ,JAEӘTݭW@A,#&uaquX}Tz)-;VYSL-!-kp!犰FGδE \?->}3r4FLy15^SU=xxr4ն(ijJ-.So$@ZWl '? !r@ _k#FX,)H "~}5e;jc-QU]Q: U|Zyb 9;WJ$QXbopRZ٭)ik;mQç?ti 繅b6_.n%GBo%@0UTwL)Tw( X\#+(%h^+X[4PieCBT>T%b ) lcb Sd8!Ѷ0L^l²a 1Fnϩ\i6NVɎ輎誄Ńtĸl @w9y4kP#CxBƅ2Y"ݮ`1`#6duCJ5`w۷0pmR-n#+e }=q5* 5;*Zy{ !5sSc6 \s4:^+\}P;GcYl.- 45x\ڜt qۣ@:=+ᶦùliGMpr!śꚻhd1vJP[p<.h|` =S$.3W!EFIi$04NQ5OQ'c8?r[~KOIDY zn*-,EQKY' hnbo cgY\#&H6pw]W1vR@5@%ӽm#dᒄ{3yb:F"ԣgnk+&11y]'+qJqO.AaH,V( Լ#o͝Qp[Z &+0lG\C;Qah x6ICO"^qohRBGLԊkw9&B[BP)HduY><+cGo6aқ}߸Q ,>OD\L1 E<ܛy?'kt=&(҄ B I *? MՓId0E o1T+B.?3DJC6zMz3eEj~粒uE O'iE0*o\>%sZM>+F$Ή $ra<5|~yrf OYo@lkIyNЙV7)Z /Ֆ/v{mvPx@1!Ip O{|?G3ZA7Ml퍿uva Кf•cTr4MK*w&AAV+zDS<,y` +ZU'49JGD'l@{(FL@et܋ '" nHN@Oɛ$+\@a3s}.S!?~ZN?3Q;PmI ue5 F1x3d*wCՕD,H gxڣ q0} ލ0ʻv. ֠~_Z뿊:BT V%U&d1(mELKb ʲnoԀʥA:_2VגL-GQN BSX [[ty >NH.1Α,1O13EEdȭFfD0"?C~DH+(owS4O^NTk+-o|QK8jTG@:Z6@&>e)+T$nA k0V0{׶3B/"FrH{&`,{˴{kF?waPCLI~i4 F_OO$S5GH>͢k.@E~5"^|. 3 qkR,)oX,f )xT C/7U3%bJR[})0e@$%N%/@d bu#˒4 {iU%!]'t$9=n{99V p6~]>rV%WRzmSDM\XW2-JR< :aK*K@>q9Fr;q) $F&!J7bcP㯣h'o3zx45׽ ?q+N{E>5Lvm{9o+inO+: z;}9k5 ?-oIsk;z_}8li&yCG5۝vvcqM#뾓  鹆cކ+ M0z:d\~f0 qE0@G|1cpqcMje?W̯F~VLjn1=ȒՒa~ԮbBv=QY:/2ۓ_%tG{TSU3pۛ68ޑ؋yt2wp BFbp0-hơVzBȇ5ߪM Ş%fĭN_EhbSK]ts ,㤴n.zȜi._ ^}4j{8|U}%eVflP_hRPO*ы4I^H$DK^jAfI%F0o>T$YIsߎ(MOOfF^DAa+]3pԅ"Mo dl īZ(oB!s yut?˟.[Z1YAU-՜uk;yO,+ǘ ^8,s2'UEkG3!VI26X"m~1 !` (pO0>ySEx]gc(e)5"brWܽ|%}unKX^gQ^WM9\-6:K4nITY"k Rf>7U\a}"))/}WoTY@*)E╃Y*v d=[C\7z7f;F6@uy2FwU !1yz&ŇY|X0Tl9舖01 Gw_"t5Vt9OJ|4VtDvW-EtDZ.iuVD%,a.wB䳓~oKQ)6c\5f" og7ieB-BxoY?/s d[xh=[ &f?O w~nG,1$>mI'Ggf9UoNF $ ljHS-gciّJh{ Y^}q5&sFir:F%Gx]x~kduqY. /$ p*OuuhŢ'򷹝m \d=ZV8;d c5[?Rp;F43Ppw*sr\~ {Ww]~R^_#Lo9~QKt~)9̞ýVouQJ@P5*  NFW[h%. LrB##u( T<'RF0{=t-G=VK.ikH^! :\&E#\psWu:Ӗv 9/F9>5\rMy{b?s+T9Wt)sW^f|/A{'zΤdt_bM}!lϑ{k(НTR 0 㛞@PD ah6ѭOsA"Ь7JRL[Ljxr&U0M8f۬nu!y#  T ~Wl#mamoacFu~ kJXK/xbsc$|<@(3Q'KLh(12Rr'i>Ei9lАC^$ ;3=Ɛ#Z-SI#DֲbJ٫t3Q5mQ5KΞuVZ*{l)~qĚ KU <GԻzHD%LyGщA+7kVrok e\cp %m,rR-Rk~>§ 8m4bEbϛ5+ψ!L+*^k;BŖ?1ßAa[VX}ućx2.Mt^dAN|ؽao=ssk[-K)y.۝=q$nK?>WW`Y' )E#5D?g50m(!8z$r1̪2*.tLI{.| >V&s yiM&1:X#N}[i#BihUˮ]Vܭ41&Qo@Ygi`8]2at}as7柪_"Zi2gfO5\S-ЗP }?P0r T$ -R+uL9h!\sD Tqv 걟ka.z{L VYz(C< B')w9{r㭲~씉Qv %{&a\s +[@A.[@M0갫:tWoaQWJbin Q +)3% Op*Jf[78ʔRKڴQQGC?ǵ~,9…aym2&(2vڮM7L=ǀDk:>vBV~t_ߵCVn~ޣ? *Ce/mۥ7=}7UܿiHYnވm7DϞ}s_W}AS}L?w6KݿCICawbIϨ_R"hL\}x>6W ;cp%rB!sτkn>u[-5 8ڠn# ^4iE889_2)Ua(x?O61l CRfxȝ0&*%15Δ>.IanErJ˞+x߂p  6 K_R/1JEw "VRv,aUr?41_̇ǗVtom twӭH_n= {5 v8x9|hLFTZ(1[A/ޟMMwÊS?`֥oՍkE Ь0[ˈm{f=Jx΋Z}h, C8@fPIA4/)Տ}ZH[xjV~ 8™a@f`V p*|`+)nl ߔD/[(dDs'h!];%E,ӌ.\BIS NљpsyڴA|-l "Z$04XpڏdGeWޙV }"*BRv5-L;)lhMjF|Y "# `6l,\`EL1 F0TcPݫdn@? A WmK;* * 1y 䉐qW1je 8u:e~qWDlD?`/yYcLK公dв hl1mcuxG,`, !Kq֛ IT1?ЫQIl6Nm͕D\Z,Lh&`"?mطPr i̎ 61j<-ᜤQ%utaogJ l漆O{-7Ϫ)DT-\'@f7 ? p2mdfZ?=B֍T/>7. Ĩ(LMIYk+C_F0<}/%>EQ,$/.h{,ܠa)P&ޒK Ken lfͪ@r\|Q Փg46V)qBU(َԒzb?+R[X\~*v<[EEϞ,@ֈY^8&/u%jqx⯇ZO"F7tVL!I -l9 wqu[R$^$|Mq39k~E޲׻737Kj _9?w.p)d,*2omy=^H#c?qDnR=esI64iĮz+<G 9㯐|ɏ_I/W E&2No?`Yfc kLbB1/CHhi\ruH*bFV/+O#{(J^1rK$ɰE#u+ %@._GUc]As•\$zihWw,PELE#m7C*,S*=NYչSsQ .f,hPk"P%:vxBiEX 7{Tx0D~6[UsߗGpI u'e]Y_=h#tIp{g4&+ D٤dj'i]Y*;FCjInAHɊX8=?6+Q=&s XGp2w ·)_zݴgJЧ߷/Ui^O%}5ZVU\g}\gl |'qfdoRU-pZ^n-gPdžc(dXb`bi͸MԳKߏ 70V9IFI<>*(M3O{М`iI:<ɦfMc^OtRhldXo,'Bu8Ŕ'q0c+~$H6Ľ&X5oCSZX7%I k.}{H%2/,3 $ʫ \-fJ/̀8"[eb:Y,u j_ͪpVuuԂ1O+2nj\wH% .`{1h`H^BK 2v /l#5];ɠṞ] O29=VtU|>W%PbaZtzYc%^9 9 ~rڏrjL=hqfIfKKՓ05D+܁i+IDA@Qat0rRE7UxXkrhY"ý,·F}\Lլ nZ!>gJQ!eg7&unLT1qMO>d)ﰩMoëC1ܟW [Ԇsİ ЭxYJǵVC7-^p5463fܸ)wct>DE[m5v,nO_ 5ZdSw{^HdF/lrLrm85OD9ICKhN"DX g61$~NSw:*Q!A--n܍6cMdRkOml%%GvJ^Sa;]Œ_K0lO/zv ׹";y43t: ɁȀtup|ʥJ@Pw#Mƀ$HCh5 Ś0viFLn@Z[J(/ x B&otٸwaRЀH_8r8 Kl̋]LҊ].M{Gv sٮ7< TeB4ATqA8W m^(."r!^skC8I|ڇ@MKM^c7 9^UE UvN["hXi}}RAi3K` ~kaF;x HX﬙>!+49|.E1[dn/i|z)_fOgw̰(Tck| MLb4Ǭ犣| &T3'vBI8zK )`,]9"I9ld`R,F߭7)ݱu~,|W&yz2B=ƓU'@OB\ acqD`~LX e"mUgu|AX|3NdS7'#7 4)oFRܡ̠~近R禮=~\ɂؘ9R`pVݰ.F^ZcC&Y7ޔ^LwzR}bY )X{xytp<+oy/W _ _8>s琎 d|\`noQ{.CB(7V/1H/+noJ[t`T vpﱆR?tu4`7!v23(=*Ek@$NćlgTh0[ jW<= ݻ3+-亶3ؓ + m<.|Ê6X7[q(O3^ֆ@sצCkvX@ɪ.jXqUaTOpEk۱ &=>PvquDQsQm|D*ɉj ۪v.!?ONDҏ7$Zeuw "m!xu":#yfI FK <`Ռ]F |s?at3%'}b2f+3tmg?V\e>ޑ"] oD

/N/.NdM4/8-'ەoXtڵUu'0YMՎY eJ *S]Zra'~ pu|RHoitj =*b?/#:Q'KEi!ZRMIHU˜N:v#wQ#"n/355򩪌x^ps*ʟD`uR_%]vϕ+H4eZ7OHIU 8'ImMt%=IP28JVrԱs/a*}C{zseat [*~Jȧbdu$Wg3ճ`KX6"#DH398ڌ7#<4OWN4q% 6Ntq/C e$8Gw ?tk[Cn=zʜ-Y^}k b0B]Wayʞ?fø9 vcf~UBczd|᝾wȪ(|LUzu5 u1HlN+Vu&7b`%kN/n= `̕nY%MV²QKl?I)k$ br<~C$'|R{@ 6i˂RmZD/w6:QZ:8n88]t3籚tqL _oI* `B$ࡓ\|amܯ)R=VK.ikH^!$#Tb?z>QV밵*a#OǭF.x"<Otd5ܭųq0}}[ܙu~v8;Zc݆{nyǬ.Lԅc+pqҚ>~6%M`JC[FiM%"3nU~i'bˍnC)s(%ڌI`fk"K8lT 6\;rF)_0Cv'a!!N5܉#MNrpt l1|Q7dMBߗ~@/ڴ}{(-#BSH6%Zz;ld:->%H4ҠYz_¤z*va j=kX5#*72Tz$]yq4IIy)D8Ј(7} 2fGEB>V4K"9:6 |Y^%ϤfY8x- ֘|s7xf1U#OEBiG[;j8\岲:$'̞.nJXqH<C`ZK¥`"k5p9k)uE<*ĆIde_%EUC$ 7|t!Z`I:_j2:qLB-3HyME(Ȥ g=.z;a㑖c??ci6Dx"KW:X``km{gX6_0i3J ÑUez\"/ŤN33Die>c\B?E\M퓰oQCnbAɴaIg)DupS}M7DB1eC6?niu.Upy,t*,J"~FpHXs"2ZAB;o͞ʅb, K8pP|lm湠zveo) "Bk'?}imߞyk?KcL^tcls$ Sv$6z@7=ؙݎơ3:Je{|Q&9/sx9?aCXNMshX6Ⱙ^+kG=V|^:A~!ѻeuz0W-h6"M\SPR.C۷V aSl0pe)t1w SjRB2K7gU5p'J_SҘ[EFh.GǑP&NcHzhXV gy gׂeO/U+@G*u;թN`XmcCG-S30v,@i*eP_{+R6?+TnWq =ynftp^erfٵ5 \C"-p²0eOQ+yګC 2w3mmO@q>-ЗP }?P0r T8h1gq'1W8B6Gu*ͧ n@Qͧ,QV Tz(C< P }F;=$̇{1 W󕯥NQ߽~Kt"Vt.1Tԋ8TP'ሔ0H"89zڲJH?}wڇmCiaë]ۧWC}]EW?EJ/CkCV慎ӫ`'9j2ُwhؽSo}A3wԎŞ,AAm[NrBN ?&v\VwqE".FeEITCJV95F*-cќ0gRN?A(asDsҒqDCe'9@/4E37,B, ՄlςRIKty-.%hk.g_Q[a1DTtIWJz&uG o-Po+(AT)`%pu6 -_=œpb (iia4u]lv Hf,"C^ x}CФjyL`P_$_*Q Pɼ+bem1AA\AhOS h#8"LH^!Ym~}Hõ߂*K:@juXӀԽ$ZF?SʙN/JcURJqY|(I*x2j6x]@З ĠH Ryი4*diL=M{'PO6ݹ۞': ㄄ \()&FzxqfQUi0JjDwILثE++ > %CV|thϗGԡ.]73Sf%Ѹ;BE5 qYpH#=^cn2hjul<1#ѝQjaLI[;:͈MsY4lU}2+GpBfC $XbITHAYs@\L`CL.6]uBhfm8&'SI*2 ~_t'ܧ-v#$gO=8gML`*xFSU; UBz& 5\ csY~TU ݺQc}Z^d"xo_<7t]XװPKHG:eb6!hjSl [Uwnќ ScVGܞ.AL>?ٚ_do (r@>Bx!>s;n" AP[WIn5P5v'oH>KK2{Zt1!Zȱ1RJ\y|#Z!O ~W?J?:~3Uqhe'LE? z] f96`w{K˳HǯvQYGE xU|_&~SgTw@K9T>wi#'۞<:a)m:]I\xl u<ɹZv(pM./I]OT%yC?'|KuD.|)9U-{y]Ł- ;13ٙ웎>o3kb2F Bl&ݪE5@8 G|BČx_gt#2 d`TVW库?ֿV X88Vygђ2K ,n}tp*;{¢ `IK0o\qa[xVܺ0=4"cLׯl6G!߈]V\gЋe- j(KvUy^Sy|M@:=Wg?&nswt9y6 N:/%Q0^r6;]7-W[R,#XOPB4#EG>f190(oDaӄ/(HpOƺyg۽L/]h&ɠ/k*5/)n8**(K sP V=N7X%M_\OFca3TX?`ۑ<=gsa +1@َ?UFDk1ReGiKiRRќL!T"$(Vi0"l8g/憰TKd?il-V:LJlЗOI5#ug(_^Z„YL ¶=/E0{R~As{0!-G$tҬ rW OA@e8UdiH,c)XA6%iӿU=*"o`Uj@ vZk͙^qz.UUUf6Gf'w^;CVܢI6@b@s ",H[]:\EI'X,77 wɘ Fz8z&ȕ(3k~㦂zaۊ7{>ZX#6} "3HwfQ{7PZچAJ }C뎋cwH-2;lRRl;eCg]^ͣM|D^A 5b6%N@=RCWw> zxߣ6={n^zPM9^8./-zaZϙ܍>*s*\sz&#"E86T96*º[ԍFm(0v\eLr "iTn"_23`UE2|&-RF6mؤiZʗ׳JDfG"CKo}u7 ֑O%{=똫F%^WUp`@9i|)5og'26d@a+&ۮl{U.n, `>ɸ(udaV{LKJꃀ=ׄ#tW -N'BQ1,IL3lVYnPҜx+".uvG.]_?j6:'*/9iHWw%Er%*m0 oM  TNߦՅcr-U_hhʢ;M#2[F2]&`vl(Replˆ)ftMBd?U^,wb_?g^+/oR*ڝ;]<>u6VN*Vn)_WKCGV‹4 i;=+Oz[ ]-O2.l\.YddaSO+,|7X=\P9ZQU6K cXsz)2w*6o\.? zEX]鳮J1P %NV}=csB&ly$)H(1nㅍNRg'K8J_Bwmz9?,с!L*lN{$:叢fޞ~A8HRr}8BVHhsqn'H05<-}j;ىpp-ٛ+F5 *^6^_CJ4ÛvW=9?f5qX](S1y3F =9E?d.r;c7=_ mWa6.QQV밵*a#O֬`x~ٍ w #4MSd:{ ;|¥36 6c݆{ny;w非p%Z_#v {,EW|h?z% 2{Ybl8PuOSlwԋSi_i/ %z*bQj5]w6b &Il%nbc_J>hOp $"Nf2}HE64&fK $#/ty ubVTUiQ h$p_C=cۀj4V`3} GV;-_V!nkk_ј_0w:`x,%,KQl4X;>oHDOhHw8YM_l>jUn#Gd)C}Lg_HEpK{*+^{yԩkB 6~:}Z,7l8*(~Lyr{UerhTuU@z˽ Bb k؊pw.*1\t_DA9ӀLNp's#;aWKmV7nKhZ1~<ήy$_TJJ v|˵4OɃumLI2jus,[uD+Hn!$șGe!ԸZ(4gz=hg ǀ* ~ C} ( 8fUwh(qՀJ} (Y1Y&^F.9v#5sM9osl}]6TpׯY`>N5ާ.lF%@ʩA|O}0$,_2O*{ZSDf|"u'`?|6өF-}xg=u\&M?Zv p!_{-e#,E\}zLkE ؑ S}Py9!qp.^:8.' V&X_BjfAUaCW1oֻfO xu]or(NecW^F=Z9o@OY*/04v1;t~DA8/V!ܻ\z 4@G.9?i Fobiy)h= œ\^s0=eoF|W]Mhqs\r6&\}%flPc]&%.jACnxg:ɘ}e"iϷm߈A5fXSQm ׋rᓞA]nhJ(5)_{Ya#WSF#J]!IɃiGڐ&GNMD8*h~q:;꠿n~r>Wl>&|[v f8<ӆch7ɀ.h$Z~lkv?ā:K,LOiG.F)y74KV|7)xj? ]"*ߌkJ'()o@!*;m0|6݄{ӿ>gLz~q/Q~Z i}}JW?ka:3rK%1+LOåQ_$δ$s5K^& M&F&3!<4!/:GWMo#l ˤhB}%%xX7i `]Ƿ -''0[g9) )/K N@F&e0ʌ^:@ d1^=!ɚ#nY+͔x׻T%KQ"aL@< 7#(tZ),6ϼA6A  tpл_]B9mTd+7 L=m[&GkTShH!Sj}CcmjO2؜XM`iվܨy#1 ++ үuN+m:R8I S Uq& 6r!gn#h^7F1ސj\-VROd4j]p9E{ј8oK KLyCLqӘ=Ҁ](!S)o!qI䵒J'HR{.4zЙZyϟvb9Q: 9/1H枉1cyc[ 6E++Ac:$]t Z&gI<" ~PQzGtOvVlHT61Nޮ(HWzn>:ns5cѱ9`lAU~![)(.i?DbIe=XL+!0},:)O!xsibemΗk#}YsaX('P8职qů (J:$~vBʟP: ͢UBDn/ ę8{zro__f5bsƓq6;2W8_ b| YYxvA1oe`@gPc&4v}ֻV$d-[ګH oCN @JYC++ͮEjNǟw 1v >EentqcVݡe5mSV`etH7)Egy8y-Gszox18^DtWX2l-r?ً(hےUS G #)C/^>\xZIAK뭪uTDBN"r57 ,d'rk Lp?]:T51=\jW8bܜnyftewS5AikdUF:\QJ0hV0vlpdHzm uZ0lP# dz{0W)Maqg'MŸ7Y; 2eQ BxoAQ< hoU^) ltiݐBn့i{%QqSVݟ Kd9O@ngݵշhYM[vշPpJӋ`\Yw$]X"^ O$])7 fXb<\zvIRpI5qXOn6R$|h(G墈<~+fzʘ#'Z#m',TP K]9Y vu'q y$#HIԮg,̘hz\ #Cdaa@_7b1^Wh  y>WG4k^r|h!HK̑Lj)ەEЋ*ZtH,t,+։ WX\ͨ+y#\_!Ӣ- @cH( z\"DŗRn}]RD>A7bY,_eF,<7$[b8qVy3ƺ }Y zL3J+IKތatSB'_yI[ʴ 6E*W *W"gUnƒ9?̨̫a 9 5-AE,P#_:J.d Ӗpn^WPBOk'"eJt^u752L!g:EZ(;w=|sVg)Jqk~& 80gW~oQ}rT㙄8PpV"r,F6mgwU@Xf~sLJHD˺&tf.eAXYgřIJaKgA 8x U'iA;\p/IV,f+ٚsl[e9bB-?}G:_;]r&9P B k_b7u'!ˌD䲨 )t֞9twK0vR&C(L7O ΒІwx2*]afT}mgònE47[Y+E!S|PPKzE?XVS6%t>eD3wߵ7hy]1V܈B &g0N\`(mUK_~sj_dkwX,xj11X[,E\(D# :UaNr77=L'a<' r(k2Sf -+Cn*ޓwMI VAĔ@Xx 0/ Op%*D}L0rѐ`@=zFnTȅ^˦z(ڠ5W㜭csL.ICa iYpɫdNo 0@`x r#D|zyB%!i) e ɋuZK}oZ{am7A7]vǛCB.FWd Õ~w|籵G Ă&$}`m¹8nj 2T D,d˕y9h_p\&Fg{(n^":j(SYaGtM`9Ge&nׄƑ"m# ʂ+vKU ыE!*I9,2VmW\T|: I.ӳlb$߿?ۖ ڿJ%Z_2ؽsT1hEMi!8l1I Uƅ73:vvxn&>QppQ͗Ig *hkn+Ky* h%@0w W7;=}&M?7L*8MqysFQ4S;Cbny"ԹG1x%VeӾ )s$/0Q䥸WXgWn81 MT=^lZɗi+4(6df~y]!VZ1 C/ 'iXRLŴdpt;qiWtmWLzwQwj8YF `9٥=Y1n[uM{IC $a,Q٢s02$OCz~/Oy- U@Ӣ g7[2n.h{_ PwCF(Fi'elT'/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ =. ;, A1 R= _GIp1iS$G96+3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(3(2(1':-P>aL w"}^ ZD E4 >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ =. ;, @0 Q= ^Fo ~^ [D F4 >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ =- :, B2 Q<eLw"_ [D E4 >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >/ >. ;, ?/ O< ]F’*%gkQZDWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAV@O<O;XB`H*ե?)l pT[EU@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@U@T?N;N:WA_H/'qs!wY`HWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAV@P<O;WB_G/&Up uX_HWBWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAT?M:M:T?\E 'kt!wZ`HWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAWAVAP<O;WA_G;ۥ2#˘,箃%m~_z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\{\xZmRdKbJcK8F͚.&o }^vYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYvYuXjPbIaIcJDҞ/(v"c{\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\{\y[nRdKbJcJ>ʗ+'r!b{\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\{\wYiO^G[D\Eӟ/)v"d{\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\z\{\y[oSeLbJcJNF"=֡1*%$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$}$p btWmR7[?ף3Ò*%|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$z#n `rVlQCAܧ7˘-'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$}$q btWmR=Fߩ6ՠ/Ò*&$$$$$$$$$$$$$$$$$$$|$mz\iObJ,Aݨ7˙-'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$~$r!cuXnRMZ"UJ;֡0Ӟ-Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.ӟ.М-)y#f{\7kVK=֡1М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-М-ѝ-Λ-(x#f{\CWOBڥ4ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.ӟ.ѝ-)y#f{\=K GAߪ8ע0Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.ӟ.М-(s!|]nSDWPBۥ4ԟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.Ӟ.ӟ.ѝ-){#h|]M_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0ɖ+%la7p^WI80111111111111111111111111111111111ާ0ʘ,%maC^XL;11111111111111111111111111111ާ0ʗ,%la=MLI@40111111111111111111ߧ0ʘ,~$dtW`^YM<11111111111111111111111111111ߧ0̙,&nbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M4LH>301111111111111111110Κ,%guX~^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MOKF<201111111111111111110ҝ-&kwYI6^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LnKE:101111111111111111111ՠ.'n y[fL^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=XLKD8101111111111111111111آ/(r!{\mQ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJC7101111111111111111111ۤ/)u"~^pT$^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLIA5001111111111111111111ݥ0ē*y#aqU:^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M LH?4011111111111111111111ާ0Ȗ+|$csVT^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M7LG>3011111111111111111111ߧ0̙,%fuWq^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MSKF<20111111111111111111110М-&ivY^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LrKE:10111111111111111111110ԟ.'mxZ`H^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=QLJD810111111111111111111111ס.(p z\kP^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJB610111111111111111111111ڣ/)t!}^oS^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLIA500111111111111111111111ܥ0‘*w"`qT0^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M#LH?401111111111111111111111ݦ0ƕ+{#brVI^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M:LG=301111111111111111111111ߧ0ʘ,~$etWe^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MWKF<2011111111111111111111110ϛ-%hvX^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LwKE:1011111111111111111111110Ҟ-&kwYU@^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=PLJD81011111111111111111111111֠.'o y[hN ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJB61011111111111111111111111آ/(r!|]mR^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLIA50011111111111111111111111ۤ/)v"~_pT(^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M&LH?40111111111111111111111111ݦ0ē*y#arU>^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ަ0Ȗ+%l`7p^VI80011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M>LG=20111111111111111111111111ާ0ɗ+}$dsVY^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111100000000000000000000000000000000000000000ݦ0Ȗ+%l`7p^VI80000000000000000000000001111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=L[KF;20111111111111111111111111ߨ0͚,%guXw^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111100000000000000000011111111111111111111111111ާ0ɗ+%la7p^VI80000000000000000000000000001111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=L{KE9101111111111111111111111110ѝ-&jwY>.^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111011359<>??????????@@ACDFFFFFFFFFFFFFFFFFFFFFFD٥5)x"k7tfaWG?????????????????????=:73110011111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=NLJC8101111111111111111111111111ԟ.'myZcJ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111110126=FPY^```````````aacefgggggggggggggggggggggeQܧ8)$7xytg```````````````````aa_YPE:41101111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJB6101111111111111111111111111ע/(q {\lQ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111110144101111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLI@5001111111111111111111111111ڣ/)t!}^oS ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111015>M`r͇΋όόόόόόόόό΋ΊΊΉ΋zόzύzώzώzώzώzώzώzώzώzώzώzώzώzώzώzώzώzώzώzώzώzόzʀzkzMt:ЏА{ӕӖАόόόόόόόόόόόόόόόόόόόύЏђҔАʂiO<200111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M(LH?3011111111111111111111111111ܥ0’*x"`qU4^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ߧ0ަ0ߧ17E]Pvђ֝ءףأأأأأأأآؠ֟נԙԙء٥٤أأأأأأأأأأأأأأأأأأؤ٥ڨܬأ֝RғaF510111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MBLG=2011111111111111111111111111ަ0Ǖ+|#csVN^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ݦ0֠.М-Ϝ-ۦ5:Ҕ3˃kN810111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=L_KF;2011111111111111111111111111ߧ0˘,$etWj^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111110٣/ʗ,('v(wgP:10111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LKE910111111111111111111111111110ϛ-&hvX^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111110ס.)y#l{`lXJ:10111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=NLJC810111111111111111111111111110Ӟ.'lxZYC^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111110ס.(n vYYD^DE@710111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M LJB610111111111111111111111111111֡.(o z[iO ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111٣/)njPWB6ި6ӟ/'%)d)cJ)Q=)J8)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)I7)L9)Q=)^F)uX)l !z+ա2+ݦ267410111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLI@400111111111111111111111111111٣/)s!|]nR^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ܥ0Ȗ+x#qUU@K8߫;ݩ;О3鯄&gnRbI^G^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F^F_HcJjPwYgp"j|,ŕ0 ˘-ԟ.ާ02210111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M,LH>301111111111111111111111111111ۤ/*v"_pT+^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ߧ0ӟ.'f^FM:n;.X(XJ̜4&p geddddddddddddddddddddddddddddddddddddddddefin t!z#}%|(MÒ+eȖ+Ӟ.ݦ01111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MELG=201111111111111111111111111111ݦ0Ŕ+z#arUC^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111110ܥ/ʗ,|#uXT?J87{9zkNӡ7’+('''''''''''''''''''''''''''''''''''''''''(*Ǖ+ʘ,ƕ,.,-ē*͙,آ/ާ01011111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LcKF;201111111111111111111111111111ߧ0ɗ,~$dtW^^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111ߨ0ע/)p gMO<H6ҕDғ̅jN߫;ۥ2ڤ0ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ڣ/ۤ1ܦ3ߩ7::8ݨ7!/ ’+Ȗ+ԟ.ܥ0ߨ01111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LKD9101111111111111111111111111111ߨ0Κ,%guX|^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111ާ0ҝ-'d\EL9u?1֞Kԙ΋rWA621111111111111111111111111111111111137=AA@B$;+lŔ+М-ڤ/ߧ011111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=NLJC71011111111111111111111111111110ѝ-&jwYD3^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111110ܥ/ʘ,|$vXT?J89֞GԚύv[E8210011111111111111111111111111001249@DEDE#,3Ò*̙,ע/ަ0111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M LIB61011111111111111111111111111111ՠ.'ny[eL^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111ߨ0آ/)q hNP<G6֟:՛ё|bK;41001111111111111111111111110112630111111111111111111111111111111ڤ/)u"~^oS#^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111110ܥ0˘,}$wYU@J8?ע֞ԙ΋u^J<411001111111111111111001137>FLPPOaS,9Ó*͚,آ/ާ011111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MIKG<20111111111111111111111111111111ܥ0Ò*y#aqU9^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111110آ/*r!iOP<H6ئנ^՜ђʁlWF:41100011111111110001137=EMRTTT9-’*ɗ+՟.ܥ0011111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LgKE;10111111111111111111111111111111ާ0Ȗ+|$csVS^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111ާ0Ӟ.'f^FM:B3ע+֟ԙύ{hUF;5210000011000001248?GNUYYY~Y6+yƕ+ѝ-ۤ/ߧ0111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LKD910111111111111111111111111111111ߧ0̙,%fuWp^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111110ܥ0̙,~$xZV@J8Dئ סX֝ӖΊziYK@94210000001247=CKRY]_^^:߾y,=ē+͚,آ/ާ01111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=NLJC7101111111111111111111111111111110М-&ivY^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111110٣/’*s!jPP<H6أנm՜ӕ΋~pcXNE?;8778:=BGNTZ_cedcPe -’*ʗ+ՠ.ݦ001111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M LIA5001111111111111111111111111111110ӟ.'lxZ^F^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111ާ0ӟ.'g_GM:D4ע֟h՜ӗЏ̅{rjb[TMGFHOX_eikkkjOk1+ƕ+ѝ-ۤ/ߧ011111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLI@4001111111111111111111111111111111ס.(p z\jP ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111ܥ0̙,$z\VAJ8Jע֟I՜ԘҔЎ͈ʁyn_PGGO]ipsr{q4s,Dē*Κ,آ/ާ0111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M2LH>3011111111111111111111111111111111٣/)s!}]nS^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111110٣/Ò*t!lQQ=H7֠՝?՚rӗҔώʁnXF@GWhas0x -’+ʗ,֠.ݦ00111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MMKF<2011111111111111111111111111111111ܥ/*w"`qT/^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111ߧ0ԟ.(h`HM:E4֠ ә΍(`nDΜ1)&R0+Ǖ+ҝ-ۤ/ߧ01111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LlKE:1011111111111111111111111111111111ݦ0Ɣ+{#brVH^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111ݦ0͚,%|]WAK8QU=Kߩ6Ò*z#lcLb#+Kē*Κ,٣/ާ011111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LKD91011111111111111111111111111111111ߧ0ʘ,~$etWd^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111110٣/ē*u"mRQ=I7 X PDާ3‘*v"auXpT-Ò*ʘ,֠.ݦ0011111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJC710111111111111111111111111111111110Λ-%hvX^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111111ߧ0ՠ.(jaIM:E5WRI:͚-%j|]vY1‘+Ǖ+Ҟ-ۥ/ߧ0111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLIA500111111111111111111111111111111110Ҟ-&kwYO<^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111ݦ0Λ-%}^XBK8Uhf_Oީ8ē*$p ؍j+Oē+ϛ-٣/ާ01111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLH?401111111111111111111111111111111111ՠ.'n y[gM^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111110ڣ/Ŕ+v"nSR=I7%rqjX<Ǖ+%y#s" ,!’*˘,֡.ݦ001111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M5LG>301111111111111111111111111111111111آ/(r!|]mR^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111ߧ0ՠ.(kbJN:F5 Ԗq9hY߫<‘*}$ٞw#91‘+Ǖ+Ӟ.ۥ/ߧ011111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MQKF<201111111111111111111111111111111111ۤ/)v"~_pT&^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111111111ݦ0ϛ-%_YCK9\o mfK͚.&t$,VŔ+ϛ-٣/ާ0111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LpKE:101111111111111111111111111111111111ݦ0ē*y#arU=^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111110ڤ/Ǖ+x"pTR>I7'msqyzfAē+&z&,#Ò*˘,ס.ݦ00111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=TLKD8101111111111111111111111111111111111ާ0Ȗ+}$dsVX^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111ߧ0֠.)lcJN:E5 jv˃͉~b߬@˙.м(. *Ȗ+Ӟ.ܥ/ߨ01111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJB7101111111111111111111111111111111111ߨ0͙,%fuXu^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111111ݦ0М-&aYCJ8edq@˃K΋K̆KsKVK9rܦ3+^Ŕ+ϛ-٣/ߧ011111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLIA50011111111111111111111111111111111110ќ-&jwY^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111111111110ۤ/ǖ+y#sVS>G6-E?B-(Ò+̙,ס.ݦ0011111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M!LH?40111111111111111111111111111111111111ԟ.'mxZbI^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111111111111ߧ0ס.)p jOQ=F5(B2"E4"H7"H7"H7"H7"H7"H7"H7"H7"H7"H7"I7"J8"N;"S?"WB"^G"oT"a QN)K . ‘+Ȗ+ԟ.ܥ0ߨ0111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M8LG=30111111111111111111111111111111111111ס/(q {\kQ^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111ަ0ѝ-'n qU`HYCZC\E\E\E\E\E\E\E\E\E\E\E]F_G`H]F[EcJmRZA>2= C+dŔ+М-ڣ/ߧ01111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MUKF<20111111111111111111111111111111111111ڣ/)t!}^oS^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111111110ۤ/Λ-(y#kdbcccccccccccccbxZlQfMfMe<87,.Ò*̙,ס.ަ011111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=LtKE:10111111111111111111111111111111111111ܥ0’*x"`qU3^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111111111ߨ0ۤ/Ӟ.Ȗ+)'''''''''''''''&z#j{\rVe/ ’+Ȗ+ԟ.ܥ0ߨ011111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=PLJD810111111111111111111111111111111111111ަ0Ǖ+{#bsVL^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111110ߧ0ݦ0ۥ/ڤ/٣/٣/٣/٣/٣/٣/٣/٣/٣/٣/٣/٣/٣/٣/آ/Ǖ+%l`e9+jƔ+М-ڤ/ߧ0111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJB610111111111111111111111111111111111111ߧ0˘,$etWi^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111111111111111111111111111110ϛ-&p ce+4Ò*̙,آ/ަ01111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLIA5001111111111111111111111111111111111110ϛ-%hvX^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&p ceգ6֣4/’+ɖ+ԟ.ܥ001111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M$LH?4011111111111111111111111111111111111110Ӟ.&lxZYC^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&p ceأ1(أ0 :+rƔ+ѝ-ڤ/ߧ011111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=Mƕ+Κ,آ/ާ011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLJB61011111111111111111111111111111111111111ާ0ɗ+}$dtW]^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&p de+-آ0ѝ-ѝ-ס.ݦ0011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=MLI@50011111111111111111111111111111111111111ߨ0͚,%guXz^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&p fe&&S'`@{86ߨ31011111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M'LH?301111111111111111111111111111111111111110ѝ-&jwYC2^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&p he̙+%v#\k[VM>3101111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%la=M@LG=201111111111111111111011111111111111111111՟.'ny[dK^XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q ie8'r!}^yutgO:200111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%lb=L]KF;201111111111111111001110001111111111111111آ/(q {\lQ]XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q je@ɗ,x"{]vSA͈$͇}dH610111111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%lc=L}KE9101111111111111110111111110111111111111111ڤ/)u!~^pT"]XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q je^ܧ5'}^cJ+ёVΌy[A41011111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%md=HLJC8101111111111111110123453210111111111111111ܥ0Ò*x#atW7\XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q jetC̚/haIG6՛ђ͈oQ;2101111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%nf=I KJB6101111111111111110136:;8510111111111111111ާ0ǖ+|$cxZQ\XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q jeqOۨ:{#gMO;DӖ!АʂfI7101111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%o i=IKI@5001111111111111110ߧ049@D?930111111111111111ߧ0̙,%f{\n[XM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q jehWE*xZT?L9ҔOύz\A410111111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%p k=H*JH?3011111111111111110ݦ03:DNG?401111111111111110М-&j_ZXM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q jec[Oҟ2n_GP<b՛ђ͉qR;21011111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%r!n=GCJG=201111111111111111ߨ0٣/ާ18DVND600111111111111110Ӟ.'mbcYWM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q je_\Tު;%tWVAM:ӗА˃gI71011111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%s!p =FaIF;201111111111111111ߧ0ՠ.آ/4?b\TI900111111111111111֡.(q eće WWM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q je]\WCŔ+kdKR=F6ҔJώ{]B4101111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%v"s!=EHE9101111111111111111ަ0ќ-ѝ-ۥ06=aYN;10111111111111111٣/)t!hۊhUVM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q je]\WHա1%{\YCN;3ԚѓΉrS<210111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+%x"u"=CGC7101111111111111111ݦ0̙,ʗ,ԟ.ڣ-"d]Q>10111111111111111ܥ/*x"kk.SUM;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111ߨ0ϛ-&q je]\WJݨ6‘*q iOS>J8ӗё˃hJ810111111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+&z#w")?D>3011111111111111110ס.*'(j@fZF401111111111111110Κ,%u"s!LPL;10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111101259>ACCCCCCCCCCCCCCCDCܧ7ē*|$s!e^\WK9ߧ0ՠ.(haIQ=m0'Ӗё˄iK8101111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ɗ+'%|#9;C>C=2011111111111111110ԟ.)%&wl*h\I501111111111111110ҝ-&x#v"JML;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111111015=IU^ceeeeeeeeeeeeeeedUު;Ŕ+'e_\WK91ݥ0˘,|$vYWBN;%ҕ<Ў}_D510111111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ʗ,(&$99bG6סғyΊtU=31011111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ʗ,('%:7;@9101111111111111111ާ0Λ-'z#x#<o j`N900111111111111111آ/(%|$ҜtEHJ<10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111111101129EXqʁΊό΋͉͇͇̇̇̇̇̇̇̇̇̇̇̆|gI:8SSQH911ަ0ϛ-%~^ZDO;<ӗё̅jL91011111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ަ0˘,)''?59>7101111111111111111ݦ0˘,'w"s!%tkbQ;10111111111111111ۤ/)&%{"#BFH<10111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111112410111111111111111ݥ0œ*'%$9@CF;10111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111111110111ާ0ݦ0ߨ3w7٤17<6101ߧ0Ӟ.'d^GP<Xإғp΋uV>310111111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ަ0Κ,Ò*)Ĕ*W16;4001111111111111111٣/ē*%s!hlqeVA20111111111111111ާ0ɗ+(&%S=@D;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111110111ߨ1ڤ/آ/٤4x$5kvXgNcJbJcJcJcJcJcJcJcJcJbJbI`H^FdKvXp )ע/431010ܥ/Ȗ+y#rUV@M:Әё̆kM920011111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ަ0ϛ-Ǖ+)ʗ+lާ0493011111111111111110ס.*$r!oUlUgXD30111111111111111ߧ0͚,)'&q9=B;10111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111111110121ܥ0ס/ՠ08+ }$o bxZtWtWtWtWtWtWtWtWtWtWtWtWtWtWz[gz#)ՠ.ߨ121011ߨ0֠.(jbJQ=yB3ҕ3Џ~aE51011111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ާ0ѝ-ʗ,Ò*͚,ܥ0262011111111111111110ԟ.)|$q ymJ8Ә ђ̆lN9200111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ߧ0ס.ќ-˘,ќ-٣/ާ03101111111111111111ާ0Λ-'x"o >nj_L700111111111111111آ/ʗ,ē*)ܥ038810111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111111111121ܥ/ס/֡12ŗ8̙-yМ-٢/ާ011111111111111111100111111111ާ0ќ-&`\EO<Fҕ-ЏbF610111111111111111ާ0ʘ,%maC^XL;10111111111111111111111111111ߨ0ڣ/ՠ.ќ-ԟ.ڣ/ާ02101111111111111111ݦ0˘,'v"n&p jaO900111111111111111ڤ/ϛ-Ȗ+Ò*ס.ߧ15610111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111110111ަ0آ/֠0k֦AϜ/+М-֠.ݦ00111111111111111111100000000010ڤ/Ŕ+u"nSVAN;ғeόwX?31011111111111111ާ0ʘ,%maC^XL;101111111111111111111111111110ݦ0ڣ/ס.آ/ۥ/ާ01101111111111111111ۥ/Ȗ+&u!ҐmukbQ<10111111111111111ݥ0Ӟ.͚,Ȗ+ԟ.ݦ02410111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111110111ߧ0ڣ/֡/֡3 4ڤ0ڣ/ݦ0ߧ00000000000000000000000000000000ߧ0ԟ-'geL[EdӘ ђ͇mO:2001111111111111ާ0ʘ,%maC^XL;101111111111111111111111111111ߧ0ݦ0ܥ/ܥ/ަ0ߧ01111111111111111111٣/Ŕ*%s!ikdT>10111111111111111ާ0ס/Ҟ-Κ,ԟ.ۤ/ߨ1310111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111121ܥ/ס/֡2,O=GFC>================================:Ӡ2&hwYz\Ӗ'АʀdG6101111111111111ާ0ʘ,%maC^XL;1011111111111111111111111111111ߨ0ߧ0ߧ0ߨ010111111111111111110ס.*$r!uYlmeVA20111111111111111ߨ0ۤ/ע/ԟ.֡.ۤ/ߧ0110111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111110111ݦ0آ/֠0dٻjqloja^]^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^]Sݪ<Ò+{#s";Ҕ]όxZ@410111111111111ާ0ʘ,%maC^XL;1011111111111111111111111111111111111111111111111111110ԟ.)|$q |lQgYD301111111111111110ަ0ܥ/ڣ/ڤ/ݦ0ߧ0110111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111110111ߧ0ڣ/֡/֡3 ʀD͉ύ͇~zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzt`DН1ѹ*Ԙ ђ͈oP:21011111111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111111ߨ0ҝ-(z#p ]m9h[G401111111111111111ߨ0ާ0ަ0ަ0ߧ00111111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111111111121ۥ/ס/֡1(ҔӖ]ԙӖЏό΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋΋Ί̅v_G:EӖ#АʁdH61011111111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111111ާ0ϛ-'x"o @n$i]J600111111111111111110011011111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111110111ݦ0آ/֡0\٣֝ ԙ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӗ ӕ ё ̆ ۩ҔUύy[A4101111111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111111ݦ0̙,'v"n(nj_M700111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111110111ߧ0٣/֡/֢6 Ԛђ͈pQ;210111111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111111ۥ/Ȗ+&u!ԐmpjaO:00111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111111121ۤ/֡/֡1$Җ АʂfH710111111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111111ڣ/Ŕ+%s!iwkcR<10111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111110111ݦ0آ/֠0WҔPύz\A41011111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111110ס/‘*$r!}_ldT?10111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111110111ߧ0٣/֡0֢6Ԛђ͉qR<2101111111ާ0ʘ,%maC^XL;101111111111111111111111111111111111111111111111111110ՠ.)|$q ~lhfWB20111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111101121ۤ/ס/֡2 ӖА˃gI7101111111ާ0ʘ,%maC^XL;10111111111111111111111111111111111111111111111111111ߨ0ҝ-(z#p _mMgYD30111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111110111ݦ0ס/֠0QҔJώ{]B410111111ާ0ʘ,%maC^XL;10111111111111111111111111111111111111111111111111111ާ0ϛ-(x"o Bm5h[G40111111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111110111ާ0٣/֠0֢6ԚѓΉrS<21011111ާ0ʘ,%maC^XL;10111111111111111111111111111111111111111111111111111ݦ0̙,'v"n*n!i]J60011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111111121ۤ/֡/֡3ӗё˃hJ81011111ާ0ʘ,%maC^XL:00000000000000000000001111111111111111111111111111111ܥ/ɖ+&u!֑moj_M80011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111110111ܥ0ס/֠0MҔBώ{^C4101111ާ0ʘ,%maC_ZO>44444444444444433321110011111111111111111111111111111ڣ/Ŕ+%s!j qjaP:1011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111111ܥ0ס.ՠ/ա3՜ђ͉qR;200111ާ0ʘ,%maCmleWNNNNNNNNNNNNNNNNMKGA:52101111111111111111111111111110ע/‘*$r!`zkcR=1011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ߨ0ݦ0ڣ/Ӟ-Ϝ-ў0ҕύ~bE410111ާ0ʘ,%maC~ʂʀunnnnnnnnnnnnnnnnonjaTE9310111111111111111111111111110ՠ.)}$q ldU?1011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ݦ0ס.͙,Ò*Ĕ-Lό?ʀjN810111ާ0ʘ,%maCΊѐђ΋͇͉̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̆Ή˃u_I92001111111111111111111111111ߨ0Ҟ-(z#p bldfWB2011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111110ڤ/˘,(&,ύvgP:10111ާ0ʘ,%l`CyЏ+ӖJԙJҔJАJАJАJАJАJАJАJАJАJАJАJАJАJАJАJѐJђJӖLԚnԘЏz]D5101111111111111111111111111ާ0ϛ-(x"o EmJgYE3011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111110ס.){#m }anYK:10111ާ0ʗ,%gxZCڦӗb͈mP:101111111111111111111111111ݦ0̙,'v"n,m2h\H4011111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111110֡.(o xZ*/AD~EB810111ާ0˘,$cnSCӘ̅sX?201111111111111111111111111ܥ0ɗ+&u!ؑmni^K6001111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111آ/)n jPS?$.ՠ0ߩ48510111ߧ0͚,%cjPE 6-UF&e{Rp[B201111111111111111111111111ڣ/Ŕ+%s!j oj`M8001111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ۥ/Ǖ+y#rVT?D3. eNi /%ē*٢/2210111ߨ0Ҟ-'loT[DR>S>T@T@T@T@T@T@T@T@T@T@T@T@T@T@T@T@T@U@Q=L9P<ZDfMo4hYD301111111111111111111111111آ/’*%r!aqkaP:101111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111ާ0Ӟ.(n lQU@L9L9N;}P<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}O;}J8}K9}VAu`HС='[h}jP}VA}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q<}Q=}R>}WA|bIsWi~$œ*ס.ߧ01111110آ/Ǖ+%l~^uWsWtWtWtWtWtWtWtWtWtWtWtWtWtWtWtWtWtWtWpTeK`HaIdK e1aVD301111111111111111111111110ՠ.)}$q ~kcS=101111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111110ۥ/Λ-(t!aqUkPkPkQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQjPaI[E^FbI5CŔ,~$gsVlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQlQnRrU{]iz#(ϛ-ۤ/ߨ01111110ݦ0՟.Ǖ+'%{#z#z#z#z#z#z#z#z#z#z#z#z#z#z#z#z#z#z#z#u"gy[nShO a1]SD30111111111111111111111111ߨ0Ӟ-({#p gl|eU@101111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE5011111111111111110ۥ/ҝ-Ò*&|#v"s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!q fxZmRiO5Vߪ:ѝ/(y#s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!s!t!v"|$&*ϛ-٣/ާ0111111110ަ0ڤ/֠.ҝ-М-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ϛ-ǖ+&p `sW `1\SC30111111111111111111111111ߧ0М-(y#o Jl`fWB201111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE50111111111111111110ާ0ۤ/ՠ.Ϝ-˘,Ȗ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+ǖ+ē*&r!avY5hQEܨ8Λ.Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ǖ+Ȗ+ɗ+͚,Ҟ-آ/ݥ0ߧ01111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111111ަ0͙,'w"o 0mFgZE301111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111110000000000000000000000000000000000000000ݥ0Ǖ+%k`5o^VH7000000000000000000000001111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111111ܥ0ɗ+&u"ܑmm/h\H501111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8011111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111111ڤ/ƕ+%t!Ŏk ni^K601111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111111آ/Ò*%s!coj`N800111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111110֠.)}$r!rkbP;10111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111110Ӟ.){#q i̎kcS=10111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC3011111111111111111111111ߧ0М-(y#p KlxeU@20111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC3011111111111111111111111ަ0͚,'w"o 1l\fXC20111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC3011111111111111111111111ܥ0ʗ,&v"ےnmBgZF30111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC3011111111111111111111111ڤ/ƕ+%t!Îk m,h\I50111111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC3011111111111111111111110آ/Ò*%s!bni^L70011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC3011111111111111111111110ՠ.)}$r!o j`N90011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111111ߨ0Ӟ.){#q eskbQ;1011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111111ߧ0М-(y#p FkdS>1011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111111ݦ0̙,'w"o +lteVA2011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111111ܥ/ɗ+&v"֒nlXfXC2011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111111ڣ/Ɣ+%t!km>hZF4011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111110ס/’*%s!}^m)i\I5011111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC301111111111111111111110՟.)}$r!yni^L7001111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111ߧ0ҝ-({#q Wp j`O9001111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111ާ0Λ-(y#p 9ukbQ<101111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111ݥ0˘,'w"o kdT>101111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE501111111111111111111111111111111111111111111111111111111111111ݦ0Ȗ+%k`5p^VI8001111111111111111111111111111111111111111111111111111111111٣/)y#gy[ `1[RC30111111111111111111111ۤ/ǖ+&v"Ȓn loeVA201111111111111111111111111111111111111111111111ߧ0˙,&mbM_"\TE500000000000000000000000000000000000000000000000000000000000000ݦ0Ȗ+%k`5p^VI8000000000000000000000000000000000000000000000000000000000000٣/)y#gy[ `1[RC30000000000000000000000آ/ē*%t!glTgXD300000000000000000000000000000000000000000000000ߧ0˙,&mbM_"\TE511111111111111111111111111111111111111111111111111111111111111ަ0ɖ+%la5p_WJ8111111111111111111111111111111111111111111111111111111111111ڣ/)z#gy[ `1\SD41111111111111111111111֡.*$s!m;h[G411111111111111111111111111111111111111111111111ߧ0̙,&nbMh"hcXIEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEBؤ4)w"j5vjf\MFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF@ҟ0'r!ƅd i1hbVHEEEEEEEEEEEEEEEEEEEEEDߪ9Κ-&y#cs&rj[IEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEECۦ6’*y#kMx"{|uiffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffcPڦ6)~$5ʁ|}ymgggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg`HԠ1'Ɵx$ y1{|thfffffffffffffffffffffdU<ʘ,'BʁʁyjfffffffffffffffffffffffffffffffffffffffffffffffdSݩ9*%M̅#ΊЏ΋˃ɀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀ~nSۧ8͚,5΋ΊЏώ̅ʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁʁ|hKף4Ɣ* ̆2΋Џ΋ʂɀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀ~pW߫<ӟ.&̆ ύёώ˄ʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀʀqWܩ;ϛ.N΋ёvӗӖЏύώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώόɀiL}9ѐё[Ӗ~ӗ~ё~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~ώ~Ί~z~a~Faߨ2όђ{ԘӕЏύώώώώώώώώώώώώώώώώώώώώΌjPo< ̅ғYԘӗёύώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώώύʂmO;(x?x?x?x?x?x?xxxxxxxxxxxxxxxxxxxxxxxxxx??????????????~>?   ???>>>>>>???xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?xx?xx?( %7`m]FmZDm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[DmYCmS>mXB>('ec]FcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcYCcXBcR>cXB;&#ik`HmZDm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[DmV@mT?d]F%fi`HmZDm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[DmT?mQ=[XB ݥ.{#PrVm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[Dm[DmZCmS>mXBCӡ6*p n n n n n n n n n n n n n n n n n n n n n n n n n n n n n n m|]lQԢ8f*o klllllllllllllllkz[kPץ8T-s!n n n n n n n n n n n n n o grUiO-ӟ2W)r!n n n n n n n n o ekP`H!CΜ3&o n n n n n n n n n n n n n m}^lQWFڤ1آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/٣/֡.&hYfI٤2ס.ס.ס.ס.ס.ס.ס.ס.ס.ס.ס.ס.ס.ס.ס.ס/ՠ.&hYSLۦ4آ.آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/٣/ʘ,u!_-Iw?ڤ1آ/آ/آ/آ/آ/آ/آ/آ/٣/ɗ+n sV7bT?٣/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/آ/٣/ס.'j\L4011111111111111111111111111111ߧ0(l^fP50111111111111111ߧ0(m^SR701111111111111Ҟ-y#b-WLB2111111111ס.w"y[QeZE11111111111111ߨ0)m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-OK?1011111111ڤ/}$}]ndYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-NJ<1011111111ݦ0&`dYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-M&I90011111111ߧ0(dG5dYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-M>H701111111110)gdK dYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-L\F501111111111Ȗ+knSdYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-L|D301111111111ϛ-p sW.dYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-TKA201111111111ԟ.u"wZGdYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-OK>101111111111٣/{#{\cdYD10111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO50111111111111111ߧ0(l^SQ701111111111111ҝ-y#b-NJ<101111111111ܥ0%_dYD10111111111111ߧ0(m\L3011111111000000000000000000000ާ0(l^fO50000000000000111ߧ0(l^SQ701111111111111ҝ-y#b-M*I9001111111111ާ0'b0# dYD10111111111111ߧ0(m\L3011111110011222222233333333333ߨ2(m_fP61222222221100011ߧ0(l^SQ701111111111111ҝ-y#b-MCG60111111111110)f]EdYD10111111111111ߧ0(m\L30111111018FSWWWWWWY\]]]]]]]]]^[ק@)tgn[WWWWWWWWWWTF6101ߧ0(l^SQ701111111111111ҝ-y#b-LaE40111111111111Ŕ+jkPdYD10111111111111ߧ0(m\L3011111103Cbz˂˃˃˃˃ʂʁ˃̅̅̅̅̅̅̅̅̅̅˄fެ@YόHЎ̆˃˃˃˃˃˃˃˃˃͇̄{V701ߧ0(l^SQ701111111111111ҝ-y#b-LC30111111111111̙,n rU&dYD10111111111111ߧ0(m\L30111111ާ0ՠ/ؤ6`Ԙ٥٦٦٦٦أ߳֞ڦڧ٦٦٦٦٦٦٦٦٦۪۪ ҔNqC01ߧ0(l^SQ701111111111111ҝ-y#b-RKA20111111111111Ҟ-s!vY=dYD10111111111111ߧ0(m\L30111111ۤ/'u!Nх^C10ߧ0(l^SQ701111111111111ҝ-y#b-N K>10111111111111ס/y#z[XdYD10111111111111ߧ0(m\L30111111ۥ/~$mR* 9ʙ/1g8XA8P<8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8Q=8S>8dK9d,y&ՠ1 ߪ9711ߧ0(l^SQ701111111111111ҝ-y#b-MJ;10111111111111ۤ/$~^vdYD10111111111111ߧ0(m\L30111111ߧ0)uXN::@ܭHŚ=s#b````````````````````afp r"g{'+ɗ,آ/111ߧ0(l^SQ701111111111111ҝ-y#b-M.I800111111111111ަ0&adYD10111111111111ߧ0(m\L301111111آ/y#aID3̈́gե@͛.̙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͙,͚-О2բ5ҟ1ʙ/ ’+UΚ,ܥ0001ߧ0(l^SQ701111111111111ҝ-y#b-MHG601111111111111ߨ0(eR=dYD10111111111111ߧ0(m\L3011111110͚,fV@}," کӖzN611111111111111113521125;ERZ[Z+)ʗ,٣/011111ߧ0(l^SQ701111111111111ҝ-y#b-N K=1011111111111111֠.w"yZNdYD10111111111111ߧ0(m\L301111111111ߧ0)uXN;Iޱן?Ӗ͉xgYLCFUafgf3z+ ƕ+ԟ.ߨ0111111ߧ0(l^SQ701111111111111ҝ-y#b-MJ;0011111111111111ڣ/|$|]kdYD10111111111111ߧ0(m\L3011111111111ڤ/~$eKF5נ&Ԙ_ё͈pLEarVtm/Ó+gϛ-ݦ01111111ߧ0(l^SQ701111111111111ҝ-y#b-M2H80011111111111111ݦ0&`dYD10111111111111ߧ0(m\L30111111111111ќ-kXB9+ ΋ OО3v"yZ-+1ʘ,ڣ/01111111ߧ0(l^SQ701111111111111ҝ-y#b-MMG60111111111111111ߧ0'cB1dYD10111111111111ߧ0(m\L30111111111111ߧ0*xZP<QZOբ6w"`w+Ǖ+ՠ.ߨ011111111ߧ0(l^SQ701111111111111ҝ-y#b-LmE401111111111111110)gbJ dYD10111111111111ߧ0(m\L301111111111111ۤ/%gMH6!ogH(u"^.Ó+qМ-ަ0111111111ߧ0(l^SQ701111111111111ҝ-y#b-LB201111111111111111Ǖ+kmRdYD10111111111111ߧ0(m\L3011111111111111Ҟ-nZD;-tpXĔ/ߥ|#'+7˘,ڤ/0111111111ߧ0(l^SQ701111111111111ҝ-y#b-PK@101111111111111111Κ,o sV,dYD10111111111111ߧ0(m\L3011111111111111ߨ0ē*{\P<Zn}{ݮJɘ.\ަ.+Ǖ+֠.01111111111ߧ0(l^SQ701111111111111ҝ-y#b-NJ=101111111111111111ԟ.u!wYDdYD10111111111111ߧ0(m\L30111111111111111ܥ0&jPF4* s^1ϰk€`@*5,ē+zМ-ާ011111111111ߧ0(l^SQ701111111111111ҝ-y#b-M I:001111111111111111آ/z#{\`dYD10111111111111ߧ0(m\L301111111111111111ԟ.y#sVeLgMgNgNgNgNhNiObJcJt+BA+?̙,ۤ/111111111111ߧ0(l^SQ701111111111111ҝ-y#b-M6H8011111111111111111ܥ/%_~dYD10111111111111ߧ0(m\L301111111111111111ߨ0ԟ.)&%%%%%%%o y[oT6+Ȗ+ס.0111111111111ߧ0(l^SQ701111111111111ҝ-y#b-MSF5011111111111111111ާ0'b eYD10111111111111ߧ0(m\L3011111111111111111100ߨ0ߨ0ߨ0ߨ0ߨ0ߨ0ާ0)nA0,ē+ѝ-ާ01111111111111ߧ0(l^SQ701111111111111ҝ-y#b-LrD40111111111111111110)fQ<iYD10111111111111ߧ0(m\L30111111111111111111111111110)n u"ڤ1’+G̙,ܥ/11111111111111ߧ0(l^SQ701111111111111ҝ-y#b-LB20111111111111111111ē*igMsYD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)n bݧ1+Ȗ+ע/011111111111111ߧ0(l^SQ701111111111111ҝ-y#b-sK?10111111111111111111˙,npS$YD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)o K8͗#ʗ+Ӟ-ߧ0111111111111111ߧ0(l^SQ701111111111111ҝ-y#`,XJ<10111111111111111111ҝ-s!uW:YD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)o u" ~%/,IDު<ߨ30111111111111111ߧ0(l^SQ701111111111111ҝ-y#~^+R I:00111111100011111111ס.x"y[UYD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)p '}$f"nKjG2011111111111111ߧ0(l^SQ701111111111111ҝ-y#{\)O8H701111111111111111111ۤ/~$}^rYD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)q ު:,Ȅc-" ӕ Ήj<101111111111111ߧ0(l^SQ701111111111111ҝ-y#|\)NUF501111110135310111111ݦ0&auYD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)q ۰S٨?Ȑl Q=5Ӗ"̄Y500111111111111ߧ0(l^SQ701111111111111ҝ-z#a)LuD30111111006A=20111111ߧ0(fF XD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)q ԭXPư+bJB1 ёSzJ20111111111111ߧ0(l^SQ701111111111111ҝ-|#k,JA201111111ݥ0ߨ4LI401111110*kȠ}1WD10111111111111ߧ0(m\L3011111111111111111111111111ߨ0)q ӫUWѠ8`T@R֞όl>1011111111111ߧ0(l^SQ701111111111111ҝ-~$z%4G?101111111ס.՟.QS701111111ɗ+q ޚv(%VD10111111111111ߧ0(m\L3011111111111111100000000000ߧ0)p ӫUX@%hNI7Ӗ̆\6001111111111ߧ0(l^SQ701111111111111ҝ-%봈+@E<101111111ѝ-œ*W[Z:01111111ϛ-w"v%9SD10111111111111ߧ0(m\L30111111111111110114666666666Ó+t!ӫUXCϛ-hYCs ђJ|L201111111111ߧ0(l^SQ701111111111111ҝ-&“/SB9001111110˘,&_7^>00111111ՠ.~$y$SPC10111111111111ߧ0(m\L301111111111111101:PbfffffffeްKƖ.ٯTYCާ0'pTN;)סύo?10111111111ߧ0(l^SQ701111111111111Ҟ-'̛1l?701111111ߨ0ē*$lbC00111111٣/&}%oKB10111111111111ߧ0(m\L301111111111111012=^ʁz͈zψwχwχwχwχwχwχwmwENV O@1ՠ.q ^G=.ӗ͇^70011111111ߧ0(l^SQ701111111111111ԟ.(ӟ0;501111111ަ0)x"׊ dG10111111ܥ0(&EA10111111111111ߧ0(m\L30111111111111111ߧ1ާ26 ]7tU,W? /S< /S< /S< /S< /S< /Q< /WA0m <ؤ571ߨ0‘*y[R>BѓB~N3011111111ߧ0(l^SQ701111111111111ՠ.*ա/7301111111ܥ/(t!bfL20111111ާ0‘*'?>10111111111111ߧ0(m\L30111111111111011ڤ0֠/)6&kp b``````bw"̙,111ڣ/{#dKF5٥Ў~qA101111111ߧ0(l^SQ701111111111111آ/Ǖ+ՠ.4201111111آ/&r!EhQ30111111ߨ0ʗ,)8;10111111111111ߧ0(m\L3011111111111011ܥ0ס0]ʘ-$̙,͚,˘,ʘ,˘,˘,˘,˘,˘,˘,ѝ-ڤ/0110˘,cWAbԘ͈a800111111ߧ0(l^SQ701111111111111ۤ/ϛ-ՠ.ߨ1101111111՟.%p ,iuU501111110ҝ-ē*ܦ2710111111111111ߧ0(m\L301111111111011ާ0آ/ԟ1 ڡ)ԟ.ڣ/000000000000000ݥ/%mRQ=ғ;Q30111111ߧ0(l^SQ701111111111111ާ0٣/ڤ/ߨ0101111111М-$ؑmjYY801111111آ/̙,آ/310111111111111ߧ0(m\L30111111111101ߨ1ڣ/ՠ0#Z/RKFFFFFFFFFFFFFFFF٨?(hwܭЏtsC1011111ߧ0(l^SQ7011111111111110ߧ0ߧ00111111110ʘ,|#f l?];01111111ݦ0ס.٣/ߨ110111111111111ߧ0(m\L30111111111011ܥ0ס/U|&͇ʂzyyyyyyyyyyyyyyzuRǗ1bԙ͉c9001111ߧ0(l^SQ701111111111111111111111111ߨ0ē*y#hNm)`?001111110ާ0ާ0011111111111111ߧ0(m\L3011111111011ަ0آ/ӟ1ןԗ&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ё&ύ&v bҔ2ʁS401111ߧ0(l^SQ701111111111111111111111111ަ0)w"obD00111111111111111111111111ߧ0(m\L301111111101ߨ1ڣ/ՠ0АkuE10111ߧ0(l^SQ701111111111111111111111111ܥ/(u!ds eH10111111111111111111111111ߧ0(m\L301111111011ۥ0֡0MԚ Ίf:0011ߧ0(l^SQ701111111111111111111111111٣/&r!FɀfM20111111111111111111111111ߧ0(m\L30111111011ݦ0آ/Ӟ2ҕ,˂U4001ߧ0(l]SQ700000000000111111111111111ՠ.%p -hR40111111111111111111111111ߧ0(m\L3011111101ߧ1٣/ՠ0АawG201ߧ0(l^SR812222221100011111111111111М-$ّmipV60111111111111111111111111ߧ0(m\L301111110ާ0آ/ՠ/E՛ Ήe901ߧ0(msTo\WWWWWWWWTG8101111111111110ʘ,|$f kTZ80111111111111111111111111ߧ0(m\L30111111ާ0͙,*Ŗ/ό(pD11ߧ0(lΊ;Ў̆˃˃˃˃˃˃˃˄͇~[80011111111111ߨ0ē*y#kPl;]<0111111111111111111111111ߧ0(m\L30111111ۤ/&jMTA10ߧ0(fӗ?vH1011111111111ާ0)w"m&`@0011111111111111111111111ߧ0(m\L30111111ܥ0%kQ7) C3&?֡2511ߨ0)cWAGS>GS?GS?GS?GS?GS?GS?GS?GT?GQ=GN;GU@nO2011111111111ܥ0(u!fpcD1011111111111111111111111ߧ0(m\L30111111ߨ0ɗ+khNaIcJdKdKdKdKdKdKdKdKdKdKdKdKdKdKdKdKdKbJYC[EZ)@lhNcJdKdKdKdKdKdKdKdKhN|]|$М-1110ѝ-$hdddddddddanSfMj`L3011111111111٣/'r!It eI1011111111111111111111111ߧ0(m\L301111111ަ0Λ-'~$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$|$}${#gtWݫAgș2%|$|$|$|$|$|$|$|$}$~$'̙,ܥ00111ߧ0٣/Ӟ.ѝ-ѝ-ѝ-ѝ-ѝ-ѝ-ѝ-ѝ-ѝ-̙,{#cm\K3011111111111ՠ.%p /˅gN2011111111111111111111111ߧ0(m\L301111111110ߧ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ܥ/'k]fMߩ4ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ާ0ߧ0011111111111111111ܥ0&im[K3011111111111М-$ۑmhS4011111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K3011111111110˘,|$Ég jjW6011111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111111ߨ0Ŕ+z#pTkO[9011111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111111ާ0)w"l6^=001111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111111ܥ0(u"fn"aA001111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111111٣/'s!GpcE101111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111111ՠ.&q ,veJ101111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111111М-%גnώgO301111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K301111111110ʗ,|$f iS401111111111111111111111ߧ0(m\L3011111111111111111111111111111ާ0(l^fO501111111111111111111111111111ۥ/&im\K30111111111ߧ0Ó*z#_GjdX701111111111111111111111ߧ0(m\L3000000000000000000000000000000ާ0(l^fO500000000000000000000000000000ۥ/&im[J30000000000ݦ0)w"ykJ[:00000000000000000000000ߧ0(m\M4111111111111111111111111111111ާ1(l_fP611111111111111111111111111112ܥ0&im\K41111111111ۤ0'u"Vm2_>11111111111111111111111ߨ1(mrjXUVVVVVVVVVVVVVVVVVVVVVVVVVVVVVTҢ:&sgmZVVVVVVVVVVVVVVVVVVVVVVVVVVVVWR͝6%orjXUVVVVVVVVVM˚2%7yvbUVVVVVVVVVVVVVVVVVVVVVVTԤ='΋]ύ˄ʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʀbۨ=gόHЎ̅˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃[٦:Mόfύ˃ʂʂʂʂʂʂʂʂʂ˂wPأ2΋ ѐ΋ʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʂʁeܪ?q՛أסנננננננננננננננננננננננננננננננЌe֝٤ؤأأأأأأأأأأأأأأأأأأأأأأأأأأأأأעЊd֝أסננננננננננԘ|q֞נأננננננננננננננננננננננננЎg                       ? ? ?  p p p p p p p p? 0? 0? 0? 0> 0> 0. 0& ' # # ! !  p x < <   > ?       ??????(0` $+?k zezfzfzfzfzfzfzfzfzfzezuXR*?ivevevevevev`u}^3{&_fzfzfzfzfz|]nhN-r"rezfzezvYbYC,4l zezfzfzfzczqUDݭHҟ2ϛ,Ϝ-Ϝ-Ϝ-Ϝ-Ϝ-Ϝ-Ϝ-Ϝ-Ϝ-͚,%רDў1Λ,Λ-Λ-Λ-ϛ-Ò*})m٨?М-Ϝ-Ϝ-Ϝ-М-(f$D@ԡ3ϛ,Ϝ-Ϝ-&wYݮHmҟ2ϛ,Ϝ-Ϝ-М-Ȗ+x#P70111111111ߧ0'M601111ՠ.-nF11111ǖ+m$G^6011Ŕ+f-Qm80111٣/%P70111111111ަ0'L501111ԟ.-nE10111Ǖ+l$D4011˙,n FPm70111آ/%P70100000000ަ0'L500001ԟ.-nE10111Ǖ+l$B3011ҝ-u!bPm70111آ/%P70014667777ߩ6+P;66641ԟ.-nE10111Ǖ+k$i?1011ס.{#Pm70111آ/%P700ߨ2Mcdeghhf٬L_vOiedd_<ԟ--nE10111Ǖ+i#U=1011ۥ/%Pm70111آ/%P701Ȗ+l$/;/aCsWAtXAtXAtXAvYBvYCvYBaDm(ްO8;ԟ--nE10111Ǖ+f!N*:0111ާ0' Pm70111آ/%P701Ҟ-lA4ְ^RʢKĕ.Ē)ē*ē*ē*ē*Ĕ.1®)() Ӟ-0ԟ.-nE10111Ǖ+d JD801110(S= Ql70111آ/%P7011)y[YχKbD636>GL(Κ-dۥ/1ԟ.-nE10111Ǖ+c Gb601111’*wX"Qk70111آ/%P7011ަ0%`H&֝|he߮G߱N^Nh Ȗ,.آ/01ԟ.-nE10111Ǖ+g!D401111ɗ+f9Rj70111آ/%P70111ԟ.o :, yϢAɣ{%G* Ӟ.ߧ011ԟ.-nE10111Ǖ+q#&B201111М-oTRi70111آ/%P701111Ò*}^f/$ 1% IٰY̞92l!Λ-oܥ0111ԟ.-nE10111ƕ+~,/?101111ՠ.v!rRi70111آ/%P701111ߧ0)~$Ũ~$ĥ|$Ől!}Iɗ,6آ/0111ԟ.-nE10111ƕ+䴋3?=101111ڤ/}#Pl70111آ/%P70111111ߧ0ߧ0ڤ/&ƕ-Œ%ԟ.ߨ01111ԟ.-nE10111ƕ*8T:010011ݦ0%ݰMr70111آ/%P7011111111ܥ0'p߰Jaߩ601111ԟ.-nE10111Ɣ*ɜ;o80011010'֩I|70111آ/%P7011111001ܥ/)+ģ]IT20111ԟ.-nE10111Ǖ+Р<50019201)ϣD70111آ/%P7011111122ܦ0,Ȝ=gswfF0011ԟ.-nE10111ɗ+դ:301ڣ/ޭD701Ȗ+˞>70111آ/%P7011102GWWT̠@BŰ'g21h:011ԟ.-nE10111Λ-٥6201М,ԨI;01ќ-̝860111آ/%P701101ߧ1ܫA5/|*|*&أ1Ӟ-k׻},W301ԟ.-nE10111ՠ.ۥ1101ɗ+ҨNb?01آ.ў240111آ/%P70101ߨ1ۤ/OOHݫBܪ?ܪ?ܫ@AB–7l"5{ZH01ԟ.-nE10111ݦ0ާ0110ē*ԬV;B01ާ0آ/10111آ/%P70111ܥ0ʐ}"xUuUuUuUuUuUhT˞?i<0ԟ.-nE100011101ާ0)ڴdG000001111آ/%P7001ާ0آ0΋"Z4ԟ--nG322101111ۥ/(Ȇ K101111111آ/%P701آ/ԟ.IvSBӞ--hk\[\T70111آ/'jO301111111آ/%P701Ȗ+cfuXCwYCwYCwYCwYCwYCuXCiO-y$$}^CvYCwYCwYC*mߩ5ס.z#n'yq)|q)|j'uǤZYB0111ӟ.%MS501111111آ/%P701ݦ0ɗ+Ó*ē*ē*ē*ē*ē**y#ѣ@Ɩ.ē*ē*ē*М-ߨ00֡.ѝ-ѝ-ҝ-(8c?0111ϛ-}$2Xw801111111آ/%P70011111111ߨ0(M61111111111̙,:c?0111ʗ,ݝv"\[;01111111آ/%P70111111111ަ0'L50111111111̙+:c?0110œ*ŋh aA?01111111آ/%O60000000000ަ/'L50000000000˘+:c?000ަ0)WAf*C00000000آ.%V?8999999999ߪ8,R=9999999999ϝ2>dG899ݨ6+pN99999999ڦ6)yPlhhhhhhhhhhfתKiuRkhhhhhhhhhiZدWEJ?Ҟ1٣/0̙,ک@201М-⾒4>8011ϛ,鴌5D<01ߧ0’*C201ǖ+dAx"ϣE=fBՠ.ߧ01̙,ک@201М-Ț7U6011ԟ.1\<01ߧ0’*C201ߧ0)Ч}$'7] ѝ-Pݦ011̙,ک@201М-ϟ9r4011٢/0z<01ߧ0’*C2011ߨ0ۥ/֡.%p֤:!ۤ0011̙,ک@201М-Ӣ93000ܥ//<01ߧ0’*C201100ܥ/*EaC001̙,ک@201М-֤82042ߧ0“/;01ߧ0’*C20006>ު<˜90ٱY;01̙,ک@201ӟ.٥61ަ0٦;70ʙ/ު901ߧ0’*C2010߫<ƛ?9ș3ѝ//rU40̙,ک@201٣/ܦ21ڤ/͟;:0Ԡ/ݧ401ߧ0’*C201ަ0Q4ްMݮKLMƜ@޹k2H1̙,ک@200ߨ001ס.Ȝ<`=0ަ0ߧ011ߧ0’*C20ߨ11 iY>̙,ܬF:85011ԟ-=7A01011ߧ0’*C2ܥ0(uW xZ!wY xY rUg~^ wY!x2+ޫ>̙,޶A|ƞIŞI߰L301М-ϵ>D10111ߧ0’*C2ߨ0ϛ-Ǖ+Ǖ+Ǖ+Ȗ+⼍(Ѿ5ʘ/Ǖ+ʗ+ܥ/ܥ/Λ,Κ,)Π>500˘,uBH20111ߧ0’*C2011111՟.ϡ=5011111ӟ-Ѣ=40ߧ0ƕ+L40000ߧ0’*D3111112ԟ.ϡ=6122222Ӟ.Ѣ>51ݦ1‘*qQc71111ߨ1Ò+]POOOOOPݮIٮRpSOPPPPPܭHگSsSPL̝9Ah;TOOOOOѣAA ` `    0 `  @ @#(  ҟ3ϛ,ϛ,Λ,Ɩ.Ϝ/͚,Ɩ0Н.Ȗ+Ŗ1Cѝ/)Ѡ6jϜ-Ŕ*7266֤894բ43٣/բ5e2Ҟ-ܩ:1֠.7֡0͢HlΠ>ʛ6̟?tܩ:բ53٣/ؤ52֠.٦81ՠ.7ۤ//{֩Ijا?֢2Pܥ/բ43٣/٥51٣/פ71ՠ.70آ.ɗ,1Lާ1ߧ0բ43٣/ۦ41ۥ/բ51ՠ.702ۦ5ŗ5ݪ;ߨ0բ43ڤ0ܦ2ߨ3ާ2֢41ՠ.707ӥDң=ԧF~8֢43ߧ0ߧ0ؤ45ܦ11ՠ.7ڣ/ӹ+690?+5(٧=П6צ=ߪ70Ԡ3801ՠ.7ۥ/ՠ.ԟ-˚0ա0ڤ/ۤ/ў0ڦ6ߧ0ϝ1c:00ՠ.B<<<٧=?<<ݪ;ݫ@;Ξ6:F<<ئ9$ $4<etmtk-3.2.22/etmTk/help/0000755000076500000240000000000012617420125014616 5ustar dagstaff00000000000000etmtk-3.2.22/etmTk/help/UserManual.html0000644000076500000240000043306312617417426017603 0ustar dagstaff00000000000000 ETM Users Manual

Overview

In contrast to most calendar/todo applications, creating items (events, tasks, and so forth) in etm does not require filling out fields in a form. Instead, items are created as free-form text entries using a simple, intuitive format and stored in plain text files.

Dates in the examples below are entered using fuzzy parsing - e.g., +7 for seven days from today, fri for the first Friday on or after today, +1/1 for the first day of next month, sun - 6d for Monday of the current week. See Dates for details.

Sample entries

  • A sales meeting (an event) [s]tarting seven days from today at 9:00am with an [e]xtent of one hour and a default [a]lert 5 minutes before the start:

    * sales meeting @s +7 9a @e 1h @a 5
  • The sales meeting with another [a]lert 2 days before the meeting to (e)mail a reminder to a list of recipients:

    * sales meeting @s +7 9a @e 1h @a 5
      @a 2d: e; who@when.com, what@where.org
  • Prepare a report (a task) for the sales meeting [b]eginning 3 days early:

    - prepare report @s +7 @b 3
  • A period [e]xtending 35 minutes (an action) spent working on the report yesterday:

    ~ report preparation @s -1 @e 35
  • Get a haircut (a task) on the 24th of the current month and then [r]epeatedly at (d)aily [i]ntervals of (14) days and, [o]n completion, (r)estart from the completion date:

    - get haircut @s 24 @r d &i 14 @o r
  • Payday (an occasion) on the last week day of each month. The &s -1 part of the entry extracts the last date which is both a weekday and falls within the last three days of the month):

    ^ payday @s 1/1 @r m &w MO, TU, WE, TH, FR
      &m -1, -2, -3 &s -1
  • Take a prescribed medication daily (a reminder) [s]tarting today and [r]epeating (d)aily at [h]ours 10am, 2pm, 6pm and 10pm [u]ntil (12am on) the fourth day from today. Trigger the default [a]lert zero minutes before each reminder:

    * take Rx @s +0 @r d &h 10, 14, 18, 22 &u +4 @a 0
  • Move the water sprinkler (a reminder) every thirty mi[n]utes on Sunday afternoons using the default alert zero minutes before each reminder:

    * Move sprinkler @s 1 @r n &i 30 &w SU &h 14, 15, 16, 17 @a 0

    To limit the sprinkler movement reminders to the [M]onths of April through September each year append &M 4, 5, 6, 7, 8, 9 to the @r entry.

  • Grandparent's day (an occasion) each year on the first Sunday in September after Labor day:

    ^ Grandparent's Day @s 2012-09-01
      @r y &M 9 &w SU &m 7, 8, 9, 10, 11, 12, 13 
  • Presidential election day (an occasion) every four years on the first Tuesday after a Monday in November:

    ^ Presidential Election Day @s 2012-11-06
      @r y &i 4 &M 11 &w TU &m 2, 3, 4, 5, 6, 7, 8 
  • Join the etm discussion group (a task) [s]tarting 14 days from today. Because of the @g (goto) link, pressing G when this item is selected in the gui would open the link using the system default application which, in this case, would be your default browser:

    - join the etm discussion group @s +14
      @g groups.google.com/group/eventandtaskmanager/topics

Starting etm

To start the etm GUI open a terminal window and enter etm at the prompt:

$ etm

If you have not done a system installation of etm you will need first to cd to the directory where you unpacked etm.

Note: if you change the window size and/or position of the etm window on your display and quit etm from the etm file menu, then the closing size and position will be restored when you restart etm.

You can add a command to use the CLI instead of the GUI. For example, to get the complete command line usage information printed to the terminal window just add a question mark:

$ etm ?
Usage:

    etm [logging level] [path] [?] [acmsv]

With no arguments, etm will set logging level 3 (warn), use settings from
the configuration file ~/.etm/etmtk.cfg, and open the GUI.

If the first argument is an integer not less than 1 (debug) and not greater
than 5 (critical), then set that logging level and remove the argument.

If the first (remaining) argument is the path to a directory that contains
a file named etmtk.cfg, then use that configuration file and remove the
argument.

If the first (remaining) argument is one of the commands listed below, then
execute the remaining arguments without opening the GUI.

    a ARG   display the agenda view using ARG, if given, as a filter.
    c ARGS  display a custom view using the remaining arguments as the
            specification. (Enclose ARGS in single quotes to prevent shell
            expansion.)
    d ARG   display the day view using ARG, if given, as a filter.
    k ARG   display the keywords view using ARG, if given, as a filter.
    m INT   display a custom view using the remaining argument, which 
            must be a positive integer, to display a custom view using the 
            corresponding entry from the file given by report_specifications 
            in etmtk.cfg.
            Use ? m to display the numbered list of entries from this file.
    n ARG   display the notes view using ARG, if given, as a filter.
    N ARGS  Create a new item using the remaining arguments as the item
            specification. (Enclose ARGS in single quotes to prevent shell
            expansion.)
    p ARG   display the path view using ARG, if given, as a filter.
    t ARG   display the tags view using ARG, if given, as a filter.
    v       display information about etm and the operating system.
    ? ARG   display (this) command line help information if ARGS = '' or,
            if ARGS = X where X is one of the above commands, then display
            details about command X. 'X ?' is equivalent to '? X'.

For example, you can print your agenda to the terminal window by adding the letter "a":

$ etm a
Today
  > set up luncheon meeting with Joe Smith           4d
Tomorrow
  * test command line event                      3pm ~ 4pm
  * Aerobics                                     5pm ~ 6pm
  - follow up with Mary Jones
Now
  Available
    - Hair cut                                      -1d
Next
  errands
    - milk and eggs
  phone
    - reservation for Saturday dinner
Someday
  ? lose weight and exercise more

You can filter the output by adding a (case-insensitive) argument:

$ etm a hair
Now
  Available
    - Hair cut                                      -1d

or etm d mar .*2014 to show your items for March, 2014.

You can add a question mark to a command to get details about the commmand, e.g.:

Usage:

    etm c <type> <groupby> [options]

Generate a custom view where type is either 'a' (action) or 'c' (composite).
Groupby can include *semicolon* separated date specifications and
elements from:
    c context
    f file path
    k keyword
    t tag
    u user

A *date specification* is either
    w:   week number
or a combination of one or more of the following:
    yy:   2-digit year
    yyyy:   4-digit year
    MM:   month: 01 - 12
    MMM:   locale specific abbreviated month name: Jan - Dec
    MMMM:   locale specific month name: January - December
    dd:   month day: 01 - 31
    ddd:   locale specific abbreviated week day: Mon - Sun
    dddd:   locale specific week day: Monday - Sunday

Options include:
    -b begin date
    -c context regex
    -d depth (CLI type a only)
    -e end date
    -f file regex
    -k keyword regex
    -l location regex
    -o omit (type c only)
    -s summary regex
    -S search regex
    -t tags regex
    -u user regex
    -w column 1 width
    -W column 2 width

Example:

    etm c 'c ddd, MMM dd yyyy -b 1 -e +1/1'

Note: The CLI offers the same views and reporting, with the exception of week and month view, as the GUI. It is also possible to create new items in the CLI with the n command. Other modifications such as copying, deleting, finishing and so forth, can only be done in the GUI or, perhaps, in your favorite text editor. An advantage to using the GUI is that it provides auto-completion and validation.

Tip: If you have a terminal open, you can create a new item or put something to finish later in your inbox quickly and easily with the "N" command. For example,

    etm N '123 456-7890'

would create an entry in your inbox with this phone number. (With no type character an "$" would be supplied automatically to make the item an inbox entry and no further validation would be done.)

Views

All views display only items consistent with the current choices of active calendars. Click the settings icon on the left side of the top menu bar to choose active calendars.

Week and month views have three panes. The top one displays a graphic illustration of scheduled times for the relevant period, the middle one displays an tree view of items grouped by date and the bottom one displays detail information. Custom view also has three panes but the top one is an entry area for providing the specification for the custom view. All other views have two panes - a tree view in the top pane and details in the bottom pane.

If a (case-insensitive) filter is entered then the display in the tree view will be limited to items that match somewhere in either the branch or the leaf. Relevant branches will automatically be expanded to show matches.

In week and month views, Home selects the current date. In all views other than week and month, Home selects the first item in the tree pane.

In all views, pressing Return with an item selected will open a context menu with options to copy, delete, edit and so forth.

In all views, clicking in the details panel with an item selected will open the item for editing if it is not repeating and otherwise prompt for the instance(s) to be changed.

In all views, pressing O will open a dialog to choose the outline depth. Pressing L will toggle the display of a column displaying item labels where, for example, an item with @a, @d and @u fields would have the label "adu". Pressing S will show a text verion of the current display suitable for copy and paste. The text version will respect the current outline depth in the view.

In custom view it is possible to export the current view in either text or CSV (comma separated values) format to a file of your choosing.

Note. In custom view you need to move the focus from the view specification entry field in order for the shortcuts O, L and S to work.

In all views:

  • if an item is selected:

    • pressing Shift-H will show a history of changes for the file containing the selected item, first prompting for the number of changes.

    • pressing Shift-X will export the selected item in iCal format.

  • if an item is not selected:

    • pressing Shift-H will show a history of changes for all files, first prompting for the number of changes.

    • pressing Shift-X will export all items in active calendars in iCal format.

Agenda View

What you need to know now beginning with your schedule for the next few days and followed by items in these groups:

  • In basket: In basket items and items with missing types or other errors.

  • Now: All scheduled (dated) tasks whose due dates have passed including delegated tasks and waiting tasks (tasks with unfinished prerequisites) grouped by available, delegated and waiting and, within each group, by the due date.

  • Next: All unscheduled (undated) tasks grouped by context (home, office, phone, computer, errands and so forth) and sorted by priority and extent. These tasks correspond to GTD's next actions. These are tasks which don't really have a deadline and can be completed whenever a convenient opportunity arises. Check this view, for example, before you leave to run errands for opportunities to clear other errands.

  • Someday: Someday (maybe) items for periodic review.

Note: Finished tasks, actions and notes are not displayed in this view.

Week and Month Views

These views only differ in whether the graphic in the top pane describes a week or a month. All dated items appear in the middle, tree pane in these view, grouped by date and sorted by starting time and item type. Displayed items include:

  • All non-repeating, dated items.

  • All repetitions of repeating items with a finite number of repetitions. This includes 'list-only' repeating items and items with &u (until) or &t (total number of repetitions) entries.

  • For repeating items with an infinite number of repetitions, those repetitions that occur within the first weeks_after weeks after the current week are displayed along with the first repetition after this interval. This assures that at least one repetition will be displayed for infrequently repeating items such as voting for president.

The graphic display in the top pane has a square cell for each week/month date. Within this cell, scheduled, busy times are indicated by segments of a square busy border that surrounds the date. This border can be regarded as a 24-hour clock face that proceeds clockwise from 12am at the lower, left-hand corner with 6 hour segments for each side: 12am - 6am moving upward on the left side, 6am - 12pm moving rightward along the top, 12pm - 6pm moving downward along the right side and, finally, 6pm - 12pm moving leftward along the bottom. When nothing is scheduled for a date, the border is blank. When only one event is scheduled for a date, say from 9am until 3pm, then the border would be colored from the middle of the top side (9am) around the top, right-hand corner and downward to the middle of the right side (3pm). When other periods are scheduled, corresponding portions of the border would be colored. If two or more scheduled periods overlap, then a small, red box appears in the lower, left-hand corner of the border to warn of the conflict.

When the top pane has the focus, the left/right cursor keys move to the previous/subsequent week or month and the up/down cursor keys move to the previous/subsequent date. Either Home or Space moves the display to the current date. Pressing J will first prompt for a fuzzy-parsed date and then "jump" to the specified date. Whenever a date is selected in the top pane, the date tree in the middle pane is scrolled, if necessary, to display the selected date first. Whenever a date is selected in either week or month view, the same date is automatically becomes the selection in the other view as well.

Note: If a date is selected for which no items are scheduled, then the last date with scheduled items on or before the selected date will become the selected date in the middle, tree pane.

Tip: Want to see your next appointment with Dr. Jones? Switch to day view and enter "jones" in the filter.

Tip. You can display a list of busy times or, after providing the needed period in minutes, a list of free times that would accommodate the requirement within the selected week/month. Both options are in the View menu.

Week View

Events and occasions displayed graphically by week with one column for each day. Left and right cursor keys change, respectively, to the previous and next week. Up and down cursor keys select, respectively, the previous and next items within the given week. Items can also be selected by moving the mouse over the item. The details for the selected item are displayed at the bottom of the screen. Pressing return with an item selected or double-clicking an item opens a context menu. Control-clicking an unscheduled time opens a dialog to create an event for that date and time.

Month View

Events and occasions displayed graphically by month. Left and right cursor keys change, respectively, to the previous and next month. Up and down cursor keys select, respectively, the previous and next days within the given month. Days can also be selected by moving the mouse over the item. A list of occasions and events for the selected day is displayed at the bottom of the screen. Double clicking a date or pressing Return with a date selected opens a dialog to create an item for that date.

The current date and days with occasions are highlighted.

Tip. You can display a list of busy times or, after providing the needed period in minutes, a list of free times that would accommodate the requirement within the selected month. Both options are in the View menu.

Tag View

All items with tag entries grouped by tag and sorted by type and relevant datetime. Note that items with multiple tags will be listed under each tag.

Tip: Use the filter to limit the display to items with a particular tag.

Keyword View

All items grouped by keyword and sorted by type and relevant datetime.

Path View

All items grouped by file path and sorted by type and relevant datetime. Use this view to review the status of your projects.

The relevant datetime is the past due date for any past due task, the starting datetime for any non-repeating item and the datetime of the next instance for any repeating item.

Note: Items that you have "commented out" by beginning the item with a # will only be visible in this view.

Note View

All notes grouped and sorted by keyword and summary.

Custom

Design your own view. See Custom view for details.

Creating New Items

Items of any type can be created by pressing N in the GUI and then providing the details for the item in the resulting dialog.

An event can also be created by double-clicking in a date cell in either Week or Month View - the corresponding date will be entered as the starting date when the dialog opens.

When editing an item, clicking on Finish or pressing Shift-Return will validate your entry. If there are errors, they will be displayed and you can return to the editor to correct them. If there are no errors, this will be indicated in a dialog, e.g.,

Task scheduled for Tue Jun 03

Save changes and exit?

Tip. When creating or editing a repeating item, pressing Finish will also display a list of instances that will be generated.

Click on Ok or press Return or Shift-Return to save the item and close the editor. Click on Cancel or press Escape to return to the editor.

If this is a new item and there are no errors, clicking on Ok or pressing Return will open a dialog to select the file to store the item with the current monthly file already selected. Pressing Shift-Return will bypass the file selection dialog and save to the current monthly file.

Editing Existing Items

Double-clicking an item or pressing Return when an item is selected will open a context menu of possible actions:

  • Copy
  • Delete
  • Edit
  • Edit file
  • Finish (unfinished tasks only)
  • Reschedule
  • Schedule new
  • Klone as timer
  • Open link (items with @g entries only)
  • Show user details (items with @u entries only)

When either Copy or Edit is chosen for a repeating item, you can further choose:

  1. this instance
  2. this and all subsequent instances
  3. all instances

When Delete is chosen for a repeating item, a further choice is available:

  1. all previous instances

Tip: Use Reschedule to enter a date for an undated item or to change the scheduled date for the item or the selected instance of a repeating item. All you have to do is enter the new (fuzzy parsed) datetime.

Timers

countdown timer

To start a countdown timer press z, change the default number of minutes if necessary and press Return. The time that the timer will expire will be displayed in the status bar with a - prefix.

If countdown_command is given in etmtk.cfg, then it will be executed when the timer expires and the countdown message dialog will appear with the last chosen number of minutes as the default. You can either press Return to start another countdown or press Escape to cancel. If activated, the time that the countdown timer will expire will be displayed in the status bar.

snooze timer

When the last alert of type m (message) is triggered for an item, the alert dialog that is displayed offers the option of snoozing, i.e., repeating the alert after a number of minutes you choose. If activated, the alert corresponding to snooze timer can be displayed along with any other remaining alerts using Tools/Show remaining alerts.

If snooze_command is given in etmtk.cfg, then it will be executed when the snooze timer expires and the alert message dialog will appear with the last chosen number of minutes as the default. You can either press Return to snooze again or press Escape to cancel.

action timer

For people who bill their time or just like to keep track of how they spend their time, etm allows you to create an action by pressing T to start a timer. You will see an entry area with a list of any existing timers below. As you enter characters in the entry area, the list below will shrink to those whose beginnings match the characters you've entered. You can either select a timer from the list to start that timer or enter new name in the entry area to create and start a new timer. If a timer is running, it will automatically be paused when you start a new timer or switch to another timer.

Tip. Devoting time to two different clients this morning? Create two timers, one for each client and just switch back and forth using T when you switch from one client to the other. The timers are ordered in the list so that the most recently paused will be at the top.

While a timer is selected, the name, elapsed time and status - running or paused - is displayed in the status bar along with the number of active timers in parentheses. Pressing I toggles the timer between running and paused. You can configure etm's options to, for example, play one sound at intervals when a timer is running and another sound when the selected timer is paused and you can also specify the length of the interval and the volume.

When one or more timers are active and none are running, idle time is accumulated and displayed, by default, in the status bar. The idle time display can be toggled on and off and accumulated idle time can be reset to zero. It is also possible to transfer minutes from accumulated idle time to the current action timer.

When you have one or more active timers, you can press Shift-T to select one to finish. The selected timer will be paused if it is running and you will be presented with an entry area to create a new action with the following details already filled in: ~ timer name @s starting datetime @e elapsed time. You can edit this entry in any way you like and then save it. When you do so, this timer will be removed from your list of active timers. You can also press Shift-I to select a timer to delete. Any accumulated time for the selected timer will be added to the accumulated idle time and the timer will be removed from the list of active timers.

It is also possible to start a timer by selecting an event, note, task or whatever, from one of etm's Views and then choosing Item/Klone as timer from the menu or pressing K. A start timer dialog will be opened with the summary of the item you selected as the name together with any @-keys from the selected item that are listed in action_keys in your etmtk.cfg. You can edit this entry if you like or just press Return to accept it and start the timer. If you already have an active timer with this name, it will be restarted. Otherwise a new timer will be created and started.

Tip. Suppose you have a client, John Smith, and will be doing some work for him this morning relating to the project "Motion". If you don't already have a task relating to this begin by creating one for today, June 16, 2015, by pressing N and entering

- work @k SmithJohn:Motion @s +0

The first activity related to this task involves a phone call to Sally. Select the task you just created and then press K to start a timer. Change work to call Sally and press Return to start the timer. When you've finished the call, press I to pause the timer. Based on this phone conversation, you decide the next activity should be to review Local Rule 4567, so once again select the task, press K and then change work to review Local Rule 4567 and press Return to start this timer. When you're done, once again press I to pause this timer. You can repeat this process as often as you like. If you need to spend more time on 4567, press T and select it from the list of timers. When you're done, you can press Shift-T to select a timer from the list and finish it. Selecting the "call Sally" timer would produce an entry for the new action something like the following

~ call Sally @k SmithJohn:Motion @s 2015-06-16 9:27am @e 12m

You can edit this action if you like, but it is already set up to bill 12 minutes to the "Motion" project for client "John Smith" for an activity labeled "call Sally" and will appear as such in reports you generate for this period, so you can just save it as it is. Do the same with your other timers and you will have a complete record of time spent by client, project and activity for the day.

The state of your active timers is saved whenever you quit etm using by choosing Quit from the file menu or using the shortcut so that whenever you restart etm on the same day, the active timers will be restored.

If etm is running when a new day begins (midnight local time) or if you stop etm and start it again on a later date, in-basket entries for each of your active timers will be created in the relevant monthly file. These entries will be exactly the same as if you had finished each of the timers save for the use of $ (in basket) rather than ~ (action) as the type character. You can edit or delete these as you wish. If a timer is selected (displayed in the status bar), then a new timer with the same name will be created for the new date but with zero elapsed time. If the timer was running at midnight, then the new timer will also be running. Idle time will automatically be reset to zero.

Sharing with other calendar applications

Both export and import are supported for files in iCalendar format in ways that depend upon settings in etmtk.cfg.

If an absolute path is entered for current_icsfolder, for example, then .ics files corresponding to the entries in calendars will be created in this folder and updated as necessary. If there are no entries in calendars, then a single file, all.ics, will be created in this folder and updated as necessary.

If an item is selected, then pressing Shift-X in the gui will export the selected item in iCalendar format to icsitem_file. If an item is not selected, pressing Shift-X will export the active calendars in iCalendar format to icscal_file.

If icssync_folder is given, then files in this folder with the extension .txt and .ics will automatically kept concurrent using export to iCalendar and import from iCalendar. I.e., if the .txt file is more recent than than the .ics then the .txt file will be exported to the .ics file. On the other hand, if the .ics file is more recent then it will be imported to the .txt file. In either case, the contents of the file to be updated will be overwritten with the new content and the last acess/modified times for both will be set to the current time.

If ics_subscriptions is given, it should be a list of [URL, FILE] tuples. The URL is a calendar subscription, e.g., for a Google Calendar subscription the URL, FILE tuple would be something like:

  ['https://www.google.com/calendar/ical/.../basic.ics', 'personal/google.txt']
    

With this entry, pressing Shift-M in the gui would import the calendar from the URL, convert it from ics to etm format and then write the result to personal/google.txt in the etm data directory. Note that this data file should be regarded as read-only since any changes made to it will be lost with the next subscription update.

Finally, when creating a new item in the etm editor, you can paste an iCalendar entry such as the following VEVENT:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//ForeTees//NONSGML v1.0//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:1403607754438-11547@127.0.0.1-33
DTSTAMP:20140624T070234
DTSTART:20140630T080000
SUMMARY:8:00 AM Tennis Reservation
LOCATION:Governors Club
DESCRIPTION: Player 1: ...
 
URL:http://www1.foretees.com/governorsclub
END:VEVENT
END:VCALENDAR

When you press Finish, the entry will be converted to etm format

^ 8:00 AM Tennis Reservation @s 2014-06-30 8am 
@d Player 1: ... 
@z US/Eastern

and you can choose the file to hold it.

The following etm and iCalendar item types are supported:

  • export from etm:

    • occasion to VEVENT without end time
    • event (with or without extent) to VEVENT
    • action to VJOURNAL
    • note to VJOURNAL
    • task to VTODO
    • delegated task to VTODO
    • task group to VTODO (one for each job)
  • import from iCalendar

    • VEVENT without end time to occasion
    • VEVENT with end time to event
    • VJOURNAL to note
    • VTODO to task

Tools

Date and time calculator

Enter an expression of the form x [+-] y where x is a date and y is either a date or a time period if - is used and a time period if + is used. Both x and y can be followed by timezones, e.g.,

 4/20 6:15p US/Central - 4/20 4:50p Asia/Shanghai:

 14h25m

or

 4/20 4:50p Asia/Shanghai + 14h25m US/Central:

 2014-04-20 18:15-0500

Fuzzy dates (other than relative date expressions using + or -) can be used to specify date entries. The local timezone is assumed when none is given.

Available dates calculator

Need to see a list of possible dates for a meeting? Get a list of busy dates from each of the members of the group and then use an expression of the form

start; end; busy

where start and end are dates and busy is a comma separated list of the busy dates or intervals for the members. E.g., if your group needs to meet between 6/1 and 6/30 and the members indicate that they cannot meet on 6/2, 6/14-6/22, 6/5-6/9, 6/11-6/15 or 6/17-6/29, then entering

6/1; 6/30; 6/2, 6/14-6/22, 6/5-6/9, 6/11-6/15, 6/17-6/29

would give:

Sun Jun 01
Tue Jun 03
Wed Jun 04
Tue Jun 10
Mon Jun 30

as the possible dates for the meeting.

Yearly calendar

Gives a display such as

      January 2014           February 2014             March 2014
  Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su
         1  2  3  4  5                    1  2                    1  2
   6  7  8  9 10 11 12     3  4  5  6  7  8  9     3  4  5  6  7  8  9
  13 14 15 16 17 18 19    10 11 12 13 14 15 16    10 11 12 13 14 15 16
  20 21 22 23 24 25 26    17 18 19 20 21 22 23    17 18 19 20 21 22 23
  27 28 29 30 31          24 25 26 27 28          24 25 26 27 28 29 30
                                                  31

       April 2014               May 2014               June 2014
  Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su
      1  2  3  4  5  6              1  2  3  4                       1
   7  8  9 10 11 12 13     5  6  7  8  9 10 11     2  3  4  5  6  7  8
  14 15 16 17 18 19 20    12 13 14 15 16 17 18     9 10 11 12 13 14 15
  21 22 23 24 25 26 27    19 20 21 22 23 24 25    16 17 18 19 20 21 22
  28 29 30                26 27 28 29 30 31       23 24 25 26 27 28 29
                                                  30

       July 2014              August 2014            September 2014
  Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su
      1  2  3  4  5  6                 1  2  3     1  2  3  4  5  6  7
   7  8  9 10 11 12 13     4  5  6  7  8  9 10     8  9 10 11 12 13 14
  14 15 16 17 18 19 20    11 12 13 14 15 16 17    15 16 17 18 19 20 21
  21 22 23 24 25 26 27    18 19 20 21 22 23 24    22 23 24 25 26 27 28
  28 29 30 31             25 26 27 28 29 30 31    29 30

      October 2014           November 2014           December 2014
  Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su    Mo Tu We Th Fr Sa Su
         1  2  3  4  5                    1  2     1  2  3  4  5  6  7
   6  7  8  9 10 11 12     3  4  5  6  7  8  9     8  9 10 11 12 13 14
  13 14 15 16 17 18 19    10 11 12 13 14 15 16    15 16 17 18 19 20 21
  20 21 22 23 24 25 26    17 18 19 20 21 22 23    22 23 24 25 26 27 28
  27 28 29 30 31          24 25 26 27 28 29 30    29 30 31

Left and right cursor keys move backward and forward a year at a time, respectively, and pressing the Home key returns to the current year.

History of changes

This requires that either git or mercurial is installed. If an item is selected show a history of changes to the file that contains the item. Otherwise show a history of changes for all etm data files. In either case, choose an integer number of the most recent changes to show or choose 0 to show all changes.

Calendars

etm supports using the directory structure in your data directory to create separate calendars. For example, my wife, erp, and I, dag, separate personal and shared items with this structure:

root etm data directory
    personal
        dag
        erp
    shared
        holidays
        birthdays
        events

Here, our etm configuration files are located in our home directories:

~dag/.etm/etmtk.cfg
~erp/.etm/etmtk.cfg

Both contain datadir entries specifying the common root data directory mentioned above with these additional entries, respectively:

In ~dag/.etm/etmtk.cfg:

    calendars
    - [dag, true, personal/dag]
    - [erp, false, personal/erp]
    - [shared, true, shared]

In ~erp/.etm/etmtk.cfg:

    calendars
    - [erp, true, personal/erp]
    - [dag, false, personal/dag]
    - [shared, true, shared]

Thus, by default, both dag and erp see the entries from their personal files as well as the shared entries and each can optionally view the entries from the other's personal files as well. See the Preferences for details on the calendars entry.

Note for Windows users. The path separator needs to be "escaped" in the calendar paths, e.g., you should enter

 - [dag, true, personal\\dag]

instead of

 - [dag, true, personal\dag]

Data Organization

etm offers many ways of organizing your data. Perhaps, the most obvious is by path, i.e., the directory structure inside your data directory. Path View presents your data using this organization and, as noted above, calendars can be specified using this structure to allow you to choose quickly the calendars whose items will appear in other etm views as well.

The other hierarchical way of organizing your data uses the keywords you specify in your items. Keyword View presents your data using this organization. E.g.,

- my task @k A:B:C
- my other task

would appear in Keyword View as:

A
    B
        C
            - my task
~ none ~
    - my other task

There are no hard and fast rules about how to use these hierarchies but the goal is a system that makes complementary uses of path and keyword and fits your needs. As with any filing system, planning and consistency are paramount. For example, one pattern of use for a business might be to use folders for departments and people and keywords for client and project.

It is also possible to add one or more tags to items and use Tag View to see the resulting organization. For example

- item 1 @t red, white, blue
- item 2 @t red @t white
- item 3 @t white @t blue
- item 4 @t red, blue
- item 5 @t white

would appear in Tag View as

blue
    - item 1
    - item 3
    - item 4
red 
    - item 1
    - item 2
    - item 4
white
    - item 1
    - item 2
    - item 3
    - item 5

A final important way of organizing your data is provided by context. This is designed to support a GTD (Getting Things Done) common practice where possible contexts includes things like phone, errands, email and so forth. Undated tasks such as

- pick up milk @c errands
- call Saul @c phone
- confirm schedule with Bob @c email

would appear Agenda View as

Next
    email
        - confirm schedule with Bob
    errands
        - pick up milk
    phone
        - call Saul
        

When you are next checking email, running errands, using the phone or whatever, you can check Agenda View to see what else might be accomplished at the same time. Note that, unlike tags, items can have at most a single context.

Colors

Versions of etm after 3.1.39 support custom settings for both foreground (font) and background colors in the GUI. If a file named colors.cfg is found in the etm directory on startup, then the color settings in this file will override the default color settings. If this file is not found, then it will be created and populated with the default color settings. This file can be opened for editing in etm using File/Open/Configuration file from the main menu.

Example files for both dark and light backgrounds are available for download and customization. You can also download colors.py, set your preferred background color inside this script and then run it to see how the different font colors would appear against your chosen background. See also the setting for style under Preferences.

Internationalization

Versions of etm after 3.1.20 provide support for languages beyond English.

End User

If you, for example, are French and would like to use a version of etm in which menu items and standard phrases are French then you need to download the file fr_FR.mo either from GitHub locale or from etmtk languages and copy it to the following location in your etmdir:

<your etmdir>
    languages
        fr_FR
            LC_MESSAGES
                fr_FR.mo
                

creating the corresponding directory structure when necessary. Be sure to get the file with the .mo extension, not the one with the .po extension. Next you need to create a file named locale.cfg in your etmdir with the line:

[[fr_FR, UTF-8], QLocale.French, QLocale.France]

perhaps modifying UTF-8 to reflect your actual file encoding.

That's it! When you next start etm, locale.cfg will be read, fr_FR will be set as the desired locale and, if it can be found in the specified directory, the translations in fr_FR.mo will be loaded. Now, e.g, instead of Agenda you will see Ordre du jour.

Translator

If you would like to assist in providing etm for a particular language, the process is pretty simple. You will need to download the program poedit. A free version is available for all major platforms.

In the etm source code, whenever a word or phrase appears that will be seen by the user, it is wrapped in a special format using _() so that, e.g., Agenda appears in the source code as _("Agenda"), Today as _("Today") and so forth.

When etm is being prepared for distribution a program called gettext is used to extract the _() entries from wherever they appear in the source and copy them to specially formatted file called etm.pot. This file can be then be used in the open-source program poedit to create a special translation files for different languages. This is how fr_FR.po was created, for example. Translation files are available from the above sources for French (fr_FR.po), German (de_DE.po), Spanish (es_ES.po) and Polish (pl_PL.mo). Alternatively, etm.pot is also available and you can use it to create whatever translation files you wish.

When a .po translation file is opened in poedit, two columns are displayed, the first lists the _() entries from the source code and the second lists the corresponding translation. E.g.,

    Agenda          Ordre du jour
    Today           Aujourd’hui

Initially, of course, the translation column is empty and it is the job of the translator to provide the translations. The pro version of poedit (~ $20) provides a third column with likely guesses about the appropriate translation. Most of the choices in fr_FR.po, in fact, came from accepting these best guesses since my knowledge of French is miniscule.

Whenever a .po file is saved in poedit, a compiled version with the extension .mo is automatically created. This compiled version is the one actually used by etm and the only file that an end user needs.

When an end user has followed the steps given above to enable support for a particular language, the actual translations will, of course be limited to those with "second column choices". Extending the example above, suppose the translator has omitted some items

    Agenda          Ordre du jour
    Yesterday
    Today           Aujourd’hui
    Tomorrow

Then whenever _("Agenda") appears in the source, it will effectively be replaced by "Ordre du jour" and whenever _("Yesterday") appears, it will be replaced by "Yesterday". I.e., when a translation is available, it will be used; otherwise, the original text will be used.

A translator can thus do as much or as little as he or she pleases and then send me the resulting .po file. I'll replace the current on-line version with this updated version so the next translator can improve upon prior results.

Item types

There are several types of items in etm. Each item begins with a type character such as an asterisk (event) and continues on one or more lines either until the end of the file is reached or another line is found that begins with a type character. The type character for each item is followed by the item summary and then, perhaps, by one or more @key value pairs - see @-Keys for details. The order in which such pairs are entered does not matter.

~ Action

A record of the expenditure of time (@e) and/or money (@x). Actions are not reminders, they are instead records of how time and/or money was actually spent. Action lines begin with a tilde, ~.

    ~ picked up lumber and paint @s mon 3p @e 1h15m @x 127.32

Entries such as @s mon 3p, @e 1h15m and @x 127.32 are discussed below under Item details. Action entries form the basis for time and expense billing using action type custom views - see Custom view for details.

Tip: You can use either path or keyword or a combination of the two to organize your actions.

* Event

Something that will happen on particular day(s) and time(s). Event lines begin with an asterick, *.

    * dinner with Karen and Al @s sat 7p @e 3h

Events have a starting datetime, @s and an extent, @e. The ending datetime is given implicitly as the sum of the starting datetime and the extent. Events that span more than one day are possible, e.g.,

    * Sales conference @s 9a wed @e 2d8h

begins at 9am on Wednesday and ends at 5pm on Friday.

An event without an @e entry or with @e 0 is regarded as a reminder and, since there is no extent, will not be displayed in busy times.

^ Occasion

Holidays, anniversaries, birthdays and such. Similar to an event with a date but no starting time and no extent. Occasions begin with a caret sign, ^.

    ^ The !1776! Independence Day @s 2010-07-04 @r y &M 7 &m 4

On July 4, 2013, this would appear as The 237th Independence Day. Here !1776!` is an example of an anniversary substitution - see Dates for details.

! Note

A record of some useful information. Note lines begin with an exclamation point, !.

! xyz software @k software:passwords @d user: dnlg, pw: abc123def

Tip: Since both the GUI and CLI note views group and sort by keyword, it is a good idea to use keywords to organize your notes.

Tasks

Tasks are reminders of something that needs to be done. There are three possible type characters for tasks: -, % and +; these are discussed below. Each of these can be further distinguished by whether or not the task has entries for @e and/or @s.

When an @e (extent) is provided for any type of task, it is regarded as an estimate of the time required to complete the task.

  • Tasks without an @s entry have no due date and are to be done whenever convenient.

  • Tasks with an @s entry that specifies 12am (or 0h) as the starting time are to be completed on or before the date specified.

  • Tasks with an @s entry that specifies a starting time other than 12am (or 0h) but without an @e entry are to be completed on or before the date and time specified. These will be displayed in etm Agenda and Day views before other tasks, sorted by and displaying the starting time.

  • Tasks with an @s entry that specifies a starting time other than 12am (or 0h) and with an @e entry are to be completed during the period that extends from the starting time until @e after the starting time. These will be displayed in etm Agenda and Day views before other tasks, sorted by and displaying the time period in the same manner as events. This period will be regarded as busy time and treated as such by etm in, e.g., Week view. [Tip: Use a non-midnight starting time and an extent when you want to block off a specific period to complete a task.]

- Task

This is the basic task and begins with a minus sign, -.

- pay bills @s Oct 25

A task with an @s entry becomes due on that date and time and past due when that date has passed. If the task also has an @b begin-by entry, then advance warnings of the task will begin appearing the specified number of days before the task is due.

% Delegated task

A task that is assigned to someone else, usually the person designated in an @u entry. Delegated tasks begin with a percent sign, %.

    % make reservations for trip @u joe @s fri

+ Task group

A collection of related tasks, some of which may be prerequisite for others. Task groups begin with a plus sign, +.

    + dog house
      @j pickup lumber and paint      &q 1
      @j cut pieces                   &q 2
      @j assemble                     &q 3
      @j paint                        &q 4

Note that a task group is a single item and is treated as such. E.g., if any job is selected for editing then the entire group is displayed.

Individual jobs are given by the @j entries. The queue entries, &q, set the order --- tasks with smaller &q values are prerequisites for subsequent tasks with larger &q values. In the example above, neither "pickup lumber" nor "pickup paint" have any prerequisites. "Pickup lumber", however, is a prerequisite for "cut pieces" which, in turn, is a prerequisite for "assemble". Both "assemble" and "pickup paint" are prerequisites for "paint".

$ In basket

A quick, don't worry about the details item to be edited later when you have the time. In basket entries begin with a dollar sign, $.

    $ joe 919 123-4567

If you create an item using etm and forget to provide a type character, an $ will automatically be inserted.

? Someday maybe

Something you don't want to forget about altogether but don't want to appear on your next or scheduled lists. Someday maybe items begin with a question mark, ?. They are displayed under the heading Someday in Agenda view so that you can easily review them whenever you like.

    ? lose weight and exercise more

# Comment

Comments begin with a hash mark, #. Such items are ignored by etm save for appearing in the path view. Stick a hash mark in front of any item that you don't want to delete but don't want to see in your other views.

= Defaults

Default entries begin with an equal sign, =. These entries consist of @key value pairs which then become the defaults for subsequent entries in the same file until another = entry is reached.

Suppose, for example, that a particular file contains items relating to "project_a" for "client_1". Then entering

= @k client_1:project_a

on the first line of the file and

=

on the twentieth line of the file would set the default keyword for entries between the first and twentieth line in the file.

@Keys

@a alert

The specification of the alert(s) to use with the item. One or more alerts can be specified in an item. E.g.,

@a 10m, 5m
@a 1h: s

would trigger the alert(s) specified by default_alert in your etmtk.cfg at 10 and 5 minutes before the starting time and a (s)ound alert one hour before the starting time.

The alert

@a 2d: e; who@what.com, where2@when.org; filepath1, filepath2

would send an email to the two listed recipients exactly 2 days (48 hours) before the starting time of the item with the item summary as the subject, with file1 and file2 as attachments and with the body of the message composed using email_template from your etmtk.cfg.

Similarly, the alert

@a 10m: t; 9191234567@vtext.com, 9197654321@txt.att.net

would send a text message 10 minutes before the starting time of the item to the two mobile phones listed (using 10 digit area code and carrier mms extension) together with the settings for sms in etmtk.cfg. If no numbers are given, the number and mms extension specified in sms.phone will be used. Here are the mms extensions for the major US carriers:

Alltel          @message.alltel.com
AT&T            @txt.att.net
Nextel          @messaging.nextel.com
Sprint          @messaging.sprintpcs.com
SunCom          @tms.suncom.com
T-mobile        @tmomail.net
VoiceStream     @voicestream.net
Verizon         @vtext.com

Finally,

@a 0: p; program_path

would execute program_path at the starting time of the item.

The format for each of these:

@a <trigger times> [: action [; arguments]]

In addition to the default action used when the optional : action is not given, there are the following possible values for action:

d   Execute alert_displaycmd in etmtk.cfg.

e; recipients[;attachments]     Send an email to recipients 
   (a comma separated list of email addresses) optionally 
   attaching attachments (a comma separated list of file paths). 
   The item summary is used as the subject of the email and 
   the expanded value of email_template from etmtk.cfg as the 
   body. If there is an entry for @i (invitees), these email 
   addresses will be appended to the list of recipients.

m   Display an internal etm message box using alert_template.

p; process      Execute the command given by process.

s   Execute alert_soundcmd in etmtk.cfg.

t [; phonenumbers]      Send text messages to phonenumbers 
  (a comma separated list of 10 digit phone numbers with the 
  sms extension of the carrier appended) with the expanded 
  value of sms.message as the text message.

v   Execute alert_voicecmd in etmtk.cfg.

Note: either e or p can be combined with other actions in a single alert but not with one another.

@b beginby

An integer number of days before the starting date time at which to begin displaying begin by notices. When notices are displayed they will be sorted by the item's starting datetime and then by the item's priority, if any.

@c context

Intended primarily for tasks to indicate the context in which the task can be completed. Common contexts include home, office, phone, computer and errands. The "next view" supports this usage by showing undated tasks, grouped by context. If you're about to run errands, for example, you can open the "next view", look under "errands" and be sure that you will have no "wish I had remembered" regrets.

@d description

An elaboration of the details of the item to complement the summary.

@e extent

A time period string such as 1d2h (1 day 2 hours). For an action, this would be the elapsed time. For a task, this could be an estimate of the time required for completion. For an event, this would be the duration. The ending time of the event would be this much later than the starting datetime.

Tip. Need to determine the appropriate value for @e for a flight when you have the departure and arrival datetimes but the timezones are different? The date calculator (shortcut Shift-D) will accept timezone information so that, e.g., entering the arrival time minus the departure time

4/20 6:15p US/Central - 4/20 4:50p Asia/Shanghai

into the calculator would give

14h25m

as the flight time.

@f done[; due]

Datetimes; tasks, delegated tasks and task groups only. When a task is completed an @f done entry is added to the task. When the task has a due date, ; due is appended to the entry. Similarly, when a job from a task group is completed in etm, an &f done or &f done; due entry is appended to the job and it is removed from the list of prerequisites for the other jobs. In both cases done is the completion datetime and due, if added, is the datetime that the task or job was due. The completed task or job is shown as finished on the completion date. When the last job in a task group is finished an @f done or @f done; due entry is added to the task group itself reflecting the datetime that the last job was done and, if the task group is repeating, the &f entries are removed from the individual jobs.

Another step is taken for repeating task groups. When the first job in a task group is completed, the @s entry is updated using the setting for @o (above) to show the next datetime the task group is due and the @f entry is removed from the task group. This means when some, but not all of the jobs for the current repetition have been completed, only these job completions will be displayed. Otherwise, when none of the jobs for the current repetition have been completed, then only that last completion of the task group itself will be displayed.

Consider, for example, the following repeating task group which repeats monthly on the last weekday on or before the 25th.

+ pay bills @s 11/23 @f 10/24;10/25
  @r m &w MO,TU,WE,TH,FR &m 23,24,25 &s -1
  @j organize bills &q 1
  @j pay on-line bills &q 3
  @j get stamps, envelopes, checkbook &q 1
  @j write checks &q 2
  @j mail checks &q 3

Here "organize bills" and "get stamps, envelopes, checkbook" have no prerequisites. "Organize bills", however, is a prerequisite for "pay on-line bills" and both "organize bills" and "get stamps, envelops, checkbook" are prerequisites for "write checks" which, in turn, is a prerequisite for "mail checks".

The repetition that was due on 10/25 was completed on 10/24. The next repetition was due on 11/23 and, since none of the jobs for this repetition have been completed, the completion of the group on 10/24 and the list of jobs due on 11/23 will be displayed initially. The following sequence of screen shots show the effect of completing the jobs for the 11/23 repetition one by one on 11/27.

@g goto

The path to a file or a URL to be opened using the system default application when the user presses G in the GUI. E.g., here's a task to join the etm discussion group with the URL of the group as the link. In this case, pressing G would open the URL in your default browser.

- join the etm discussion group @s +1/1
  @g http://groups.google.com/group/eventandtaskmanager/topics
  

Template expansion is supported so it is also possible to use a mailto link such as the following:

- the subject of the email @d The body of the email 
  @g mailto:sam@what.com?cc=joe@when.net\&subject=!summary!\&body=!d!
  

Pressing G with this item selected would create a new message in your email application with "To: sam@what.com", "Cc: joe@when.net", "Subject: The subject of the email" and "The body of the email" already entered.

Tip. Have a pdf file with the agenda for a meeting? Stick an @g entry with the path to the file in the event you create for the meeting. Then whenever the meeting is selected, G will bring up the agenda.

@h history

Used internally with task groups to track completion done;due pairs.

@i invitees

An email address or a list of email addresses for people participating in the item. These email addresses will be appended to the list of recipients for email alerts.

@j job

Component tasks or jobs within a task group are given by @j job entries. @key value entries prior to the first @j become the defaults for the jobs that follow. &key value entries given in jobs use & rather than @ and apply only to the specific job.

Many key-value pairs can be given either in the group task using @ or in the component jobs using &:

@c or &c    context
@d or &d    description
@e or &e    extent
@f or &f    done[; due] datetime
@k or &k    keyword
@l or &l    location
@u or &u    user

The key-value pair &h is used internally to track job done;due completions in task groups.

The key-value pair &q (queue position) can only be given in component jobs where it is required. Key-values other than &q and those listed above, can only be given in the initial group task entry and their values are inherited by the component jobs.

@k keyword

A heirarchical classifier for the item. Intended for actions to support time billing where a common format would be client:job:category. etm treats such a keyword as a heirarchy so that an action report grouped by month and then keyword might appear as follows

    27.5h) Client 1 (3)
        4.9h) Project A (1)
        15h) Project B (1)
        7.6h) Project C (1)
    24.2h) Client 2 (3)
        3.1h) Project D (1)
        21.1h) Project E (2)
            5.1h) Category a (1)
            16h) Category b (1)
    4.2h) Client 3 (1)
    8.7h) Client 4 (2)
        2.1h) Project F (1)
        6.6h) Project G (1)

An arbitrary number of heirarchical levels in keywords is supported.

@l location

The location at which, for example, an event will take place.

@m memo

Further information about the item not included in the summary or the description. Since the summary is used as the subject of an email alert and the description is commonly included in the body of an email alert, this field could be used for information not to be included in the email.

@n noshow

Only tasks of type "-" or "%". A value or list of values from d, k, and t, that specify views in which the task should not be shown.

d) day

Do not display the task in the day views: agenda, week and month.

Since an undated task would not appear in week or month view, using "d" for such a task only prevents it from being displayed in the "next" section of agenda view. Using "d" for a dated task, on the other hand, prevents it from being displayed in the day lists of agenda, week and month views as well as the "now" section of agenda view.

k) keyword

Do not display the task in keyword view.

t) tag

Do not display the task in tag view.

E.g., with the entry "@n d, k, t", a task would appear only in path view.

This can provide a "poor man's cron" in which a repeating task with a process alert could be used to run a process at specified times without cluttering the etm views unnecessarily. It could also be used to trigger a periodic reminder during the day to, e.g., take a prescription medication, without filling your day lists.

Tip. Want to be reminded when a meeting should end without seeing an extra reminder for the meeting in your day lists? Create a task with a sound alert at the ending time and then add "@n d" to hide it from your day lists and, optionally, "@o s" to automatically remove past due instances.

@o overdue

Repeating tasks only. One of the following choices: k) keep, r) restart, or s) skip. Details below.

@p priority

Either 0 (no priority) or an integer between 1 (highest priority) and 9 (lowest priority). Primarily used with undated tasks.

@q datetime

Used to provide a timestamp for an item. Intended primarily to provide a first-in-first-out queue for related, undated tasks. E.g., the following

- first in queue @c queue @q 2015-10-06 10a @z US/Eastern
- second in queue @c queue @q 2015-10-07 12p @z US/Eastern
- third in queue @c queue @q 2015-10-08 9a @z US/Eastern
- fourth in queue @c queue @q 2015-10-09 8a @z US/Eastern

would appear in Agenda view at 11:35am on 2015-10-09 grouped by the context "queue" and ordered by age with the oldest first:

Next
   ...
   queue
       - first in queue                           3d2h       
       - second in queue                        1d23h35m     
       - third in queue                          1d2h35m     
       - fourth in queue                          3h35m      

@r repetition rule

The specification of how an item is to repeat. Repeating items must have an @s entry as well as one or more @r entries. Generated datetimes are those satisfying any of the @r entries and falling on or after the datetime given in @s. Note that the datetime given in @s will only be included if it matches one of the datetimes generated by the @r entry.

A repetition rule begins with

@r frequency

where frequency is one of the following characters:

y       yearly
m       monthly
w       weekly
d       daily
h       hourly
n       minutely
l       list (a list of datetimes will be provided using @+)

The @r frequency entry can, optionally, be followed by one or more &key value pairs:

&i: interval (positive integer, default = 1) E.g, with frequency w, interval
    3 would repeat every three weeks.
&t: total (positive integer) Include no more than this number of repetitions.
&s: bysetpos (integer). When multiple dates satisfy the rule, take the date 
    from this position in the list, e.g, &s 1 would choose the first element 
    and &s -1 the last. See the payday example below for an illustration of 
    bysetpos.
&u: until  (datetime) Only include repetitions with starting times falling 
    on before this datetime.
&M: bymonth (1, 2, ..., 12)
&m: bymonthday (1, 2, ..., 31) Use, e.g., -1 for the last day of the month.
&W: byweekno (1, 2, ..., 53)
&w: byweekday (*English* weekday abbreviation SU ... SA). Use, e.g., 3WE 
    for the 3rd Wednesday or -1FR, for the last Friday in the month.
&h: byhour (0 ... 23)
&n: byminute (0 ... 59)
&E: byeaster (integer number of days before, < 0, or after, > 0, Easter)

Repetition examples:

  • 1st and 3rd Wednesdays of each month.

    ^ 1st and 3rd Wednesdays
      @r m &w 1WE, 3WE
  • Payday (an occasion) on the last week day of each month. (The &s -1 entry extracts the last date which is both a weekday and falls within the last three days of the month.)

    ^ payday @s 2010-07-01
      @r m &w MO, TU, WE, TH, FR &m -1, -2, -3 &s -1
  • Take a prescribed medication daily (an event) from the 23rd through the 27th of the current month at 10am, 2pm, 6pm and 10pm and trigger an alert zero minutes before each event.

    * take Rx @d 10a 23  @r d &u 11p 27 &h 10, 14 18, 22 @a 0
  • Vote for president (an occasion) every four years on the first Tuesday after a Monday in November. (The &m range(2,9) requires the month day to fall within 2 ... 8 and thus, combined with &w TU to be the first Tuesday following a Monday.)

    ^ Vote for president @s 2012-11-06
      @r y &i 4 &M 11 &m range(2,9) &w TU
  • Ash Wednesday (an occasion) that occurs 46 days before Easter each year.

    ^ Ash Wednesday 2010-01-01 @r y &E -46

  • Easter Sunday (an occasion).

    ^ Easter Sunday 2010-01-01 @r y &E 0

A repeating task may optionally also include an @o <k|s|r> entry (default = k):

  • @o k: Keep the current due date if it becomes overdue and use the next due date from the recurrence rule if it is finished early. This would be appropriate, for example, for the task 'file tax return'. The return due April 15, 2009 must still be filed even if it is overdue and the 2010 return won't be due until April 15, 2010 even if the 2009 return is finished early.

  • @o s: Skip overdue due dates and set the due date for the next repetition to the first due date from the recurrence rule on or after the current date. This would be appropriate, for example, for the task 'put out the trash' since there is no point in putting it out on Tuesday if it's picked up on Mondays. You might just as well wait until the next Monday to put it out. There's also no point in being reminded until the next Monday.

  • @o r: Restart the repetitions based on the last completion date. Suppose you want to mow the grass once every ten days and that when you mowed yesterday, you were already nine days past due. Then you want the next due date to be ten days from yesterday and not today. Similarly, if you were one day early when you mowed yesterday, then you would want the next due date to be ten days from yesterday and not ten days from today.

@s starting datetime

When an action is started, an event begins or a task is due.

@t tags

A tag or list of tags for the item.

@u user

Intended to specify the person to whom a delegated task is assigned. Could also be used in actions to indicate the person performing the action.

@v action_rates key

Actions only. A key from action_rates in your etmtk.cfg to apply to the value of @e. Used in actions to apply a billing rate to time spent in an action. E.g., with

    minutes: 6
    action_rates:
        br1: 45.0
        br2: 60.0

then entries of @v br1 and @e 2h25m in an action would entail a value of 45.0 * 2.5 = 112.50.

@w action_markups key

A key from action_markups in your etmtk.cfg to apply to the value of @x. Used in actions to apply a markup rate to expense in an action. E.g., with

    weights:
        mr1: 1.5
        mr2: 10.0

then entries of @w mr1 and @x 27.50 in an action would entail a value of 27.50 * 1.5 = 41.25.

@x expense

Actions only. A currency amount such as 27.50. Used in conjunction with @w above to bill for action expenditures.

@z time zone

The time zone of the item, e.g., US/Eastern. The starting and other datetimes in the item will be interpreted as belonging to this time zone.

Tip. You live in the US/Eastern time zone but a flight that departs Sydney on April 20 at 9pm bound for New York with a flight duration of 14 hours and 30 minutes. The hard way is to convert this to US/Eastern time and enter the flight using that time zone. The easy way is to use Australia/Sydney and skip the conversion:

* Sydney to New York @s 2014-04-23 9pm @e 14h30m @z Australia/Sydney

This flight will be displayed while you're in the Australia/Sydney time zone as extending from 9pm on April 23 until 11:30am on April 24, but in the US/Eastern time zone it will be displayed as extending from 7am until 9:30pm on April 23.

@+ include

A datetime, e.g., @+ 20150420T0930, or list of datetimes to be added to the repetitions generated by @r rrule entries. If only a date is provided, 12:00am is assumed.

@- exclude

A datetime or list of datetimes to be removed from the repetitions generated by @r rrule entries. If only a date is provided, 12:00am is assumed.

Note that to exclude a datetime from the recurrence rule, the @- datetime must exactly match both the date and time generated by one of the @r rrule entries.

Example of using @- and @+:

@s 2014-02-19 4pm
@r m &w 3WE 
@+ 20140924T1600, 20141029T1600 
@- 20140917T1600, 20141015T1600

Dates

Fuzzy dates

When either a datetime or an time period is to be entered, special formats are used in etm. Examples include entering a starting datetime for an item using @s and jumping to a date using Ctrl-J.

Suppose, for example, that it is currently 8:30am on Friday, February 15, 2013. Then, fuzzy dates would expand into the values illustrated below.

    mon 2p or mon 14h    2:00pm Monday, February 19
    fri                  12:00am Friday, February 15
    9a -1/1 or 9h -1/1   9:00am Tuesday, January 1
    +2/15                12:00am Monday, April 15 2013
    8p +7 or 20h +7      8:00pm Friday, February 22
    -14                  8:30am Friday, February 1
    now                  8:30am Friday, February 15

Note that expressions using + or - give datetimes relative to the current datetime.

12am is the default time when a time is not explicity entered. E.g., +2/15 in the examples above gives 12:00am on April 15.

To avoid ambiguity, always append either 'a', 'p' or 'h' when entering an hourly time, e.g., use 1p or 13h.

Time periods

Time periods are entered using the format WwDdHhMm where W, D, H and M are integers and w, d, h and m refer to weeks, days, hours and minutes respectively. For example:

    2h30m                2 hours, 30 minutes
    2w3d                 2 weeks, 3 days
    45m                  45 minutes

As an example, if it is currently 8:50am on Friday February 15, 2013, then entering now + 2d4h30m into the date calculator would give 2013-02-17 1:20pm.

Tip. Need to schedule a reminder in 15 minutes? Use @s +15m.

Time zones

Dates and times are always stored in etm data files as times in the time zone given by the entry for @z. On the other hand, dates and times are always displayed in etm using the local time zone of the system.

For example, if it is currently 8:50am EST on Friday February 15, 2013, and an item is saved on a system in the US/Eastern time zone containing the entry

@s now @z Australia/Sydney

then the data file would contain

@s 2013-02-16 12:50am @z Australia/Sydney

but this item would be displayed as starting at 8:50am 2013-02-15 on the system in the US/Eastern time zone.

Tip. Need to determine the flight time when the departing timezone is different that the arriving timezone? The date calculator (shortcut Shift-D) will accept timezone information so that, e.g., entering the arrival time minus the departure time

4/20 6:15p US/Central - 4/20 4:50p Asia/Shanghai

into the calculator would give

14h25m

as the flight time.

Anniversary substitutions

An anniversary substitution is an expression of the form !YYYY! that appears in an item summary. Consider, for example, the occassion

^ !2010! anniversary @s 2011-02-20 @r y

This would appear on Feb 20 of 2011, 2012, 2013 and 2014, respectively, as 1st anniversary, 2nd anniversary, 3rd anniversary and 4th anniversary. The suffixes, st, nd and so forth, depend upon the translation file for the locale.

Easter

An expression of the form easter(yyyy) can be used as a date specification in @s entries and in the datetime calculator. E.g.

@s easter(2014) 4p

would expand to 2014-04-20 4pm. Similarly, in the date calculator

easter(2014) - 48d

(Rose Monday) would return 2014-03-03. In repeating items easter(yyyy) is replaced by &E, e.g.,

^ Easter Sunday @s 2010-01-01 @r y &E 0
^ Ash Wednesday @s 2010-01-01 @r y &E -46
^ Rose Monday @s 2010-01-01 @r y &E -48

Preferences

Configuration options are stored in a file named etmtk.cfg which, by default, belongs to the folder .etm in your home directory. When this file is edited in etm (Shift Ctrl-P), your changes become effective as soon as they are saved --- you do not need to restart etm. These options are listed below with illustrative entries and brief descriptions.

Template expansions

The following template expansions can be used in alert_displaycmd, alert_template, alert_voicecmd, email_template, sms_message and sms_subject below.

  • !summary!

the item's summary (this will be used as the subject of email and message alerts)

  • !start_date!

the starting date of the event

  • !start_time!

the starting time of the event

  • !time_span!

the time span of the event (see below)

  • !alert_time!

the time the alert is triggered

  • !time_left!

the time remaining until the event starts

  • !when!

the time remaining until the event starts as a sentence (see below)

  • !next!

how long before the starting time the next alert will be triggered

  • !next_alert!

how long before the starting time the next alert will be triggered as a sentence (see below)

  • !d!

the item's @d (description)

  • !l!

the item's @l (location)

The value of !next! for some illustrative cases:

  • The current alert is the last

    !next!: None

    !next_alert!: 'This is the last alert.'

  • The next alert is at the start time

    !next!: 'at the start time'

    !next_alert!: 'The next alert is at the start time.'

  • The next alert is 5 minutes before the start time:

    !next!: '5 minutes before the start time'

    !next_alert!: 'The next alert is 5 minutes before the start time.'

The value of !time_span! depends on the starting and ending datetimes. Here are some examples:

  • if the start and end datetimes are the same (zero extent): 10am Wed, Aug 4

  • else if the times are different but the dates are the same: 10am - 2pm Wed, Aug 4

  • else if the dates are different: 10am Wed, Aug 4 - 9am Thu, Aug 5

  • additionally, the year is appended if a date falls outside the current year:

    10am - 2pm Thu, Jan 3 2013
    10am Mon, Dec 31 - 2pm Thu, Jan 3 2013

Here are values of !time_left! and !when! for some illustrative periods:

  • 2d3h15m

    time_left : '2 days 3 hours 15 minutes'
    when      : '2 days 3 hours 15 minutes from now'
  • 20m

    time_left : '20 minutes'
    when      : '20 minutes from now'
  • 0m

    time_left : ''
    when      : 'now'

Note that 'now', 'from now', 'days', 'day', 'hours' and so forth are determined by the translation file in use.

Options

action_interval

action_interval: 1

Every action_interval minutes, execute action_timercmd when the timer is running and action_pausecmd when the timer is paused. Choose zero to disable executing these commands.

action_keys

action_keys: "k"

When klone is used to create an action timer, copy the values of the @-keys in this string from the item to the timer. With the default, "k", klone will copy the item's @k entry, if there is one, in addition to the summary when creating the action. Replacing "k", with "c" would cause klone to copy the item's @c entry in addition to the summary. With "ck", both the @c and @k entries would be copied. Any key that is valid for an action can be used.

E.g., when

- my task @c my context @k my keyword @t my tag

is selected, then the default would create a timer with the name my task @k my keyword.

action_markups

action_markups:
    default: 1.0
    mu1: 1.5
    mu2: 2.0

Possible markup rates to use for @x expenses in actions. An arbitrary number of rates can be entered using whatever labels you like. These labels can then be used in actions in the @w field so that, e.g.,

... @x 25.80 @w mu1 ...

in an action would give this expansion in an action template:

!expense! = 25.80
!charge! = 38.70

action_minutes

action_minutes: 6

Round action times up to the nearest action_minutes in action custom view. Possible choices are 1, 6, 12, 15, 30 and 60. With 1, no rounding is done and times are reported as integer minutes. Otherwise, the prescribed rounding is done and times are reported as floating point hours.

action_rates

action_rates:
    default: 30.0
    br1: 45.0
    br2: 60.0

Possible billing rates to use for @e times in actions. An arbitrary number of rates can be entered using whatever labels you like. These labels can then be used in the @v field in actions so that, e.g., with action_minutes: 6 then:

... @e 75m @v br1 ...

in an action would give these expansions in an action template:

!hours! = 1.3
!value! = 58.50

If the label default is used, the corresponding rate will be used when @v is not specified in an action.

Note that etm accumulates group totals from the time and value of individual actions. Thus

... @e 75m @v br1 ...
... @e 60m @v br2 ...

would aggregate to

!hours!  = 2.3     (= 1.3 + 1)
!value! = 118.50   (= 1.3 * 45.0 + 1 * 60.0)

action_template

action_template: '!hours!h) !label! (!count!)'

Used for action type custom view. With the above settings for action_minutes and action_template, a custom view might appear as follows:

27.5h) Client 1 (3)
    4.9h) Project A (1)
    15h) Project B (1)
    7.6h) Project C (1)
24.2h) Client 2 (3)
    3.1h) Project D (1)
    21.1h) Project E (2)
        5.1h) Category a (1)
        16h) Category b (1)
4.2h) Client 3 (1)
8.7h) Client 4 (2)
    2.1h) Project F (1)
    6.6h) Project G (1)

Available template expansions for action_template include:

  • !label!: the item or group label.

  • !count!: the number of children represented in the reported item or group.

  • !minutes!: the total time from @e entries in minutes rounded up using the setting for action_minutes.

  • !hours!: if action_minutes = 1, the time in hours and minutes. Otherwise, the time in floating point hours.

  • !value!: the billing value of the rounded total time. Requires an action entry such as @v br1 and a setting for action_rates.

  • !expense!: the total expense from @x entries.

  • !charge!: the billing value of the total expense. Requires an action entry such as @w mu1 and a setting for action_markups.

  • !total!: the sum of !value! and !charge!.

Note: when aggregating amounts in action type custom view, billing and markup rates are applied first to times and expenses for individual actions and the resulting amounts are then aggregated. Similarly, when times are rounded up, the rounding is done for individual actions and the results are then aggregated.

action_timer

action_timer:
    paused: 'play ~/.etm/sounds/timer_paused.wav'
    running: 'play ~/.etm/sounds/timer_running.wav'

The command running is executed every action_interval minutes whenever the action timer is running and paused every minute when the action timer is paused.

agenda

agenda_days: 2
agenda_colors: 2
agenda_indent: 2
agenda_omit: [ac, fn, ns]
agenda_width1: 43
agenda_width2: 17

Sets the number of days to display in agenda view and other parameters affecting the display in the CLI. The colors setting only affects output to current_html. Items in agenda_omit will not be displayed in the agenda day list. Possible choices include:

  • ac: actions

  • by: begin by warnings

  • fn: finished tasks

  • ns: notes (dated)

  • oc: occasions

alert_default

alert_default: [m]

The alert or list of alerts to be used when an alert is specified for an item but the type is not given. Possible values for the list include:

  • d: display (requires alert_displaycmd)

  • m: message (using alert_template)

  • s: sound (requires alert_soundcmd)

  • v: voice (requires alert_voicecmd)

alert_displaycmd

alert_displaycmd: growlnotify -t !summary! -m '!time_span!'

The command to be executed when d is included in an alert. Possible template expansions are discussed at the beginning of this tab.

alert_soundcmd

alert_soundcmd: 'play ~/.etm/sounds/etm_alert.wav'

The command to execute when s is included in an alert. Possible template expansions are discussed at the beginning of this tab.

alert_template

alert_template: '!time_span!\n!l!\n\n!d!'

The template to use for the body of m (message) alerts. See the discussion of template expansions at the beginning of this tab for other possible expansion items.

alert_voicecmd

alert_voicecmd: say -v 'Alex' '!summary! begins !when!.'

The command to be executed when v is included in an alert. Possible expansions are are discussed at the beginning of this tab.

alert_wakecmd

alert_wakecmd: ~/bin/SleepDisplay -w

If given, this command will be issued to "wake up the display" before executing alert_displaycmd.

ampm

ampm: true

Use ampm times if true and twenty-four hour times if false. E.g., 2:30pm (true) or 14:30 (false).

completions_width

completions_width: 36

The width in characters of the auto completions popup window.

calendars

calendars:
- [dag, true, personal/dag]
- [erp, false, personal/erp]
- [shared, true, shared]

These are (label, default, path relative to datadir) tuples to be interpreted as separate calendars. Those for which default is true will be displayed as default calendars. E.g., with the datadir below, dag would be a default calendar and would correspond to the absolute path /Users/dag/.etm/data/personal/dag. With this setting, the calendar selection dialog would appear as follows:

When non-default calendars are selected, busy times in the "week view" will appear in one color for events from default calendars and in another color for events from non-default calendars.

Only data files that belong to one of the calendar directories or their subdirectories will be accessible within etm.

cfg_files

cfg_files:
    - completions: []
    - reports:     []
    - users:       []

Each of the three list brackets can contain one or more comma separated absolute file paths. Additionally, paths corresponding to active calendars in the datadir directory are searched for files named completions.cfg, reports.cfg and users.cfg and these are processed in addition to the ones from cfg_files.

Note. Windows users should place each absolute path in quotes and escape backslashes, i.e., use \\ anywhere \ appears in a path.

  • Completions

    Each line in a completions file provides a possible completion when using the editor. E.g. with these completions

    @c computer
    @c home
    @c errands
    @c office
    @c phone
    @z US/Eastern
    @z US/Central
    @z US/Mountain
    @z US/Pacific
    dnlgrhm@gmail.com

    entering, for example, "@c" in the editor and pressing Ctrl-Space, would popup a list of possible completions. Choosing the one you want and pressing Return would insert it and close the popup.

    Up and down arrow keys change the selection and either Tab or Return inserts the selection.

  • Reports

    Each line in a reports file provides a possible reports specification. These are available when using the CLI m command and in the GUI custom view. See Custom view for details.

  • Users

    User files contain user (contact) information in a free form, text database. Each entry begins with a unique key for the person and is followed by detail lines each of which begins with a minus sign and contains some detail about the person that you want to record. Any detail line containing a colon should be quoted, e.g.,

    jbrown:
    - Brown, Joe
    - jbr@whatever.com
    - 'home: 123 456-7890'
    - 'birthday: 1978-12-14'
    dcharles:
    - Charles, Debbie
    - dch@sometime.com
    - 'cell: 456 789-0123'
    - 'spouse: Rebecca'

    Keys from this file are added to auto-completions so that if you type, say, @u jb and press Ctrl-Space, then @u jbrown would be offered for completion.

    If an item with the entry @u jbrown is selected in the GUI, you can press "u" to see a popup with the details:

    Brown, Joe
    jbr@whatever.com
    home: 123 456-7890
    birthday: 1978-12-14

countdown timer

countdown_command: ''
countdown_minutes: 10

If countdown_command is given, it will be executed when the timer expires; otherwise a beep will be sounded. The default number of minutes for a countdown is given by countdown_minutes. When a timer is active, the time that the timer will expire is displayed in the status bar using the format -H:M:S(am/pm). When a countdown and a snooze timer are both active, the one that will expire first is displayed in the status bar.

current files

current_htmlfile:  ''
current_textfile:  ''
current_icsfolder:  ''
current_indent:    3
current_opts:      ''
current_width1:    40
current_width2:    17

If absolute file paths are entered for current_textfile and/or current_htmlfile, then these files will be created and automatically updated by etm as as plain text or html files, respectively. If current_opts is given then the file will contain a report using these options; otherwise the file will contain an agenda. Indent and widths are taken from these setting with other settings, including color, from report or agenda, respectively.

If an absolute path is entered for current_icsfolder, then ics files corresponding to the entries in calendars will be created in this folder and updated as necessary. If there are no entries in calendars, then a single file, all.ics, will be created in this folder and updated as necessary.

Hint: fans of geektool can use the shell command cat <current_textfile> to have the current agenda displayed on their desktops.

datadir

datadir: ~/.etm/data

All etm data files are in this directory.

dayfirst

dayfirst: false

If dayfirst is False, the MM-DD-YYYY format will have precedence over DD-MM-YYYY in an ambiguous date. See also yearfirst.

details_rows

details_rows: 4

The number of rows to display in the bottom, details panel of the main window.

display_idletime

display_idletime: True

Show idle time in the status bar by default if True. Display can be toggled on and off in the File/Timer menu. Idle time is accumulated when there are are one or more active timers and none are running.

early_hour

early_hour: 6

When scheduling an event or action with a starting time that begins before this hour, append the query "Is __ the starting time you intended?" to the confirmation. Use 0 to disable this warning altogether. The default, 6, will warn for starting times before 6am.

edit_cmd

edit_cmd: ~/bin/vim !file! +!line!

This command is used in the command line version of etm to create and edit items. When the command is expanded, !file! will be replaced with the complete path of the file to be edited and !line! with the starting line number in the file. If the editor will open a new window, be sure to include the command to wait for the file to be closed before returning, e.g., with vim:

edit_cmd: ~/bin/gvim -f !file! +!line!

or with sublime text:

edit_cmd: ~/bin/subl -n -w !file!:!line!

email_template

email_template: 'Time: !time_span!
Locaton: !l!


!d!'

Note that two newlines are required to get one empty line when the template is expanded. This template might expand as follows:

    Time: 1pm - 2:30pm Wed, Aug 4
    Location: Conference Room

    <contents of @d>

See the discussion of template expansions at the beginning of this tab for other possible expansion items.

etmdir

etmdir: ~/.etm

Absolute path to the directory for etmtk.cfg and other etm configuration files.

exportdir

exportdir: ~/.etm

Absolute path to the directory for exported CSV files.

encoding

encoding: {file: utf-8, gui: utf-8, term: utf-8}

The encodings to be used for file IO, the GUI and terminal IO.

filechange_alert

filechange_alert: 'play ~/.etm/sounds/etm_alert.wav'

The command to be executed when etm detects an external change in any of its data files. Leave this command empty to disable the notification.

fontsize_fixed

fontsize_fixed: 0

Use this font size in the details panel, editor and reports. Use 0 to keep the system default.

fontsize_tree

fontsize_tree: 0

Use this font size in the gui treeviews. Use 0 to keep the system default.

Tip: Leave the font sizes set to 0 and run etm with logging level 2 to see the system default sizes.

freetimes

freetimes:
    opening:  480  # 8*60 minutes after midnight = 8am
    closing: 1020  # 17*60 minutes after midnight = 5pm
    minimum:   30  # 30 minutes
    buffer:    15  # 15 minutes

Only display free periods between opening and closing that last at least minimum minutes and preserve at least buffer minutes between events. Note that each of these settings must be an interger number of minutes.

E.g., with the above settings and these busy periods:

Busy periods in Week 16: Apr 14 - 20, 2014
------------------------------------------
Mon 14: 10:30am-11:00am; 12:00pm-1:00pm; 5:00pm-6:00pm
Tue 15: 9:00am-10:00am
Wed 16: 8:30am-9:30am; 2:00pm-3:00pm; 5:00pm-6:00pm
Thu 17: 11:00am-12:00pm; 6:00pm-7:00pm; 7:00pm-9:00pm
Fri 18: 3:00pm-4:00pm; 5:00pm-6:00pm
Sat 19: 9:00am-10:30am; 7:30pm-10:00pm

This would be the corresponding list of free periods:

Free periods in Week 16: Apr 14 - 20, 2014
------------------------------------------
Mon 14: 8:00am-10:15am; 11:15am-11:45am; 1:15pm-4:45pm
Tue 15: 8:00am-8:45am; 10:15am-5:00pm
Wed 16: 9:45am-1:45pm; 3:15pm-4:45pm
Thu 17: 8:00am-10:45am; 12:15pm-5:00pm
Fri 18: 8:00am-2:45pm; 4:15pm-4:45pm
Sat 19: 8:00am-8:45am; 10:45am-5:00pm
Sun 20: 8:00am-5:00pm
----------------------------------------
Only periods of at least 30 minutes are displayed.

When displaying free times in week view you will be prompted for the shortest period to display using the setting for minimum as the default.

Tip: Need to tell someone when you're free in a given week? Jump to that week in week view, press Ctrl-F, set the minimum period and then copy and paste the resulting list into an email.

iCalendar settings

icscal_file

If an item is not selected, pressing Shift-X in the gui will export the active calendars in iCalendar format to this file.

icscal_file: ~/.etm/etmcal.ics

icsitem_file

If an item is selected, pressing Shift-X in the gui will export the selected item in iCalendar format to this file.

icsitem_file: ~/.etm/etmitem.ics

icssync_folder

icssync_folder: ''

A relative path from etmdata to a folder. If given, files in this folder with the extension .txt and .ics will automatically kept concurrent using export to iCalendar and import from iCalendar. I.e., if the .txt file is more recent than than the .ics then the .txt file will be exported to the .ics file. On the other hand, if the .ics file is more recent then it will be imported to the .txt file. In either case, the contents of the file to be updated will be overwritten with the new content and the last acess/modified times for both will be set to the current time.

Note that the calendar application you use to modify the .ics file will impose restrictions on the subsequent content of the .txt file. E.g., if the .txt file has a note entry, then this note will be exported by etm as a VJOURNAL entry to the .ics file. But VJOURNAL entries are not be recognized by many (most) calendar apps. When importing this file to such an application, the note will be omitted and thus will be missing from the .ics file after the next export from the application. The note will then be missing from the .txt file as well after the next automatic update. Restricting the content to events should be safe with with any calendar application.

Additionally, if an absolute path is entered for current_icsfolder, then ics files corresponding to the entries in calendars will be created in this folder and updated as necessary. If there are no entries in calendars, then a single file, all.ics, will be created in this folder and updated as necessary.

ics_subscriptions

ics_subscriptions: []

A list of (URL, path) tuples for automatic updates. The URL is a calendar subscription, e.g., for a Google Calendar subscription the entry might be something like:

ics_subscriptions:
    - ['https://www.google.com/calendar/ical/.../basic.ics', 'personal/dag/google.txt']
    

With this entry, pressing Shift-M in the gui would import the calendar from the URL, convert it from ics to etm format and then write the result to personal/google.txt in the etm data directory. Note that this data file should be regarded as read-only since any changes made to it will be lost with the next subscription update.

local_timezone

local_timezone: US/Eastern

This timezone will be used as the default when a value for @z is not given in an item.

message_last

message_last: 0

The number of seconds to display the message alert for an item before closing it when it is the last. With 0, the message dialog will be kept open indefinitely.

message_next

message_next: 0

The number of seconds to display the message alert for an item before closing it when it is not the last alert. With 0, the message dialog will be kept open indefinitely.

monthly

monthly: monthly

Relative path from datadir. With the settings above and for datadir the suggested location for saving new items in, say, October 2012, would be the file:

~/.etm/data/monthly/2012/10.txt

The directories monthly and 2012 and the file 10.txt would, if necessary, be created. The user could either accept this default or choose a different file.

outline_depth

outline_depth: 2

The default outline depth to use when opening keyword, note, path or tag view. Once any view is opened, use Ctrl-O to change the depth for that view.

prefix

prefix: "\n  "
prefix_uses: "rj+-tldm"

Apply prefix (whitespace only) to the @keys in prefix_uses when displaying and saving items. The default would cause the selected elements to begin on a newline and indented by two spaces. E.g.,

+ summary @s 2014-05-09 12am @z US/Eastern
  @m memo
  @j job 1 &f 20140510T1411;20140509T0000 &q 1
  @j job 2 &f 20140510T1412;20140509T0000 &q 2
  @j job 3 &q 3
  @d description

report

report_begin:           '1'
report_end:             '+1/1'
report_colors:          2
report_width1:          61
report_width2:          19

Report begin and end are fuzzy parsed dates specifying the default period for reports that group by dates. Each line in the file specified by report_specifications provides a possible specification for a report. E.g.

a MMM yyyy; k[0]; k[1:] -b -1/1 -e 1
a k, MMM yyyy -b -1/1 -e 1
c ddd MMM d yyyy
c f

In custom view these appear in the report specifications pop-up list. A specification from the list can be selected and, perhaps, modified or an entirely new specification can be entered. See Custom view for details. See also the agenda settings above.

retain_ids

retain_ids: false

If true, the unique ids that etm associates with items will be written to the data files and retained between sessions. If false, new ids will be generated for each session.

Retaining ids enables etm to avoid duplicates when importing and exporting iCalendar files.

show_finished

show_finished: 1

Show this many of the most recent completions of repeated tasks or, if 0, show all completions.

smtp

smtp_from: dnlgrhm@gmail.com
smtp_id: dnlgrhm
smtp_pw: **********
smtp_server: smtp.gmail.com

Required settings for the smtp server to be used for email alerts.

sms

sms_message: '!summary!'
sms_subject: '!time_span!'
sms_from: dnlgrhm@gmail.com
sms_pw:  **********
sms_phone: 0123456789@vtext.com
sms_server: smtp.gmail.com:587

Required settings for text messaging in alerts. Enter the 10-digit area code and number and mms extension for the mobile phone to receive the text message when no numbers are specified in the alert. The illustrated phone number is for Verizon. Here are the mms extensions for the major carriers:

Alltel          @message.alltel.com
AT&T            @txt.att.net
Nextel          @messaging.nextel.com
Sprint          @messaging.sprintpcs.com
SunCom          @tms.suncom.com
T-mobile        @tmomail.net
VoiceStream     @voicestream.net
Verizon         @vtext.com

snooze

snooze_command: ''
snooze_minutes: 10

If snooze_command is given, it will be executed when the timer expires; otherwise a beep will be sounded. The default number of minutes for a snooze is given by snooze_minutes. When a snooze timer is active, the time that the timer will expire is displayed in the status bar in the format +H:M:S(am/pm). When a countdown and a snooze timer are both active, the one that will expire first is displayed in the status bar.

style

style: default

The style to be used for Tk/Tcl widgets. Options for linux include clam, alt, default and classic. Options for OSX add aqua. Note that aqua does not support background colors for buttons and may not be suitable with darker background colors.

sundayfirst

sundayfirst: false

The setting affects only the twelve month calendar display.

update_minutes

update_minutes: 15

Update current_html, current_text and the files in icssync_folder when the number of minutes past the hour modulo update_minutes is equal to zero. I.e. with the default, the update would occur on the hour and at 15, 30 and 45 minutes past the hour. Acceptable settings are integers between 1 and 59. Note that with a setting greater than or equal to 30, the update will occur only twice each hour.

vcs_settings

vcs_settings:
  command: ''
  commit: ''
  dir: ''
  file: ''
  history: ''
  init: ''
  limit: ''

These settings are ignored unless the setting for vcs_system below is either git or mercurial.

Default values will be provided for these settings based on the choice of vcs_system below. Any of the settings that you define here will overrule the defaults.

Here, for example, are the default values of these settings for git under OS X:

vcs_settings:
    command: '/usr/bin/git --git-dir {repo} --work-dir {work}'
    commit: '/usr/bin/git --git-dir {repo} --work-dir {work} add */\*.txt
        && /usr/bin/git --git-dir {repo} --work-dir {work} commit -a -m "{mesg}"'
    dir: '.git'
    file: ''
    history: '/usr/bin/git -git-dir {repo} --work-dir {work} log
        --pretty=format:"- %ar: %an%n%w(70,0,4)%s" -U1  {numchanges}
            {file}'
    init: '/usr/bin/git init {work}; /usr/bin/git -git-dir {repo}
        --work-dir {work} add */\*.txt; /usr/bin/git-git-dir {repo}
            --work-dir {work} commit -a -m "{mesg}"'
    limit: '-n'

In these settings, {mesg} will be replaced with an internally generated commit message, {numchanges} with an expression that depends upon limit that determines how many changes to show and, when a file is selected, {file} with the corresponding path. If ~/.etm/data is your etm datadir, the {repo} would be replaced with ~/.etm/data/.git and {work} with ~/.etm/data.

Leave these settings empty to use the defaults.

vcs_system

vcs_system: ''

This setting must be either '' or git or mercurial.

If you specify either git or mercurial here (and have it installed on your system), then etm will automatically commit any changes you make to any of your data files. The history of these changes is available in the GUI with the show changes command (Ctrl-H) and you can, of course, use any git or mercurial commands in your terminal to, for example, restore a previous version of a file.

weeks_after

weeks_after: 52

In the day view, all non-repeating, dated items are shown. Additionally all repetitions of repeating items with a finite number of repetitions are shown. This includes 'list-only' repeating items and items with &u (until) or &t (total number of repetitions) entries. For repeating items with an infinite number of repetitions, those repetitions that occur within the first weeks_after weeks after the current week are displayed along with the first repetition after this interval. This assures that for infrequently repeating items such as voting for president, at least one repetition will be displayed.

yearfirst

yearfirst: true

If yearfirst is true, the YY-MM-DD format will have precedence over MM-DD-YY in an ambiguous date. See also dayfirst.

Custom view

You can create a custom display of your items using either the custom view in the GUI or the "c" command in the CLI. In both cases, you enter a specification that determines which items will be displayed and how they will be grouped and sorted.

A view specification begins with a type character, either a or c, followed by a groupby setting and then, perhaps, by one or more view options.

View type

  • a: action

    Actions only. Expenditures of time and money recorded in actions with output formatted using action_template computations and expansions. See Preferences for further details about the role of action_template in formatting actions output. Note that only actions are included in this view and, by default, all actions in your active calendars will be included.

  • c: composite

    Any item types, including actions, but without action_template computations and expansions. Note that only unfinished tasks and unfinished instances of repeating tasks will be displayed. By default, all items from your active calendars will be included.

Groupby setting

A semicolon separated list that determines how items will be grouped and sorted. Possible elements include elements from

  • c: context

  • f: file path

  • k: keyword

  • l: location

  • t: tag

  • u: user

and/or a date specifications where a date specification is either

  • w: week number

or a combination of one or more of the following:

  • yy: 2-digit year

  • yyyy: 4-digit year

  • MM: month: 01 - 12

  • MMM: locale specific abbreviated month name: Jan - Dec

  • MMMM: locale specific month name: January - December

  • dd: month day: 01 - 31

  • ddd: locale specific abbreviated week day: Mon - Sun

  • dddd: locale specific week day: Monday - Sunday

Note that the groupby specification affects which items will be displayed. Items that are missing an element specified in groupby will be omitted from the output. E.g., undated tasks and notes will be omitted when a date specification is included, items without keywords will be omitted when k is included and so forth. The latter behavior depends upon the value of the -m MISSING option.

When a date specification is not included in the groupby setting, undated notes and tasks will be potentially included, but only those instances of dated items that correspond to the relevant datetime of the item of the item will be included, where the relevant datetime is the past due date for any past due tasks, the starting datetime for any non-repeating item and the datetime of the next instance for any repeating item.

Within groups, items are automatically sorted by date, type and time.

Groupby examples

For example, the specification c ddd, MMM dd yyyy would group by year, month and day together to give output such as

Fri, Apr 1 2011
    items for April 1
Sat, Apr 2 2011
    items for April 2
...

On the other hand, the specification a w; u; k[0]; k[1:] would group by week number, user and keywords to give output such as

13.1) 2014 Week 14: Mar 31 - Apr 6
   6.3) agent 1
      1.3) client 1
         1.3) project 2
            1.3) Activity (12)
      5) client 2
         4.5) project 1
            4.5) Activity (21)
         0.5) project 2
            0.5) Activity (22)
   6.8) agent 2
      2.2) client 1
         2.2) project 2
            2.2) Activity (13)
      4.6) client 2
         3.9) project 1
            3.9) Activity (23)
         0.7) project 2
            0.7) Activity (23)

With the heirarchial elements, file path and keyword, it is possible to use parts of the element as well as the whole. Consider, for example, the file path A/B/C with the components [A, B, C]. Then for this file path:

f[0] = A
f[:2] = A/B
f[2:] = C
f = A/B/C

Suppose that keywords have the format client:project. Then grouping by year and month, then client and finally project to give output such as

specification: a MMM yyyy; u; k[0]; k[1] -b 1 -e +1/1

13.1) Feb 2014
   6.3) agent 1
      1.3) client 1
         1.3) project 2
            1.3) Activity 12
      5) client 2
         4.5) project 1
            4.5) Activity 21
         0.5) project 2
            0.5) Activity 22
   6.8) agent 2
      2.2) client 1
         2.2) project 2
            2.2) Activity 13
      4.6) client 2
         3.9) project 1
            3.9) Activity 23
         0.7) project 2
            0.7) Activity 23

View Options

View options are listed below. View type c supports all options except -d. Type a supports all options except -o. These options can be used to further limit which items are displayed.

-b BEGIN_DATE

Fuzzy parsed date. When a date specification is provided, limit the display of dated items to those with datetimes falling on or after this datetime. Relative day and month expressions can also be used so that, for example, -b -14 would begin 14 days before the current date and -b -1/1 would begin on the first day of the previous month. It is also possible to add (or subtract) a time period from the fuzzy date, e.g., -b mon + 7d would begin with the second Monday falling on or after today. Default: None.

-c CONTEXT

Regular expression. Limit the display to items with contexts matching CONTEXT (ignoring case). Prepend an exclamation mark, i.e., use !CONTEXT rather than CONTEXT, to limit the display to items which do NOT have contexts matching CONTEXT.

-d DEPTH

CLI only. In the GUI use View/Set outline depth. The default, -d 0, includes all outline levels. Use -d 1 to include only level 1, -d 2 to include levels 1 and 2 and so forth. This setting applies to the CLI only. In the GUI use the command set outline depth.

For example, modifying the specification above by adding -d 3 would give the following:

specification: a MMM yyyy; u; k[0]; k[1] -b 1 -e +1/1 -d 3

13.1) Feb 2014
   6.3) agent 1
      1.3) client 1
      5) client 2
   6.8) agent 2
      2.2) client 1
      4.6) client 2

-e END_DATE

Fuzzy parsed date. When a date specification is provided, limit the display of dated items to those with datetimes falling before this datetime. As with BEGIN_DATE relative month expressions can be used so that, for example, -b -1/1 -e 1 would include all items from the previous month. As with -b, period strings can be appended, e.g., -b mon -e mon + 7d would include items from the week that begins with the first Monday falling on or after today. Default: None.

-f FILE

Regular expression. Limit the display to items from files whose paths match FILE (ignoring case). Prepend an exclamation mark, i.e., use !FILE rather than FILE, to limit the display to items from files whose path does NOT match FILE.

-k KEYWORD

Regular expression. Limit the display to items with contexts matching KEYWORD (ignoring case). Prepend an exclamation mark, i.e., use !KEYWORD rather than KEYWORD, to limit the display to items which do NOT have keywords matching KEYWORD.

-l LOCATION

Regular expression. Limit the display to items with a location matching LOCATION (ignoring case). Prepend an exclamation mark, i.e., use !LOCATION rather than LOCATION, to limit the display to items which do NOT have a location that matches LOCATION.

-m MISSING

Either 0 (the default) or 1. When 1 include items that would otherwise be excluded because of a non-date, groupby specification. E.g., c k would omit items without a keyword entry, but c k -m 1 would include such items under a "None" heading. This option does not apply to date specifications, i.e., if a date specification is part of the groupby setting, then undated items will be excluded whatever the value of -m.

-o OMIT

String. Composite type only. Show/hide a)ctions, d)elegated tasks, e)vents, g)roup tasks, n)otes, o)ccasions, s)omeday items and/or t)asks. For example, -o on would show everything except occasions and notes and -o !on would show only occasions and notes.

-s SUMMARY

Regular expression. Limit the display to items containing SUMMARY (ignoring case) in the item summary. Prepend an exclamation mark, i.e., use !SUMMARY rather than SUMMARY, to limit the display to items which do NOT contain SUMMARY in the summary.

Regular expression. Composite type only. Limit the display to items containing SEARCH (ignoring case) anywhere in the item or its file path. Prepend an exclamation mark, i.e., use !SEARCH rather than SEARCH, to limit the display to items which do NOT contain SEARCH in the item or its file path.

-t TAGS

Comma separated list of case insensitive regular expressions. E.g., use

-t tag1, !tag2

or

-t tag1, -t !tag2

to display items with one or more tags that match 'tag1' but none that match 'tag2'.

-u USER

Regular expression. Limit the display to items with user matching USER (ignoring case). Prepend an exclamation mark, i.e., use !USER rather than USER, to limit the display to items which do NOT have a user that matches USER.

-w WIDTH

Non-negative integer. Truncate the output for column 1 if it exceeds this integer. Do not truncate if this integer is zero.

-W WIDTH

Non-negative integer. Truncate the output for column 2 if it exceeds this integer.

Saving view specifications

You can save view specifications in your specifications file, ~./etm/reports.cfg by default, and then select them in the selection box at the bottom of the custom view window in the GUI or from a list in the CLI.

You can also add specifications to file in the GUI by selecting any item from the list and then replacing the content with anything you like. Press Return to add your specification temporarily to the list. Note that the original entry will not be affected. When you leave the custom view you will have an opportunity to save the additions you have made. If you choose a file, your additions will be inserted into the list and it will be opened for editing.

Shortcuts

File                                                     
    New                                                  
        Item                                      N      
        File                                   Shift-N   
    Timer                                                
        Start action timer                        T      
        Finish action timer                    Shift-T   
        Toggle current timer                      I      
        Delete action timer                    Shift-I   
        Assign idle time                        Ctrl-I   
        Reset idle to zero minutes                       
        Toggle idle timer display                        
        Countdown timer                           Z      
    Open                                                 
        Data file ...                          Shift-F   
        Configuration file ...                 Shift-C   
        Preferences                            Shift-P   
        Scratchpad                             Shift-S   
    ----                                                 
    Quit                                        Ctrl-Q   
View                                                     
    Agenda                                      Ctrl-A   
    Week                                        Ctrl-W   
    Month                                       Ctrl-M   
    Tag                                         Ctrl-T   
    Keyword                                     Ctrl-K   
    Path                                        Ctrl-P   
    Note                                        Ctrl-N   
    Custom                                      Ctrl-C   
    ----                                                 
    Set outline filter                          Ctrl-F   
    Clear outline filter                     Shift-Ctrl-F
    Toggle labels                                 L      
    Set outline depth                             O      
    Toggle finished                               X      
Item                                                     
    Copy                                          C      
    Delete                                    BackSpace  
    Edit                                          E      
    Edit file                                  Shift-E   
    Finish                                        F      
    Move                                          M      
    Reschedule                                    R      
    Schedule new                                  S      
    Klone as timer                                K      
    Show date and time details                    D      
    Open link                                     G      
    Show user details                             U      
Tools                                                    
    Home                                         Home    
    Jump to date                                  J      
    ----                                                 
    Show remaining alerts for today               A      
    List busy times in week/month                 B      
    List free times in week/month                 F      
    Date and time calculator                   Shift-D   
    Available dates calculator                 Shift-A   
    Yearly calendar                            Shift-Y   
    ----                                                 
    Show outline as text                       Shift-O   
    Print outline                                 P      
    Export to iCal                             Shift-X   
    Update calendar subscriptions              Shift-M   
    History of changes                         Shift-H   
Custom                                                   
    Create and display selected report          Return   
    Export report in text format ...            Ctrl-T   
    Export report in csv format ...             Ctrl-X   
    Save changes to report specifications       Ctrl-W   
    Expand report list                           Down    
Help                                                     
    Search                                               
    Shortcuts                                     ?      
    User manual                                   F1     
    About                                         F2     
    Check for update                              F3     

Edit

Show completions                              Ctrl-Space 
Cancel                                          Escape   
Save and Close                                  Ctrl-S   
etmtk-3.2.22/etmTk/icons/0000755000076500000240000000000012617420125015001 5ustar dagstaff00000000000000etmtk-3.2.22/etmTk/icons/etmlogo.gif0000644000076500000240000001142012315112135017126 0ustar dagstaff00000000000000GIF87a@*U3&<)7*>,E.>.F /A/90C0@2@33 7I99:Q;M >Y@ @` B^G_KcNoOkOgRnUqUr X{Yv]|] `peffghi(i k)kn!op!rt"uw'yUy[y7z${$~@%&,'('*++7V2=8,F-H-3 8E/=1GJ/D0/1FTK1`5D^;UZBNm=LRGNTaRJ^\dy@M`h꿿_ze]kjsfyqtyfUmUժ! ! ICCRGBG1012 HLinomntrRGB XYZ  1acspMSFTIEC sRGB-HP cprtP3desclwtptbkptrXYZgXYZ,bXYZ@dmndTpdmddvuedLview$lumimeas $tech0 rTRC< gTRC< bTRC< textCopyright (c) 1998 Hewlett-Packard CompanydescsRGB IEC61966-2.1sRGB IEC61966-2.1XYZ QXYZ XYZ o8XYZ bXYZ $descIEC http://www.iec.chIEC http://www.iec.chdesc.IEC 61966-2.1 Default RGB colour space - sRGB.IEC 61966-2.1 Default RGB colour space - sRGBdesc,Reference Viewing Condition in IEC61966-2.1,Reference Viewing Condition in IEC61966-2.1view_. \XYZ L VPWmeassig CRT curv #(-27;@EJOTY^chmrw| %+28>ELRY`gnu| &/8AKT]gqz !-8COZfr~ -;HUcq~ +:IXgw'7HYj{+=Oat 2FZn  % : O d y  ' = T j " 9 Q i  * C \ u & @ Z t .Id %A^z &Ca~1Om&Ed#Cc'Ij4Vx&IlAe@e Ek*Qw;c*R{Gp@j>i  A l !!H!u!!!"'"U"""# #8#f###$$M$|$$% %8%h%%%&'&W&&&''I'z''( (?(q(())8)k))**5*h**++6+i++,,9,n,,- -A-v--..L.../$/Z///050l0011J1112*2c223 3F3334+4e4455M555676r667$7`7788P8899B999:6:t::;-;k;;<' >`>>?!?a??@#@d@@A)AjAAB0BrBBC:C}CDDGDDEEUEEF"FgFFG5G{GHHKHHIIcIIJ7J}JK KSKKL*LrLMMJMMN%NnNOOIOOP'PqPQQPQQR1R|RSS_SSTBTTU(UuUVV\VVWDWWX/X}XYYiYZZVZZ[E[[\5\\]']x]^^l^__a_``W``aOaabIbbcCccd@dde=eef=ffg=ggh?hhiCiijHjjkOkklWlmm`mnnknooxop+ppq:qqrKrss]sttptu(uuv>vvwVwxxnxy*yyzFz{{c{|!||}A}~~b~#G k͂0WGrׇ;iΉ3dʋ0cʍ1fΏ6n֑?zM _ɖ4 uL$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2TF[p(@Xr4Pm8Ww)Km,@*o H*T#FL$.BsѢ(#E\q3˗0]f4GnaA>e:&T-ZuӚE^=jkVf6ejU˜kR?t5ewL%PH,D%3"(H&9BNqO.9Zlq؅,itMڀ5 lfj @gZ ӵq;yӭ˷6_fe.6rY+ .ңM#0ޚtzqGx 2yT/=1^.E0 .tDKݲL.u!"tuDrPuWS 2"kptXwSdl^KqRh .SٕEgH8#6yaDd"KDc09wTHࣛX$cK@@Kz ebEGƢ.5@[fKB܂PqG-MZK vj?TzT(*/uQAIA);F+ zA\AgKk-ekGdPP [M.D%HA2h-RB %~ux1(1:dJTˆtq֎dZ/U!<,R~ Ygx!dE EP*'ELRY`gnu| &/8AKT]gqz !-8COZfr~ -;HUcq~ +:IXgw'7HYj{+=Oat 2FZn  % : O d y  ' = T j " 9 Q i  * C \ u & @ Z t .Id %A^z &Ca~1Om&Ed#Cc'Ij4Vx&IlAe@e Ek*Qw;c*R{Gp@j>i  A l !!H!u!!!"'"U"""# #8#f###$$M$|$$% %8%h%%%&'&W&&&''I'z''( (?(q(())8)k))**5*h**++6+i++,,9,n,,- -A-v--..L.../$/Z///050l0011J1112*2c223 3F3334+4e4455M555676r667$7`7788P8899B999:6:t::;-;k;;<' >`>>?!?a??@#@d@@A)AjAAB0BrBBC:C}CDDGDDEEUEEF"FgFFG5G{GHHKHHIIcIIJ7J}JK KSKKL*LrLMMJMMN%NnNOOIOOP'PqPQQPQQR1R|RSS_SSTBTTU(UuUVV\VVWDWWX/X}XYYiYZZVZZ[E[[\5\\]']x]^^l^__a_``W``aOaabIbbcCccd@dde=eef=ffg=ggh?hhiCiijHjjkOkklWlmm`mnnknooxop+ppq:qqrKrss]sttptu(uuv>vvwVwxxnxy*yyzFz{{c{|!||}A}~~b~#G k͂0WGrׇ;iΉ3dʋ0cʍ1fΏ6n֑?zM _ɖ4 uL$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2TF[p(@Xr4Pm8Ww)Km,5XM˜YRXvIB [Jsc߫Dk`$fvN- ;etmtk-3.2.22/etmTk/icons/icon_clock.gif0000644000076500000240000000115612534671735017613 0ustar dagstaff00000000000000GIF89ajf\:k%ǺQQquwz'0'1歵{~t}v~")4醍{rt񃎛wyilԄy}hk÷U_w|ø3JSZ_hq!j,ˀjj^FXRSTN]=:P>$ `; HV' @g8b IcOQK9JEBA(256+W7C,iLZY_de?- !f)\.*<1M/GD3&[%"慡@8Q%(P @&pȐÄ 8ƒ@@r漙h͟;etmtk-3.2.22/etmTk/icons/icon_pause.gif0000644000076500000240000000056512534671735017640 0ustar dagstaff00000000000000GIF89a9BBff99sbbɏuu؛pp։ww݈mmҗXXwwjjzzee{{eekkmmԔQQnneěrr؃뉉mmvv܂顡mmnnuuWW[[tt܏nnԮ⚚!9,P(\lA&Z0PXb.4z< Ze7h,mW pA;x)\xz&E$0+7|" 'z% E Zm,m4B* 3CIIA;etmtk-3.2.22/etmTk/icons/icon_play.gif0000644000076500000240000000113112534671735017456 0ustar dagstaff00000000000000GIF89aJff̨99sBBbbuu؏wwݏXX횚mm҉嗗wwۈ耀jjnnԔxxrr؛ttکppփ롡nnժ~~剉ee{{ggee̖uu˾⮮mmeeooՈmmuuۆWWzzvv܍ttnn҄ꖖ||bbljYY^^æ!J,JJ! *05 #>,2 $.+-D? 6IH<…% BɼC81AE= F94G7 ; 3)'hh0@ (   ">|p6 `p8H?(M&*ie@;etmtk-3.2.22/etmTk/icons/icon_plus.gif0000644000076500000240000000055412534671735017504 0ustar dagstaff00000000000000GIF89a:] _F^ S*eab`8JD8GE\I V+T*M:V4 R(J+dVS):cxW!$- R)& 6!:,@P p9<DtY5 /ǀ YǤL HPB1/~ U25!# 4 79,'03+L-_ bBN^`:d18.KA;etmtk-3.2.22/etmTk/icons/icon_refresh.gif0000644000076500000240000000035212534671735020153 0ustar dagstaff00000000000000GIF89aaGS*T9_^`U9bZT;X[D!,g$0XP+a2€PZ@0d\Y C9 V oJvx{< #w007#!;etmtk-3.2.22/etmTk/icons/icon_search.gif0000644000076500000240000000107312534671735017763 0ustar dagstaff00000000000000GIF89aA3fZ;f٦EON̙ #hWg۝jT_ X0}usv0wM!A,AA 1&-38 ,' *!40:<;.6( = 2"59$>7)+?@ׂ%#/  A hOR 4@… ;etmtk-3.2.22/etmTk/icons/icon_settings.gif0000644000076500000240000000062512534671735020360 0ustar dagstaff00000000000000GIF89a=(4?S^iS`iLVawt~며s}x훣p{.9D߿/;FwlyBMWfmw눕րclv(3>r}عP]gEOYMV`!=,p*Xä؃3 K8at:2tQR@B#V(@'' ~|4(569O<65(4O"/OO/"O..%3-3%B11C+77B )22)**=JBA;etmtk-3.2.22/etmTk/icons/icon_stop.gif0000644000076500000240000000112612534671735017502 0ustar dagstaff00000000000000GIF89aFff̨99sBBbbuuwwݏxxuu۩pp֛Ꚛnnwwۈ蔔rrmmjjzzttoottډ||mmggeẻ鍍~~媪vv܌듓nn{{nnծeemmWW^^Ç\\uu!F,FF 1 DC02,6*-B?+7AE>.3/!# ;=') (" $@<&5 4`Ćpǃ8蠱!@B$8@ D"GL;etmtk-3.2.22/etmTk/v.py0000644000076500000240000000002312617417731014511 0ustar dagstaff00000000000000version = "3.2.22" etmtk-3.2.22/etmTk/version.py0000644000076500000240000000005712617417731015740 0ustar dagstaff00000000000000version = "3.2.22 [2015-11-07 09:55:57 -0500]" etmtk-3.2.22/etmTk/view.py0000755000076500000240000051305612617201354015230 0ustar dagstaff00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import (absolute_import, division, print_function, unicode_literals) import os # import sys import re import uuid from copy import deepcopy import subprocess from dateutil.tz import tzlocal import codecs import logging import logging.config logger = logging.getLogger() import platform if platform.python_version() >= '3': import tkinter from tkinter import ( BOTH, Button, Canvas, CENTER, CURRENT, END, Entry, FLAT, Frame, INSERT, IntVar, Label, LEFT, Menu, OptionMenu, PanedWindow, PhotoImage, RAISED, Scrollbar, StringVar, Tk, TOP, Toplevel, W, X, ) from tkinter import ttk from tkinter import font as tkFont utf8 = lambda x: x unicode = str else: import Tkinter as tkinter from Tkinter import ( BOTH, Button, Canvas, CENTER, CURRENT, END, Entry, FLAT, Frame, INSERT, IntVar, Label, LEFT, Menu, OptionMenu, PanedWindow, PhotoImage, RAISED, Scrollbar, StringVar, Tk, TOP, Toplevel, W, X, ) import ttk import tkFont def utf8(s): return s tkversion = tkinter.Tcl().eval('info patchlevel') import etmTk.data as data from dateutil.parser import parse from calendar import Calendar from decimal import Decimal from etmTk.data import ( fmt_weekday, fmt_dt, fmt_date, str2hsh, hsh2str, tstr2SCI, leadingzero, relpath, s2or3, send_mail, send_text, get_changes, checkForNewerVersion, datetime2minutes, calyear, expand_template, id2Type, fmt_shortdatetime, get_reps, get_current_time, windoz, mac, setup_logging, gettz, commandShortcut, rrulefmt, tree2Text, date_calculator, AFTER, export_ical_item, export_ical_active, fmt_time, fmt_period, TimeIt, getReportData, getFileTuples, getAllFiles, updateCurrentFiles, availableDates, syncTxt, update_subscription) from etmTk.dialog import MenuTree, Timer, ReadOnlyText, MessageWindow, TextDialog, OptionsDialog, GetInteger, GetRepeat, GetDateTime, GetString, FileChoice, FINISH, STOPPED, PAUSED, RUNNING, ONEDAY, ONEMINUTE, SOMEREPS, ALLREPS, type2Text, SimpleEditor from datetime import datetime, time, date ETM = "etm" FILTER = _("filter") FILTERCOLOR = "gray" # Views # AGENDA = _('Agenda') DAY = _('Day') WEEK = _("Week") MONTH = _("Month") PATH = _('Path') KEYWORD = _('Keyword') TAG = _('Tag') NOTE = _('Note') CUSTOM = _("Custom") CALENDARS = _("Calendars") COPY = _("Copy") EDIT = _("Edit") DELETE = _("Delete") FILE = _("File") NEW = _("New") TIMER = _("Timer") OPEN = _("Open") VIEW = _("View") ITEM = _("Item") TOOLS = _("Tools") HELP = _("Help") MAKE = _("Make report") PRINT = _("Print") EXPORTTEXT = _("Export report in text format ...") EXPORTCSV = _("Export report in CSV format ...") SAVESPECS = _("Save changes to report specifications") CLOSE = _("Close") SEP = "----" LASTLTR = re.compile(r'([a-z])$') this_dir, this_filename = os.path.split(__file__) USERMANUAL = os.path.normpath(os.path.join(this_dir, "help", "UserManual.html")) ICONSETTINGS = os.path.normpath(os.path.join(this_dir, "icons", "icon_settings.gif")) ICONPLUS = os.path.normpath(os.path.join(this_dir, "icons", "icon_plus.gif")) # ICONLOGO = os.path.normpath(os.path.join(this_dir, "icons", "etmlogo.gif")) class App(Tk): def __init__(self, path=None): Tk.__init__(self, className="EtmTk") self.minsize(360, 460) self.uuidSelected = None self.timerItem = None self.monthly_calendar = Calendar() self.itemAlerts = [] self.activeAlerts = [] self.loop = loop self.options = loop.options self.countdownActive = False self.countdownMinutes = self.loop.options['countdown_minutes'] self.countdownTime = None self.messageAlerts = {} BGCOLOR = self.options['background_color'] self.BGCOLOR = BGCOLOR HLCOLOR = self.options['highlight_color'] self.HLCOLOR = HLCOLOR FGCOLOR = self.options['foreground_color'] self.FGCOLOR = FGCOLOR CALENDAR_COLORS = self.options['calendar_colors'] self.ACTIVEFILL = CALENDAR_COLORS['active'] self.CONFLICTFILL = CALENDAR_COLORS['conflict'] self.CURRENTFILL = CALENDAR_COLORS['current'] self.GRIDCOLOR = CALENDAR_COLORS['grid'] self.OCCASIONFILL = CALENDAR_COLORS['occasion'] self.BUSYBAR = CALENDAR_COLORS['busybar'] self.CURRDATE = CALENDAR_COLORS['date'] self.OTHERDATE = CALENDAR_COLORS['grid'] self.YEARPAST = CALENDAR_COLORS['year_past'] self.YEARCURRENT = CALENDAR_COLORS['year_current'] self.YEARFUTURE = CALENDAR_COLORS['year_future'] self.configure(background=BGCOLOR, highlightcolor=HLCOLOR, takefocus=False) self.option_add('*tearOff', False) self.menu_lst = [] self.menutree = MenuTree() self.chosen_date = None self.active_date = None self.canvas_date = None self.busy_info = None self.weekly = False self.monthly = False self.specsModified = False self.active_tree = {} self.protocol("WM_DELETE_WINDOW", self.quit) root = "_" self.week_height = 76 self.month_height = 280 style = self.options['style'] s = ttk.Style() styles = s.theme_names() if style in styles: logger.info("using style {0}".format(style)) else: logger.warn("style {0} is not an option from {1} - using default".format(style, ", ".join(styles))) style = 'default' s.theme_use(style) loop.tkstyle = style ttk.Style().configure("bg.TButton", background=BGCOLOR, activebackground=BGCOLOR, highlightcolor=HLCOLOR, foreground=FGCOLOR, relief=RAISED, takefocus=False) tkfixedfont = tkFont.nametofont("TkFixedFont") if 'fontsize_fixed' in self.options and self.options['fontsize_fixed']: tkfixedfont.configure(size=self.options['fontsize_fixed']) logger.info("using fixedfont size: {0}".format(tkfixedfont.actual()['size'])) self.tkfixedfont = tkfixedfont tktreefont = tkFont.nametofont("TkDefaultFont") treefontfamily = tktreefont['family'] if 'fontsize_tree' in self.options and self.options['fontsize_tree']: tktreefont.configure(size=self.options['fontsize_tree']) logger.info("using treefont size: {0}".format(tktreefont.actual()['size'])) self.tktreefont = tktreefont ef = "%a %b %d" if 'ampm' in loop.options and loop.options['ampm']: self.efmt = "%I:%M%p {0}".format(ef) else: self.efmt = "%H:%M {0}".format(ef) self.default_calendars = deepcopy(loop.options['calendars']) # create the root node for the menu tree self.menutree.create_node(root, root) # leaf: (parent, (option, [accelerator]) self.outline_depths = {} for view in DAY, WEEK, MONTH, TAG, KEYWORD, NOTE, PATH: # set all to the default logger.debug('Setting depth for {0} to {1}'.format(view, loop.options['outline_depth'])) self.outline_depths[view] = loop.options['outline_depth'] # set CUSTOM to 0 self.outline_depths[AGENDA] = 0 self.outline_depths[CUSTOM] = 0 self.topbar = topbar = Frame(self, bd=0, relief="flat", highlightcolor=HLCOLOR, background=BGCOLOR, takefocus=False) topbar.pack(side="top", fill="x", expand=0, padx=0, pady=0) self.box_value = StringVar() self.custom_box = ttk.Combobox(self.topbar, textvariable=self.box_value, font=self.tkfixedfont) self.statusbar = Frame(self, bd=0, relief="flat", highlightcolor=HLCOLOR, highlightthickness=0, background=BGCOLOR, takefocus=False) self.statusbar.pack(side="bottom", fill="x", expand=0, padx=4, pady=2) self.topwindow = topwindow = PanedWindow(self, orient="vertical", sashwidth=2, sashrelief='flat', background=BGCOLOR) self.topwindow.pack(side="top", padx=0, fill=BOTH, expand=1) self.toppane = toppane = Frame( topwindow, bd=0, relief="flat", highlightthickness=0, highlightcolor=HLCOLOR, highlightbackground=BGCOLOR, background=BGCOLOR, takefocus=False) self.toppane.pack(side="top", fill=BOTH, expand=1) self.canvas = Canvas( self.toppane, background=BGCOLOR, bd=2, relief="flat", highlightthickness=3, highlightbackground=BGCOLOR, highlightcolor=HLCOLOR) self.canvas.pack(fill=BOTH, expand=1, padx=2, pady=0) self.botwindow = botwindow = PanedWindow(topwindow, orient="vertical", sashwidth=0, sashpad=0, bd=0, sashrelief='flat', background=BGCOLOR ) topwindow.add(botwindow, padx=0) self.treepane = treepane = Frame( botwindow, bd=0, relief="flat", highlightthickness=3, highlightcolor=HLCOLOR, highlightbackground=BGCOLOR, background=BGCOLOR, takefocus=False) botwindow.add(treepane, padx=2, pady=0, stretch="first") ttk.Style().configure("Treeview", bd=0, padding=2, highlightthickness=0, background=BGCOLOR, foreground=FGCOLOR, highlightcolor=HLCOLOR, fieldbackground=BGCOLOR, ) self.tree = ttk.Treeview(treepane, show='tree', columns=["#1", "#2"], selectmode='browse') self.tree.pack(fill="both", expand=1, padx=0, pady=0) self.content = ReadOnlyText( botwindow, font=self.tkfixedfont, wrap="word", padx=3, bd=2, relief="sunken", height=loop.options['details_rows'], width=46, takefocus=False, highlightthickness=0, highlightcolor=HLCOLOR, background=BGCOLOR, highlightbackground=BGCOLOR, foreground=FGCOLOR ) botwindow.add(self.content, padx=2, pady=2, stretch="never") self.canvas.bind('', (lambda e: self.selectId(event=e, d=0))) self.canvas.bind("", self.on_select_item) self.canvas.bind("", self.on_select_item) self.canvas.bind("", lambda e: self.on_activate_item(event=e)) self.canvas.bind("", lambda e: self.on_activate_item(event=e)) self.canvas.bind('', (lambda e: self.priorWeekMonth(event=e))) self.canvas.bind('', (lambda e: self.nextWeekMonth(event=e))) self.canvas.bind('', (lambda e: self.selectId(event=e, d=-1))) self.canvas.bind('', (lambda e: self.selectId(event=e, d=1))) self.canvas.bind('', self.goHome) self.canvas.bind("", self.on_resize) self.canvas.bind("b", lambda event: self.after(AFTER, self.showBusyPeriods)) self.canvas.bind("f", lambda event: self.after(AFTER, self.showFreePeriods)) # main menu self.menubar = menubar = Menu(self) menu = _("Menu") self.add2menu(root, (menu,)) # File menu filemenu = Menu(menubar, tearoff=0) path = FILE self.add2menu(menu, (path, )) self.newmenu = newmenu = Menu(filemenu, tearoff=0) filemenu.add_cascade(label=NEW, menu=newmenu) self.add2menu(path, (NEW, )) path = NEW label = _("Item") l = "N" c = "n" newmenu.add_command(label=label, command=self.newItem) # self.bindTop("n", self.newItem) self.bindTop(c, lambda e: self.after(AFTER, self.newItem(e))) self.canvas.bind(c, lambda e: self.after(AFTER, self.newItem(e))) newmenu.entryconfig(0, accelerator=l) self.add2menu(path, (label, l)) l = "Shift-N" c = "N" label = _("File") newmenu.add_command(label=label, command=self.newFile) self.bindTop(c, self.newFile) newmenu.entryconfig(1, accelerator=l) self.add2menu(path, (label, l)) path = FILE self.timermenu = timermenu = Menu(filemenu, tearoff=0) filemenu.add_cascade(label=TIMER, menu=timermenu) self.add2menu(path, (TIMER, )) path = TIMER self.actionTimer = Timer(self, options=loop.options) label = _("Start action timer") l = "T" c = 't' timermenu.add_command(label=label, command=self.actionTimer.selectTimer) self.bindTop(c, self.actionTimer.selectTimer) timermenu.entryconfig(0, accelerator=l) self.add2menu(path, (label, l)) label = _("Finish action timer") l = "Shift-T" c = "T" timermenu.add_command(label=label, command=self.finishActionTimer) self.bindTop(c, self.finishActionTimer) timermenu.entryconfig(1, accelerator=l) self.add2menu(path, (label, l)) label = _("Toggle current timer") l = "I" c = 'i' timermenu.add_command(label=label, command=self.actionTimer.toggleCurrent) self.bindTop(c, self.actionTimer.toggleCurrent) timermenu.entryconfig(2, accelerator=l) self.add2menu(path, (label, l)) label = _("Delete action timer") l = "Shift-I" c = "I" timermenu.add_command(label=label, command=self.actionTimer.deleteTimer) self.bind(c, self.actionTimer.deleteTimer) timermenu.entryconfig(3, accelerator=l) self.add2menu(path, (label, l)) label = _("Assign idle time") l, c = commandShortcut('i') timermenu.add_command(label=label, command=self.adjustIdle) self.bindTop(c, self.adjustIdle) timermenu.entryconfig(4, accelerator=l) self.add2menu(path, (label, l)) label = _("Reset idle to zero minutes") l = "" timermenu.add_command(label=label, command=self.actionTimer.clearIdle) timermenu.entryconfig(5, accelerator=l) self.add2menu(path, (label, l)) label = _("Toggle idle timer display") l = "" timermenu.add_command(label=label, command=self.actionTimer.toggleIdle) timermenu.entryconfig(6, accelerator=l) self.add2menu(path, (label, l)) label = _("Countdown timer") l = "Z" c = "z" timermenu.add_command(label=label, command=self.setcountdownTimer) timermenu.entryconfig(7, accelerator=l) self.bind(c, self.setcountdownTimer) self.add2menu(path, (label, l)) self.actionTimer.updateMenu() path = FILE # Open openmenu = Menu(filemenu, tearoff=0) self.add2menu(path, (OPEN, )) path = OPEN l = "Shift-F" c = "F" label = _("Data file ...") openmenu.add_command(label=label, command=self.editData) self.bindTop(c, self.editData) openmenu.entryconfig(0, accelerator=l) self.add2menu(path, (label, l)) l = "Shift-C" c = "C" label = _("Configuration file ...") openmenu.add_command(label=label, command=self.editCfgFiles) self.bindTop(c, self.editCfgFiles) openmenu.entryconfig(1, accelerator=l) self.add2menu(path, (label, l)) l = "Shift-P" c = "P" label = _("Preferences") openmenu.add_command(label=label, command=self.editConfig) self.bindTop(c, self.editConfig) openmenu.entryconfig(2, accelerator=l) self.add2menu(path, (label, l)) l = "Shift-S" c = "S" file = loop.options['scratchpad'] # label = relpath(file, loop.options['etmdir']) label = _("Scratchpad") openmenu.add_command(label=label, command=self.editScratch) self.bindTop(c, self.editScratch) openmenu.entryconfig(3, accelerator=l) self.add2menu(path, (label, l)) filemenu.add_cascade(label=OPEN, menu=openmenu) path = FILE filemenu.add_separator() self.add2menu(path, (SEP, )) # quit l, c = commandShortcut('q') label = _("Quit") filemenu.add_command(label=label, underline=0, command=self.quit) self.bind(c, self.quit) # w self.add2menu(path, (label, l)) menubar.add_cascade(label=path, underline=0, menu=filemenu) self.toolsmenu = viewmenu = Menu(menubar, tearoff=0) self.viewmenu = viewmenu = Menu(menubar, tearoff=0) path = VIEW self.add2menu(menu, (path, )) # # agenda # l = label = AGENDA # toolsmenu.add_command(label=label, command=self.agendaView) self.vm_options = [[AGENDA, 'a'], [DAY, 'd'], [WEEK, 'w'], [MONTH, 'm'], [TAG, 't'], [KEYWORD, 'k'], [PATH, 'p'], [NOTE, 'n'], [CUSTOM, 'c'], ] self.view2cmd = {'a': self.agendaView, 'd': self.dayView, 'm': self.showMonthly, 'p': self.pathView, 'k': self.keywordView, 'n': self.noteView, 't': self.tagView, 'c': self.customView, 'w': self.showWeekly} self.viewname2cmd = {} self.view = self.vm_options[0][0] self.currentView = StringVar(self) self.currentView.set(self.view) self.vm_opts = [x[0] for x in self.vm_options] for i in range(len(self.vm_options)): label = self.vm_options[i][0] k = self.vm_options[i][1] if label == DAY: continue elif label == "-": self.viewmenu.add_separator() # self.add2menu(VIEW, (SEP, )) else: l, c = commandShortcut(k) viewmenu.add_command(label=label, command=self.view2cmd[k]) # self.bind(c, lambda e, x=k: self.after(AFTER, self.view2cmd[x])) self.bindTop(c, self.view2cmd[k]) viewmenu.entryconfig(i, accelerator=l) self.add2menu(path, (label, l)) viewmenu.add_separator() self.add2menu(path, (SEP, )) # apply filter l, c = commandShortcut('f') label = _("Set outline filter") viewmenu.add_command(label=label, underline=1, command=self.setFilter) self.bindTop(c, self.setFilter) viewmenu.entryconfig(10, accelerator=l) self.add2menu(path, (label, l)) # clear filter l = "Shift-Ctrl-F" label = _("Clear outline filter") viewmenu.add_command(label=label, underline=1, command=self.clearFilter) viewmenu.entryconfig(11, accelerator=l) self.add2menu(path, (label, l)) # toggle showing labels l = "L" c = "l" label = _("Toggle labels") viewmenu.add_command(label=label, underline=1, command=self.toggleLabels) self.bindTop(c, self.toggleLabels) viewmenu.entryconfig(12, accelerator=l) self.add2menu(path, (label, l)) # expand to depth l = "O" c = "o" label = _("Set outline depth") viewmenu.add_command(label=label, underline=1, command=self.expand2Depth) self.bindTop(c, self.expand2Depth) viewmenu.entryconfig(13, accelerator=l) self.add2menu(path, (label, l)) # toggle showing finished l = "X" c = "x" label = _("Toggle finished") viewmenu.add_command(label=label, underline=1, command=self.toggleFinished) self.bindTop(c, self.toggleFinished) viewmenu.entryconfig(14, accelerator=l) self.add2menu(path, (label, l)) menubar.add_cascade(label=path, underline=0, menu=viewmenu) # Item menu self.itemmenu = itemmenu = Menu(menubar, tearoff=0) self.itemmenu.bind("", self.closeItemMenu) self.itemmenu.bind("", self.closeItemMenu) self.em_options = [ [_('Copy'), 'c'], [_('Delete'), 'D'], [_('Edit'), 'e'], [_('Edit file'), 'E'], [_('Finish'), 'f'], [_('Move'), 'm'], [_('Reschedule'), 'r'], [_('Schedule new'), 's'], [_('Klone as timer'), 'k'], [_('Show date and time details'), 'd'], [_('Open link'), 'g'], [_('Show user details'), 'u'], ] path = ITEM self.add2menu(menu, (path, )) self.edit2cmd = { 'c': self.copyItem, 'D': self.deleteItem, 'e': self.editItem, 'E': self.editItemFile, 'f': self.finishItem, 'm': self.moveItem, 'r': self.rescheduleItem, 's': self.scheduleNewItem, 'd': self.showDateTimeDetails, 'g': self.openWithDefault, 'u': self.showUserDetails, 'k': self.kloneTimer} self.em_opts = [x[0] for x in self.em_options] for i in range(len(self.em_options)): label = self.em_options[i][0] k = self.em_options[i][1] if k == 'D': l = "BackSpace" c = "" elif k == 'E': l = "Shift-E" c = "E" else: l = k.upper() c = k itemmenu.add_command(label=label, underline=0, command=self.edit2cmd[k]) if k == 'f': self.tree.bind(c, self.edit2cmd[k]) else: self.bindTop(c, self.edit2cmd[k]) itemmenu.entryconfig(i, accelerator=l) self.add2menu(path, (label, l)) menubar.add_cascade(label=path, underline=0, menu=itemmenu) # tools menu path = TOOLS self.add2menu(menu, (path, )) self.toolsmenu = toolsmenu = Menu(menubar, tearoff=0) # go home l = "Home" label = _("Home") toolsmenu.add_command(label=label, command=self.goHome) toolsmenu.entryconfig(0, accelerator=l) self.add2menu(path, (label, l)) self.bindTop('', self.goHome) # go to date l = "J" c = "j" label = _("Jump to date") toolsmenu.add_command(label=label, command=self.goToDate) self.bindTop(c, self.goToDate) toolsmenu.entryconfig(1, accelerator=l) self.add2menu(path, (label, l)) toolsmenu.add_separator() # 2 self.add2menu(path, (SEP, )) # show alerts l = "A" c = "a" label = _("Show remaining alerts for today") toolsmenu.add_command(label=label, underline=1, command=self.showAlerts) self.bindTop(c, self.showAlerts) toolsmenu.entryconfig(3, accelerator=l) self.add2menu(path, (label, l)) l = "B" c = 'b' label = _("List busy times in week/month") toolsmenu.add_command(label=label, underline=5, command=self.showBusyPeriods) toolsmenu.entryconfig(4, accelerator=l) self.add2menu(path, (label, l)) l = "F" c = 'f' label = _("List free times in week/month") toolsmenu.add_command(label=label, underline=5, command=self.showFreePeriods) toolsmenu.entryconfig(5, accelerator=l) # set binding in showWeekly self.add2menu(path, (label, l)) # date calculator l = "Shift-D" c = "D" label = _("Date and time calculator") toolsmenu.add_command(label=label, underline=12, command=self.dateCalculator) self.bindTop(c, self.dateCalculator) toolsmenu.entryconfig(6, accelerator=l) self.add2menu(path, (label, l)) # available date calculator l = "Shift-A" c = "A" label = _("Available dates calculator") toolsmenu.add_command(label=label, underline=12, command=self.availableDateCalculator) self.bindTop(c, self.availableDateCalculator) toolsmenu.entryconfig(7, accelerator=l) self.add2menu(path, (label, l)) l = "Shift-Y" c = "Y" label = _("Yearly calendar") toolsmenu.add_command(label=label, underline=8, command=self.showCalendar) self.bindTop(c, self.showCalendar) toolsmenu.entryconfig(8, accelerator=l) self.add2menu(path, (label, l)) toolsmenu.add_separator() # 9 self.add2menu(path, (SEP, )) # popup active tree l = "Shift-O" c = "O" label = _("Show outline as text") toolsmenu.add_command(label=label, underline=1, command=self.popupTree) self.bindTop(c, self.popupTree) toolsmenu.entryconfig(10, accelerator=l) self.add2menu(path, (label, l)) # print active tree l = "P" c = "p" label = _("Print outline") toolsmenu.add_command(label=label, underline=1, command=self.printTree) self.bindTop("p", self.printTree) toolsmenu.entryconfig(11, accelerator=l) self.add2menu(path, (label, l)) # export l = "Shift-X" c = "X" label = _("Export to iCal") toolsmenu.add_command(label=label, underline=1, command=self.exportToIcal) self.bind(c, self.exportToIcal) toolsmenu.entryconfig(12, accelerator=l) self.add2menu(path, (label, l)) # update subscriptions l = "Shift-M" c = "M" label = _("Update calendar subscriptions") toolsmenu.add_command(label=label, underline=1, command=self.updateSubscriptions) self.bind(c, self.updateSubscriptions) toolsmenu.entryconfig(13, accelerator=l) self.add2menu(path, (label, l)) # changes if loop.options['vcs_system']: l = 'Shift-H' c = 'H' label = _("History of changes") toolsmenu.add_command(label=label, underline=1, command=self.showChanges) self.bind(c, lambda event: self.after(AFTER, self.showChanges)) toolsmenu.entryconfig(14, accelerator=l) self.add2menu(path, (label, l)) menubar.add_cascade(label=path, menu=toolsmenu, underline=0) self.toolsmenu.entryconfig(1, state="disabled") for i in range(4, 6): self.toolsmenu.entryconfig(i, state="disabled") # report path = CUSTOM self.add2menu(menu, (path, )) self.custommenu = reportmenu = Menu(menubar, tearoff=0) self.rm_options = [[MAKE, 'm'], [EXPORTTEXT, 't'], [EXPORTCSV, 'x'], [SAVESPECS, 'w']] self.rm2cmd = {'m': self.makeReport, 't': self.exportText, 'x': self.exportCSV, 'w': self.saveSpecs} self.rm_opts = [x[0] for x in self.rm_options] for i in range(len(self.rm_options)): label = self.rm_options[i][0] k = self.rm_options[i][1] l = k.upper() c = k reportmenu.add_command(label=label, underline=0, command=self.rm2cmd[k]) reportmenu.entryconfig(i, state="disabled") self.add2menu(CUSTOM, (_("Create and display selected report"), "Return")) self.add2menu(CUSTOM, (_("Export report in text format ..."), "Ctrl-T")) self.add2menu(CUSTOM, (_("Export report in csv format ..."), "Ctrl-X")) self.add2menu(CUSTOM, (_("Save changes to report specifications"), "Ctrl-W")) self.add2menu(CUSTOM, (_("Expand report list"), "Down")) menubar.add_cascade(label=path, menu=reportmenu, underline=0) # help helpmenu = Menu(menubar, tearoff=0) path = HELP self.add2menu(menu, (path, )) # search is built in self.add2menu(path, (_("Search"), )) label = _("Shortcuts") helpmenu.add_command(label=label, underline=1, accelerator="?", command=self.showShortcuts) self.add2menu(path, (label, "?")) self.bindTop("?", self.showShortcuts) label = _("User manual") helpmenu.add_command(label=label, underline=1, accelerator="F1", command=self.help) self.add2menu(path, (label, "F1")) self.bind("", lambda e: self.after(AFTER, self.help)) label = _("About") helpmenu.add_command(label="About", accelerator="F2", command=self .about) self.bind("", self.about) self.add2menu(path, (label, "F2")) # check for updates label = _("Check for update") helpmenu.add_command(label=label, underline=1, accelerator="F3", command=self.checkForUpdate) self.add2menu(path, (label, "F3")) self.bind("", lambda e: self.after(AFTER, self.checkForUpdate)) menubar.add_cascade(label="Help", menu=helpmenu) self.config(menu=menubar) self.history = [] self.index = 0 self.count = 0 self.count2id = {} self.now = get_current_time() self.today = self.now.date() self.popup = '' self.value = '' self.firsttime = True self.mode = 'command' # or edit or delete self.item_hsh = {} self.depth2id = {} self.prev_week = None self.next_week = None self.curr_week = None self.week_beg = None self.itemSelected = None self.uuidSelected = None self.dtSelected = None self.rowSelected = None self.currentTime = StringVar(self) self.currentTime.set("") # self.title(ETM) self.title(self.currentTime.get()) self.columnconfigure(0, minsize=300, weight=1) self.rowconfigure(1, weight=2) # self.etmlogo = PhotoImage(file=ICONLOGO) # self.iconphoto(True, self.etmlogo) # report self.custom_box.bind("<>", self.newselection) self.bind("", self.makeReport) self.bind("", self.makeReport) self.bind("", self.quit) self.saved_specs = [''] self.getSpecs() if self.specs: self.value_of_combo = self.specs[0] self.custom_box['values'] = self.specs self.custom_box.current(0) self.saved_specs = deepcopy(self.specs) self.custom_box.configure( width=30, # background=FGCOLOR, # foreground=BGCOLOR, takefocus=False) iconsize = "22" self.settingsIcon = PhotoImage(file=ICONSETTINGS) self.settingsbutton = ttk.Button( topbar, command=self.selectCalendars, style="bg.TButton", takefocus=False, width=0 ) self.settingsbutton.config(image=self.settingsIcon) self.settingsbutton.pack(side="left", padx=4, pady=2) self.newIcon = PhotoImage(file=ICONPLUS) self.newbutton = ttk.Button(topbar, command=self.newItem, style="bg.TButton", takefocus=False, width=0) self.newbutton.config(image=self.newIcon) self.newbutton.pack(side="right", padx=4, pady=2) windowtitle = Label(topbar, textvariable=self.currentView, bd=1, relief="flat", padx=8, pady=0) windowtitle.pack(side="left") windowtitle.configure(background=BGCOLOR, foreground=FGCOLOR) # filter self.filterValue = StringVar(self) self.filterValue.set('') self.filterValue.trace_variable("w", self.filterView) self.fltr = Entry(topbar, textvariable=self.filterValue, width=14, bg=BGCOLOR, highlightcolor=HLCOLOR, highlightbackground=BGCOLOR, foreground=FGCOLOR, highlightthickness=3, bd=0, takefocus=False) self.fltr.pack(side="left", padx=0, expand=1, fill=X) self.fltr.bind("", self.setFilter) self.fltr.bind("", self.clearFilter) self.fltr.bind('', self.leaveFilter) self.bind("", self.clearFilter) self.filter_active = False self.weekly = False self.col2_width = tktreefont.measure('abcdgklmprtuX') self.col3_width = tktreefont.measure('10:30pm ~ 11:30pmX') self.text_width = 260 logger.info('column widths: {0}, {1}, {2}'.format(self.text_width, self.col2_width, self.col3_width)) self.tree.column('#0', minwidth=140, width=self.text_width, stretch=1) self.labels = False # don't show the labels column to start with by setting width=0 self.tree.column('#1', minwidth=0, width=0, stretch=0, anchor='center') self.tree.column('#2', width=self.col3_width, stretch=0, anchor='center') self.tree.bind('<>', self.OnSelect) self.tree.bind('', self.OnActivate) self.tree.bind('', self.OnActivate) self.tree.bind('', self.OnActivate) self.tree.bind('', self.nextItem) self.tree.bind('', self.prevItem) for t in tstr2SCI: self.tree.tag_configure(t, foreground=tstr2SCI[t][1]) self.date2id = {} self.root = ('', '_') self.content.bind('', self.cleartext) self.content.bind('', self.focus_next_window) self.content.bind("", self.editItem) self.pendingAlerts = IntVar(self) self.pendingAlerts.set(0) self.pending = ttk.Button(self.statusbar, textvariable=self.pendingAlerts, command=self.showAlerts, style="bg.TButton", width=0, takefocus=False ) self.pending.pack(side="right", expand=0, padx=2, pady=2) self.countdownStatus = StringVar(self) self.countdownStatus.set("") self.countdown_time = countdown_time = Label(self.statusbar, textvariable=self.countdownStatus, bd=0, relief="flat", anchor=W, justify=LEFT, padx=2, pady=0) countdown_time.pack(side="right", expand=0, fill=X, padx=6) countdown_time.configure(background=BGCOLOR, foreground=FGCOLOR, highlightthickness=0) self.timerStatus = StringVar(self) self.timerStatus.set("") self.timer_status = timer_status = Label(self.statusbar, textvariable=self.timerStatus, bd=0, relief="flat", anchor=W, justify=LEFT, padx=2, pady=0) timer_status.pack(side="right", expand=1, fill=X, padx=6) timer_status.configure(background=BGCOLOR, foreground=FGCOLOR, highlightthickness=0) self.timerTitle = StringVar(self) self.timerTitle.set("") self.timer_title = timer_title = Label(self.statusbar, textvariable=self.timerTitle, bd=0, relief="flat", anchor=W, justify=LEFT, padx=2, pady=0) timer_title.pack(side="left", expand=1, fill=X, padx=0) timer_title.configure(background=BGCOLOR, foreground=FGCOLOR, highlightthickness=0) # set cal_regex here and update it in updateCalendars self.cal_regex = None if loop.calendars: cal_pattern = r'^%s' % '|'.join( [x[2] for x in loop.calendars if x[1]]) self.cal_regex = re.compile(cal_pattern) logger.debug("cal_pattern: {0}".format(cal_pattern)) self.default_regex = None if 'calendars' in loop.options and loop.options['calendars']: calendars = loop.options['calendars'] default_pattern = r'^%s' % '|'.join( [x[2] for x in calendars if x[1]]) self.default_regex = re.compile(default_pattern) self.add2menu(root, (EDIT, )) self.add2menu(EDIT, (_("Show completions"), "Ctrl-Space")) self.add2menu(EDIT, (_("Cancel"), "Escape")) self.add2menu(EDIT, (FINISH, "Ctrl-S")) # start clock self.updateClock() self.year_month = [self.today.year, self.today.month] # showView will be called from updateClock self.updateAlerts() self.etmgeo = os.path.normpath(os.path.join(loop.options['etmdir'], ".etmgeo")) self.restoreGeometry() self.etmtimers = os.path.normpath(os.path.join(loop.options['etmdir'], ".etmtimers")) self.showWeekly() # hack to fix focus issue in agenda self.agendaView() def on_resize(self, event): if self.weekly: self.canvas.after_idle(self.showWeek, ) elif self.monthly: self.canvas.after_idle(self.showMonth, ) def bindTop(self, c, cmd, e=None): if e and e.char != c: # ignore Control-c return self.tree.bind(c, lambda e: self.after(AFTER, cmd(e))) self.canvas.bind(c, lambda e: self.after(AFTER, cmd(e))) def toggleLabels(self, e=None): if e and e.char != "l": return if self.labels: width0 = self.tree.column('#0')['width'] self.tree.column('#0', width=width0 + self.col2_width) self.tree.column('#1', width=0) self.labels = False else: width0 = self.tree.column('#0')['width'] self.tree.column('#0', width=width0 - self.col2_width) self.tree.column('#1', width=self.col2_width) self.labels = True def toggleFinished(self, e=None): if e and e.char != "x": return if loop.options['hide_finished']: loop.options['hide_finished'] = False else: loop.options['hide_finished'] = True logger.debug('reloading data') # self.updateAlerts() if self.weekly: self.updateDay() self.showWeek() elif self.monthly: self.updateDay() self.showMonth() else: self.showView() def saveGeometry(self): str = self.geometry() fo = open(self.etmgeo, 'w') fo.write(str) fo.close() def restoreGeometry(self): if os.path.isfile(self.etmgeo): fo = open(self.etmgeo, "r") str = fo.read() fo.close() tup = [x.strip() for x in str.split(',')] if tup: self.geometry(tup[0]) def closeItemMenu(self, event=None): if self.weekly or self.monthly: self.canvas.focus_set() else: self.tree.focus_set() self.itemmenu.unpost() def add2menu(self, parent, child): if child == (SEP, ): id = uuid.uuid1() elif len(child) > 1 and child[1]: id = uuid.uuid1() m = LASTLTR.search(child[1]) if m: child = tuple(child) else: id = child[0] if len(child) >= 2: leaf = "{0}::{1}".format(child[0], child[1]) else: leaf = "{0}::".format(child[0]) self.menutree.create_node(leaf, id, parent=parent) def confirm(self, parent=None, title="", prompt="", instance="xyz"): ok, value = OptionsDialog(parent=self, title=_("confirm").format(instance), prompt=prompt).getValue() return ok def selectCalendars(self): if self.default_calendars: prompt = _("Display items from calendars selected below.") title = CALENDARS if self.weekly or self.monthly: master = self.canvas else: master = self.tree values = OptionsDialog(parent=self, master=master, title=title, prompt=prompt, opts=loop.calendars, radio=False, yesno=False).getValue() if values != loop.calendars: loop.calendars = values loop.options['calendars'] = values data.setConfig(loop.options) self.updateCalendars() else: prompt = _("No calendars have been specified in etmtk.cfg.") self.textWindow(self, CALENDARS, prompt, opts=self.options) def updateCalendars(self, *args): cal_pattern = r'^%s' % '|'.join( [x[2] for x in loop.calendars if x[1]]) self.cal_regex = re.compile(cal_pattern) self.update() self.updateAlerts() if self.weekly: self.updateDay() self.showWeek() elif self.monthly: self.updateDay() self.showMonth() else: self.showView() def quit(self, e=None): ans = True if self.actionTimer.currentStatus == RUNNING: ans = self.confirm( title=_('Quit'), prompt=_("An action timer is running.\nDo you really want to quit?"), parent=self) else: ans = self.confirm( title=_('Quit'), prompt=_("Do you really want to quit?"), parent=self) if ans: self.actionTimer.pauseTimer() self.saveGeometry() self.destroy() def donothing(self, e=None): """For testing""" logger.debug('donothing') return "break" def openWithDefault(self, e=None): if not self.itemSelected or 'g' not in self.itemSelected: return(False) # path = self.itemSelected['g'] path = expand_template(self.itemSelected['g'], self.itemSelected) if windoz: os.startfile(path) return() if mac: cmd = 'open' + " {0}".format(path) else: cmd = 'xdg-open' + " {0}".format(path) subprocess.call(cmd, shell=True) return def printWithDefault(self, s, e=None): fo = codecs.open(loop.tmpfile, 'w', loop.options['encoding']['file']) # add a trailing formfeed fo.write(s) fo.close() if windoz: os.startfile(loop.tmpfile, "print") return else: cmd = "lp -s -o media='letter' -o cpi=12 -o lpi=8 -o page-left=48 -o page-right=48 -o page-top=48 -o page-bottom=48 {0}\n".format(loop.tmpfile) # cmd = "lpr -l {0}".format(loop.tmpfile) subprocess.call(cmd, shell=True) return def showUserDetails(self, e=None): if not self.itemSelected or 'u' not in self.itemSelected: return if not loop.options['user_data']: return user = self.itemSelected['u'] if user in loop.options['user_data']: detail = "\n".join(loop.options['user_data'][user]) else: detail = _("No record was found for {0}".format(user)) self.textWindow(self, user, detail, opts=loop.options) return def dateCalculator(self, event=None): prompt = """\ Enter an expression of the form "x [+-] y" where x is a date and y is either a date or a time period if "-" is used and a time period if "+" is used. Both x and y can be followed by timezones, e.g., 4/20 6:15p US/Central - 4/20 4:50p Asia/Shanghai = 14h25m 4/20 4:50p Asia/Shanghai + 14h25m US/Central = 2014-04-20 18:15-0500 The local timezone is used when none is given.""" GetString(parent=self, title=_('date and time calculator'), prompt=prompt, opts=loop.options, process=date_calculator) return def availableDateCalculator(self, event=None): prompt = """\ Enter an expression of the form start; end; busy where start and end are dates and busy is comma separated list of busy dates or busy intervals, .e.g, 6/1; 6/30; 6/2, 6/14-6/22, 6/5-6/9, 6/11-6/15, 6/17-6/29 returns: Sun Jun 01 Tue Jun 03 Wed Jun 04 Tue Jun 10 Mon Jun 30\ """ GetString(parent=self, title=_('available dates calculator'), prompt=prompt, opts={}, process=availableDates, font=self.tkfixedfont) return def exportToIcal(self, e=None): if self.itemSelected: self._exportItemToIcal() else: self._exportActiveToIcal() def _exportItemToIcal(self): if 'icsitem_file' in loop.options: res = export_ical_item(self.itemSelected, loop.options['icsitem_file']) if res: prompt = _("Selected item successfully exported to {0}".format(loop.options['icsitem_file'])) else: prompt = _("Could not export selected item.") else: prompt = "icsitem_file is not set in etmtk.cfg" MessageWindow(self, 'Selected Item Export', prompt) def _exportActiveToIcal(self, event=None): if 'icscal_file' in loop.options: res = export_ical_active(loop.file2uuids, loop.uuid2hash, loop.options['icscal_file'], loop.calendars) if res: prompt = _("Active calendars successfully exported to {0}".format(loop.options['icscal_file'])) else: prompt = _("Could not export active calendars.") else: prompt = "icscal_file is not set in etmtk.cfg" MessageWindow(self, 'Active Calendar Export', prompt) def newItem(self, e=None): # hack to avoid Ctrl-n activation if e and e.char != "n": return if self.weekly or self.monthly: master = self.canvas else: master = self.tree if self.view in [AGENDA, WEEK, MONTH]: if self.active_date: if self.itemSelected: if 's' in self.itemSelected: text = " @s {0}".format(self.active_date) elif 'c' in self.itemSelected: text = " @c {0}".format(self.itemSelected['c']) else: text = " " else: text = " @s {0}".format(self.active_date) elif self.canvas_date: text = " @s {0}".format(self.canvas_date) else: text = " " changed = SimpleEditor(parent=self, master=master, start=text, options=loop.options).changed elif self.view in [KEYWORD, NOTE] and self.itemSelected: if self.itemSelected and 'k' in self.itemSelected: text = " @k {0}".format(self.itemSelected['k']) else: text = "" changed = SimpleEditor(parent=self, master=master, start=text, options=loop.options).changed elif self.view in [TAG]: if self.itemSelected and 't' in self.itemSelected: text = " @t {0}".format(", ".join(self.itemSelected['t'])) else: text = "" changed = SimpleEditor(parent=self, master=master, start=text, options=loop.options).changed else: changed = SimpleEditor(parent=self, master=master, options=loop.options).changed if changed: logger.debug('changed, reloading data') loop.do_update = True self.updateAlerts() if self.weekly: self.updateDay() self.showWeek() elif self.monthly: self.updateDay() self.showMonth() else: self.showView() def which(self, act, instance="xyz"): prompt = "\n".join([ _("You have selected an instance of a repeating"), _("item. What do you want to {0}?").format(act)]) if act == DELETE: opt_lst = [ _("this instance"), _("this and all subsequent instances"), _("all instances"), _("all previous instances")] else: opt_lst = [ _("this instance"), _("this and all subsequent instances"), _("all instances")] if self.weekly or self.monthly: master = self.canvas else: master = self.tree index, value = OptionsDialog(parent=self, master=master, title=_("instance: {0}").format(instance), prompt=prompt, opts=opt_lst, yesno=False).getValue() return index, value def copyItem(self, e=None): """ newhsh = selected, rephsh = None """ if not self.itemSelected: return if e and e.char != 'c': return if 'r' in self.itemSelected: choice, value = self.which(COPY, self.dtSelected) logger.debug("{0}: {1}".format(choice, value)) if not choice: self.tree.focus_set() return self.itemSelected['_dt'] = self.dtSelected else: ans = self.confirm( parent=self.tree, title=_('Confirm'), prompt=_("Open a copy of this item?")) if not ans: self.tree.focus_set() return choice = 3 if self.weekly or self.monthly: master = self.canvas else: master = self.tree hsh_cpy = deepcopy(self.itemSelected) hsh_cpy['fileinfo'] = None title = _("new item") self.mode = 'new' if choice in [1, 2]: # we need to modify the copy according to the choice dt = hsh_cpy['_dt'].replace( tzinfo=tzlocal()).astimezone(gettz(hsh_cpy['z'])) dtn = dt.replace(tzinfo=None) if choice == 1: # this instance for k in ['_r', 'o', '+', '-']: if k in hsh_cpy: del hsh_cpy[k] hsh_cpy['s'] = dtn elif choice == 2: # this and all subsequent instances if u'+' in hsh_cpy: tmp_cpy = [] for d in hsh_cpy['+']: if d >= dtn: tmp_cpy.append(d) hsh_cpy['+'] = tmp_cpy if u'-' in hsh_cpy: tmp_cpy = [] for d in hsh_cpy['-']: if d >= dtn: tmp_cpy.append(d) hsh_cpy['-'] = tmp_cpy hsh_cpy['s'] = dtn changed = SimpleEditor(parent=self, master=master, newhsh=hsh_cpy, rephsh=None, options=loop.options, title=title, modified=True).changed if changed: self.updateAlerts() if self.weekly: self.updateDay() self.showWeek() elif self.monthly: self.updateDay() self.showMonth() else: self.showView(row=self.topSelected) else: if self.weekly or self.monthly: self.canvas.focus_set() else: self.tree.focus_set() def setmessageAlert(self, e=None): hsh = self.alertHsh alertId = hsh['alertId'] # (summary, s) if alertId in self.messageAlerts: default_minutes = self.messageAlerts[alertId][0] else: default_minutes = loop.options['snooze_minutes'] msg = _("""\ ----------------------------------------------------------- Repeat this alert? This is the last alert scheduled for this item. To repeat it, enter the number of minutes from now for the repetition.\ """) alert_msg = _("""\ {0} ({1}) {2} {3}\ """.format( expand_template('!summary!', hsh), expand_template('!when!', hsh), expand_template(self.options['alert_template'], hsh), msg)) minutes = GetRepeat( parent=self, title=_("alert - {0}".format(fmt_time(self.now, options=loop.options))), prompt=alert_msg, opts=[1,], default=default_minutes, close=self.options['message_last']*1000 ).value if not minutes: if alertId in self.messageAlerts: del self.messageAlerts[alertId] self.updateAlerts() return # we're snoozing for "minutes" after hitting snooze now = datetime.now() if now.second > 30: now = now + ONEMINUTE now = now.replace(second=0, microsecond=0) hsh['at'] = now + minutes * ONEMINUTE wait = (hsh['at'] - datetime.now()).seconds * 1000 alert_id = self.after(wait, self.clearmessageAlert, alertId) self.messageAlerts[alertId] = [minutes, hsh, alert_id] self.updateAlertList() def clearmessageAlert(self, alertId): if ('snooze_command' in self.options and self.options['snooze_command']): ccmd = self.options['snooze_command'] subprocess.call(ccmd, shell=True) else: Tk.bell(self) self.alertHsh = self.messageAlerts[alertId][1] self.setmessageAlert() def setcountdownTimer(self, e=None): """ get time period, default integer minutes, start timer """ if self.countdownActive: # prompt to cancel ans = self.confirm( title=_('Confirm'), prompt=_("Cancel the countdown?"), parent=self.tree) if ans: self.after_cancel(self.countdownActive) self.countdownActive = False self.countdownTime = None self.setcountdownStatus() self.countdownMinutes = loop.options['countdown_minutes'] return prompt = _("""\ Start a countdown timer? Enter an integer number of minutes for the timer below.""") mm = GetInteger(parent=self, title=_("Countdown timer"), prompt=prompt, opts=[1,], default=self.countdownMinutes).value if not mm: # reset the default self.countdownMinutes = loop.options['countdown_minutes'] return self.countdownMinutes = mm ms = mm * 60 * 1000 self.countdownTime = datetime.now() + mm * ONEMINUTE self.setcountdownStatus() self.countdownActive = self.after(ms, self.clearcountdownTimer) def clearcountdownTimer(self, e=None): self.countdownActive = False self.countdownTime = None self.setcountdownStatus() if ('countdown_command' in self.options and self.options['countdown_command']): ccmd = self.options['countdown_command'] subprocess.call(ccmd, shell=True) else: Tk.bell(self) self.setcountdownTimer() def setcountdownStatus(self, e=None): if self.countdownTime: ds = fmt_time(self.countdownTime, seconds=True, options=self.options) self.countdownStatus.set(ds) else: self.countdownStatus.set("") def deleteItem(self, e=None): if not self.itemSelected: return logger.debug('{0}: {1}'.format(self.itemSelected['_summary'], self.dtSelected)) indx = 3 if 'r' in self.itemSelected: indx, value = self.which(DELETE, self.dtSelected) logger.debug("{0}: {1}/{2}".format(self.dtSelected, indx, value)) if not indx: if self.weekly or self.monthly: self.canvas.focus_set() else: self.tree.focus_set() return self.itemSelected['_dt'] = self.dtSelected else: ans = self.confirm( title=_('Confirm'), prompt=_("Delete this item?"), parent=self.tree) if not ans: self.tree.focus_set() return loop.item_hsh = self.itemSelected loop.cmd_do_delete(indx) if 's' in self.itemSelected: alertId = (self.itemSelected['_summary'], self.itemSelected['s']) else: alertId = None if alertId and alertId in self.messageAlerts: # cancel exising snooze - no need to updateAlerts self.after_cancel(self.messageAlerts[alertId][2]) del self.messageAlerts[alertId] self.updateAlertList() self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showMonth() else: self.tree.focus_set() self.showView(row=self.topSelected) self.filterView() def moveItem(self, e=None): if not self.itemSelected: return if e and e.char != 'm': return logger.debug('{0}: {1}'.format(self.itemSelected['_summary'], self.dtSelected)) oldrp, begline, endline = self.itemSelected['fileinfo'] oldfile = os.path.join(loop.options['datadir'], oldrp) newfile = self.getDataFile(title="moving from {0}:".format(oldrp), start=oldfile) if not (newfile and os.path.isfile(newfile)): return if newfile == oldfile: return ret = loop.append_item(self.itemSelected, newfile) if ret != "break": # post message and quit prompt = _("""\ Adding item to {1} failed - aborted removing item from {2}""".format( newfile, oldfile)) MessageWindow(self, 'Error', prompt) return loop.item_hsh = self.itemSelected ret = loop.delete_item() self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showWeek() else: self.tree.focus_set() self.showView(row=self.topSelected) def editItem(self, e=None): if not self.itemSelected: return logger.debug('starting editItem: {0}; {1}, {2}'.format(self.itemSelected['_summary'], self.dtSelected, type(self.dtSelected))) if self.weekly or self.monthly: master = self.canvas else: master = self.tree choice = 3 title = ETM start_text = None filename = None if self.itemSelected['itemtype'] == '$': start_text = self.itemSelected['entry'] hsh_rev = deepcopy(self.itemSelected) elif 'r' in self.itemSelected: # repeating choice, value = self.which(EDIT, self.dtSelected) logger.debug("{0}: {1}".format(choice, value)) if self.weekly or self.monthly: self.canvas.focus_set() else: self.tree.focus_set() logger.debug(('dtSelected: {0}, {1}'.format(type(self.dtSelected), self.dtSelected))) self.itemSelected['_dt'] = self.dtSelected if not choice: self.tree.focus_set() return hsh_rev = hsh_cpy = None self.mode = 2 # replace if choice in [1, 2]: self.mode = 3 # new and replace - both newhsh and rephsh title = _("new item") hsh_cpy = deepcopy(self.itemSelected) hsh_rev = deepcopy(self.itemSelected) # we will be editing and adding hsh_cpy and replacing hsh_rev dt = hsh_cpy['_dt'].replace( tzinfo=tzlocal()).astimezone(gettz(hsh_cpy['z'])) dtn = dt.replace(tzinfo=None) if choice == 1: # this instance if 'f' in hsh_rev: for i in range(len(hsh_rev['f'])): d = hsh_rev['f'][i][0] if d == dtn: hsh_cpy['f'] = hsh_rev['f'].pop(i) break if '+' in hsh_rev and dtn in hsh_rev['+']: hsh_rev['+'].remove(dtn) if not hsh_rev['+'] and hsh_rev['r'] == 'l': del hsh_rev['r'] del hsh_rev['_r'] else: hsh_rev.setdefault('-', []).append(dt) for k in ['_r', 'o', '+', '-']: if k in hsh_cpy: del hsh_cpy[k] hsh_cpy['s'] = dtn elif choice == 2: # this and all subsequent instances tmp_cpy = [] if 'f' in hsh_rev: for i in range(len(hsh_rev['f'])): d = hsh_rev['f'][i][0] if d >= dtn: tmp_cpy.append(hsh_rev['f'].pop(i)) if tmp_cpy: hsh_cpy['f'] = tmp_cpy # dtn will be the done date, we need the due date for the copy dtn = hsh_cpy['f'][0][1] print('got dtn', dtn) else: del hsh_cpy['f'] tmp_rev = [] for h in hsh_rev['_r']: if 'f' in h and h['f'] != u'l': h['u'] = dtn - ONEMINUTE tmp_rev.append(h) if tmp_rev: hsh_rev['_r'] = tmp_rev else: del hsh_rev['_r'] if u'+' in hsh_rev: tmp_rev = [] tmp_cpy = [] for d in hsh_rev['+']: if d < dtn: tmp_rev.append(d) else: tmp_cpy.append(d) if tmp_rev: hsh_rev['+'] = tmp_rev else: del hsh_rev['+'] if tmp_cpy: hsh_cpy['+'] = tmp_cpy else: del hsh_cpy['+'] if u'-' in hsh_rev: tmp_rev = [] tmp_cpy = [] for d in hsh_rev['-']: if d < dtn: tmp_rev.append(d) else: tmp_cpy.append(d) if tmp_rev: hsh_rev['-'] = tmp_rev else: del hsh_rev['-'] if tmp_cpy: hsh_cpy['-'] = tmp_cpy else: del hsh_cpy['-'] hsh_cpy['s'] = dtn else: # replace self.mode = 2 hsh_rev = deepcopy(self.itemSelected) logger.debug("mode: {0}; newhsh: {1}; rephsh: {2}".format(self.mode, hsh_cpy is not None, hsh_rev is not None)) changed = SimpleEditor(parent=self, master=master, file=filename, newhsh=hsh_cpy, rephsh=hsh_rev, options=loop.options, title=title, start=start_text).changed if changed: logger.debug("starting if changed") loop.do_update = True if 's' in self.itemSelected: alertId = (self.itemSelected['_summary'], self.itemSelected['s']) else: alertId = None if alertId and alertId in self.messageAlerts: # cancel exising snooze - no need to updateAlerts self.after_cancel(self.messageAlerts[alertId][2]) del self.messageAlerts[alertId] self.updateAlertList() self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showMonth() else: self.tree.focus_set() self.showView(row=self.topSelected) self.update_idletasks() logger.debug("leaving if changed") else: if self.weekly or self.monthly: self.canvas.focus_set() else: self.tree.focus_set() logger.debug('ending editItem') self.filterView() return def editItemFile(self, e=None): if not self.itemSelected: return logger.debug('starting editItemFile: {0}; {1}, {2}, {3}'.format(self.itemSelected['_summary'], self.dtSelected, type(self.dtSelected), self.itemSelected['fileinfo'])) self.editFile(e, file=os.path.join(loop.options['datadir'], self.itemSelected['fileinfo'][0]), line=self.itemSelected['fileinfo'][1]) def editFile(self, e=None, file=None, line=None, config=False): if e and e.char and e.char not in ["F", "E", "C", "S", "P"]: logger.debug('e.char: "{0}"'.format(e.char)) return titlefile = os.path.normpath(relpath(file, loop.options['datadir'])) logger.debug('file: {0}; config: {1}'.format(file, config)) if self.weekly or self.monthly: master = self.canvas else: master = self.tree changed = SimpleEditor(parent=self, master=master, file=file, line=line, options=loop.options, title=titlefile).changed logger.debug('changed: {0}'.format(changed)) if changed: logger.debug("config: {0}".format(config)) if config: current_options = deepcopy(loop.options) (user_options, options, use_locale) = data.get_options( d=loop.options['etmdir']) loop.options = self.options = options if options['calendars'] != current_options['calendars']: self.updateCalendars() loop.do_update = True logger.debug("changed - calling updateAlerts") self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showMonth() else: self.tree.focus_set() self.showView() return changed def editConfig(self, e=None): file = loop.options['config'] self.editFile(e, file=file, config=True) def editCfgFiles(self, e=None): other = [] if 'colors' in loop.options: other.append(loop.options['colors']) if 'cfg_files' in loop.options: for key in ['completions', 'reports', 'users']: other.extend(loop.options['cfg_files'][key]) prefix, tuples = getFileTuples(loop.options['datadir'], include=r'*.cfg', other=other) ret = FileChoice(self, "open configuration file", prefix=prefix, list=tuples).returnValue() if not (ret and os.path.isfile(ret)): return False self.editFile(e, file=ret, config=True) def getReportsFile(self, e=None): ret = FileChoice(self, title="append to reports file", prefix=self.loop.options['etmdir'], list=self.loop.options['report_files']).returnValue() if not (ret and os.path.isfile(ret)): return False return ret def getDataFile(self, e=None, title="data file", start=''): prefix, tuples = getFileTuples(loop.options['datadir'], include=r'*.txt') ret = FileChoice(self, title=title, prefix=prefix, list=tuples, start=start).returnValue() if not (ret and os.path.isfile(ret)): return False return ret def editScratch(self, e=None): file = loop.options['scratchpad'] self.editFile(e, file=file) def editColors(self, e=None): file = loop.options['colors'] self.editFile(e, file=file) def editData(self, e=None): if e and e.char != "F": return prefix, tuples = getFileTuples(loop.options['datadir'], include=r'*.txt', all=False) ret = FileChoice(self, "open data file", prefix=prefix, list=tuples).returnValue() if not (ret and os.path.isfile(ret)): return False self.editFile(e, file=ret) def newFile(self, e=None): if e and e.char != "N": return other = [os.path.join(loop.options['etmdir'], 'etmtk.cfg')] if 'cfg_files' in loop.options: for key in ['completions', 'reports', 'users']: other.extend(loop.options['cfg_files'][key]) prefix, tuples = getFileTuples(loop.options['datadir'], include=r'*', other=other, all=True) filename = FileChoice(self, "create new file", prefix=prefix, list=tuples, new=True).returnValue() if not filename: return if os.path.isfile(filename): prompt = _("Aborting. File {0} already exists.").format(filename) MessageWindow(self, title=_("new file"), prompt=prompt) else: pth = os.path.split(filename)[0] if not os.path.isdir(pth): os.makedirs(pth) fo = codecs.open(filename, 'w', loop.options['encoding']['file']) fo.write("") fo.close() prompt = _('created: {0}').format(filename) if self.weekly or self.monthly: p = self.canvas else: p = self.tree ans = self.confirm( parent=p, title=_('etm'), prompt=_("file: {0}\nhas been created.\nOpen it for editing?").format(filename)) if ans: self.editFile(None, filename) def finishItem(self, e=None): if e and e.char != "f": return if not (self.itemSelected and self.itemSelected['itemtype'] in ['-', '+', '%']): return prompt = _("""\ Enter the completion date for the item or return an empty string to use the current date. Relative dates and fuzzy parsing are supported.""") d = GetDateTime(parent=self, title=_('date'), prompt=prompt) chosen_day = d.value if chosen_day is None: return () logger.debug('completion date: {0}'.format(chosen_day)) loop.item_hsh = self.itemSelected if 's' in self.itemSelected: alertId = (self.itemSelected['_summary'], self.itemSelected['s']) else: alertId = None loop.cmd_do_finish(chosen_day, options=loop.options) if alertId and alertId in self.messageAlerts: # cancel exising snooze - no need to updateAlerts self.after_cancel(self.messageAlerts[alertId][2]) del self.messageAlerts[alertId] self.updateAlertList() else: self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showMonth() else: self.tree.focus_set() self.showView(row=self.topSelected) self.filterView() def rescheduleItem(self, e=None): if e and e.char != 'r': return if not self.itemSelected: return loop.item_hsh = self.itemSelected if self.dtSelected: loop.old_dt = old_dt = self.dtSelected title = _('rescheduling {0}').format(old_dt.strftime( rrulefmt)) else: loop.old_dt = None title = _('scheduling an undated item') logger.debug('dtSelected: {0}'.format(self.dtSelected)) prompt = _("""\ Enter the new date and time for the item or return an empty string to use the current time. Relative dates and fuzzy parsing are supported.""") dt = GetDateTime(parent=self, title=title, prompt=prompt) new_dt = dt.value if new_dt is None: return if 's' in self.itemSelected: alertId = (self.itemSelected['_summary'], self.itemSelected['s']) else: alertId = None if alertId and alertId in self.messageAlerts: # cancel exising snooze - no need to updateAlerts self.after_cancel(self.messageAlerts[alertId][2]) del self.messageAlerts[alertId] self.updateAlertList() else: self.updateAlerts() new_dtn = new_dt.astimezone(gettz(self.itemSelected['z'])).replace(tzinfo=None) logger.debug('rescheduled from {0} to {1}'.format(self.dtSelected, new_dtn)) loop.cmd_do_reschedule(new_dtn) self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showMonth() else: self.tree.focus_set() self.showView(row=self.topSelected) self.filterView() def scheduleNewItem(self, e=None): if e and e.char != 's': return if not self.itemSelected: return loop.item_hsh = self.itemSelected if self.dtSelected: loop.old_dt = self.dtSelected title = _('adding new instance') else: loop.old_dt = None title = _('scheduling an undated item') logger.debug('dtSelected: {0}'.format(self.dtSelected)) prompt = _("""\ Enter the new date and time for the item or return an empty string to use the current time. Relative dates and fuzzy parsing are supported.""") dt = GetDateTime(parent=self, title=title, prompt=prompt) new_dt = dt.value if new_dt is None: return new_dtn = new_dt.astimezone(gettz(self.itemSelected['z'])).replace(tzinfo=None) logger.debug('scheduled new instance: {0}'.format(new_dtn)) loop.cmd_do_schedulenew(new_dtn) self.updateAlerts() if self.weekly: self.canvas.focus_set() self.updateDay() self.showWeek() elif self.monthly: self.canvas.focus_set() self.updateDay() self.showMonth() else: self.tree.focus_set() self.showView(row=self.topSelected) self.filterView() def showDateTimeDetails(self, e=None): if not self.itemSelected: return if e and e.char != 'd': return pre = post = warn = "" hsh = self.itemSelected if 'r' in hsh: pre = _("Repeating ") elif 's' in hsh: dt = hsh['s'] if hsh['itemtype'] in ['*', '~']: dtfmt = fmt_shortdatetime(hsh['s'], self.options) else: if not dt.hour and not dt.minute: dtfmt = fmt_date(dt, short=True) else: dtfmt = fmt_shortdatetime(hsh['s'], self.options) post = _(" starting {0}.").format(dtfmt) else: # unscheduled pre = _("Unscheduled ") prompt = "{0}{1}{2}".format(pre, type2Text[hsh['itemtype']], post) if 'r' in hsh: showing_all, reps = get_reps(self.loop.options['bef'], hsh) try: repsfmt = [unicode(x.strftime(rrulefmt)) for x in reps] except: repsfmt = [unicode(x.strftime("%X %x")) for x in reps] logger.debug("{0}: {1}".format(showing_all, repsfmt)) if showing_all: reps = ALLREPS else: reps = SOMEREPS prompt = "{0}, {1}:\n\n {2}".format(prompt, reps, "\n ".join(repsfmt)) self.textWindow(parent=self, title=_("Date and time details"), prompt=prompt, opts=self.options) def showAlerts(self, e=None): # hack to avoid activating with Ctrl-a if e and e.char != "a": return t = _('remaining alerts for today') header = "{0:^10}\t{1:^7}\t{2:^10}{3:<26}".format( _('alert'), _('event'), _('type'), _('summary')) divider = '-' * 55 if self.activeAlerts: s = '%s\n%s\n%s' % ( header, divider, "\n".join( ["{0:^10}\t{1:^7}\t{2:^10}{3:<26}".format(x[1], x[2], x[3], x[4]) for x in self.activeAlerts])) else: s = _("None ") self.textWindow(self, t, s, opts=self.options) def adjustIdle(self, e=None): if not self.actionTimer.currentTimer: return timer = self.actionTimer.currentTimer now = datetime.now() restart = False if self.actionTimer.idlestart: idle = (now - self.actionTimer.idlestart) + self.actionTimer.idletime restart = True elif self.actionTimer.idletime: idle = self.actionTimer.idletime else: idle = 0 * ONEMINUTE if idle < ONEMINUTE: return # get idle time in integer minutes im = idle.days * 24 * 60 im += idle.seconds // 60 hsh = self.actionTimer.activeTimers[timer] tot = hsh['total'] if self.actionTimer.currentStatus == RUNNING: tot += now - hsh['start'] prompt = _("""\ Currently "{0}" has an elapsed time of {1}. Enter the number of minutes that you would like to add to this timer and subtract from idle time, currently {2}.""".format(timer, fmt_period(tot), fmt_period(idle))) mm = GetInteger(parent=self, title=_("Adjust timer"), prompt=prompt, opts=[1,im], default=1).value if not mm: return d = mm * ONEMINUTE if restart: self.actionTimer.idlestart = now self.actionTimer.idletime = idle self.actionTimer.idletime -= d self.actionTimer.activeTimers[timer]['total'] += d self.updateTimerStatus() self.actionTimer.saveTimers() def agendaView(self, e=None): self.setView(AGENDA) def dayView(self, e=None): self.setView(DAY) def pathView(self, e=None): self.setView(PATH) def keywordView(self, e=None): self.setView(KEYWORD) def tagView(self, e=None): self.setView(TAG) def customView(self, e=None): # TODO: finish this self.content.delete("1.0", END) # self.fltr.forget() self.clearTree() self.setView(CUSTOM) pass def noteView(self, e=None): self.setView(NOTE) def updateDay(self, e=None): self.mode = "command" self.process_input(event=e, cmd='d') def setView(self, view, row=None): self.rowSelected = None if view in [DAY, WEEK, MONTH]: self.toolsmenu.entryconfig(1, state="normal") else: self.toolsmenu.entryconfig(1, state="disabled") if self.weekly and view not in [DAY, WEEK]: self.closeWeekly() if self.monthly and view not in [DAY, MONTH]: self.closeMonthly() if view == CUSTOM: logger.debug('showing custom_box') self.fltr.forget() self.custom_box.pack(side="left", fill=X, padx=0, expand=1) self.custom_box.focus_set() for i in range(len(self.rm_options)): self.custommenu.entryconfig(i, state="normal") else: if self.view == CUSTOM: # we're leaving custom view logger.debug('removing custom_box') self.custom_box.forget() self.fltr.pack(side="left", padx=0, expand=1, fill=X) for i in range(len(self.rm_options)): self.custommenu.entryconfig(i, state="disabled") self.saveSpecs() self.view = view logger.debug("setView view: {0}. Calling showView.".format(view)) self.showView(row=row) def filterView(self, e=None, *args): self.depth2id = {} fltr = self.filterValue.get() cn = self.vm_options[self.vm_opts.index(self.view)][1] if cn in ['w', 'm']: # with week or month views use the day view command cn = 'd' cmd = "{0} {1}".format(cn, fltr) self.mode = 'command' self.process_input(event=e, cmd=cmd) def showView(self, e=None, row=None): tt = TimeIt(loglevel=2, label="{0} view".format(self.view)) self.depth2id = {} self.currentView.set(self.view) fltr = self.filterValue.get() if self.view != CUSTOM: cmd = "{0} {1}".format( self.vm_options[self.vm_opts.index(self.view)][1], fltr) self.mode = 'command' self.process_input(event=e, cmd=cmd) if row: row = max(0, row - 1) self.tree.yview(row) tt.stop() def showBusyPeriods(self, e=None): if e and e.char != "b": return if self.busy_info is None: return() theweek, weekdays, busy_lst, occasion_lst = self.busy_info theweek = _("Busy periods in {0}").format(theweek) lines = [theweek, '-' * len(theweek)] ampm = loop.options['ampm'] s1 = s2 = '' for i in range(len(busy_lst)): times = [] for tup in busy_lst[i]: t1 = max(7 * 60, tup[0]) t2 = min(23 * 60, max(420, tup[1])) if t1 != t2: t1h, t1m = (t1 // 60, t1 % 60) t2h, t2m = (t2 // 60, t2 % 60) if ampm: if t1h == 12: s1 = 'pm' elif t1h > 12: t1h -= 12 s1 = 'pm' else: s1 = 'am' if t2h == 12: s2 = 'pm' elif t2h > 12: t2h -= 12 s2 = 'pm' else: s2 = 'am' T1 = "%d:%02d%s" % (t1h, t1m, s1) T2 = "%d:%02d%s" % (t2h, t2m, s2) times.append("%s-%s" % (T1, T2)) if times: lines.append("%s: %s" % (weekdays[i], "; ".join(times))) s = "\n".join(lines) self.textWindow(parent=self, title=_('busy times'), prompt=s, opts=self.options) def showFreePeriods(self, e=None): if e and e.char != 'f': return if self.busy_info is None or 'freetimes' not in loop.options: return() ampm = loop.options['ampm'] om = loop.options['freetimes']['opening'] cm = loop.options['freetimes']['closing'] mm = loop.options['freetimes']['minimum'] bm = loop.options['freetimes']['buffer'] prompt = _("""\ Enter the shortest time period you want displayed in minutes.""") mm = GetInteger(parent=self, title=_("depth"), prompt=prompt, opts=[0], default=mm).value if mm is None: self.canvas.focus_set() return theweek, weekdays, busy_lst, occasion_lst = self.busy_info theweek = _("Free periods in {0}").format(theweek) lines = [theweek, '-' * len(theweek)] s1 = s2 = '' for i in range(len(busy_lst)): times = [] busy = [] for tup in busy_lst[i]: t1 = max(om, tup[0] - bm) t2 = min(cm, max(om, tup[1]) + bm) if t2 > t1: busy.append((t1, t2)) lastend = om free = [] for tup in busy: if tup[0] - lastend >= mm: free.append((lastend, tup[0])) lastend = tup[1] if cm - lastend >= mm: free.append((lastend, cm)) for tup in free: t1, t2 = tup if t1 != t2: t1h, t1m = (t1 // 60, t1 % 60) t2h, t2m = (t2 // 60, t2 % 60) if ampm: if t1h == 12: s1 = 'pm' elif t1h > 12: t1h -= 12 s1 = 'pm' else: s1 = 'am' if t2h == 12: s2 = 'pm' elif t2h > 12: t2h -= 12 s2 = 'pm' else: s2 = 'am' T1 = "%d:%02d%s" % (t1h, t1m, s1) T2 = "%d:%02d%s" % (t2h, t2m, s2) times.append("%s-%s" % (T1, T2)) if times: lines.append("%s: %s" % (weekdays[i], "; ".join(times))) lines.append('-' * len(theweek)) lines.append("Only periods of at least {0} minutes are displayed.".format(mm)) s = "\n".join(lines) self.textWindow(parent=self, title=_('free times'), prompt=s, opts=self.options) def getWeek(self, chosen_day=None): if chosen_day is None: chosen_day = get_current_time().date() yn, wn, dn = chosen_day.isocalendar() self.prev_week = chosen_day - 7 * ONEDAY self.next_week = chosen_day + 7 * ONEDAY self.curr_week = chosen_day if dn > 1: days = dn - 1 else: days = 0 if type(chosen_day) is not date: chosen_day = chosen_day.date() self.week_beg = weekbeg = chosen_day - days * ONEDAY logger.debug('week_beg: {0}'.format(self.week_beg)) weekend = chosen_day + (6 - days) * ONEDAY weekdays = [] weekdates = [] day = weekbeg self.active_date = weekbeg busy_lst = [] occasion_lst = [] matching = self.cal_regex is not None and self.default_regex is not None while day <= weekend: weekdays.append(s2or3(day.strftime("%a"))) weekdates.append(leadingzero.sub("", day.strftime("%d"))) isokey = day.isocalendar() day = day + ONEDAY ybeg = weekbeg.year yend = weekend.year mbeg = weekbeg.month mend = weekend.month # busy_lst: list of days 0 (monday) - 6 (sunday) where each day is a list of (start minute, end minute, id, summary-time str and file info) tuples if mbeg == mend: header = "{0} - {1}".format( fmt_dt(weekbeg, '%b %d'), fmt_dt(weekend, '%d, %Y')) elif ybeg == yend: header = "{0} - {1}".format( fmt_dt(weekbeg, '%b %d'), fmt_dt(weekend, '%b %d, %Y')) else: header = "{0} - {1}".format( fmt_dt(weekbeg, '%b %d, %Y'), fmt_dt(weekend, '%b %d, %Y')) header = leadingzero.sub('', header) theweek = _("{0} {1}: {2}").format(_("Week"), wn, header) return theweek, weekdays, weekdates def closeWeekly(self, event=None): self.week_height = self.topwindow.panecget(self.toppane, "height") self.topwindow.forget(self.toppane) self.weekly = False self.tree.pack(fill="both", expand=1, padx=4, pady=0) self.update_idletasks() for i in range(4, 6): self.toolsmenu.entryconfig(i, state="disabled") self.bind("", self.setFilter) def showWeekly(self, event=None, chosen_day=None): """ Open the canvas at the current week """ self.custom_box.forget() tt = TimeIt(loglevel=2, label="week view") logger.debug("chosen_day: {0}; active_date: {1}".format(chosen_day, self.active_date)) if self.weekly: # we're in weekview already return if self.monthly: self.closeMonthly() self.content.delete("1.0", END) self.setView(DAY) self.view = WEEK if chosen_day is not None: self.chosen_date = chosen_day elif self.active_date: self.chosen_date = self.active_date else: self.chosen_date = get_current_time().date() self.topwindow.add(self.toppane, padx=0, pady=0, before=self.botwindow, height=self.week_height) if self.options['ampm']: self.hours = ["{0}am".format(i) for i in range(7, 12)] + ['12pm'] + ["{0}pm".format(i) for i in range(1, 12)] else: self.hours = ["{0}:00".format(i) for i in range(7, 24)] for i in range(4, 6): self.toolsmenu.entryconfig(i, state="normal") self.showWeek(event=event, week=0) self.weekly = True self.canvas.focus_set() tt.stop() def priorWeekMonth(self, event=None): if self.weekly: self.showWeek(event, week=-1) elif self.monthly: self.showMonth(event, month=-1) def nextWeekMonth(self, event=None): if self.weekly: self.showWeek(event=event, week=1) elif self.monthly: self.showMonth(event=event, month=1) def showWeek(self, event=None, week=None): self.selectedId = None matching = self.cal_regex is not None and self.default_regex is not None busy_dates = [] self.current_day = get_current_time().date() logger.debug('self.current_day: {0}, minutes: {1}'.format(self.current_day, self.current_minutes)) self.x_win = self.toppane.winfo_width() # self.y_win = self.canvas.winfo_height() self.y_win = self.toppane.winfo_height() logger.debug("win: {0}, {1}".format(self.x_win, self.y_win)) logger.debug("event: {0}, week: {1}, chosen_day: {2}".format(event, week, self.chosen_date)) use_active = False if week in [-1, 0, 1]: if week == 0: day = get_current_time() elif week == 1: day = self.next_week elif week == -1: day = self.prev_week if type(day) is not date: day = day.date() self.chosen_date = day elif self.active_date: use_active = True self.year_month = [self.active_date.year, self.active_date.month] day = self.chosen_date = self.active_date else: return logger.debug('week active_date: {0}'.format(self.active_date)) theweek, weekdays, weekdates = self.getWeek(day) busy_lst = [] occasion_lst = [] weekdaynum = day.isocalendar()[2] # reset day to Monday of the current week day = day - (weekdaynum - 1) * ONEDAY if use_active: scrolldate = self.chosen_date self.canvas_idpos = weekdaynum - 1 else: scrolldate = day self.canvas_idpos = 0 self.scrollToDate(scrolldate) self.OnSelect() self.canvas.delete("all") l = 5 r = 5 t = 22 b = 5 if event: logger.debug('event: {0}'.format(event)) w, h = event.width, event.height if type(w) is int and type(h) is int: self.canvas_width = w self.canvas_height = h else: w = self.canvas.winfo_width() h = self.canvas.winfo_height() else: w = self.canvas.winfo_width() h = self.week_height logger.debug("w: {0} {1}; h: {2} {3}, l: {4} {5}, t: {6} {7}".format(w, type(w), h, type(h), l, type(l), t, type(t))) self.margins = (w, h, l, r, t, b) self.week_x = x_ = Decimal(w - 1 - l - r) / Decimal(7) self.week_y = y_ = Decimal(h - 1 - t - b) logger.debug("x: {0}, y: {1}".format(x_, y_)) # week self.currentView.set(theweek) self.busyHsh = {} # occasions busy_ids = set([]) monthid2date = {} # weekdays intervals = [360, 720, 1080, 1440] busywidth = 2 offset = 6 indent = 7 nightcolor = self.BUSYBAR morningcolor = self.BUSYBAR afternooncolor = self.BUSYBAR eveningcolor = self.BUSYBAR conf_ids = [] self.today_id = None self.timeline = None self.last_minutes = None for i in range(7): fill = self.CURRDATE flagcolor = None busytimes = 0 start_x = l + i * x_ end_x = start_x + x_ start_y = int(t) end_y = start_y + y_ xy = int(start_x), int(start_y), int(end_x), int(end_y) p = int(l + x_ / 2 + x_ * i), int(t + y_ / 2) tl_x = bl_x = int(l + x_ * i) tl_y = tr_y = int(t) tr_x = br_x = int(tl_x + x_) bl_y = br_y = int(tl_y + y_) w_ = x_ - 12 h_ = y_ - 12 thisdate = (day + i * ONEDAY) isokey = thisdate.isocalendar() tags = [] id = self.canvas.create_rectangle(xy, outline="", width=0) busy_ids.add(id) monthid2date[id] = thisdate today = (thisdate == self.current_day) if today: tags.append('current_day') if loop.occasions is not None and isokey in loop.occasions: bt = [] for item in loop.occasions[isokey]: it = list(item) if matching: if not self.cal_regex.match(it[-1]): continue mtch = (self.default_regex.match(it[-1]) is not None) else: mtch = True it.append(mtch) item = tuple(it) bt.append(item) occasion_lst.append(bt) if bt: if not today: tags.append('occasion') self.busyHsh.setdefault(id, []).extend(["^ {0}".format(x[0]) for x in bt]) else: occasion_lst.append([]) if loop.busytimes is not None and isokey in loop.busytimes: bt = [] overlap = False for item in loop.busytimes[isokey]: it = list(item) if it[0] == it[1]: # skip reminders continue if matching: if not self.cal_regex.match(it[-1]): continue mtch = (self.default_regex.match(it[-1]) is not None) else: mtch = True it.append(mtch) item = tuple(it) bt.append(item) busy_lst.append(bt) busy_dates.append(thisdate.strftime("%a %d")) if bt: lastend = 0 for pts in bt: busytimes += pts[1] - pts[0] self.busyHsh.setdefault(id, []).append("* {0}".format(pts[2])) if pts[0] < lastend: overlap = True lastend = pts[1] if overlap: flagcolor = self.CONFLICTFILL tags.append('busy') busylines = [[], [], [], []] # each side 360 minutes plus 2 times bar width for pts in bt: pt1 = max(0, pts[0]) pt2 = min(pts[1], 1440) tmp = [[], [], [], []] for ii in range(0, 4): if pt1 >= intervals[ii]: continue tmp[ii].append(pt1) for jj in range(ii, 4): if jj > ii: tmp[jj].append(intervals[jj-1]) if pt2 <= intervals[jj]: tmp[jj].append(pt2) break else: tmp[jj].append(intervals[jj]) break for ii in range(4): if tmp[ii]: busylines[ii].append(tmp[ii]) if busylines: for side in range(4): lines = busylines[side] if lines: if side == 0: # left for line in lines: bx = ex = bl_x + offset by = bl_y - indent - int(Decimal((line[0])/360) * h_) ey = bl_y - indent - int(Decimal((line[1])/360) * h_) self.canvas.create_line((bx, by, ex, ey), fill=nightcolor, width=busywidth, tag="busy") elif side == 1: # top for line in lines: by = ey = tl_y + offset bx = tl_x + indent + int(Decimal((line[0]-360)/360) * w_) ex = tl_x + indent + int(Decimal((line[1]-360)/360) * w_) self.canvas.create_line((bx, by, ex, ey), fill=morningcolor, width=busywidth, tag="busy") elif side == 2: # right for line in lines: bx = ex = tr_x - offset by = tr_y + indent + int(Decimal((line[0]-720)/360) * h_) ey = tr_y + indent + int(Decimal((line[1]-720)/360) * h_) self.canvas.create_line((bx, by, ex, ey), fill=afternooncolor, width=busywidth, tag="busy") elif side == 3: # bottom for line in lines: by = ey = br_y - offset bx = br_x - indent - int(Decimal((line[0]-1080)/360) * w_) ex = br_x - indent - int(Decimal((line[1]-1080)/360) * w_) self.canvas.create_line((bx, by, ex, ey), fill=eveningcolor, width=busywidth, tag="busy") bx = bl_x + offset - 1.5 * busywidth ex = bl_x + offset + .5 * busywidth by = bl_y - indent + 1.5 * busywidth ey = bl_y - indent - .5 * busywidth if flagcolor: self.canvas.create_rectangle((bx, by, ex, ey), fill=flagcolor, outline=flagcolor, tag="busy") else: busy_lst.append([]) busy_dates.append(thisdate.strftime("%a %d")) if 'current_day' in tags: self.canvas.itemconfig(id, tag='current_day', fill=self.CURRENTFILL) elif 'occasion' in tags: self.canvas.itemconfig(id, tag='occasion', fill=self.OCCASIONFILL) elif 'busy' in tags: self.canvas.itemconfig(id, tag='busy', fill=self.BGCOLOR) else: self.canvas.itemconfig(id, tag='default', fill=self.BGCOLOR) # if fill: self.canvas.create_text(p, text="{0}".format(weekdates[i]), fill=self.BUSYBAR) busy_ids = list(busy_ids) self.conf_ids = conf_ids # border # xy = int(l), int(t), int(l + x_ * 7), int(t + y_) # self.canvas.create_rectangle(xy, tag="grid") # verticals for i in range(0, 8): xy = int(l + x_ * i), int(t-18), int(l + x_ * i), int(t + y_) self.canvas.create_line(xy, fill=self.GRIDCOLOR, tag="grid") for i in range(7): p = int(l + x_ / 2 + x_ * i), int(t - 10) self.canvas.create_text(p, text="{0}".format(weekdays[i]), fill=self.BUSYBAR) self.busy_info = (theweek, busy_dates, busy_lst, occasion_lst) self.busy_ids = busy_ids self.busy_ids.sort() for id in self.busy_ids: self.canvas.tag_bind(id, '', self.on_enter_item) # self.canvas.tag_bind(id, '', self.on_leave_item) self.canvas_ids = self.busy_ids self.monthid2date = monthid2date def closeMonthly(self, event=None): self.month_height = self.topwindow.panecget(self.toppane, "height") self.topwindow.forget(self.toppane) self.monthly = False self.tree.pack(fill="both", expand=1, padx=4, pady=0) self.update_idletasks() for i in range(4, 6): self.toolsmenu.entryconfig(i, state="disabled") self.bind("", self.setFilter) def showMonthly(self, event=None, chosen_day=None): """ Open the canvas at the current week """ self.custom_box.forget() tt = TimeIt(loglevel=2, label="month view") logger.debug("chosen_day: {0}; active_date: {1}".format(chosen_day, self.active_date)) if self.monthly: # we're in month view already return if self.weekly: self.closeWeekly() self.content.delete("1.0", END) for i in range(4, 6): self.toolsmenu.entryconfig(i, state="normal") self.setView(DAY) self.view = MONTH self.currentView.set(MONTH) if chosen_day is not None: self.chosen_date = chosen_day elif self.active_date: self.chosen_date = self.active_date else: self.chosen_date = get_current_time().date() self.topwindow.add(self.toppane, padx=0, pady=0, before=self.botwindow, height=self.month_height) self.showMonth(event=event) self.monthly = True self.canvas.focus_set() tt.stop() def showMonth(self, event=None, month=None): # self.canvas.focus_set() self.selectedId = None matching = self.cal_regex is not None and self.default_regex is not None busy_lst = [] busy_dates = [] occasion_lst = [] self.current_day = get_current_time().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) logger.debug('self.current_day: {0}, minutes: {1}'.format(self.current_day, self.current_minutes)) self.x_win = self.canvas.winfo_width() self.y_win = self.canvas.winfo_height() month_day = 1 use_active = False if month in [-1, 0, 1]: if month == 0: self.year_month = [self.current_day.year, self.current_day.month] elif month == 1: self.year_month[1] += 1 if self.year_month[1] > 12: self.year_month[1] -= 12 self.year_month[0] += 1 elif month == -1: self.year_month[1] -= 1 if self.year_month[1] < 1: self.year_month[1] += 12 self.year_month[0] -= 1 elif self.active_date: use_active = True self.year_month = [self.active_date.year, self.active_date.month] month_day = self.active_date.day else: return logger.debug('month active_date: {0}'.format(self.active_date)) day = date(self.year_month[0], self.year_month[1], month_day) if use_active: scrolldate = self.chosen_date self.canvas_idpos = month_day - 1 # self.canvas_idpos = weekdaynum - 1 else: scrolldate = day self.canvas_idpos = 0 self.scrollToDate(scrolldate) weeks = self.monthly_calendar.monthdatescalendar(*self.year_month) num_weeks = len(weeks) weekdays = [s2or3(x.strftime("%a")) for x in weeks[0]] themonth = weeks[1][0].strftime("%B %Y") self.canvas.delete("all") l = 5 r = 5 t = 22 b = 5 if event: logger.debug('event: {0}'.format(event)) w, h = event.width, event.height if type(w) is int and type(h) is int: self.canvas_width = w self.canvas_height = h else: w = self.canvas.winfo_width() h = self.canvas.winfo_height() else: w = self.canvas.winfo_width() # h = self.canvas.winfo_height() h = self.month_height logger.debug("w: {0}, h: {1}, l: {2}, t: {3}".format(w, h, l, t)) self.margins = (w, h, l, r, t, b) self.month_x = x_ = Decimal(w - 1 - l - r) / Decimal(7) self.month_y = y_ = Decimal(h - 1 - t - b) / Decimal(num_weeks) logger.debug("x: {0}, y: {1}".format(x_, y_)) # month p = l + int((w - 1 - l - r) / 2), 20 self.currentView.set(themonth) self.busyHsh = {} # occasions busy_ids = set([]) monthid2date = {} # self.canvas.bind('', self.on_leave_item) # monthdays intervals = [360, 720, 1080, 1440] busywidth = 2 offset = 6 indent = 7 nightcolor = self.BUSYBAR morningcolor = self.BUSYBAR afternooncolor = self.BUSYBAR eveningcolor = self.BUSYBAR for j in range(num_weeks): for i in range(7): busytimes = 0 flagcolor = None start_x = l + i * x_ end_x = start_x + x_ start_y = int(t + y_ * j) end_y = start_y + y_ xy = int(start_x), int(start_y), int(end_x), int(end_y) p = int(l + x_ / 2 + x_ * i), int(t + y_ * j + y_ / 2) # pp = int(l + x_ + x_ * i), int(t + y_ * j + y_ ) tl_x = bl_x = int(l + x_ * i) tl_y = tr_y = int(t + y_ *j) tr_x = br_x = int(tl_x + x_) bl_y = br_y = int(tl_y + y_) w_ = x_ - 12 h_ = y_ - 12 thisdate = weeks[j][i] isokey = thisdate.isocalendar() month = thisdate.month tags = [] if (month != self.year_month[1]): fill = self.OTHERDATE else: fill = self.CURRDATE id = self.canvas.create_rectangle(xy, outline="", width=0) busy_ids.add(id) monthid2date[id] = thisdate today = (thisdate == self.current_day.date()) bt = [] if today: tags.append('current_day') if loop.occasions is not None and isokey in loop.occasions: bt = [] for item in loop.occasions[isokey]: it = list(item) if matching: if not self.cal_regex.match(it[-1]): continue mtch = (self.default_regex.match(it[-1]) is not None) else: mtch = True it.append(mtch) item = tuple(it) bt.append(item) occasion_lst.append(bt) if bt: if not today: tags.append('occasion') self.busyHsh.setdefault(id, []).extend(["^ {0}".format(x[0]) for x in bt]) else: occasion_lst.append([]) if loop.busytimes is not None and isokey in loop.busytimes: bt = [] overlap = False for item in loop.busytimes[isokey]: it = list(item) if it[0] == it[1]: # skip reminders continue if matching: if not self.cal_regex.match(it[-1]): continue mtch = (self.default_regex.match(it[-1]) is not None) else: mtch = True it.append(mtch) item = tuple(it) bt.append(item) busy_lst.append(bt) busy_dates.append(thisdate.strftime("%a %d")) if bt: lastend = 0 for pts in bt: busytimes += pts[1] - pts[0] self.busyHsh.setdefault(id, []).append("* {0}".format(pts[2])) if pts[0] < lastend: overlap = True lastend = pts[1] if overlap: flagcolor = self.CONFLICTFILL tags.append('busy') busylines = [[], [], [], []] # each side 360 minutes plus 2 times bar width for pts in bt: pt1 = max(0, pts[0]) pt2 = min(pts[1], 1440) tmp = [[], [], [], []] for ii in range(0, 4): if pt1 >= intervals[ii]: continue tmp[ii].append(pt1) for jj in range(ii, 4): if jj > ii: tmp[jj].append(intervals[jj-1]) if pt2 <= intervals[jj]: tmp[jj].append(pt2) break else: tmp[jj].append(intervals[jj]) break for ii in range(4): if tmp[ii]: busylines[ii].append(tmp[ii]) if busylines: for side in range(4): lines = busylines[side] if lines: if side == 0: # left for line in lines: bx = ex = bl_x + offset by = bl_y - indent - int(Decimal((line[0])/360) * h_) ey = bl_y - indent - int(Decimal((line[1])/360) * h_) self.canvas.create_line((bx, by, ex, ey), fill=nightcolor, width=busywidth, tag="busy") elif side == 1: # top for line in lines: by = ey = tl_y + offset bx = tl_x + indent + int(Decimal((line[0]-360)/360) * w_) ex = tl_x + indent + int(Decimal((line[1]-360)/360) * w_) self.canvas.create_line((bx, by, ex, ey), fill=morningcolor, width=busywidth, tag="busy") elif side == 2: # right for line in lines: bx = ex = tr_x - offset by = tr_y + indent + int(Decimal((line[0]-720)/360) * h_) ey = tr_y + indent + int(Decimal((line[1]-720)/360) * h_) self.canvas.create_line((bx, by, ex, ey), fill=afternooncolor, width=busywidth, tag="busy") elif side == 3: # bottom for line in lines: by = ey = br_y - offset bx = br_x - indent - int(Decimal((line[0]-1080)/360) * w_) ex = br_x - indent - int(Decimal((line[1]-1080)/360) * w_) self.canvas.create_line((bx, by, ex, ey), fill=eveningcolor, width=busywidth, tag="busy") bx = bl_x + offset - 1.5 * busywidth ex = bl_x + offset + .5 * busywidth by = bl_y - indent + 1.5 * busywidth ey = bl_y - indent - .5 * busywidth if flagcolor: self.canvas.create_rectangle((bx, by, ex, ey), fill=flagcolor, outline=flagcolor, tag="busy") else: busy_lst.append([]) busy_dates.append(thisdate.strftime("%a %d")) if 'current_day' in tags: self.canvas.itemconfig(id, tag='current_day', fill=self.CURRENTFILL) elif 'occasion' in tags: self.canvas.itemconfig(id, tag='occasion', fill=self.OCCASIONFILL) elif 'busy' in tags: self.canvas.itemconfig(id, tag='busy', fill=self.BGCOLOR) if fill: self.canvas.create_text(p, text="{0}".format(weeks[j][i].day), fill=fill) busy_ids = list(busy_ids) for id in busy_ids: self.canvas.tag_bind(id, '', self.on_enter_item) self.canvas.tag_bind(id, '', self.on_leave_item) # border # xy = int(l), int(t), int(l + x_ * 7), int(t + y_ * num_weeks + 1) # self.canvas.create_rectangle(xy, tag="grid") # verticals for i in range(0, 8): xy = int(l + x_ * i), int(t-18), int(l + x_ * i), int(t + y_ * num_weeks) self.canvas.create_line(xy, fill=self.GRIDCOLOR, tag="grid") # horizontals for j in range(0, num_weeks): xy = int(l), int(t + y_ * j), int(l + x_ * 7), int(t + y_ * j) self.canvas.create_line(xy, fill=self.GRIDCOLOR, tag="grid") # days for i in range(7): p = int(l + x_ / 2 + x_ * i), int(t - 10) self.canvas.create_text(p, text="{0}".format(weekdays[i]),fill = self.CURRDATE) self.busy_info = (themonth, busy_dates, busy_lst, occasion_lst) self.busy_ids = busy_ids self.busy_ids.sort() self.canvas_ids = self.busy_ids self.monthid2date = monthid2date def selectId(self, event, d=1): ids = self.busy_ids if self.canvas_idpos is None: self.canvas_idpos = 0 old_id = None else: if self.canvas_idpos < len(self.canvas_ids): old_id = self.canvas_ids[self.canvas_idpos] else: old_id = self.canvas_ids[0] if old_id in ids: tags = self.canvas.gettags(old_id) if 'current_day' in tags: self.canvas.itemconfig(old_id, fill=self.CURRENTFILL) elif 'occasion' in tags: self.canvas.itemconfig(old_id, fill=self.OCCASIONFILL) elif self.weekly: self.canvas.itemconfig(old_id, fill=self.BGCOLOR) else: self.canvas.itemconfig(old_id, fill=self.OCCASIONFILL) self.canvas.tag_lower(old_id) if d == -1: self.canvas_idpos -= 1 if self.canvas_idpos < 0: self.priorWeekMonth(event=event) self.canvas_idpos = len(self.canvas_ids) - 1 elif d == 1: self.canvas_idpos += 1 if self.canvas_idpos > len(self.canvas_ids) - 1: self.nextWeekMonth(event=event) self.canvas_idpos = 0 if old_id is not None and old_id in self.busy_ids: tags = self.canvas.gettags(old_id) if 'current_day' in tags: self.canvas.itemconfig(old_id, fill=self.CURRENTFILL) elif 'occasion' in tags: self.canvas.itemconfig(old_id, fill=self.OCCASIONFILL) elif 'busy' in tags: self.canvas.itemconfig(old_id, fill=self.BGCOLOR) else: self.canvas.itemconfig(old_id, fill=self.BGCOLOR) self.selectedId = id = self.canvas_ids[self.canvas_idpos] self.active_date = self.monthid2date[id] if type(self.active_date) is not date: self.active_date = self.active_date.date() self.canvas_date = self.active_date self.scrollToDate(self.active_date) self.canvas_idpos = self.canvas_ids.index(id) if id in self.busy_ids: self.canvas.itemconfig(id, fill=self.ACTIVEFILL) if id in self.busyHsh: txt = "\n".join(self.busyHsh[id]) self.content.delete("1.0", END) self.content.insert("1.0", txt) else: self.content.delete("1.0", END) self.setFocus(e=event) def setFocus(self, e): self.canvas.focus() self.canvas.focus_set() def on_enter_item(self, e): if self.canvas_idpos is not None: old_id = self.canvas_ids[self.canvas_idpos] if old_id in self.busy_ids: tags = self.canvas.gettags(old_id) if 'current_day' in tags: self.canvas.itemconfig(old_id, fill=self.CURRENTFILL) elif 'occasion' in tags: self.canvas.itemconfig(old_id, fill=self.OCCASIONFILL) else: self.canvas.itemconfig(old_id, fill=self.BGCOLOR) self.selectedId = id = self.canvas.find_withtag(CURRENT)[0] self.active_date = self.monthid2date[id] self.canvas_date = self.monthid2date[id] self.canvas_idpos = self.canvas_ids.index(id) if id in self.busy_ids: self.canvas.itemconfig(id, fill=self.ACTIVEFILL) if id in self.busyHsh: txt = "\n".join(self.busyHsh[id]) self.content.delete("1.0", END) self.content.insert("1.0", txt) else: self.content.delete("1.0", END) def on_leave_item(self, e): self.content.delete("1.0", END) id = self.canvas.find_withtag(CURRENT)[0] if id in self.busy_ids: tags = self.canvas.gettags(id) if 'current_date' in tags: self.canvas.itemconfig(id, fill=self.CURRENTFILL) elif 'occasion' in tags: self.canvas.itemconfig(id, fill=self.OCCASIONFILL) else: self.canvas.itemconfig(id, fill=self.BGCOLOR) else: self.canvas.itemconfig(id, fill=self.BGCOLOR) def on_select_item(self, event): if self.monthly or self.weekly: self.newItem() else: return "break" def on_activate_item(self, event): if self.monthly or self.weekly: self.newItem() def newEvent(self, event): logger.debug("event: {0}".format(event)) self.canvas.focus_set() px = event.x py = event.y (w, h, l, r, t, b) = self.margins x = Decimal(w - 1 - l - r) / Decimal(7) # x per day intervals rx = int(round(Decimal(px - l) / x - Decimal(0.5))) # number of days dt = (self.week_beg + rx * ONEDAY).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) dfmt = dt.strftime("%a %b %d") s = "* @s {0}".format(dfmt) changed = SimpleEditor(parent=self, master=self.canvas, start=s, options=loop.options).changed if changed: logger.debug('changed, updating alerts, ...') self.updateAlerts() if self.weekly: self.updateDay() self.showWeek(event=event) elif self.monthly: self.updateDay() self.showMonth(event=event) else: self.showView() def showCalendar(self, e=None): cal_year = 0 opts = loop.options cal_pastcolor = self.YEARPAST cal_currentcolor = self.YEARCURRENT cal_futurecolor = self.YEARFUTURE def showYear(x=0): global cal_year if x: cal_year += x else: cal_year = 0 cal = "\n".join(calyear(cal_year, options=opts)) if cal_year > 0: col = cal_futurecolor elif cal_year < 0: col = cal_pastcolor else: col = cal_currentcolor t.configure(fg=col) t.delete("0.0", END) t.insert("0.0", cal) win = Toplevel(highlightcolor=self.HLCOLOR, background=self.BGCOLOR) win.title(_("Calendar")) f = Frame(win) # pack the button first so that it doesn't disappear with resizing b = ttk.Button(win, text=_('OK'), style="bg.TButton", command=win.destroy, default='active') b.pack(side='bottom', fill=tkinter.NONE, expand=0, pady=0) win.bind('', (lambda e, b=b: b.invoke())) win.bind('', (lambda e, b=b: b.invoke())) win.bind('', (lambda e, b=b: b.invoke())) t = ReadOnlyText(f, wrap="word", padx=2, pady=2, bd=2, relief="sunken", font=self.tkfixedfont, takefocus=False, background=self.BGCOLOR, highlightcolor=self.HLCOLOR) win.bind('', (lambda e: showYear(-1))) win.bind('', (lambda e: showYear(1))) win.bind('', (lambda e: showYear())) showYear() t.pack(side='left', fill=tkinter.BOTH, expand=1, padx=0, pady=0) ysb = Scrollbar(f, orient='vertical', command=t.yview, width=8) ysb.pack(side='right', fill=tkinter.Y, expand=0, padx=0, pady=0) t.configure(yscroll=ysb.set) f.pack(padx=2, pady=2, fill=tkinter.BOTH, expand=1) win.focus_set() win.grab_set() win.transient(self) win.wait_window(win) def newCommand(self, e=None): self.newValue.set(self.newLabel) def showShortcuts(self, e=None): if e and e.char != "?": return res = self.menutree.showMenu("_") self.textWindow(parent=self, title='etm', opts=self.options, prompt=res, modal=False) def help(self, event=None): path = USERMANUAL if windoz: os.startfile(USERMANUAL) return() if mac: cmd = 'open' + " {0}".format(path) else: cmd = 'xdg-open' + " {0}".format(path) subprocess.call(cmd, shell=True) return True def about(self, event=None): res = loop.do_v("") self.textWindow(parent=self, title='etm', opts=self.options, prompt=res, modal=False) def checkForUpdate(self, event=None): res = checkForNewerVersion()[1] self.textWindow(parent=self, title='etm', prompt=res, opts=self.options) def showChanges(self, event=None): if not loop.options['vcs_system']: prompt = """An entry for 'vcs_system' in etmtk.cfg is required but missing.""" self.textWindow(parent=self, title="etm", prompt=prompt, opts=loop.options) return if self.itemSelected: f = self.itemSelected['fileinfo'][0] fn = ' {0}"{1}"'.format(self.options['vcs']['file'], os.path.normpath(os.path.join(self.options['datadir'], f))) title = _("Showing changes for {0}.").format(f) else: fn = "" title = _("Showing changes for all files.") logger.debug('fn: {0}'.format(fn)) prompt = _("""\ {0} If an item is selected, changes will be shown for the file containing the item. Otherwise, changes will be shown for all files. Enter an integer number of changes to display or 0 to display all changes.""").format(title) depth = GetInteger( parent=self, title=_("Changes"), prompt=prompt, opts=[0], default=10).value if depth is None: return () if depth == 0: # all changes numstr = "" else: numstr = "{0} {1}".format(loop.options['vcs']['limit'], depth) command = loop.options['vcs']['history'].format( repo=loop.options['vcs']['repo'], work=loop.options['vcs']['work'], numchanges=numstr, rev="{rev}", desc="{desc}", file=fn) logger.debug('vcs history command: {0}'.format(command)) tt = TimeIt(loglevel=2, label="showChanges") s = subprocess.check_output(command, shell=True, universal_newlines=True) tt.stop() p = s2or3(s) if not p: p = 'no output from command:\n {0}'.format(command) p = "\n".join([x for x in p.split('\n') if not (x.startswith('index') or x.startswith('diff') or x.startswith('\ No newline'))]) self.textWindow(parent=self, title=title, prompt=s2or3(p), opts=self.options) def focus_next_window(self, event): event.widget.tk_focusNext().focus() return "break" def goHome(self, event=None): today = get_current_time().date() if self.weekly: self.showWeek(event=event, week=0) self.scrollToDate(today) elif self.monthly: self.showMonth(event=event, month=0) self.scrollToDate(today) elif self.view == DAY: self.scrollToDate(today) else: self.tree.focus_set() self.tree.focus(1) self.tree.selection_set(1) self.tree.yview(0) return def nextItem(self, e=None): item = self.tree.selection()[0] if item: next = self.tree.next(item) if next: next = int(next) next -= 1 self.tree.focus(next) self.tree.selection_set(next) def prevItem(self, e=None): item = self.tree.selection()[0] if item: prev = self.tree.prev(item) if prev: prev = int(prev) prev += 1 self.tree.focus(prev) self.tree.selection_set(prev) def OnSelect(self, event=None, uuid=None, dt=None): """ Tree row has gained selection. """ logger.debug("starting OnSelect with uuid: {0}".format(uuid)) self.content.delete("1.0", END) if uuid is None: # tree view if not self.tree.selection(): return item = self.tree.selection()[0] self.rowSelected = int(item) logger.debug('rowSelected: {0}'.format(self.rowSelected)) # type_chr is the actual type, e.g., "-" type_chr = self.tree.item(item)['text'][0] uuid, dt, hsh = self.getInstance(item) logger.debug('tree rowSelected: {0}; {1}; {2} {3}'.format(self.rowSelected, self.tree.item(item)['text'], dt, hsh)) if self.canvas == self.canvas.focus_get(): # canvas has focus logger.debug("using canvas active_date: {0}; {1}".format(self.active_date, self.tree.item(item)['text'])) elif self.view in [AGENDA, WEEK, MONTH]: if self.rowSelected in self.id2date: if dt is None: # we have the date selected self.active_date = self.id2date[self.rowSelected] logger.debug("active_date from id2date: {0}; {1}".format(self.active_date, self.tree.item(item)['text'])) else: # we have an item self.active_date = dt.date() logger.debug('active date from dt: {0}; {1}'.format(self.active_date, self.tree.item(item))) else: if dt: self.active_date = dt.date() logger.debug('active date from dt: {0}; {1}'.format(self.active_date, self.tree.item(item))) else: self.active_date = None logger.debug('active_date: {0}'.format(self.active_date)) if hsh: type_chr = hsh['itemtype'] self.update_idletasks() if uuid is not None: isRepeating = ('r' in hsh and dt) if isRepeating: logger.debug('selected: {0}, {1}'.format(dt, type(dt))) item = "{0} {1}".format(_('selected'), dt) self.itemmenu.entryconfig(1, label="{0} ...".format(self.em_opts[1])) self.itemmenu.entryconfig(2, label="{0} ...".format(self.em_opts[2])) else: self.itemmenu.entryconfig(1, label=self.em_opts[1]) self.itemmenu.entryconfig(2, label=self.em_opts[2]) item = _('selected') isUnfinished = (type_chr in ['-', '+', '%']) hasLink = ('g' in hsh and hsh['g']) hasUser = ('u' in hsh and hsh['u']) l1 = hsh['fileinfo'][1] l2 = hsh['fileinfo'][2] if l1 == l2: lines = "{0} {1}".format(_('line'), l1) else: lines = "{0} {1}-{2}".format(_('lines'), l1, l2) self.filetext = filetext = "{0}, {1}".format(hsh['fileinfo'][0], lines) if 'errors' in hsh and hsh['errors']: text = "{1}\n\n{2}: {3}\n\n{4}: {5}".format(item, hsh['entry'].lstrip(), _("Errors"), hsh['errors'], _("file"), filetext) else: text = "{1}\n\n{2}: {3}".format(item, hsh['entry'].lstrip(), _("file"), filetext) for i in [0, 1, 2, 3, 5, 6, 7, 8, 9]: # everything except finish (4), open link (10) and show user (11) self.itemmenu.entryconfig(i, state='normal') if isUnfinished: self.itemmenu.entryconfig(4, state='normal') else: self.itemmenu.entryconfig(4, state='disabled') if hasLink: self.itemmenu.entryconfig(10, state='normal') else: self.itemmenu.entryconfig(10, state='disabled') if hasUser: self.itemmenu.entryconfig(11, state='normal') else: self.itemmenu.entryconfig(11, state='disabled') self.uuidSelected = uuid self.itemSelected = hsh logger.debug('dt selected: {0}, {1}'.format(dt, type(dt))) self.dtSelected = dt else: text = "" for i in range(12): self.itemmenu.entryconfig(i, state='disabled') self.itemSelected = None self.uuidSelected = None self.dtSelected = None r = self.tree.identify_row(1) if r: self.topSelected = int(r) else: self.topSelected = 1 logger.debug("row: {0}; uuid: {1}; instance: {2}, {3}; top: {4}".format(self.rowSelected, self.uuidSelected, self.dtSelected, type(self.dtSelected), self.topSelected)) self.content.insert(INSERT, text) self.update_idletasks() logger.debug('ending OnSelect') return def OnActivate(self, event): """ Return pressed with tree row selected """ if not self.itemSelected: return "break" item = self.tree.selection()[0] uuid, dt, hsh = self.getInstance(item) x = self.winfo_rootx() + 350 y = self.winfo_rooty() + 50 logger.debug("id: {0}, coords: {1}, {2}".format(id, x, y)) self.itemmenu.post(x, y) self.itemmenu.focus_set() return "break" def getInstance(self, item): instance = self.count2id[item] logger.debug('starting getInstance: {0}; {1}'.format(item, instance)) if instance is not None: uuid, dt = self.count2id[item].split("::") hsh = loop.uuid2hash[uuid] dt = parse(dt) logger.debug('returning uuid: {0}, dt: {1}'.format(uuid, dt)) return uuid, dt, hsh else: logger.debug('returning None') return None, None, None def updateTimerStatus(self): title, status = self.actionTimer.getStatus() self.timerTitle.set(title) self.timerStatus.set(status) def updateClock(self): tt = TimeIt(loglevel=2, label="updateClock") self.now = get_current_time() self.current_minutes = self.now.hour * 60 + self.now.minute nxt = (60 - self.now.second) * 1000 - self.now.microsecond // 1000 nowfmt = "etm - {1} {0}".format( s2or3(self.now.strftime("%a %b %d")), s2or3(self.now.strftime(loop.options['reprtimefmt']).lower()), ) logger.debug('next update in {0} milliseconds.'.format(nxt)) self.after(nxt, self.updateClock) nowfmt = leadingzero.sub("", nowfmt) self.currentTime.set("{0}".format(nowfmt)) self.title(self.currentTime.get()) today = self.now.date() newday = (today != self.today) self.today = today new, modified, deleted = get_changes( self.options, loop.file2lastmodified) if newday or new or modified or deleted: if newday: logger.info('newday') self.actionTimer.newDay() # update 'bef' using a naive datetime now = datetime.now() year, wn, dn = now.isocalendar() weeks_after = self.options['weeks_after'] if dn > 1: days = dn - 1 else: days = 0 week_beg = now - days * ONEDAY bef = (week_beg + (7 * (weeks_after + 1)) * ONEDAY) self.options['bef'] = bef logger.info("new: {0}; modified: {1}; deleted: {2}".format(len(new), len(modified), len(deleted))) logger.debug('calling loadData') loop.loadData() # we now have file2uuids ... if self.weekly: logger.debug('calling showWeek') self.updateDay() self.showWeek() if newday: self.scrollToDate(today) elif self.monthly: logger.debug('calling showMonth') self.updateDay() self.showMonth() if newday: self.scrollToDate(today) else: logger.debug('calling showView') self.showView() if self.current_minutes % loop.options['update_minutes'] == 0: if loop.do_update: updateCurrentFiles(loop.rows, loop.file2uuids, loop.uuid2hash, loop.options) loop.do_update = False if loop.options['icssync_folder']: fullpath = os.path.join(loop.options['datadir'], loop.options['icssync_folder']) prefix, files = getAllFiles(fullpath, include="*") base_files = set([]) # file_lst = [] for tup in files: base, ext = os.path.splitext(tup[0]) if ext in [".txt", ".ics"]: base_files.add(base) file_lst = list(base_files) datadir = loop.options['datadir'] for file in file_lst: relfile = relpath(file, datadir) logger.debug('calling syncTxt: {0}; {1}'.format(datadir, relfile)) syncTxt(self.loop.file2uuids, self.loop.uuid2hash, datadir, relfile) # any updated txt files will be reloaded in the next update self.updateAlerts() if self.actionTimer.currentStatus == RUNNING or (self.actionTimer.idleactive and self.actionTimer.showIdle): title, status = self.actionTimer.getStatus() self.timerTitle.set(title) self.timerStatus.set(status) if self.actionTimer.currentMinutes >= 1: if (self.options['action_interval'] and self.actionTimer.currentMinutes % loop.options['action_interval'] == 0): logger.debug('action_minutes trigger: {0} {1}'.format(self.actionTimer.currentMinutes, self.actionTimer.currentStatus)) if self.actionTimer.currentStatus == 'running': if ('running' in loop.options['action_timer'] and loop.options['action_timer']['running']): tcmd = loop.options['action_timer']['running'] logger.debug('running: {0}'.format(tcmd)) subprocess.call(tcmd, shell=True) elif self.actionTimer.currentStatus == 'paused': if ('paused' in loop.options['action_timer'] and loop.options['action_timer']['paused']): tcmd = loop.options['action_timer']['paused'] logger.debug('paused: {0}'.format(tcmd)) subprocess.call(tcmd, shell=True) tt.stop() def updateAlerts(self): self.update_idletasks() if loop.alerts: logger.debug('updateAlerts: {0}'.format(len(loop.alerts))) alerts = deepcopy(loop.alerts) if alerts and loop.options['calendars']: alerts = [x for x in alerts if self.cal_regex.match(x[-1])] if alerts: # alerts = [(minutes, id, hsh), ...] curr_minutes = datetime2minutes(self.now) td = -1 # pop old alerts while td < 0 and alerts: td = alerts[0][0] - curr_minutes if td < 0: a = alerts.pop(0) # alerts for this minute will have td's < 1.0 if td < 1.0: if ('alert_wakecmd' in loop.options and loop.options['alert_wakecmd']): cmd = s2or3(loop.options['alert_wakecmd']) subprocess.call(cmd, shell=True) while td < 1.0 and alerts: hsh = alerts[0][2] alerts.pop(0) actions = hsh['_alert_action'] if 's' in actions: if ('alert_soundcmd' in self.options and self.options['alert_soundcmd']): scmd = s2or3(expand_template( self.options['alert_soundcmd'], hsh)) subprocess.call(scmd, shell=True) else: self.textWindow(parent=self, title="etm", prompt=_("""\ A sound alert failed. The setting for 'alert_soundcmd' is missing from your etmtk.cfg."""), opts=self.options) if 'd' in actions: if ('alert_displaycmd' in self.options and self.options['alert_displaycmd']): dcmd = s2or3(expand_template( self.options['alert_displaycmd'], hsh)) subprocess.call(dcmd.encode(loop.options['encoding']['gui']), shell=True) else: self.textWindow(parent=self, title="etm", prompt=_("""\ A display alert failed. The setting for 'alert_displaycmd' is missing \ from your etmtk.cfg."""), opts=self.options) if 'v' in actions: if ('alert_voicecmd' in self.options and self.options['alert_voicecmd']): vcmd = s2or3(expand_template( self.options['alert_voicecmd'], hsh)) subprocess.call(vcmd, shell=True) else: self.textWindow(parent=self, title="etm", prompt=_("""\ An email alert failed. The setting for 'alert_voicecmd' is missing from \ your etmtk.cfg."""), opts=self.options) if 'e' in actions: missing = [] for field in ['smtp_from', 'smtp_id', 'smtp_pw', 'smtp_server']: if not self.options[field]: missing.append(field) if missing: self.textWindow(parent=self, title="etm", prompt=_("""\ An email alert failed. Settings for the following variables are missing \ from your etmtk.cfg: %s.""" % ", ".join(["'%s'" % x for x in missing])), opts=self.options) else: subject = hsh['summary'] message = expand_template( self.options['email_template'], hsh) arguments = hsh['_alert_argument'] recipients = [str(x).strip() for x in arguments[0]] if 'i' in hsh and hsh['i']: # invitees for invitee in hsh['i']: recipients.append(str(invitee).strip()) if len(arguments) > 1: attachments = [str(x).strip() for x in arguments[1]] else: attachments = [] if subject and message and recipients: send_mail( smtp_to=recipients, subject=subject, message=message, files=attachments, smtp_from=self.options['smtp_from'], smtp_server=self.options['smtp_server'], smtp_id=self.options['smtp_id'], smtp_pw=self.options['smtp_pw']) if 't' in actions: missing = [] for field in ['sms_from', 'sms_message', 'sms_phone', 'sms_pw', 'sms_server', 'sms_subject']: if not self.options[field]: missing.append(field) if missing: self.textWindow(parent=self, title="etm", prompt=_("""\ A text alert failed. Settings for the following variables are missing \ from your 'emt.cfg': %s.""" % ", ".join(["'%s'" % x for x in missing])), opts=self.options) else: message = expand_template( self.options['sms_message'], hsh) subject = expand_template( self.options['sms_subject'], hsh) arguments = hsh['_alert_argument'] if arguments: sms_phone = ",".join([str(x).strip() for x in arguments[0]]) else: sms_phone = self.options['sms_phone'] if message: send_text( sms_phone=sms_phone, subject=subject, message=message, sms_from=self.options['sms_from'], sms_server=self.options['sms_server'], sms_pw=self.options['sms_pw']) if 'p' in actions: arguments = hsh['_alert_argument'] proc = str(arguments[0][0]).strip() cmd = s2or3(expand_template(proc, hsh)) subprocess.call(cmd, shell=True) if 'm' in actions: # put this last since the internal message window is modal and thus blocking id = hsh['I'] if hsh['next'] is None: # last alert for this item - add an alertId self.alertHsh = hsh self.setmessageAlert() else: self.alertMessage = """\ {0} ({1}) {2} --------------------------------------------------- Next alert: {3}.\ """.format( expand_template('!summary!', hsh), expand_template('!when!', hsh), expand_template(self.options['alert_template'], hsh), hsh['next']) TextDialog( self, title=_("alert - {0}".format(fmt_time( self.now, options=loop.options))), prompt=self.alertMessage, close=self.options['message_next']*1000 ) if not alerts: break td = alerts[0][0] - curr_minutes self.itemAlerts = alerts self.updateAlertList() def updateAlertList(self): self.activeAlterts = [(x[2]['at'], x[2]['alert_time'], x[2]['_event_time'], ", ".join(x[2]['_alert_action']), x[2]['summary'][:26]) for x in self.itemAlerts] for id in self.messageAlerts: x = self.messageAlerts[id] at = fmt_time(x[1]['at'], seconds=True, options=self.options) self.activeAlterts.append((x[1]['at'], at, x[1]['_event_time'], _('snooze'), x[1]['summary'][:26])) self.activeAlterts.sort() if self.activeAlterts: if len(self.activeAlterts) > 1: self.pendingAlerts.set("{0} +{1}".format(self.activeAlterts[0][1], len(self.activeAlterts) - 1)) self.activeAlerts = self.activeAlterts else: self.pendingAlerts.set("{0}".format(self.activeAlterts[0][1])) self.activeAlerts = self.activeAlterts else: self.pendingAlerts.set('~') self.activeAlerts = [] def textWindow(self, parent, title=None, prompt=None, opts=None, modal=True): TextDialog(parent, title=title, prompt=prompt, opts=opts, modal=modal) def goToDate(self, e=None): if e and e.char != "j": return prompt = _("""\ Return an empty string for the current date or a date to be parsed. Relative dates and fuzzy parsing are supported.""") if self.view not in [DAY, WEEK, MONTH]: return d = GetDateTime(parent=self, title=_('date'), prompt=prompt) day = d.value logger.debug('day: {0}'.format(day)) if day is None: return self.chosen_date = day.date() self.active_date = day.date() if self.weekly: self.showWeek(event=e, week=None) elif self.monthly: self.showMonth(event=e, month=None) self.scrollToDate(day.date()) return def setFilter(self, e=None): if self.view in [CUSTOM]: return self.filter_active = True # self.motionmenu.entryconfig(6, state="disabled") # self.motionmenu.entryconfig(7, state="normal") self.fltr.configure(bg=self.BGCOLOR, fg=self.FGCOLOR, state="normal") self.fltr.focus_set() def clearFilter(self, e=None): if self.view in [CUSTOM]: return self.filter_active = False # self.motionmenu.entryconfig(6, state="normal") # self.motionmenu.entryconfig(7, state="disabled") self.filterValue.set('') self.fltr.configure(bg=self.BGCOLOR, fg=self.FGCOLOR) self.tree.focus_set() if self.rowSelected: self.tree.focus(self.rowSelected) self.tree.selection_set(self.rowSelected) self.tree.see(self.rowSelected) def leaveFilter(self, e=None): self.tree.focus_set() if self.rowSelected: self.tree.focus(self.rowSelected) self.tree.selection_set(self.rowSelected) self.tree.see(self.rowSelected) def kloneTimer(self, e=None): """ """ # hack to avoid activating with Ctrl-k if e and e.char != "k": return if not self.uuidSelected: return hsh = loop.uuid2hash[self.uuidSelected] self.timerItem = self.uuidSelected logger.debug('item: {0}'.format(hsh)) name = hsh['_summary'] for k in self.options['action_keys']: if k in hsh and hsh[k]: if type(hsh[k]) is list: v = ", ".join(hsh[k]) else: v = hsh[k] name += " @{0} {1}".format(k, v) self.actionTimer.selectTimer(name=name) def finishActionTimer(self, e=None): if e and e.char != "T": return thsh = self.actionTimer.finishTimer(e=e) if not thsh: return self.updateTimerStatus() hsh = {"itemtype": "~", "_summary": thsh['summary'], "s": thsh['start'], "e": thsh['total']} changed = SimpleEditor(parent=self, newhsh=hsh, rephsh=None, options=loop.options, title=_("new action"), modified=True).changed if changed: # clear status and reload self.actionTimer.deleteTimer(timer = self.actionTimer.selected) self.updateAlerts() if self.weekly: self.updateDay() self.showWeek() elif self.monthly: self.updateDay() self.showMonth() else: self.showView(row=self.topSelected) self.updateTimerStatus() def gettext(self, event=None): s = self.e.get() if s is not None: return s else: return '' def cleartext(self, event=None): self.showView() return 'break' def process_input(self, event=None, cmd=None): """ """ if not cmd: return True if self.mode == 'command': cmd = cmd.strip() # if cmd[0] in ['a', 'c']: if cmd[0] in ['a']: # simple command history for report commands if cmd in self.history: self.history.remove(cmd) self.history.append(cmd) self.index = len(self.history) - 1 else: parts = cmd.split(' ') if len(parts) == 2: try: i = int(parts[0]) except: i = None if i: parts.pop(0) parts.append(str(i)) cmd = " ".join(parts) try: res = loop.do_command(cmd) except: return _('could not process command "{0}"').format(cmd) elif self.mode == 'delete': loop.cmd_do_delete(cmd) res = '' elif self.mode == 'finish': loop.cmd_do_finish(cmd) res = '' elif self.mode == 'new_date': res = loop.new_date(cmd) if not res: res = _('command "{0}" returned no output').format(cmd) logger.debug('no output') self.clearTree() return () if type(res) == dict: self.showTree(res, event=event) else: # not a hash => not a tree self.textWindow(self, title='etm', prompt=res, opts=self.options) return 0 def expand2Depth(self, e=None): if e and e.char != "o": return prompt = _("""\ Enter an integer depth to expand branches or 0 to expand all branches completely.""") depth = GetInteger( parent=self, title=_("depth"), prompt=prompt, opts=[0], default=0).value if depth is None: return () maxdepth = max([k for k in self.depth2id]) logger.debug('expand2Depth {0}: {1}/{2}'.format(self.view, depth, maxdepth)) if self.view in [AGENDA, DAY, KEYWORD, NOTE, TAG, PATH, CUSTOM]: self.outline_depths[self.view] = depth logger.debug('outline_depths: {0}'.format(self.outline_depths)) if depth == 0: # expand all for k in self.depth2id: for item in self.depth2id[k]: self.tree.item(item, open=True) else: depth -= 1 depth = max(depth, 0) logger.debug('using depth: {0}; {1}'.format(depth, maxdepth)) for i in range(depth): if i in self.depth2id: for item in self.depth2id[i]: try: self.tree.item(item, open=True) except: logger.exception('open: {0}, {1}'.format(i, item)) for i in range(depth, maxdepth + 1): if i in self.depth2id: for item in self.depth2id[i]: try: self.tree.item(item, open=False) except: logger.exception('open: {0}, {1}'.format(i, item)) def scrollToDate(self, date=None): if not loop.prevnext or date is None: return if self.view not in [DAY, WEEK, MONTH] or date not in loop.prevnext: return # new: go to the first date on or **after**, i.e., prevnext last active_date = loop.prevnext[date][1] if active_date not in self.date2id: return if self.weekly: pos = date.isocalendar()[2] - 1 self.canvas_idpos = pos elif self.monthly: pos = date.day - 1 self.canvas_idpos = pos uid = self.date2id[active_date] self.active_date = date self.canvas_date = date self.scrollToId(uid) def scrollToId(self, uid): self.update_idletasks() # self.tree.focus_set() self.tree.focus(uid) self.tree.selection_set(uid) self.tree.yview(int(uid) - 1) def showTree(self, tree, event=None): self.date2id = {} self.id2date = {} self.clearTree() self.count = 0 self.count2id = {} self.active_tree = tree self.depth2id = {} self.add2Tree(u'', tree[self.root], tree) loop.count2id = self.count2id self.tree.tag_configure('treefont', font=self.tktreefont) self.content.delete("0.0", END) if event is None: if self.view in [DAY, WEEK, MONTH] and self.active_date: self.scrollToDate(self.active_date) else: if self.view in [AGENDA, TAG, KEYWORD, NOTE, PATH]: if self.filter_active: depth = 0 else: depth = self.outline_depths[self.view] if depth == 0: # expand all for k in self.depth2id: for item in self.depth2id[k]: self.tree.item(item, open=True) else: maxdepth = max([k for k in self.depth2id]) depth -= 1 depth = max(depth, 0) for i in range(depth): for item in self.depth2id[i]: self.tree.item(item, open=True) for i in range(depth, maxdepth + 1): for item in self.depth2id[i]: self.tree.item(item, open=False) self.goHome() def popupTree(self, e=None): # if self.weekly or self.monthly: # return if not self.active_tree: return depth = self.outline_depths[self.view] if loop.options: if 'report_indent' in loop.options: indent = loop.options['report_indent'] if 'report_width1' in loop.options: width1 = loop.options['report_width1'] if 'report_width2' in loop.options: width2 = loop.options['report_width2'] else: indent = 4 width1 = 43 width2 = 20 res = tree2Text(self.active_tree, indent=indent, width1=width1, width2=width2, depth=depth) if not res[0][0]: res[0].pop(0) prompt = "\n".join(res[0]) self.textWindow(parent=self, title='etm', opts=self.options, prompt=prompt, modal=False) def printTree(self, e=None): if e and e.char != "p": return if not self.active_tree: return ans = self.confirm(parent=self.tree, prompt=_("""Print current outline?""")) if not ans: return False depth = self.outline_depths[self.view] if loop.options: if 'report_indent' in loop.options: indent = loop.options['report_indent'] if 'report_width1' in loop.options: width1 = loop.options['report_width1'] if 'report_width2' in loop.options: width2 = loop.options['report_width2'] else: indent = 4 width1 = 43 width2 = 20 res = tree2Text(self.active_tree, indent=indent, width1=width1, width2=width2, depth=depth) if not res[0][0]: res[0].pop(0) res[0].append('') s = "{0}".format("\n".join(res[0])) self.printWithDefault(s) def clearTree(self): """ Remove all items from the tree """ self.active_tree = {} for child in self.tree.get_children(): self.tree.delete(child) def add2Tree(self, parent, elements, tree, depth=0): max_depth = 100 for text in elements: self.count += 1 # text is a key in the element (tree) hash # these keys are (parent, item) tuples if text in tree: # this is a branch item = " {0}".format(text[1]) # this is the label of the parent children = tree[text] # these are the children tuples of item oid = self.tree.insert(parent, 'end', iid=self.count, text=item, open=(depth <= max_depth)) self.depth2id.setdefault(depth, set([])).add(oid) # recurse to get children self.count2id[oid] = None self.add2Tree(oid, children, tree, depth=depth + 1) else: # this is a leaf if len(text[1]) == 4: uuid, item_type, col1, col3 = text[1] dt = '' else: # len 5 day view with datetime appended uuid, item_type, col1, col3, dt = text[1] if item_type: # This hack avoids encoding issues under python 2 col1 = "{0} {1}".format(id2Type[item_type], col1) if type(col3) == int: col3 = '%s' % col3 else: col3 = s2or3(col3) # Drop the instance information from the id id = uuid.split(':')[0] if id in loop.uuid2labels: col2 = loop.uuid2labels[id] else: col2 = "***" if item_type not in ["=", "ib"]: logger.warn('Missing key {0} for {1} {2}'.format(id, col1, col3)) oid = self.tree.insert(parent, 'end', iid=self.count, text=col1, open=(depth <= max_depth), values=[col2, col3], tags=(item_type, 'treefont')) self.count2id[oid] = "{0}::{1}".format(uuid, dt) if dt: if item_type == 'by': # we want today, not the starting date for this d = get_current_time().date() else: if type(dt) is datetime: d = dt.date() else: d = parse(dt).date() if d and d not in self.date2id: # logger.debug('date2id[{0}] = {1}'.format(d, parent)) self.date2id[d] = int(parent) if int(parent) not in self.id2date: # logger.debug('id2date[{0}] = {1}'.format(int(parent), d)) self.id2date[int(parent)] = d def makeReport(self, event=None): if self.view != CUSTOM: return self.outline_depths[CUSTOM] = 0 self.value_of_combo = self.custom_box.get() if not self.value_of_combo.strip(): return try: res = getReportData( self.value_of_combo, self.loop.file2uuids, self.loop.uuid2hash, self.loop.options, cli=False) if not res: res = _("Report contains no output.") if self.value_of_combo not in self.specs: self.specs.append(self.value_of_combo) self.specs.sort() self.specs = [x for x in self.specs if x] self.custom_box["values"] = self.specs self.specsModified = True logger.debug("spec: {0}".format(self.value_of_combo)) except: logger.exception("could not process: {0}".format(self.value_of_combo)) res = _("'{0}' could not be processed".format(self.value_of_combo)) if type(res) == dict: self.showTree(res, event=event) else: # not a hash => not a tree self.textWindow(self, title='etm', prompt=res, opts=self.options) self.custom_box.focus_set() return 0 def getSpecs(self, e=None): self.specs = [] if 'reports' in loop.options: self.specs = loop.options['reports'] def saveSpecs(self, e=None): # called when changing from custom view or # when calling save changes to specs if self.view != CUSTOM: return if not self.specsModified: return # remove duplicates self.specs = list(set(self.specs)) self.specs.sort() added = [x for x in self.specs if x not in self.saved_specs] if not added: self.specsModified = False return ans = self.confirm(parent=self, prompt=_("""Save the additions to your report specifications? {0} """.format("\n ".join(added)))) if ans: file = self.getReportsFile() if not (file and os.path.isfile(file)): return with codecs.open(file, 'r', loop.options['encoding']['file']) as fo: lines = fo.readlines() lines.extend(added) lines.sort() content = "\n".join([x.strip() for x in lines if x.strip()]) with codecs.open(file, 'w', loop.options['encoding']['file']) as fo: fo.write(content) logger.debug("saved: {0}".format(file)) self.getSpecs() self.custom_box['values'] = self.specs self.value_of_combo = self.specs[0] self.specsModified = False def exportText(self): if self.view != CUSTOM: return logger.debug("spec: {0}".format(self.value_of_combo)) tree = getReportData( self.value_of_combo, self.loop.file2uuids, self.loop.uuid2hash, self.loop.options, export=False) text = "\n".join([x for x in tree2Text(tree)[0]]) prefix, tuples = getFileTuples(loop.options['etmdir'], include=r'*.text', all=True) filename = FileChoice(self, "text file", prefix=prefix, list=tuples, ext="text", new=False).returnValue() if not filename: return False fo = codecs.open(filename, 'w', self.options['encoding']['file']) fo.write(text) fo.close() MessageWindow(self, "etm", "Exported text to {0}".format(filename)) def exportCSV(self): if self.view != CUSTOM: return logger.debug("spec: {0}".format(self.value_of_combo)) data = getReportData( self.value_of_combo, self.loop.file2uuids, self.loop.uuid2hash, self.loop.options, export=True) prefix, tuples = getFileTuples(loop.options['exportdir'], include=r'*.csv', all=True) filename = FileChoice(self, "csv file", prefix=prefix, list=tuples, ext="csv", new=True).returnValue() if not filename: return import csv as CSV c = CSV.writer(open(filename, "w"), delimiter=",", lineterminator="\n") for line in data: c.writerow(line) MessageWindow(self, "etm", "Exported CSV to {0}".format(filename)) def updateSubscriptions(self, e=None): if not self.loop.options['ics_subscriptions']: MessageWindow(self, 'etm', "A configuration setting for 'ics_subscriptions' is required but missing.") return good = [] bad = [] msg = [] for url, rp in self.loop.options['ics_subscriptions']: fp = os.path.join(self.loop.options['datadir'], rp) logger.debug('updating: {0}, {1}'.format(rp, fp)) res = update_subscription(url, fp) if res: good.append(rp) else: bad.append(rp) if good: msg.append(_("Succesfully updated:\n {0}").format("\n ".join(good))) if bad: msg.append(_("Not updated:\n {0}").format("\n ".join(bad))) MessageWindow(self, "etm", "\n".join(msg)) def newselection(self, event=None): self.value_of_combo = self.custom_box.get() loop = None log_levels = { '1': logging.DEBUG, '2': logging.INFO, '3': logging.WARN, '4': logging.ERROR, '5': logging.CRITICAL } def main(dir=None): # debug, info, warn, error, critical global loop etmdir = '' logger.debug('in view.main with dir: {0}'.format(dir)) # For testing override etmdir: if dir is not None: etmdir = dir logger.debug('using etmdir: {0}'.format(etmdir)) (user_options, options, use_locale) = data.get_options(etmdir) loop = data.ETMCmd(options=options) loop.tkversion = tkversion app = App() app.mainloop() if __name__ == "__main__": setup_logging('3') main() etmtk-3.2.22/etmtk.egg-info/0000755000076500000240000000000012617420125015420 5ustar dagstaff00000000000000etmtk-3.2.22/etmtk.egg-info/dependency_links.txt0000644000076500000240000000000112617420076021473 0ustar dagstaff00000000000000 etmtk-3.2.22/etmtk.egg-info/not-zip-safe0000644000076500000240000000000112331254656017655 0ustar dagstaff00000000000000 etmtk-3.2.22/etmtk.egg-info/PKG-INFO0000666000076500000240000000253612617420076016534 0ustar dagstaff00000000000000Metadata-Version: 1.1 Name: etmtk Version: 3.2.22 Summary: event and task manager Home-page: http://people.duke.edu/~dgraham/etmtk Author: Daniel A Graham Author-email: daniel.graham@duke.edu License: License :: OSI Approved :: GNU General Public License (GPL) Description: manage events and tasks using simple text files Platform: Any Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Environment :: MacOS X Classifier: Environment :: Win32 (MS Windows) Classifier: Environment :: X11 Applications Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows :: Windows Vista Classifier: Operating System :: Microsoft :: Windows :: Windows 7 Classifier: Operating System :: OS Independent Classifier: Operating System :: POSIX Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Topic :: Office/Business Classifier: Topic :: Office/Business :: News/Diary Classifier: Topic :: Office/Business :: Scheduling etmtk-3.2.22/etmtk.egg-info/requires.txt0000644000076500000240000000011512617420076020022 0ustar dagstaff00000000000000python-dateutil>=1.5 PyYaml>=3.10 [icalendar] icalendar>=3.8.4 pytz>=2015.1 etmtk-3.2.22/etmtk.egg-info/SOURCES.txt0000644000076500000240000000147012617420077017314 0ustar dagstaff00000000000000MANIFEST.in README.txt etm etmtk setup.py etmTk/CHANGES etmTk/__init__.py etmTk/data.py etmTk/dialog.py etmTk/etm.1 etmTk/etm.appdata.xml etmTk/etm.desktop etmTk/etm.xpm etmTk/etmlogo.gif etmTk/etmlogo.icns etmTk/etmlogo.ico etmTk/v.py etmTk/version.py etmTk/view.py etmTk/help/UserManual.html etmTk/icons/etmlogo.gif etmTk/icons/icon_check.gif etmTk/icons/icon_check_green.gif etmTk/icons/icon_check_red.gif etmTk/icons/icon_clock.gif etmTk/icons/icon_pause.gif etmTk/icons/icon_play.gif etmTk/icons/icon_plus.gif etmTk/icons/icon_refresh.gif etmTk/icons/icon_search.gif etmTk/icons/icon_settings.gif etmTk/icons/icon_stop.gif etmtk/data.py etmtk/v.py etmtk.egg-info/PKG-INFO etmtk.egg-info/SOURCES.txt etmtk.egg-info/dependency_links.txt etmtk.egg-info/not-zip-safe etmtk.egg-info/requires.txt etmtk.egg-info/top_level.txtetmtk-3.2.22/etmtk.egg-info/top_level.txt0000644000076500000240000000000612617420076020153 0ustar dagstaff00000000000000etmTk etmtk-3.2.22/MANIFEST.in0000644000076500000240000000026512534706254014353 0ustar dagstaff00000000000000include etmTk/CHANGES include etmTk/etm.1 include etmTk/etm.xpm include etmTk/etm.desktop include etmTk/etm.appdata.xml include etmTk/help/UserManual.html include etmTk/icons/*.gif etmtk-3.2.22/PKG-INFO0000644000076500000240000000253612617420125013705 0ustar dagstaff00000000000000Metadata-Version: 1.1 Name: etmtk Version: 3.2.22 Summary: event and task manager Home-page: http://people.duke.edu/~dgraham/etmtk Author: Daniel A Graham Author-email: daniel.graham@duke.edu License: License :: OSI Approved :: GNU General Public License (GPL) Description: manage events and tasks using simple text files Platform: Any Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Environment :: MacOS X Classifier: Environment :: Win32 (MS Windows) Classifier: Environment :: X11 Applications Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows :: Windows Vista Classifier: Operating System :: Microsoft :: Windows :: Windows 7 Classifier: Operating System :: OS Independent Classifier: Operating System :: POSIX Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Topic :: Office/Business Classifier: Topic :: Office/Business :: News/Diary Classifier: Topic :: Office/Business :: Scheduling etmtk-3.2.22/README.txt0000644000076500000240000001720012327025722014302 0ustar dagstaff00000000000000etm Tk manage events and tasks using simple text files etm is an acronym for event and task manager. In contrast to most calendar/todo applications, creating items (events, tasks, and so forth) in etm does not require filling out fields in a form. Instead, items are created as free-form text entries using a simple, intuitive format and stored in plain text files. This project is hosted on GitHub. Sample entries -------------- Items in etm begin with a type character such as an asterisk (event) and continue on one or more lines either until the end of the file is reached or another line is found that begins with a type character. The beginning type character for each item is followed by the item summary and then, perhaps, by one or more @key value pairs. The order in which such pairs are entered does not matter. - A sales meeting (an event) [s]tarting seven days from today at 9:00am and [e]xtending for one hour with a default [a]lert 5 minutes before the start: * sales meeting @s +7 9a @e 1h @a 5 - The sales meeting with another [a]lert 2 days before the meeting to (e)mail a reminder to a list of recipients: * sales meeting @s +7 9a @e 1h @a 5 @a 2d: e; who@when.com, what@where.org - Prepare a report (a task) for the sales meeting [b]eginning 3 days early: - prepare report @s +7 @b 3 - A period [e]xtending 35 minutes (an action) spent working on the report yesterday: ~ report preparation @s -1 @e 35 - Get a haircut (a task) on the 24th of the current month and then [r]epeatedly at (d)aily [i]ntervals of (14) days and, [o]n completion, (r)estart from the completion date: - get haircut @s 24 @r d &i 14 @o r - Payday (an occasion) on the last week day of each month. The &s -1 part of the entry extracts the last date which is both a weekday and falls within the last three days of the month): ^ payday @s 1/1 @r m &w MO, TU, WE, TH, FR &m -1, -2, -3 &s -1 - Take a prescribed medication daily (a reminder) [s]tarting today and [r]epeating (d)aily at [h]ours 10am, 2pm, 6pm and 10pm [u]ntil (12am on) the fourth day from today. Trigger the default [a]lert zero minutes before each reminder: * take Rx @s +0 @r d &h 10, 14, 18, 22 &u +4 @a 0 - Move the water sprinkler (a reminder) every thirty mi[n]utes on Sunday afternoons using the default alert zero minutes before each reminder: * Move sprinkler @s 1 @r w &w SU &h 14, 15, 16, 17 &n 0, 30 @a 0 To limit the sprinkler movement reminders to the [M]onths of April through September each year change the @r entry to this: @r w &w SU &h 14, 15, 16, 17 &n 0, 30 &M 4, 5, 6, 7, 8, 9 or this: @r n &i 30 &w SU &h 14, 15, 16, 17 &M 4, 5, 6, 7, 8, 9 - Presidential election day (an occasion) every four years on the first Tuesday after a Monday in November: ^ Presidential Election Day @s 2012-11-06 @r y &i 4 &M 11 &m 2, 3, 4, 5, 6, 7, 8 &w TU - Join the etm discussion group (a task) [s]tarting on the first day of the next month. Because of the @g (goto) link, pressing Ctrl-G when the details of this item are displayed in the gui would open the link using the system default application which, in this case, would be your default browser: - join the etm discussion group @s +1/1 @g http://groups.google.com/group/eventandtaskmanager/topics Installation ------------ Source installation under OS X, Linux or Windows Python 2.7.x or python >= 3.3.0 is required. The following python packages are required for etm but are not included in the python standard library: - dateutil (1.5 is OK but >= 2.1 is strongly recommended) - PyYaml (>= 3.10) - icalendar (>=3.5 for python 2, >= 3.6 for python 3) Tk and the python module tkinter are also required but are typically already installed on most modern operating systems. If needed, installation instructions are given at www.tkdocs.com/tutorial/install.html. Installing etm Download 'etmtk-x.x.x.tar.gz' from this site, unpack the tarball, cd to the resulting directory and do the normal sudo python setup.py install for a system installation. You can then run from any directory either $ etm ? for information about command line usage or $ etm to open the etm gui. Alternatively, you can avoid doing a system installation and simply run either $ python etm ? or $ python etm or $ ./etm from this directory. Installing Git or Mercurial Having one of these version control systems is optional but strongly recommended! With either progam installed, etm will automatically commit any change made to any data file. You can see the history of your changes either to a specific file or to any file from the GUI and, of course, you have the entire range of possibilities for showing changes, restoring previous versions and so forth from the command line. Git Download Git from http://git-scm.com/downloads Install git and then in a terminal enter your personal information $ git config --global user.name "John Doe" $ git config --global user.email johndoe@example.com the editor you would like to use $ git config --global core.editor vim and the diff program $ git config --global merge.tool vimdiff Usage information can be obtained in several ways from the terminal $ git help $ git --help $ man git- Finally, Pro Git by Scott Chacon is available to read or download at: http://git-scm.com/book/en If you have been using Mercurial and would like to give Git a try, you can import your etm Mercurial records into Git as follows: $ cd $ git clone git://repo.or.cz/fast-export.git $ git init new_temp_repo $ cd new_temp_repo $ ~/fast-export/hg-fast-export.sh -r /path/to/etm/datadir $ git checkout HEAD If an "unnamed head" error is reported, try adding --force to the end of the fast-export line. At this point, you should have a copy of your etm datadir in new_temp_repo along with a directory, .git, that you can copy to the root of your etm datadir where it will join its Mercurial counterpart, .hg. You can then delete new_temp_repo. You can now open etmtk.cfg for editing and change the setting for vcs_system to vcs_system: git Mercurial Download Mercurial from http://mercurial.selenic.com/ install it and then create the file ~/.hgrc, if it doesn't already exist, with at least the following two lines: [ui] username = Your Name New etm users By default, etm will use the directory ~/.etm The first time you run etm it will create, if necessary, the following: ~/.etm/ ~/.etm/etmtk.cfg ~/.etm/completions.cfg ~/.etm/reports.cfg ~/.etm/data/ If the data directory needs to be created, then a file ~/.etm/data/sample.txt will be added with illustrative entries. Similarly, the *.cfg files will be populated with useful entries. Previous etm users The first time you run etm, it will copy your current configuration settings from ~/.etm/etm.cfg to ~/.etm/etmtk.cfg. You can make any changes you like to the latter file without affecting the former. You can switch back and forth between etm_qt and etm. Any changes made to your data files by either one will be compatible with the other one. Feedback -------- Please share your ideas in the discussion group at GoogleGroups. License ------- Copyright (c) 2009-2014 Daniel Graham. All rights reserved. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. etmtk-3.2.22/setup.cfg0000644000076500000240000000007312617420125014423 0ustar dagstaff00000000000000[egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 etmtk-3.2.22/setup.py0000644000076500000240000000544012612462613014322 0ustar dagstaff00000000000000# from distutils.core import setup from setuptools import setup, find_packages from etmTk.v import version import glob import sys INSTALL_REQUIRES = ["python-dateutil>=1.5", "PyYaml>=3.10"] EXTRAS_REQUIRE = {"icalendar": ["icalendar>=3.8.4", "pytz>=2015.1"]} APP = ['etm'] OPTIONS = {'build': {'build_exe': 'releases/etmtk-{0}'.format(version)}, 'build_exe': {'icon': 'etmTk/etmlogo.gif', 'optimize': '2', 'compressed': 1}, 'build_mac': {'iconfile': 'etmTk/etmlogo.gif', 'bundle_name': 'etm'}, 'Executable': {'targetDir': 'releases/etmtk-{0}'.format(version)} } setup( name='etmtk', version=version, include_package_data=True, zip_safe=False, url='http://people.duke.edu/~dgraham/etmtk', description='event and task manager', long_description='manage events and tasks using simple text files', platforms='Any', license='License :: OSI Approved :: GNU General Public License (GPL)', author='Daniel A Graham', author_email='daniel.graham@duke.edu', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Environment :: MacOS X', 'Environment :: Win32 (MS Windows)', 'Environment :: X11 Applications', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: GNU General Public License (GPL)', 'Operating System :: MacOS :: MacOS X', 'Operating System :: Microsoft :: Windows :: Windows Vista', 'Operating System :: Microsoft :: Windows :: Windows 7', 'Operating System :: OS Independent', 'Operating System :: POSIX', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Topic :: Office/Business', 'Topic :: Office/Business :: News/Diary', 'Topic :: Office/Business :: Scheduling'], packages=['etmTk'], scripts=['etm'], install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, # extras_require={"icalendar": ["icalendar>=3.8.4"]}, package_data={ 'etmTk': ['icons/*', 'etm.desktop', 'etm.appdata.xml', 'CHANGES', 'etm.1', 'etm.xpm'], 'etmTk/help' : ['help/UserManual.html'], 'etmTk/icons': ['icons/*']}, data_files=[ ('share/man/man1', ['etmTk/etm.1']), ('share/doc/etm', ['etmTk/CHANGES']), ('share/pixmaps', ['etmTk/etm.xpm']), ('share/icons', glob.glob('etmTk/icons/*.gif')), ('share/applications', ['etmTk/etm.desktop']), ('share/appdata', ['etmTk/etm.appdata.xml']), ] )