././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2760603 gtimelog-0.12.0/0000775000175000017500000000000014603245772011245 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/.coveragerc0000664000175000017500000000052114515757026013366 0ustar00mgmg[run] source = gtimelog omit = src/gtimelog/main.py src/gtimelog/paths.py src/gtimelog/utils.py src/gtimelog/secrets.py src/gtimelog/debian-paths.py src/gtimelog/tests/* cover_pylib = False [report] exclude_lines = pragma: nocover if __name__ == '__main__': except ImportError: except NameError: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/.gitattributes0000644000175000017500000000027413543636505014141 0ustar00mgmg# Line numbers in .po files change all this time, so do # # git config filter.po.clean 'msgcat - --no-location' # # to make the diffs more readable *.po filter=po *.pot filter=po ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/.gitignore0000664000175000017500000000026014515757026013235 0ustar00mgmggtimelog.egg-info dist build temp .coverage *.py[co] tmp/ .tox/ .pc/ gtimelog.1 gtimelogrc.5 gtimelogrc.sample *~ .*.swp tags coverage.xml locale .coverage.* .flatpak-builder/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712146174.0 gtimelog-0.12.0/CHANGES.rst0000664000175000017500000003721214603243376013052 0ustar00mgmgChangelog --------- 0.12.0 (2024-04-03) ~~~~~~~~~~~~~~~~~~~ - This version talks to an SMTP server instead of relying on /usr/sbin/sendmail for email sending. This should work even in flatpaks. - New command line options: --prefs, --email-prefs. - Use libsecret instead of gnome-keyring. - GTK 3.18 or newer is now required (GH: #131). - Soap 3.0 is now required (GH: #238). - Fixed an AttributeError in the undocumented remote task list feature (GH: #153). - Make the undocumented remote task list feature validate TLS certificates (GH: #214). - Add Python 3.8, 3.9, 3.10, 3.11, and 3.12 support. - Drop Python 2.7, 3.5, and 3.6 support. - Add support for positive time offset syntax in entries. - Focus the task entry on Ctrl+L (GH: #213). - Change entry search to be fuzzy. It is now only required to enter characters of the entry in the correct order to find an entry. - Enforce minimum and maximum size for the task pane (GH: #219). - Task pane now preserves the order of task groups to match the order in tasks.txt (GH: #224). - Grouped task entries can now be sorted by start date, name, duration or according to tasks.txt order (GH: #228). - Add the ability to change the last entry using Ctrl+Shift+BackSpace (GH: #247). 0.11.3 (2019-04-23) ~~~~~~~~~~~~~~~~~~~ - Use a better workaround for window.present() not working on Wayland. - Fix a rare AssertionError on quit. - Fix problem with "Edit log" and "Edit tasks" menu entries on Windows (GH: #133). - Do not include ``***`` entries in slacking total (GH: #138). - Show average time per day spent on filtered tasks (GH: #146). - Drop Python 3.4 support. 0.11.2 (2018-11-03) ~~~~~~~~~~~~~~~~~~~ - Window menu now includes items previously shown only in the app menu: Preferences, About (GH: #126). - Keyboard shortcuts window (press Ctrl+Shift+?). - Dropped the help page (there was only one and it was only listing keyboard shortcuts, and it was also incomplete and had no translations). - Bugfix: if timelog.txt was a symlink, changes to the symlink target would not get noticed automatically (GH: #128). 0.11.1 (2018-07-18) ~~~~~~~~~~~~~~~~~~~ * The undocmented remote task list over HTTP(S) feature is now able to ask for basic HTTP authentication credentials and store them in gnome-keyring (GH: #109). * Bugfix: entries with just a category and no task that did not have a trailing space after the ':' were considered to be uncategorised (GH: #117). * Add Python 3.7 support. * Drop Python 3.3 support. 0.11 (2017-12-16) ~~~~~~~~~~~~~~~~~ * A complete rewrite of the user interface, to better fit GNOME 3 (GH: #31). Requires GTK+ 3.10, but newer versions are better. * History browsing can show you weeks/months, not just days. * You can filter the displayed tasks, with a total shown at the bottom (GH: #88). * There's now a preferences dialog (GH: #47). * Window size and task pane size/visibility are remembered across restarts (GH: #30). * Settings are stored in GSettings. The old config file will be imported on first startup. * Work hours and office hours are separate settings now (GH: #46). * Native support for emailing reports. Requires a configured MTA on the local machine (i.e. /usr/sbin/sendmail). * There's a help page listing all the keyboard shortcuts. * The user interface can be translated (and is translated into Lithuanian). Reports are an exception (GH: #45). * More efficient file change watching (GH: #11). * Dropped features: - No more tray icons. - Dropped --tray, --toggle, --quit, --sample-config command line options. - The "Reload" menu option and hot key are gone -- reloading is automatic now. - Report for a custom date range is gone. - "Complete report in spreadsheet" is gone. - "Work/slacking stats in spreadsheet" is gone. - Setting for editor is gone: the default file association for text files will be used. - Settings for mailer is gone: mail sending is internal now. - Setting for spreadsheet is gone. - Separate setting to show remaining office hours is gone (set office hours to 0 to hide the estimate). 0.10.0 (2015-09-29) ~~~~~~~~~~~~~~~~~~~ * Use Tango colors in the main text buffer (GH: #13). * Allow tagging entries (GH: #19) - The syntax is ``category: text -- tag1 tag2`` - Per-tag summaries show up in reports * Use GtkApplication instead of own DBus server for enforcing single-instance. - Drop --replace, --ignore-dbus command-line options because of this. - Require glib and gio to be version 2.40 or newer for sane GtkApplication-based command line parsing (check with ``pkg-config --modversion glib-2.0 gio-2.0``). * Remove obsolete code: - Drop support for Python 2.6 (PyGObject dropped support for it long ago). - Drop PyGtk/Gtk+ 2 support code (it didn't work since 0.9.1 anyway). - Drop EggTrayIcon support (it was for Gtk+ 2 only anyway). - Drop the --prefer-pygtk command-line option. * Disable tray icon by default for new users (existing gtimelogrc files will be untouched). * Improve tray icon selection logic for best contrast (GH: #29). 0.9.3 (2015-09-29) ~~~~~~~~~~~~~~~~~~ * Adding new entries didn't update total weekly numbers (GH: #28). 0.9.2 (2014-09-28) ~~~~~~~~~~~~~~~~~~ * Note that Gtk+ 2.x is no longer supported (this regressed somewhere between 0.9.0 and 0.9.1, but I didn't notice because I have no access to a system that has Gtk+ 2.x). * Fix setup.py to work on Python 3 when your locale is not UTF-8 (LP: #1263772). * Fix two Gtk-CRITICAL warnings on startup (GH: #14). * Fix Unicode warning when adding entries (GH: #20). * Speed up entry addition (GH: #21). * Fix Unicode error when navigating history with PageUp/PageDown (GH: #22). * Update current task time when autoreloading (GH: #23). * Fix 'LocaleError: unknown encoding:' on Mac OS X (GH: #25). * Fix 'TypeError: unorderable types: NoneType() < str()' in summary view on Python 3 (GH: #26). 0.9.1 (2013-12-23) ~~~~~~~~~~~~~~~~~~ * Manual pages for gtimelog(1) and gtimelogrc(5). 0.9.0 (2013-12-04) ~~~~~~~~~~~~~~~~~~ * New custom date range report by Rohan Mitchell. * Moved to GitHub. * HACKING.txt renamed to CONTRIBUTING.rst. * Tests no longer require PyGTK/PyGObject. * Add back Python 2.6 support (not 100% guaranteed, I don't have PyGObject for 2.6). * Add Python 3.3 support. 0.8.1 (2013-02-10) ~~~~~~~~~~~~~~~~~~ * Fix strftime problem on Windows (LP: #1096489). * Fix gtimelog.desktop validation (LP: #1051226). * Use gtimelog icon instead of gnome-week.png. * Use XDG Base Directory Specification for config and data files (~/.config/gtimelog and ~/.local/share/gtimelog). There's no automatic migration: if ~/.gtimelog exists, it will continue to be used. * Fix Unicode errors when user's name is non-ASCII (LP: #1117109). * Dropped Python 2.6 support (by accident). 0.8.0 (2012-08-24) ~~~~~~~~~~~~~~~~~~ * History browsing (LP: #220778). * New setting to hide the tasks pane on startup (LP: #767096). * Reload timelog.txt automatically when it changes (LP: #220775). * Fix segfault on startup (LP: #1016212). * Summary view (Alt-3) that shows total work in each category. * Fix popup menu on the task pane (LP: #1040031). * New command-line option: --prefer-pygtk. Only useful for testing against the deprecated PyGtk bindings instead of the modern pygobject-introspection. * New command-line option: --quit. * Fix popup menu of the tray icon (LP: #1039977). * Fix crash on exit when using Gtk+ 2 (LP: #1040088). * New command-line option: --debug. * New command-line option: --version. 0.7.1 (2012-02-01) ~~~~~~~~~~~~~~~~~~ * Fix reporting problems with non-ASCII characters when using gobject-introspection (LP: #785578). * Fix ^C not exiting the app when using gobject-introspection. * Implement panel icon color autodetection logic that was missing in the gobject-introspection case (LP: #924390). * New command-line option: --help. * New command-line option: --replace. Requires that the running version support the new DBus method 'Quit', which was also added in this version. * Messages printed to stdout are prefixed by "gtimelog" (GUI app output often ends up in ~/.xsession-errors, it's polite to identify yourself when writing there). * DBus errors do not pass silently. 0.7.0 (2011-09-21) ~~~~~~~~~~~~~~~~~~ * Use gobject-introspection by default, using pygtk only as a fallback. This will require a newer gir1.2-pango-1.0 than what's in Ubuntu Oneiric (LP: #855076) and still suffers from key presses being ignored (LP: #849732). Unset the environment variable UBUNTU_MENUPROXY to work around the latter bug. * Rework the gi/pygtk imports so that only the minimum is wrapped in a try-except. * Use /usr/bin/env python in #! line, though this should be hard-coded to the installed version of Python in the Debian package. * Other code cleanup (e.g. use new-style classes via __metaclass__, remove ancient workaround for missing `set` built-in). 0.6.1 (2011-09-20) ~~~~~~~~~~~~~~~~~~ * Fix two crashes when using GI. Given by Martin Pitt. 0.6.0 (2011-08-23) ~~~~~~~~~~~~~~~~~~ * Ctrl-Q now quits. (LP: #750092) * Fixed UnboundLocalError. (LP: #778285) Given by Jeroen Langeveld. * Ported from PyGTK to GI. This supports GTK 2 and GTK 3 with GI now, but still works with PyGTK. Contributed by Martin Pitt . Packager's note: If you want to use GI, you need to change the package's dependencies from pygtk to the package that provides the GTK and Pango typelibs (e. g. gir1.2-gtk-2.0 and gir1.2-pango-1.0 on Debian/Ubuntu). It also requires pygobject >= 2.27.1. * Hide the main window on Esc. Fixes LP: #716257. Contributed by Vladislav Naumov (https://launchpad.net/~vnaum). 0.5.0 (2011-01-28) ~~~~~~~~~~~~~~~~~~ * Switched from Glade to GtkBuilder. This fixes those strange theme problems GTimeLog had with Ubuntu's Radiance and especially Ambiance. (LP: #644393) Packagers note: src/gtimelog/gtimelog.glade is gone, it was replaced by src/gtimelog/gtimelog.ui. It needs to be installed into /usr/share/gtimelog/. * GTimeLog now supports Ubuntu's application indicators. There's a new configuration option, ``prefer_app_indicator``, defaulting to true. Fixes LP: #523461. * GTimeLog tries to detect your theme color and make the tray icon dark or bright, for good contrast. This is a hack that doesn't work reliably, but is better than nothing. Fixes LP: #700428. Packagers note: there's a new icon file, src/gtimelog/gtimelog-small-bright.png. It needs to be installed into /usr/share/gtimelog/. * Made GTimeLog a single instance application. Requires python-dbus. The following command line options are supported:: gtimelog --ignore-dbus Always launch a new application instance, do not start the DBus service. gtimelog --toggle If GtimeLog already running, show or hide the GTimeLog window, otherwise launch a new application instance. gtimelog If GtimeLog already running, bring the GTimeLog window to the front, otherwise launch a new application instance. Contributed by Bruce van der Kooij (https://launchpad.net/~brucevdk), Fixes LP: #356495. * New option: start_in_tray. Defaults to false. Contributed by Bruce van der Kooij (https://launchpad.net/~brucevdk), as part of his patch for LP: #356495. * New command-line option: --tray. Makes GTimeLog start minimized, or exit without doing anything if it's already running. * Added some documentation for contributors: HACKING.txt. * Daily reports include totals by category. Contributed by Laurynas Speičys . * The tasks pane can be toggled by pressing F9 and has a close button. * Alternative weekly and monthly report style, can be chosen by adding ``report_style = categorized`` to ~/.gtimelog/gtimelogrc. Contributed by Laurynas Speičys . * Bugfix: always preserve the order of entries, even when they have the same timestamp (LP: #708825). 0.4.0 (2010-09-03) ~~~~~~~~~~~~~~~~~~ * Added configuration variable 'chronological' to control initial view of either Chronological (True) or Grouped (False). Contributed by Barry Warsaw (LP: #628876) * Recognize $GTIMELOG_HOME environment variable to use something other than ~/.gtimelog as the configuration directory. Contributed by Barry Warsaw (LP: #628873) * Changed application name to 'GTimeLog Time Tracker' in the desktop file (Debian #595280) 0.3.2 (2010-07-22) ~~~~~~~~~~~~~~~~~~ * Double-clicking a category in task list tries hard to focus the input box (fixes: https://bugs.launchpad.net/gtimelog/+bug/608734). * Change default mailer to quote the command passed to x-terminal-emulator -e; this makes it work with Terminator (also tested with xterm and gnome-terminal). Fixes https://bugs.launchpad.net/gtimelog/+bug/592552. Note: if you've used gtimelog before, you'll have to manually edit ~/.gtimelog/gtimelogrc and change the mailer line from mailer = x-terminal-emulator -e mutt -H %s to mailer = x-terminal-emulator -e "mutt -H %s" * Use xdg-open by default for editing timelog.txt and opening spreadsheets. Fixes https://bugs.launchpad.net/gtimelog/+bug/592560. Note: if you've used gtimelog before, you'll have to manually edit ~/.gtimelog/gtimelogrc and change editor = gvim spreadhsheet = oocalc %s to editor = xdg-open spreadsheet = xdg-open %s 0.3.1 (2009-12-18) ~~~~~~~~~~~~~~~~~~ * Fixed broken sdist (by adding MANIFEST.in, since setuptools doesn't understand bzr by default). * Added Makefile for convenience (make distcheck, make release). 0.3 (2009-12-17) ~~~~~~~~~~~~~~~~ * Fix DeprecationWarning: the sets module is deprecated. * Use gtk.StatusIcon if egg.trayicon is not available (https://bugs.launchpad.net/gtimelog/+bug/209798). * Option to select between old-style and new-style the tray icons: 'prefer_old_tray_icon' in ~/.gtimelog/gtimelogrc * Option to disable the tray icon altogether by adding 'show_tray_icon = no' to ~/.gtimelog/gtimelogrc (https://bugs.launchpad.net/gtimelog/+bug/255618). * Handle directory names with spaces (https://bugs.launchpad.net/gtimelog/+bug/328118). * Show version number in the About dialog (https://bugs.launchpad.net/gtimelog/+bug/308750). Packagers take note: the main module was renamed from gtimelog.gtimelog to gtimelog.main. If you have wrapper scripts that used to import 'main' from gtimelog.gtimelog, you'll have to change them. 0.2.5 ~~~~~ * Don't open a console window on Windows. * Moved the primary GTimeLog source repository to Bazaar hosted on Launchpad. 0.2.4 ~~~~~ * Show time spent at the office (https://bugs.launchpad.net/gtimelog/+bug/238515). * Closing the main window minimizes GTimeLog to the system tray (https://bugs.launchpad.net/gtimelog/+bug/239271) * Ability to time-offset new log item (https://bugs.launchpad.net/bugs/291356) 0.2.3 ~~~~~ * Fix duplicates in the completion popup after you reload the log file (https://bugs.launchpad.net/gtimelog/+bug/238505). * Change status to Beta in setup.py -- while I still consider it to be less polished than it should, there are people who find it useful already. 0.2.2 ~~~~~ * Tweak setup.py to get a sane page at https://pypi.python.org/pypi/gtimelog/ 0.2.1 ~~~~~ * Entries with `***` are skipped from reports (bug 209750) * Help -> Online Documentation opens a browser with some help (bug 209754) * View -> Tasks allows you to hide the Tasks pane (bug 220773) 0.2.0 ~~~~~ * Reorganize the source tree properly. * Bump intermediate revision number to celebrate. 0.0.85 ~~~~~~ * First setuptools-based release (`easy_install gtimelog` now works). Changes in older versions ~~~~~~~~~~~~~~~~~~~~~~~~~ You'll have to dig through Git logs to discover those, if you're really that interested: https://github.com/gtimelog/gtimelog/commits ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/CONTRIBUTING.rst0000644000175000017500000000173013543636505013705 0ustar00mgmgContributing to GTimeLog ======================== Contributions are welcome, and not just code patches. I'd love to see * user interface design sketches * icons * documentation * translations * installers for Mac OS X and Windows Bugs ---- Please `use GitHub `_ to report bugs or feature requests. We also have an older issue tracker on `Launchpad `_. Some bugs haven't been moved over to GitHub yet. You may also contact Marius Gedminas or Barry Warsaw by email. Source code ----------- It's on GitHub: https://github.com/gtimelog/gtimelog Get the latest version with :: $ git clone https://github.com/gtimelog/gtimelog Run it without installing :: $ cd gtimelog $ make $ ./gtimelog Tests ----- Run the test suite with :: $ ./runtests or, to test against all supported Python versions :: $ pip install tox $ tox ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/CONTRIBUTORS.rst0000664000175000017500000000211714556177605013743 0ustar00mgmgContributors ============ In alphabetic order: - "ijk" - Adomas Paltanavičius - Barry Warsaw - Chris Beaven - Christian Theune - Dafydd Harries - Daniel Kraft - Danielle Madeley - Eduardo Habkost - Emanuele Aina - Eric Lavarde - Gaute Amundsen - Gintautas Miliauskas - Harald Friessnegger - Heimen Stoffels - Holger Brandhorst - Ignas Mikalajūnas - Jamu Kakar - Jean Jordaan - Jeroen Langeveld - Jonatan Cloutier - Jonathan Snyder - Kees Cook - Lars Wirzenius - Laurynas Speičys - Martin Pitt - Michael Vogt - Michael Howitz - Nathan Pratta Teodosio - Olivier Crête - Patrick Gerken - Radek Muzatko - Rodrigo Daunoravicius - Rohan Mitchell - Shirish Agarwal शिरीष अग्रवाल - Stéphane Mangin - Thom May - Till Hofmann - Tomaz Canabrava - Vikas Yadav - Živilė Gedminaitė Their contributions include patches (including those that didn't make it into the mainline), helpful suggestions, icons, configuration tips for integration with other software, offers for co-maintainership. Apologies to anyone I may have omitted. If you drop me a note, I'll correct the omission. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1560382518.0 gtimelog-0.12.0/COPYING0000644000175000017500000004315213500306066012270 0ustar00mgmgGNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. {description} Copyright (C) {year} {fullname} 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 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. {signature of Ty Coon}, 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147268.0 gtimelog-0.12.0/MANIFEST.in0000664000175000017500000000110114603245504012765 0ustar00mgmginclude COPYING include *.rst *.txt include Makefile *.mk include gtimelog include gtimelog.desktop include gtimelog.desktop.in include gtimelog.appdata.xml include runtests include tox.ini include appveyor.yml include .coveragerc include .gitignore include .gitattributes include benchmark.py recursive-include src *.png *.ui *.xml *.css *.rst gschemas.compiled recursive-include docs *.png *.rst *.css Makefile recursive-include scripts *.py *.rst recursive-include src/gtimelog/po *.po *.pot *.in recursive-include flatpak *.yaml prune docs/build prune src/gtimelog/locale ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711447453.0 gtimelog-0.12.0/Makefile0000664000175000017500000000731514600516635012707 0ustar00mgmg# # Options # PYTHON = python3 FILE_WITH_VERSION = src/gtimelog/__init__.py FILE_WITH_CHANGELOG = CHANGES.rst # Let's use the tox-installed coverage because we'll be sure it's there and has # the necessary plugins. COVERAGE = .tox/coverage/bin/coverage # # Interesting targets # manpages = gtimelog.1 po_dir = src/gtimelog/po po_files = $(wildcard $(po_dir)/*.po) mo_dir = src/gtimelog/locale mo_files = $(patsubst $(po_dir)/%.po,$(mo_dir)/%/LC_MESSAGES/gtimelog.mo,$(po_files)) schema_dir = src/gtimelog/data schema_files = $(schema_dir)/gschemas.compiled runtime_files = $(schema_files) $(mo_files) .PHONY: all all: $(manpages) $(runtime_files) ##: build everything .PHONY: run run: $(runtime_files) ##: run directly from the source tree ./gtimelog .PHONY: test test: ##: run tests tox -p auto .PHONY: check check: check-desktop-file check-appstream-metadata ##: run tests and additional checks .PHONY: check check-desktop-file: ##: validate desktop file desktop-file-validate gtimelog.desktop .PHONY: check check-appstream-metadata: ##: validate appstream metadata file appstreamcli validate --strict --explain --pedantic gtimelog.appdata.xml .PHONY: coverage coverage: ##: measure test coverage tox -e coverage .PHONY: coverage-diff coverage-diff: coverage ##: find untested code in this branch $(COVERAGE) xml diff-cover coverage.xml .PHONY: flake8 flake8: ##: check for style problems tox -e flake8 .PHONY: isort isort: ##: check for badly sorted improts tox -e isort .PHONY: update-translations update-translations: ##: extract new translatable strings from source code and ui files git config filter.po.clean 'msgcat - --no-location' cd $(po_dir) && intltool-update -g gtimelog -p for po in $(po_files); do msgmerge -U $$po $(po_dir)/gtimelog.pot; done .PHONY: mo-files mo-files: $(mo_files) .PHONY: flatpak flatpak: ##: build a flatpak package # you may need to install the platform and sdk before this will work # flatpak install flathub org.gnome.Platform//3.82 org.gnome.Sdk//3.38 # note that this builds the code from git master, not your local working tree! flatpak-builder --force-clean build/flatpak flatpak/org.gtimelog.GTimeLog.yaml # to run it do # flatpak-builder --run build/flatpak flatpak/org.gtimelog.GTimeLog.yaml gtimelog .PHONY: flatpak-install flatpak-install: ##: build and install a flatpak package # you may need to install the platform and sdk before this will work # flatpak install flathub org.gnome.Platform//3.38 org.gnome.Sdk//3.38 # note that this builds the code from git master, not your local working tree! flatpak-builder --force-clean build/flatpak flatpak/org.gtimelog.GTimeLog.yaml --install --user # to run it do # flatpak run org.gtimelog.GTimeLog $(mo_dir)/%/LC_MESSAGES/gtimelog.mo: $(po_dir)/%.po @mkdir -p $(@D) msgfmt -o $@ $< $(schema_files): $(schema_dir)/org.gtimelog.gschema.xml glib-compile-schemas $(schema_dir) .PHONY: clean clean: ##: clean build artifacts rm -rf temp tmp build gtimelog.egg-info $(runtime_files) $(mo_dir) find -name '*.pyc' -delete include release.mk .PHONY: distcheck distcheck: distcheck-wheel # add to the list of checks defined in release.mk .PHONY: distcheck-wheel distcheck-wheel: @pkg_and_version=`$(PYTHON) setup.py --name`-`$(PYTHON) setup.py --version` && \ unzip -l dist/$$pkg_and_version-py2.py3-none-any.whl | \ grep -q gtimelog.mo && \ echo "wheel seems to be ok" %.1: %.rst rst2man $< > $@ %.5: %.rst rst2man $< > $@ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2760603 gtimelog-0.12.0/PKG-INFO0000664000175000017500000001346114603245772012347 0ustar00mgmgMetadata-Version: 2.1 Name: gtimelog Version: 0.12.0 Summary: A Gtk+ time tracking application Home-page: https://gtimelog.org/ Author: Marius Gedminas Author-email: marius@gedmin.as License: GPL Keywords: time log logging timesheets gnome gtk Classifier: Development Status :: 4 - Beta Classifier: Environment :: X11 Applications :: GTK Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Office/Business Requires-Python: >= 3.7 Description-Content-Type: text/x-rst Provides-Extra: test License-File: COPYING GTimeLog ======== GTimeLog is a simple app for keeping track of time. .. image:: https://github.com/gtimelog/gtimelog/workflows/build/badge.svg?branch=master :target: https://github.com/gtimelog/gtimelog/actions :alt: build status .. image:: https://ci.appveyor.com/api/projects/status/github/gtimelog/gtimelog?branch=master&svg=true :target: https://ci.appveyor.com/project/mgedmin/gtimelog :alt: build status (on Windows) .. image:: https://coveralls.io/repos/gtimelog/gtimelog/badge.svg?branch=master :target: https://coveralls.io/r/gtimelog/gtimelog?branch=master :alt: test coverage .. contents:: .. image:: https://raw.github.com/gtimelog/gtimelog/master/docs/gtimelog.png :alt: screenshot Installing ---------- GTimeLog is packaged for Debian and Ubuntu:: sudo apt-get install gtimelog For Ubuntu, sometimes a newer version can usually be found in the PPA: https://launchpad.net/~gtimelog-dev/+archive/ppa Fedora also holds a package of gtimelog to be installed with:: sudo dnf install gtimelog You can fetch the latest released version from PyPI :: $ pip install gtimelog $ gtimelog You can run it from a source checkout (without an explicit installation step):: $ git clone https://github.com/gtimelog/gtimelog $ cd gtimelog $ ./gtimelog System requirements: - Python (3.6+) - PyGObject - gobject-introspection type libraries for Gtk, Gdk, GLib, Gio, GObject, Pango, Soup, Secret - GTK+ 3.18 or newer Documentation ------------- This is work in progress: - `docs/index.rst`_ contains an overview - `docs/formats.rst`_ describes the file formats .. _docs/index.rst: https://github.com/gtimelog/gtimelog/blob/master/docs/index.rst .. _docs/formats.rst: https://github.com/gtimelog/gtimelog/blob/master/docs/formats.rst Resources --------- Website: https://gtimelog.org Mailing list: gtimelog@googlegroups.com (archive at https://groups.google.com/group/gtimelog) IRC: #gtimelog on chat.libera.net Source code: https://github.com/gtimelog/gtimelog Report bugs at https://github.com/gtimelog/gtimelog/issues There's an old bugtracker at https://bugs.launchpad.net/gtimelog I sometimes also browse distribution bugs: - Ubuntu https://bugs.launchpad.net/ubuntu/+source/gtimelog - Debian https://bugs.debian.org/gtimelog Credits ------- GTimeLog was mainly written by Marius Gedminas . Barry Warsaw stepped in as a co-maintainer when Marius burned out. Then Barry got busy and Marius recovered. Many excellent contributors are listed in `CONTRIBUTORS.rst`_ .. _CONTRIBUTORS.rst: https://github.com/gtimelog/gtimelog/blob/master/src/gtimelog/CONTRIBUTORS.rst Changelog --------- 0.12.0 (2024-04-03) ~~~~~~~~~~~~~~~~~~~ - This version talks to an SMTP server instead of relying on /usr/sbin/sendmail for email sending. This should work even in flatpaks. - New command line options: --prefs, --email-prefs. - Use libsecret instead of gnome-keyring. - GTK 3.18 or newer is now required (GH: #131). - Soap 3.0 is now required (GH: #238). - Fixed an AttributeError in the undocumented remote task list feature (GH: #153). - Make the undocumented remote task list feature validate TLS certificates (GH: #214). - Add Python 3.8, 3.9, 3.10, 3.11, and 3.12 support. - Drop Python 2.7, 3.5, and 3.6 support. - Add support for positive time offset syntax in entries. - Focus the task entry on Ctrl+L (GH: #213). - Change entry search to be fuzzy. It is now only required to enter characters of the entry in the correct order to find an entry. - Enforce minimum and maximum size for the task pane (GH: #219). - Task pane now preserves the order of task groups to match the order in tasks.txt (GH: #224). - Grouped task entries can now be sorted by start date, name, duration or according to tasks.txt order (GH: #228). - Add the ability to change the last entry using Ctrl+Shift+BackSpace (GH: #247). 0.11.3 (2019-04-23) ~~~~~~~~~~~~~~~~~~~ - Use a better workaround for window.present() not working on Wayland. - Fix a rare AssertionError on quit. - Fix problem with "Edit log" and "Edit tasks" menu entries on Windows (GH: #133). - Do not include ``***`` entries in slacking total (GH: #138). - Show average time per day spent on filtered tasks (GH: #146). - Drop Python 3.4 support. 0.11.2 (2018-11-03) ~~~~~~~~~~~~~~~~~~~ - Window menu now includes items previously shown only in the app menu: Preferences, About (GH: #126). - Keyboard shortcuts window (press Ctrl+Shift+?). - Dropped the help page (there was only one and it was only listing keyboard shortcuts, and it was also incomplete and had no translations). - Bugfix: if timelog.txt was a symlink, changes to the symlink target would not get noticed automatically (GH: #128). Older versions ~~~~~~~~~~~~~~ See the `full changelog`_. .. _full changelog: https://github.com/gtimelog/gtimelog/blob/master/CHANGES.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/README.rst0000664000175000017500000000516614515757026012746 0ustar00mgmgGTimeLog ======== GTimeLog is a simple app for keeping track of time. .. image:: https://github.com/gtimelog/gtimelog/workflows/build/badge.svg?branch=master :target: https://github.com/gtimelog/gtimelog/actions :alt: build status .. image:: https://ci.appveyor.com/api/projects/status/github/gtimelog/gtimelog?branch=master&svg=true :target: https://ci.appveyor.com/project/mgedmin/gtimelog :alt: build status (on Windows) .. image:: https://coveralls.io/repos/gtimelog/gtimelog/badge.svg?branch=master :target: https://coveralls.io/r/gtimelog/gtimelog?branch=master :alt: test coverage .. contents:: .. image:: https://raw.github.com/gtimelog/gtimelog/master/docs/gtimelog.png :alt: screenshot Installing ---------- GTimeLog is packaged for Debian and Ubuntu:: sudo apt-get install gtimelog For Ubuntu, sometimes a newer version can usually be found in the PPA: https://launchpad.net/~gtimelog-dev/+archive/ppa Fedora also holds a package of gtimelog to be installed with:: sudo dnf install gtimelog You can fetch the latest released version from PyPI :: $ pip install gtimelog $ gtimelog You can run it from a source checkout (without an explicit installation step):: $ git clone https://github.com/gtimelog/gtimelog $ cd gtimelog $ ./gtimelog System requirements: - Python (3.6+) - PyGObject - gobject-introspection type libraries for Gtk, Gdk, GLib, Gio, GObject, Pango, Soup, Secret - GTK+ 3.18 or newer Documentation ------------- This is work in progress: - `docs/index.rst`_ contains an overview - `docs/formats.rst`_ describes the file formats .. _docs/index.rst: https://github.com/gtimelog/gtimelog/blob/master/docs/index.rst .. _docs/formats.rst: https://github.com/gtimelog/gtimelog/blob/master/docs/formats.rst Resources --------- Website: https://gtimelog.org Mailing list: gtimelog@googlegroups.com (archive at https://groups.google.com/group/gtimelog) IRC: #gtimelog on chat.libera.net Source code: https://github.com/gtimelog/gtimelog Report bugs at https://github.com/gtimelog/gtimelog/issues There's an old bugtracker at https://bugs.launchpad.net/gtimelog I sometimes also browse distribution bugs: - Ubuntu https://bugs.launchpad.net/ubuntu/+source/gtimelog - Debian https://bugs.debian.org/gtimelog Credits ------- GTimeLog was mainly written by Marius Gedminas . Barry Warsaw stepped in as a co-maintainer when Marius burned out. Then Barry got busy and Marius recovered. Many excellent contributors are listed in `CONTRIBUTORS.rst`_ .. _CONTRIBUTORS.rst: https://github.com/gtimelog/gtimelog/blob/master/src/gtimelog/CONTRIBUTORS.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1560382518.0 gtimelog-0.12.0/TODO.rst0000644000175000017500000000116013500306066012525 0ustar00mgmg- [ ] Update documentation - [ ] Shorter README - what is it - how it looks - how to install and run - where to find documentation - where to report bugs - where to find source code - [ ] Newer screenshots - [ ] Create a man page with rst2man - [ ] Fix icon in .desktop file for the Debian package - [ ] Release 0.9.0 to PyPI and the Ubuntu PPA - [ ] 'Edit config file' menu entry - [ ] Weekly summary in the main GUI window (or sidebar) - [ ] Keep all the data in memory please - [ ] Python 3 port - [ ] I18n - [ ] Usability: - [ ] Internal reporting - [ ] Preferences dialog ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/appveyor.yml0000664000175000017500000000154714515757026013646 0ustar00mgmgversion: build-{build}-{branch} environment: matrix: # https://www.appveyor.com/docs/installed-software#python lists available # versions - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python39" - PYTHON: "C:\\Python310" - PYTHON: "C:\\Python311" - PYTHON: "C:\\Python312" init: - "echo %PYTHON%" install: - ps: | if (-not (Test-Path $env:PYTHON)) { curl -o install_python.ps1 https://raw.githubusercontent.com/matthew-brett/multibuild/11a389d78892cf90addac8f69433d5e22bfa422a/install_python.ps1 .\install_python.ps1 } - ps: if (-not (Test-Path $env:PYTHON)) { throw "No $env:PYTHON" } - "set PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - python --version - pip install -U virtualenv # upgrade pip in tox's virtualenvs - pip install tox build: off test_script: - tox -e py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/benchmark.py0000775000175000017500000001252714556177605013571 0ustar00mgmg#!/usr/bin/python3 import gc import os import sys import time from operator import itemgetter pkgdir = os.path.join(os.path.dirname(__file__), 'src') sys.path.insert(0, pkgdir) from gtimelog.settings import Settings from gtimelog.timelog import TimeLog, parse_datetime fns = [] def mark(fn): fns.append(fn) return fn def unmark(fn): return fn def benchmark(fn, correct_output): gc.collect() print("{}:".format(fn.__name__), end="") m = float("inf") n = 0 output = fn() if output != correct_output: print(" [NB incorrect output]") else: print() t00 = time.time() while n < 3 or time.time() - t00 < 3: t0 = time.time() fn() t1 = time.time() d = t1 - t0 m = min(m, d) print("\r{:.3f}s".format(d), end="") sys.stdout.flush() n += 1 if n > 100: break tot = time.time() - t00 print("\rmin {:.3f}s avg {:.3f}s (n={})\n".format(m, tot / n, n)) @unmark def just_read(): filename = Settings().get_timelog_file() open(filename).readlines() @unmark def split(): filename = Settings().get_timelog_file() for line in open(filename): if ': ' not in line: continue time, entry = line.split(': ', 1) @unmark def parse_one(): filename = Settings().get_timelog_file() for line in open(filename): if ': ' not in line: continue time, entry = line.split(': ', 1) try: time = parse_datetime(time) except ValueError: continue @unmark def parse_two(): # slower than parse_one filename = Settings().get_timelog_file() for line in open(filename): try: time, entry = line.split(': ', 1) time = parse_datetime(time) except ValueError: continue @unmark def parse_three(): # fastest filename = Settings().get_timelog_file() for line in open(filename): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue @unmark def parse_and_strip(): filename = Settings().get_timelog_file() for line in open(filename): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() @unmark def parse_and_collect(): items = [] filename = Settings().get_timelog_file() for line in open(filename): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() items.append((time, entry)) return items @unmark def parse_and_sort_incorrectly(): items = [] filename = Settings().get_timelog_file() for line in open(filename): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() items.append((time, entry)) items.sort() # XXX: can reorder lines return items @mark def parse_and_sort(): items = [] filename = Settings().get_timelog_file() for line in open(filename): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() items.append((time, entry)) items.sort(key=itemgetter(0)) return items @mark def parse_and_sort_unicode(): items = [] filename = Settings().get_timelog_file() for line in open(filename, 'rb').read().decode('UTF-8').splitlines(): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() items.append((time, entry)) items.sort(key=itemgetter(0)) return items @unmark def parse_and_sort_unicode_piecemeal(): items = [] filename = Settings().get_timelog_file() for line in open(filename, 'rb'): time, sep, entry = line.partition(b': ') if not sep: continue try: time = parse_datetime(time.decode('ASCII')) except (ValueError, UnicodeError): continue entry = entry.strip().decode('UTF-8') items.append((time, entry)) items.sort(key=itemgetter(0)) return items @mark def parse_and_sort_python3(): items = [] filename = Settings().get_timelog_file() for line in open(filename, 'r', encoding='UTF-8'): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() items.append((time, entry)) items.sort(key=itemgetter(0)) return items @mark def full(): return TimeLog(Settings().get_timelog_file(), Settings().virtual_midnight).items def main(): correct = full() for fn in fns: benchmark(fn, correct) if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/constraints.txt0000664000175000017500000000016114515757026014355 0ustar00mgmg# exclude the two broken versions, see https://github.com/nedbat/coveragepy/issues/909 coverage != 5.0, != 5.0.1 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2720604 gtimelog-0.12.0/docs/0000775000175000017500000000000014603245772012175 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/docs/Makefile0000644000175000017500000000065113543636505013635 0ustar00mgmg.PHONY: build build: rm -rf build && mkdir build rst2html --stylesheet mg.css --link-stylesheet website.rst build/index.html rst2html --stylesheet mg.css --link-stylesheet index.rst build/docs.html rst2html --stylesheet mg.css --link-stylesheet formats.rst build/formats.html cp gtimelog.png build/gtimelog.png cp mg.css build/mg.css .PHONY: preview preview: # pip install restview restview --css mg.css website.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/docs/footer.rst0000644000175000017500000000041413543636505014222 0ustar00mgmg------ .. class:: footer Copyright © 2005–2017, Marius Gedminas. This page should be `valid `__ HTML and CSS. It's maintained `on GitHub also `__. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/docs/formats.rst0000644000175000017500000000701413543636505014402 0ustar00mgmgData Formats ============ These tools were designed for easy interoperability. The data formats are both human and machine readable, easy to edit, easy to parse. .. contents:: timelog.txt ----------- Here is a formal grammar:: file ::= (entry|day-separator|comment|old-style-comment)* entry ::= timestamp ":" SPACE title NEWLINE day-separator ::= NEWLINE comment ::= "#" anything* NEWLINE old-style-comment ::= anything* NEWLINE title ::= anything* *timestamp* is ``YYYY-MM-DD HH:MM`` with a single space between the date and the time. *anything* is any character except a newline. *NEWLINE* is whatever Python considers it to be (i.e. CR LF or just LF). GTimeLog adds a blank line between days. It ignores them when loading, but this is likely to change in the future. GTimeLog considers any lines not starting with a valid timestamp to be comments. This is likely to change in the future, so please use '#' to indicate real comments if you find you need them. All lines should be sorted by time. Currently GTimeLog won't complain if they're not, and it will sort them to compensate. GTimeLog doesn't re-write the file, it only appends to it. Example:: # this is a comment 2015-09-14 08:03: arrived at work ** 2015-09-14 11:57: project-foo: working on task #1234 2015-09-14 13:04: lunch ** 2015-09-14 16:34: project-foo: working on task #1234 2015-09-14 16:57: checking mail 2015-09-15 08:01: arrived at work ** ... Bugs: - There's no place for timezones. If you want to track your travel times with GTimeLog, you're gonna have a bad time. - If you work late at night and change the value of "virtual midnight", old historical entries can be misinterpreted. tasks.txt --------- Task list is a text file, with one task per line. Empty lines and lines starting with a '#' are ignored. Task names should consist of a group name (project name, XP-style story, whatever), a colon, and a task name. Tasks will be grouped. If there is no colon on a line, the task will be grouped under "Other". Example:: # usual everyday tasks mail sysadmining # project tasks project-foo: fix bug with frobnicator (GH: #42) project-foo: implement feature X (GH: #123) project-bar: daily standup Daily report emails ------------------- Daily reports look like this:: random text random text Entry title Duration Entry title Duration random text Entry title Duration Entry title Duration random text Formal grammar:: report ::= (entry|comment)* entry ::= title space space duration newline comment ::= anything* newline title ::= anything* duration ::= hours "," space minutes | hours space minutes | hours | minutes hours ::= number space "hour" | number space "hours" minutes ::= number space "min" There is a convention that entries that include two asterisks in their titles indicate slacking or pauses between work activities. sentreports.log --------------- This is a comma-separated-value (CSV) file that logs all sent reports. The columns are: timestamp, report kind (daily/weekly/monthly), report date, recipient's email address. Weekly report dates use the ISO week numbering (YYYY/WW). Example:: 2015-09-09 13:11:41,dayly,2015-09-09,activity@example.com 2015-09-09 13:12:39,weekly,2015/37,activity@example.com 2015-09-09 13:12:44,monthly,2015-09,activity@example.com 2015-09-09 13:12:57,daily,2015-09-09,activity@example.com .. include:: footer.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/docs/gtimelog.png0000644000175000017500000014577013543636505014526 0ustar00mgmgPNG  IHDRn;eȗsBIT|dtEXtSoftwaregnome-screenshot> IDATxwxTU $zKPDQе]6\.֕EA@zH$SL ~gv̜wι*!B!Yog@!B!k !B!M!B!|nB!B$pB!B'B!B8 ܄B!I&B!>N7!B!q !B!M!B!|)SU)B!B:SY$lB!BME \n !B!(j7$po&B![< ļ)j&OvkmB!.< k[O)psggg_!B!/w+gg(pmSGk.O@'B! vh ڬkw !B!'\Rڂ8O[Wnn9 Мm'B!h*uYsܓ垨5ps'r \r-B!BwT Μuܝ2p$hsםuΎN>B!6]V`@|]gAk둓7!B!DC--Hs IS݀fV[(Xs7\!B!!΂jKCUUaVpEw5w*B!BQ7g;ǫ5dV=v׵q5qB!B6O5FSymi i;[sg}B!Bԕ H\kUU+_WYW3xkn5%g3B: vs9JYބB!gYsYh4j[ߪ뜥]5D=n΂6GaUB!Bw \U *G@x2pz3G#v;6Zf0?~*U !B!jɊUe]111l6҈]E0VudաU8n]Kz:NzڄB!=???m5gʯ;Uk0} ު肱UرcǎQTTvfE  >>$ogE!;v ;;&Mߟ6mڐr!hkƴX,&U ZV1 IRC*ZmpdΝq5`0 7o'NJھ2/_޽{kB!hJmkۊvlji\vQUevf3:G>"Ʃz|4drVIW]}fmlFֻuʹ}! `ɒ% !§xؿ.]괍M||< ֛.UUh4̟7믶6fQQM6G\V ΣPUVsȤ]ʪrYCv;vܼ=ы7~zzHVjkƗb:dQ\oaSkF?Z-fAl!7mIG0 %tB_5#ҾN!ğǗl6{; u(q5$5dG,7g*5ى5? |4B6'({U.]nTWk/*4_BU1L&w,'W➚1RU 1lN׸UP}{ܺ$%6vwA MŒ9'%L~~/s-=z}ԓh;2dX'3%m z+/L@3-oǷ3( lEw_.#TFV36w?q}sKtds>MYii D>>: MHgqkHn_,t6a[FFfkScWf]BێjiwOfį HF'/|YŤ4I8نzS—_>c7d/NBu;K?>BmKZ˕bxU3 D6NU!6/gZ:s8qsjw*9__ ܄:~?};2L8d<}8]o>'ƵBgi ە.4e1% ?Tl_ȍ-O2rB@ۭ d#ՄǗbx; u21w:pv{j E5Mw2YJT^yu}7y±׮]8ҡ6ծ9;&-@EvTU:#Z\S؀'2iX5Ɣ'UӸ7 Y1Za:a5?B% BĜ/YY jA׫{]+xOɳf2#}6q8t?t0ГGf?O??asHsZ7+kB !i#*uCp$;;͘3-l6ߙ*|o$w_ 44|iv4=W8[j ܽޭf23+`A[Zz:qqlLnv:{g6OʑGyxc Rf܏_ŋ>CP``Tݒ+fz^*6m)S35Zބ1_F;W;k%a1>~=f銧//n/16Qp狗pÕ AL99Anx ߞSN?˖PPP릶vzv;WvuްuZ˯g[$iî{:m:|EQxhc>LM"YAJ[P|-=#8ޝ= Ɉnwk{uLY=oxZ0^'77< !(b1Y8?-@k۹͓v-]~n?J/;TUnc0`6:-W.o| eάCTrLzGNn.3xtbcy D]5BԓĄga3"pbvSm`@UUm3WʖukW;дMKMMKcӘD4m^j%vroVIUU1Ղi{7fv𖙕EbNnߑ\{ʠٳ0 l6U<8O;*B!͓INNG#e;Om*'+wg s4-O<_ު)z !BYfXdrqՖ0?V+7o!Ɍ 1şŶ x$00}FQmN]VQn*YfCYo912H P#Gо=oz^=m{r2vpknu,E!TUY3Mn+Co2o%={֭ޔǢitV˙3gjݧJn^F~~~maά{VkݏCQ?e :so 2q4 {V 3jtI/wJF=˯ +mHHnQ_Xi3WX}6m?\{[e=t$ ̘nE de:J io$$q$jjLVI;f;Maݷ_;Z8]nwz_Q7wN܁n{49͓11*pͷ2 Ry!X3nr͈ kƙR}XfoDz 믽U]\7Y?s* gV6+*9s]hzYZy'3*J6#++ڤMAᛚ0 \SjFhxsP 2<(s+pA.4ɏP.tvf~@䨶4mv ]-zN{:1SyL)r3y%؊N<:A\5CBLB(++\}2U~{6:VTMyA޻i єm$łjfRVVhrMYYtPɺ﬒ );íGRjh^W@jlذnÎܐ2 ŔڭfaزSIDOawtƺ+2%gټB"LLtP6l24ZRKcckG~q2L4Kёs#/Uߐc8(;V]~\]}f!hF~ݸ)z]հ)g1s]H6۶U%,î@g plE?2k{t0T<֒GHxP3r-NRPIcՊ YSm$_<ėOdDpʞ*YM !+hʿ;ҦӥX-sJ^{=?ocϮM| y2|'$q۸KX-*yg~}hӮev-_Ɏkw(*@nţ@\!Nzy QzBEc/q bRZX(HN 8='p%7w:i;[+'0uZle6jss؋r%@_/?gJbV\h4v|8&4u#Gꕬ2QGP`FEpX,)iJƎVdQqhTy?SwBZq8'W` SXv Md8p9ͮ%KI ]nN  Sr/v'bi׮}{eʅ]æjP4:2 &S!Ǘ Du^5^EEE7DiiEE2TG ğ|Eߚˬ6j;~U-׎ƂTFrROYKkk0k?u 0vHU lBtA;y=N_ȘjLb$b J9SĪү{O_BU|2otO,*)c?=; k;fx{;;1FZq;jm*-"[rם۶cǡ Y ZDs0$g 6*вy' 8q,`X.ВuP IDAT4MnPٵ{3 mPAyTlX2;C[S!ȌF_d]xłN#(0ݻ*}zN:TUyzGϽśy0͍)hFoD=7/.:=-W ZMcs&s'͢Zp:ʕK۪akerl~u5P|IW:vʰ~XIӯb)D\ۤs)ٺ;7ʾԏ܎aW!h驇(*'H$t4RjMJ- &N֭O(1ݧҺUK_]>#VG0U"Lh Fuc(J @P#_VCAo3Ĵpy`yԧ;|[U8yÇ(..ϨgtR~E3j`~]|^ ɝѱ ǎQޛq]p*ۊ`6s9WyOao-UP[`BQU.9n|lʇjb`=7bwL8aرctk'\!Cx%m_ȃllْvy%Bf~ʲ8* ?Bsmעl6 [u!4:٩r\?!Qo9ÇΦSNMV48pm!jk,(( ((IP͛G=0 ju:zNV|h4ʇ(ܙ+׸uEQؽ{7ōVt|G?CiӦ IIIx%Bg¡C;anrr nz-<խk6f7j~BfM7iTw???\.yNd2Lx$z"v$h.D !һhT:iATeT^b[q[3i ؼVt$sΝɬuZ+))A!YPڌ/A[#jYȋ:0z;BTO4A!B!M!B!|n-B!=>}1/B! mذ1/B! 9B!h޼yҾOl^K[&'B!B'B!B8 ܄B!I&B!>N7!B!q !B!M!B!|7Co|&P-jsyj'#س=/g cVljd5OϡY^2@QBԙW>?ԼUjMUzm%4O:ܳ)~9;&(|xvB*кgd1$ l``yȓ-9A$C%K@g?Hʴ2L͉ùuO}YX8u$FWzy 9v 1wi6 v43bP94F$$<8\8B#.DE4zr>A~DߑVznz((I ѰŌo͵c&X)|~2=sG?ڄc a=9Gc- 3!/l /Jj' c\m;t%h8O_v/L֏'JYO^Wз}]w)4f7Z)S29E/~ϭ8]Yg.ԍrZN=Iy['L6| !8Vonwm(:((TqqѮe\>l(-Q=[6!]G'!Dy'p+HŦ/X}%~a Sgߏwslu'U]{v/^de]욚oRB!J {%n/?foiI]ND\qԜB}(#nGPՅ&NMg΢=-/%EG!PG\7V9,GxѼ7V^DIj<}x @^Ⅹp2U5 ҷry(snz.=>32g\sv3L6ϲL̅;/_?ӏ1k&^΋%%Cmfo2'bCVuZ, 8aWQigzAQc꼭9g#=oe]9U׺cC?LxGG־ޝei,ny΃Tbcϯ2㹼|8!6odm|)}! ׆jd_2ӌBW7EBƧjGw6LS nֿ-LYYm)3#ip=}3N/oki{ (,qjѴVt؇زUھo&(vvʼ5&./_4˵qC\$hBHf.ڂv\=dI Y6=G hE>1&'}Z1EYyv̽׭tG'v+4[\Cwd8a˝O;;a7A3gs!Xy4Ɩ {&.!*e w=#e,/gS)pP![m+(njaPNJ$ !PT+6S C! KYX(9z[(v-]ʾB^ͥ&+hy'p ^ɢ k&%g5裛u:mթ տaJFSH #bA@)>9rg)zjGRx6TFp Nx Lb񇽢7ņu,5g|;ϺU%-ɑed^}ى(^= F{uPځ03'J(;uӺ8ri)4N=}.pe5g9-W)q]+CܝƳ]Uˮj[pa>]/~{{x|\EoBڰ}H{c/ܮ>#ꭖSB"viz&}d h 8UѢo(#t$wZBŬ;n#˕H A ׶A>CΊ UzTl'",)̗d8n(tXѡhh?ϳeg̀)U~\}{aZw;_nmSGNbInaZx='[V6u!1^ES\\٢Ru:8g-{shCDX2yJW9v< -Fb#||n hC[\|-[{J{U-Bİ)R_RuoD~U<[uZ_ :KGb~כS\Tqg1~!s<PmvtQ}NDc͒W,ڜAa-ղ3(Khލ}7nVMn͍ Q ^nlbsA ȋy܂V\ʸD8rn$c "wQ7{!.6֜͡tLjC1'7dNYPLtNd-aG)ω+%'St#/@ oA2ҲvڬŊ|,v ؛^x"Wn䬳/$G9Sj916bre,A/37}glQO:׳o?`Tf7fl8 \:°awkwrN~t#Qf0 =\Vh~%㻗7hj9[] !.f*)+X+[FnՃ)3nv}bؾYR1 ѝ0 ٽd5CQWqqZ txE#d|-k0_=ULS9=p`vTx~ι@ز7tS|lM3$Ř8wcȍ>Э|4ytq\A Ӿ6DIFo1SGsu3 bD6OAf?]y?0U?]C1"|_xcaoj;}Zgl-tkn" z+^?olBnm1,Ve \"u>'NL_g˝0tH<~/tč}-f~D](k Pv5i<ͶvqK۪aTIņn%Ǚ3:ugԓbVDShLd6g,Vo uNUc] !.jʹ\Ndܺ%;b{to\S0D䪁-њ=b*Za+6aZX,XVʰX,n߾SzhO+1Z'TS! kkCq-{&]//4LEzsJѴ͛ĉkƩ _psul_9[)g0+&ZF=ͯ]SAi܏j܀[-osJ ]FqCdz]x_!ٳѣ^_NCףjFSPڣBDh2vrnᤩ5KyqtmB4w9X #1$^x+xEhwkş6C3yhłB!H&DQ)~K[w4m1wvO9=䟌!4B!i h\]݉pSnO%ys^pSsV1?fP](B!|?XTt۹tVׄIQv%*> Cj79Bg%=nB!B$pB!B'B!B8 ܄B!$B!8{ M!΂OHJ !B!M!̯S(vV\>ls"B9x-pSd5Oϡ`դO'uG^az ҞO`DJX.JzO >"1"ʕQA}h+_ʓ;y)Z͝ό!\*![%lqלQN=+ONGFH4\>É6)QΜF: " Uyld׋e3|=(a%G^0L()*[DvՍgՋ'EQ.ȓ>4#OrqQ B!h ޽MכWR%~h* ;֝s1\/I8pNw G3}@Z==-fM@u3\{;8w֯Dpy~/Z^׫}?Qhp3R?ቄeo|[*E4]5{3%ZÉs[X3?r^4?/)o3{Ad|N'*yĉb8+mO]K[Bܓ/&="޻n4Ummo9‡cT,8Kqn^iYu/LD\.y~9/d[xtͽsmp|Kf˩R Ϲ}#S*Z|ݦ|%02fCׯZ6P~y)VOs'ǰr^]@O|wuE~ atVط Ş)By-pSC7Sv:wޏ1Y0g}h~zNp8m 8mf.߽4} eIՎjEd_GzZ_N@xJ`zb>Q1zLPl 4~6󘹢5O|#mL-ͳ=?~t%otW\UϟeWռa7A3gs{Մ}oIDk !k q>2I@W;(=+s|[<N1HѐI mGngT8BCrLكCxF^ !DnD IDATʗ XLd%uV/tU+(jEOÿp 5Ԫ=_sK :@4ihMN@P` ~*WH6/WRB>0iDQ ~l !AxM!2cd[1=9G> 0SN)r:AOvDdQ|F+ΚIY fOQ 1& jq -{w~[qnfFmڊUUQ"v~cx,mHhC|~1DsC%M ƨsC%;o06|Ǫ|C{XNW&ZE?>멵$E( 9,i錺Bo]Pz44 9+6TS|ܢwh|mh+%I%Jy/d !, >(bҭP١Vo֖K;Uɣ9vVk&_?>g:wðhm(pi9 ڶGVy{[WrM55m> W=9׌'~,m>5KÜ pIj:*TԖI7W2U £GnkA\6RSSX$]^a]J*F>F :Aog2 $ š/um`#idzٷMFxqs3ۦa'[?ncqr'waUUǿ̓"fINCZZN?,nj ^Ko2-nfY6YFY8$&*2Og"*(ys^Zg~^{5o%o:n<MvJ,Y[_ehͽomf"=q6R#.&Sܰ$mftnp LlEsY,DF!$e QqҤ}N` ȴo{l9cĚº9|ُ#q. X:b1F|:=j:~z{CWѕ8{$ Noϱ<ѱZ7^ߍeoWS7!=ή ᳕-'D:T6n@#sp3Iᏹ3ba&Crd͗gQ\J^^wN<͹8v (K#w=j|}A___|<Pc7ۖ}> _NgUxMNgK擝EVVfl21^َk%@jM"Vbͷߕ=XJl5qцxW7Cr3|7:]!DTMKvNҜ8$|KbtteP5}D5^ [B*s- dښh cй{;H+No,&7Yd`6ßK`yr˛+x3? aCi[߀C8ȟߠ}9[n*xܮ;H&l\g\@ۄ#r oAx? ;Fg㸧i4qx̻܉Aw4;ɪ? 'QìGY;9Ox6ނk[9*vY0Y[5؎~/mH˥^Ύprn˜y$ّzNN8~prcb$=k !nٲm{KvN-[ō|͝/OkBM(<㖰@ ͨ[ᙎS!@6j.p1͘L&f3Ftctt44QYY.^3{mȈ_Ñn7Z.lWg&.݃ޢ]Eد5r3-n_1Efn[d G.oV_}{[!51v&mZ/8vt9i-{ln5{cw%|DšX7EٴUîm|>Jx0yn?Qh3"՟?^04+f,!nS'44NVQѠjh4ER.5F_VȗVR. ~Ɛymj~汤|NjyAxK&*Q#w\|yQ9P3VLBj!mFnMd5rfLF7;kɣІFŽo_N&j2ei>fot+}U!n7&oIڴhMa⇶öFU4~u+3f(^3O;MEՠ }a]p5Gcu u'o|Pa$`^uG!Dt9yk؏R{rŽ80w:}t_[ljFr[/x)6$/n fhuof꣸js9Pֹ;\{{=@هƎ2KL>B!51<;ȣ>]{Xۿ7ttr3ꙎUFfB!(fB}2B!BI&B!5$nB!BQI&B!5$nB!BQI&b&yſxx;@ޡD<suG"BTjKlfspB+ 0w#M$M'r39ѣb:ԵVPn$ =H:Uk~s`TMhWF$|3][j{vv/^Xq;oGE_x8ks BA׮0g+=\U>Bq)',vq ?q2%ߣҼEQ0xkI)Z|+f2S626>I#EAi<Ѻ:q)ؐVh!Tow"JJ} $! ,LϐVU`(U[$Zp$o[L8(4]5\o ymtU?Ve5w~{^s1浊q̛NJQLm-nB!DUˮq%o<_G){>NŞfr#Ix2/qh1 87=ї7JIhRPH "ih⧺ajtB,ԕ~~hI;б3_:ݜin2m#R)(peDҿ~0cROk@ y7N|)P phF?r ESw{ w]\ZbN[v:+(&[c#s2i|H6E& ֍*Ł&ݧpNYj{3"ь袠Co^fDҿ~ckE1?a-=7`e@((N7k3rldŵ1RJ& Aa[xt9 oai]AQ4MKΞIjU=m -) !EcJfӇcbx,_޹oӽ=e5C_|Bz7o=18]2J8.W'̥@qFD<^Uj< NwηO,}gf^:F!}.̤yy9#zt[kIdavkx\Ѭeų҃W%-=ŝvO9b.=W98p VŬ_܍1ol#׶%844K1 s35' >091% z^XR?*KnWEG HZӵcv5$g91{5CΕT FfJܶ`Z&.-^˅L27J=]F/~ԶU5RY+h&?]|nA kSE-B!jjKܔz x7YB'Пx#oL%+shv:jzN݅++WGyw./nGq/P;v/8,U`|ksXGT1`.YY#K B1WtGB}mSC_dҊy_de=~!kqBwr*_{U<.ͽi*ܿ*B=B6 qtnFߑ1$GsUًP&2;Dzw6-=NMが Nd0z)˫tl&ѣR~,FEG|KcPPZY(ѬCimɌ)8K^h1hlVۥ.P(V 6Lf;ĥL)B)4kh筻;|T|plC} &̏93/:ՄڵnyĞ%V`zXlŲ;ޙNv'MQ1eƩcVя}Q\ݯ cTdg`٪z, Q* Z+y'4ة%:4<9l܂Ӯ^KStEWs /6 ~Ҍb12ƆpԊƱb.~u#Cwz6@1f ϥUx~Gߙ֮QYe )(p zB!juAQkQo[QG-v90SJ\Ӹ4ͱ7ˏgCNNy&{Ѣv[a}zxdNR'r1'+p!-RK!9c\:߲\[N,ep)vêm1MҲUh);N NͱKO͸iH5K^O&VUR}q._ʪuّ^ľ/vX:'x }@‘=|AOk9U BU%o>'tɞfOm޲ 4r!9{_O Bqz+ή;=]s9Xpcšjs6,%cL$]'f]Js$YxfFO5N `2M|.e`H9āUz4iF'2$[楰nN8dcHlC6;XLeƃ#&|O?Z4iUte<IBpsjtt,֍W{nN')ϕXl8y)s=,GESݨ/$޳s8v8:*h`V^^Z#UrC6_Dq+y9x=N;4RG|W~.ן+7C}|}}iG/>7ѹy닏jL~۲Ç  Iti|"lÒMfVY9rl'7s8Ǐ-58=(cwX+"7"Bq͌%e;N'i]zN\h1}:vPUuX"80}t$;^x[4uw2mZf41\y7l~Kzvx,[2oNCg"K΃^NԹA29]Ci4cOkxk%6wD|~)Ce _Q\(nTjx%0|8w=Z2<5w,8u{V썕+ڛ 56qqUmhLaoB'?ꯄmnН!N LT0kQhNǿ!;VvξV &cf3P ]p '&ٞG›S7'^`ͳN 7r[uE熷/EO{ZGU2 `Ld@~ ."B|@6j.p1͘L&f3Ftctt44QYY.^s<-mCF$$v&E-`&.݃ޢ]̱_}DXYܱ.a5aeϴ`~k=S[kɒ%=øn^7耧X6|NjyA!n"0>śod ّんx~GkQT߬4΅LF;kZ9ԅ6FqśsߜL4j"=mfcYq\a2|<^VB*r?u5TƳE! ;c_7JQ۞xo 'Z ]5l~ gW j ~?1) 2|!B+#}ZEAqjNYIhItQP{š>QQiwچٖ}Gkky:ь^XZ.hAeIQ {g\\ V;])#D#mOO_i27/ƧJ&U }X}AoBQ3 8bY/F̌LyroƢ)$Zb+=vg+F2|тɛGX`wǢSO;L~4(q̗+gɩ>|QYz;o@!*)BQ\iݳWᯭLd֦KFt IDATsG2wt{m'[/qX(i̍~x*p㼹˵O_0o/M Qk捽j h YYy y#lP8yCyQH&BQXIuqIM;ѓ&B6lez*OzO˨OؒF%8y|Lf;ĥjʅtr`<}3?zW`}Ƀ*kn-O!*)BQXN,ep)vêm1Moj\n 7i;~Tqico&''L6L4>9UEm%S/`t:ZUVFAV.*pQ27!B!$qB!B7rثDR`So"i= W"# I߃ӽ(y7wQ$ PVaL1L7Y_Y칿s0SF!BڨD7DOQ F[ ^Wpq2z W լ? d@Ӓsdȡl[!BQU uhZ] 5f*i7 k+$phw2*}[WZE!iߞ|K#BKR2B!Bn՗UJ.ypHNG4WLRAK0|Lr#I:Mn_Mn;g 8"|U$|#-(>$7Dg_>̵ُ{?wn3 j\p&xt~gC[o.#95[sL%fol<m3-tfGƲ5GH<şЭ}1JGףJk`СU*RF!BڭMNbO'Gh&Ӌ%k'NJG@ʜ:DyF#]k&\q.>&F7fbWܥu v}wp󙎩q/P=Wq#8:^#1@_3 ߳4Bi2͆zwkA9PGPͽ kݾu]{ O Gh4cC*ZF!Bڭ%nP(tғA&f9\8">v_Ѣ(AՂBS`^*CI!Zr%4*Wf%?;B!Wmʬ x+G:gK׬Ul=Ī'V8tzCǢtttPe?#=7t#7ۂޥqՖs}O†ȿd{{~υB!V vbZ RfL&fшd1::jlB!DZd G0djW=j4Z-Z]TEQˊ\{B!B !B!D 'B!Bp !BeDQQY?;0egnuG%mIB!s mQho*I܄B!DT.9zCv'C%B!ꒌHfL BPt 0v?<A)O ʛO2xUT+WBT+I܄B![V=b7ϭ':ƧĤP98p VŬ_܍-!(B7!BFB}mSCLdwecv! 5nB!uŠQYm1Ab`XIuqIM;ѓ&B !j9&BqXS>Ub=SK(m !n5I܄B!ncx\i٪),E 8i҈>c'ȔqKU_f@{ՃQ ?,t|&&ۓwzQ .,hT*NܕG)TGXrC"7$c1o,>#^6?&~5?s!,|Ώea֒!Ѯ#ʵ3er0&9W qk,oBi? /3ݬz4w"9JJ}g*;$8D8h|N aRF$KKcPi;j`%u|܌{ uZrl`TJ9!:•U #Oi=3/I7EW2 {koݤMׁ/Rc޼MQ282rQqg+kMN*Hgot-"=n^E5QphڞIYYW4"˽4+(#B!jZ6d.ypHNG4w `K&{㳜< ŏ#͍$aT4Z}5wA\\Jf9T|H4ӓhx SEsOG{~!lg?L^W3*hA76Hb W߶ևv'9UN uX3bY/\h7]}c/LH po bC(&D4-ZsjzE5ζg4kqjHXhm@?wn3 jp7őƭ9]&ǀRg|o,[s9\ {hݚأ@ٱV8Oghҍnٻ}gr6jO\Z+җ *^0*AVH!BQԲ 'fDH\.oƮv<ݕdũ)!z8'עڞZނ_)Cս[_a^LŒpZC6`+>5Ne)k=#q~!hRQW VoFPxOrfimwNLܽ|o`>_ַ@?TvW/-F'q(vmVR2B!&eJa0~p:uP'gE8k c@U1ܢSݻki2t&XK-Eq./nGq/P;v/8,Ž^q#86De 簎.Fc,f[r> .Ѕfvhz]+b?>ilٞ@<Χ]pKzAvcA˹hiHޭq*+ԉS*–ؔJ{/URP_'g9م}Avit0esL*Mr ޝ!Ф&\v]Bso 7cB)Z4I~ m<5𧑳7Zyc]ij0b4qO -B!Ij4JQʵNF8; ahꇡiw.pW˚SDѢDAjfG:gKfɓKv籢risѢPtlV3ƽ9aLy6TnqIqS$lp_?L7lMGǿ~'%oMg|ʈhVQPofR?[q+r_7N859BL9;#h\oSmxŝ|xM\4IKbJ=iZXrTl緌0.edK5]nH߈{vdX#~ʄ;~m)>muU7e\5Ƈ.) !B,'2baնbԦJʕPpl9Wͩ {o"Ě4q~壍`#ƮזWS21%U#]}p9Y$qB!SIse8NsіJ&?v2.2ɚ͇8mB14 U# ,r/NMq 㩇 ڈq#ї㵥kf3i6kf`2Oz%nv3ٵHxfOͺ<,爋I,OM7!Bۘ>8w=Z2<5w˭3er0&9s5g;1wKnkz4MBcG?~oh?i$@ Ж뵥khs_22sKF|g˩ I2mZf41\yհS,@6j.p1͘L&f3Ftctt44!BdF]acrwcacVͺ33Rҫ[*w4 0Kf)ϟOhh(:V{գFAբhPՅJ*\Ej%3nB!9D4MOSv!B!ff<ƙ7l,>>\4Ц=w.dԭ4YiO(}t=Xl%3II}dtp!){px71S%3R^n$ =H:TR/X=?NӖxμ=ś lǕ;۩,W`n3+!U{>k7 *>@>7m`{c3ZC=HZsUn6tU\ϧ0cC%VE]i~;٢oclLfY94WM&z$kTe$Zp$orӣű$! ,Lϐv]Ee# njL{cq9DU-G"^î~ctemFe(h7ly%~6o7:sf&E gu(GǮC9kqw)JJ}MbQ7Կ\:񃜛ٸmȊy~[t~8s=Q>?fNxos+n?ekӺ5r3!vBJ;舣z5pwGAׁ/4Oa݁A[zΣQ`&Z+*Κ ?%*#*CŸ[a?_FE(18i)TZ7Z oe\:s;ٸm|cof=3Up:Îcs+4ٲm{KvN-[kF4&u-Z4t̗Nu7gڿqHT '?\Q̘Aӡa~yl덃ųS8ߟϮ͇BhdG3zMc|Ag>.cxJp q{J)Ζ2`e@((N7k3e'LĿ>x68;> [yۑߜZZPhItQP{ɫ1%d;wbb\ǏEΓV/ˬ~ckE1?&LmgmX9džݵBm‘(}ǩ%  %[ƻ:͍$`ġ:4 H^[Qxc!nT G&z}UXDD9`*kqJ|6.[UnC􍤝k{NEά)&G3ׯV~F3"ބúY8Ф)}}1Kݟ9BLm"gg ٻ(ďnz# $TE",8=@φʩpgExJѳT@D@ @BȀ5/4;̳<3ߙgf*UpNl6 E;v,ӈaCSeQw[S 꿉ڱmL,+eiNJ2 uN3g~Eސ_Rc5(ݿ0Q홯j̣9W^8@]*uVJ4+p򵚟{&\~.k:Zew\ 8Q39qv/mx:MZy!j<6JOwI_j^a^0__И+"edF4kMveGMQQTE!V0we(OJh>9egU:;[#]NB'e SVjoV,3JG~jD L':ն!U6dfLj.;n}PyۨYK^Ԛ\vfNkiVԟr)~\}9Z;#\uzi~Mԫ͋*o gAYRMR}#=*e(0"LΪ^%~V6N[EEQ֦mJ[ :ެf69_GFioVJ)c&G:e1ahʭupK:29lajsEoumY_u-r+<[lo8P#wj';[) io4M}&aj+-LN=EQx$SEiT۵VʷPmM__P}ئQ Ulj7Fn[_A!MohӞtԁj3MsjUVbʛ^X7Sr_! i+8tB>?YrKf/N\fLV)>/=dʿC?u£7Z2(c14!g/WG2W/lή 9_iܔ4wbxlhkVjYpnG 92T庽INOm+̈vǪ}T_:FtpA垉u ɢN727Go)@%ΪX]s{khT]<& &4wlylLO2NYoQϿ+yѾ-JꂨΩ,pGQ.dJIӣʌ3||'C?  :ty{JGr^ҜJ1d7=']?Аy"T+*ʵ'!6dأC'$fwW⫸gy k嫰ʊr]>o e1ĵ!UaH,ΤA*$',Z!y[&3+ G&*//Oyyy*p*\;Q͏W1-Q;NdD R.ի:"Wup*,*;.Ϟy8mtܠ͛ K4승z~L\Cp-^2O_\ї˨tKWN§"5ڡz+0W6-S_ϯTG]=C)y0e7^[O*[첝NMA 䛙xw 6ԯ#wzV yΊ{LPurvaǨbYk j1H[~3K41ZQVUV8IMې{VCJ~Uc<[PFnjqq< ڔ9?/e(qo~W%3(bmj{y"JV@Lj16M!=~R ) v;ߦi#-̐5BM|WG[Q2P 蝫뇌 v'jژZ%WQvnHTuT ;jjӼizzO:\Vgk[#oaL)s)ȑĘ*{J~e6xNqÓS{+om٭ڽhQΏT~E4圆ySjbZv(jy{Wa7m鏆NԡOqU;ڿmelIk Jmʍ&U+k8Zm?Ae z͑6Spc猾RN^,nY7.> l?+4볺0@GQJV\}k>W:85YL^zI:t8_.!.cf;6qtQ8bo|L缮쫆l.bᰁjS@\{4PlIP*͓2-`uSꗤ EJ[LX7N3M|XnY`ꭧ^Uy*簀U)*Z8Ojkst=[ݶ+HFLByyH)^_T-`5?9ůyv+Os5Uk=NV>j("ߘ6rb%ju=\OpvV @yGc..[soU Lc+~TS]i8m6jd; /xs3S+Ul?PG3޾ڻ2s)lAO-9PPJi=grj݈z:s2x@IEi06>>V]tV"v;\‹am/:;}}c(s2V/c~IACRp`eZ58[[^;.`%5N#aȿa_=AyJVݺl;OUBё?b{CFǗh'BwF{iJr^O]NT'}]%f~\ JSI^g`ʲߨ7R@fZ際zqMFӻ[OH"12Ԑp.ej~Gw5 7RұmH[#m(}:_~uT 1\t s?nngO5D vԥ"k2|k)+u_))ߔܻ5ZcN+7ߣ;)LH269egie8vQGt8%]Ly;STơN6;oQɇd[9VD)rbڽ"^UGJ?P*HT2ջwLTljLq;KolxLW·);j/* 4YlԯYv;>:KXWVӷ띾۟o'ʷ[Mӝݸ;KK6ߋ a1WDJ赐kپ\e?TJ*ޙTz 6zTJ[I}Rp`9[YlfA2/yQk*{kȻr}ZiVͼwfIΟgam׌/դm}5tflբiUTf_Rwz{у [=ԟ_jehʫW׎ sSM ./c?W؏:s5#9+Tu}S%|9^&ʭi*zKۭճՁgMoֱ]\N}6{ԃm'}Fv}':ۘk%[7c4q=)o.e F8[\[][Wp`fEehˮ ~zgZ-Kf`:tiRYN7↚2!]ռ!nH!jTQGvmexw-BnЍݶB.KY=R}#=*> X ThUoؔ7y%|o:O/C8V>q ~|VIXi|rZ>F)= 7%(EjqR_xg@HF;S@xlQ*<){ (#;ȓ8|cT!ݻvxJݨ^PӆTUԶn,+tẢ]#ՍUwiT;S)n5oÃ>s%[vǪ}TXue7y}=zk_uNmj.yfV{@CihP:R/<y)H]j^xvOKJy7G)^{=K(y\N<($IH9#72N :(87SAst UPg պmUW3~nZQ6gEU\!ị̌߰!L*s\C? UǝoM늌=^:tJ1d7=']ũR[kOG 17i5ѦK?g/ecz!%(-onoS%o^{IgU^>_.{Zdy/PjZ +U+ڷEiX]UV.gS__OOY^^QO CtKJl*{jјŇUWZ+ŭ33T9a kAjW)YOo~@W_@áȋ=JʿLbSpy C˫%;˽*CRn QϿ.SgYRCaW](o ںiy!ʒeIףֈ[[GǜC-JK&.HѺT4<2֐L <{niZr6o&4.yӊ-Hwzth9dWxl;WU;o;ZcS]j<m딫M?pmzOOܢ)rxr-ȑc_265%6NRdgM9)*2G.~}j4~ _Vߴi* sdzMɝ+OAGƣ=/&Inۡw6JqӶ U~+(k*WE> v;ߦi#-̐5BM|WG[XnF7^wwemڝ#o%^?h$w} W2G @ԡB +wݯީ{Uv'jژZ%WQvnHT}$#O_O~@SV4ȡ-[KK~5`{z՗/tSbt*sT[][8UCo](gD &FY)[ɡbRӸŦu O*U_zU¿l7~^?VV@x ]V7 *;i]jsr½2uzj=0Vfi4 8IMY_{tg VO= |mhgU-5qz2PЖQG}A=R*):.y~O[Q3|P@ոI]o&iJxB3OVIldR>쥄[FSR]w-&Y*4*XG5*6 ?|${1r!2|B刈#:^5v\DߨJߜ#)̐hC*uwE =:_[=mtFU;Ń*z ihTF$Sg?\s%NǛ5XS` u P,~mtW t_*TΐKuOO$Әsmj:Z?@)ڹH ZKXtgUr6ר+r Z|!?!ح-~#JbRSŦ4COU[CQ||Ɍvuܦ);}kŨq&jRrj@eGS#C%9{dd+8 b~/ѩ;Bcժ9JzZU:izc1nr̢"3srrL̽{fRR`nܸ\vj*sٲeM=yǛQy5]zWӿɃ. 3 ;gtQ++_@-sQJfYɦ52x/Is̩"T(qftP9VY汹KifW{9tIvş_Z-_nB_3эfiY`n|4tvzLqWb}JY^AfpL4͵bMg׹>osm^TǼ"4 ֛55gz22Eʫ2+SݯěO~ya/{͢g j5|q[to걌|= * ʜs͕w60kei6.ʓU'R?G i;}vKt*WTo9rVeשIpu>Hݦ՟PzfȯNbbbԠ||# &F11Ѫ#gxbbb(G3sՏuz-3I,P1-Gz My s?Np;'OY_2)m3a8]q`3jS/ܪؼlB|vJ6?VU XM־6G{ݣuߝrlt=ѫgQPM/wyտn-x}=n3;zLUXbsʐS\Sn ]''GitIlY7GE=BvE\^v-ۯ젖-۫CK"}?Bty?W`4҈UTVp'>8rL@ kZ_ּ^<#e/MMxg޻2'1w\ >qfͭoP]V agW쭯.͘^ӥ8=7 1T,Gp#n`q78X ,Gp#pLGES=Us"а9;/k8Ou+$z@p8]6o~pmڨu|Pb Qvm^ 8]ZPaa~\)7/O-[4/C:6Y4N+6azƅn +3꼒Y4kLOU\j{a'9 nfiؐ[ԹcR_oA;6T>>>e~'e SVjoV,3dSq7߭{Gڙ઻#(罒lAj9pf,Msk^u2W*ڮW_Ij֯*تE^1Yf6!CYצ58QƏkj~ o*PfA2$ɯj @C[BC>tnņ$:[joh/4e-u?)b8 ر?yzMvrvUTT$u֭ܿkpܹs5|+@*Rmewbjg3G SJpvKСNqv9vl6۱0㦣JwYp(H\or.=CU5z69+5/Wk_^QߺsS~W6 կījtaPM 8;ld/ {.Sğ"SRkZ~j>x'-z(OW9J:TŨ!j>m0n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78X ,Gp#n`q78XL~ƍU:t@8nݺgO`q78X ,wF*y>JHHPZZk(@W Դir;+[5V V۶meq׫;vheO>r8ggʕ+˭Dp4uA2 T 4eٴ~2CKZZy$psZd \ZjttQIT BIDATjeކ,; VE۩6g7jU #so #s+n`q78XUcm\q+n Rh/|.*_1/Mh1Z4-DOlO>hi4cHjJA)*i)kb ݽ۝ ~%ٽo'~ӡ?ob6mLgLgG4wa^OG%:Z+Ӷ_|;g5ϲm|\[{Ncf9_[^?u3sMnE׊;nоgVMZ1s@$GϞUiz"]+w/up@oNuDo1wI7F3$긺WUO֨׶Mf~ei!k=;{?\|^({46e%97_^Ց-i#Ӿ>x??;OB__%S42Th^,gVUF-^;ֹ! /ʹ1M9IR߂y|VnKg_|V~|@s"rM3]MWFϫX-]/$y\O:uK5gt\V n_?";ڴp^Oj羦G?:Uzn<^ { 2 wŠuxw>%+ɳnSх7]֘>[UeJE':ԗi U+u~ ,т뛯oљzܯ77rƮĭꍎwU&tj"ʋw^ _}96 mi:-Jw艧_u{?֦KZMژЌ.=hܩ{ھ/CoqUTҕ4/[Keyޔu8N}jVjTZ?~6o[KȈlnbΝڸqc䱑mڴ=j={ݻwkxxXS===Q.ole2)Ϧ8J,/D5 l Y%4SR-.ptNDͶ66Wuu"Y=f{PuV~hXqPptQ[Q14PI,nf<|-^DmBAbB`<^(TVأ+˱߁ԹxVɨ2`TdzTQ~NwHMXԡCol߯Ç\.gS.u C͐VDNN_E= lZuɓ'w^w;@* V^AJ6otoay,lNU2Ć7H)Jr\qU*c`wmIL9s+JL[IP(ж}0uf)tpމ6:oq3Uޢtfz-}SD.ڡfL&\.7%5Y3YXTX o"+ڢMN\VHnЖfw^93es]M\וyY,{hV|ϯ&j3霰1JF M,,jZ>L}[QkCY8átTS#+nM`r45luB45 nǿLն`83 ^#--\ٶ/$Tن3# mm g3FR nR| bqU$Ņ WUKrΤ7)yТNgFlY7.dq&T21 J,\ٶ%4ۀ$eVFZ \53UKv jZq%Vjaٶ_H*N$PVT`?hŽ0IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/docs/index.rst0000664000175000017500000001412414515757026014042 0ustar00mgmgGTimeLog Documentation ====================== Overview ======== Here's how it works: every day, when you arrive to work, start up gtimelog and type "arrived \*\*". Then start doing some activity (e.g. reading mail, or working on a task). Whenever you stop doing an activity (either when you have finished it, or when you switch to working on something else), type the name of the activity into the gtimelog prompt. Try to use the same text if you make several entries for an activity. History helps here -- type a prefix and then use the PageUp/PageDown keys to choose the appropriate task. They key principle here is to name the activity after you've stopped working on it, and not when you've started. Of course you can type the activity name upfront, and just delay pressing the Enter key until you're done. Work and rest ============= There are two kinds of activities: ones that count as billable work (coding, planning, writing proposals or reports, answering work-related email), and ones that don't (browsing the web for fun, reading personal email, chatting with a friend on the phone for two hours, going out for a lunch break). To indicate which activities are not work related add two asterisks to the activity name:: lunch ** browsing slashdot ** napping on the couch ** If you want some activity (or non-activity) to be completely omitted from the reports, use three asterisks:: break *** Categories ========== Work activities can also include a category name, e.g.:: project1: fixing bug #1234 project1: refactoring tessts project2: fixing buildbot sysadmining: upgrading work laptop The tasks are grouped by category in the reports. Each entry may be additionally labelled with multiple (space-separated) tags, e.g.:: project3: upgrade webserver -- sysadmin www front-end project3: restart mail server -- sysadmin mail Reports will then include an additional breakdown by tag: for each tag, the total time spent in entries marked with that tag is shown. Note that these times will (likely) not add up to the total reporting time, as each entry may be marked with several tags. Tags must be separated from the rest of the entry by " -- ", i.e., double-dash surrounded by spaces. Tags will *not* be shown in the main UI pane. Back-dating Entries =================== If you forget to enter an activity, you can enter it after the fact by prefixing it with a full time ("09:30 morning meeting") or a two digit minute-offset ("-10 morning meeting" or "+10 morning meeting"). Where "-" offsets from the the current time and "+" offsets from the last entry. Note that the new activity must still be after the last entered event and before the current time, or things will become confusing! Tasks pane ========== There's a Tasks pane that lists common tasks. Click on a task to transfer it to the input box at the bottom. Saves typing. There's a menu option to edit the tasks file. Tasks are kept in a file named **tasks.txt** in the GTimeLog data directory (**~/.local/share/gtimelog/** or, for backwards compatibility, **~/.gtimelog/**). Feel free to edit it with any text editor of your choice. GTimeLog will watch the modification time and reload it automatically. There's a hidden option in gsettings for fetching the task list from an Internet URL. This way you can use a wiki or something to keep a shared task list. The downloaded task list is cached so you can work offline. The menu contains an option to fetch an updated version. Use dconf-editor to enable it (/org/gtimelog, keys remote-task-list, task-list-url, task-list-edit-url). Display ======= GTimeLog displays all the things you've done today, and calculates the total time you spent working, the total time you spent "slacking", and the sum total for convenience. It also advises you how much time you still have to work today to get 8 hours of work done, and how much time is left just to have spent a workday at the office (the number of hours in a day is configurable). There are three basic views: one shows all the activities in chronological order, with starting and ending times; another groups all entries with the same title into one activity and just shows the total duration; and a third one groups all entries from the same categories into one line with the total duration. It is possible to sort the grouped entries by start time, alphanumerically (by task name), by duration (to better see where you've spent the most time) or respecting the order of the task list. You can use the headerbar buttons or Alt+Left/Right to see what you did on any previous day. Hit the Home button (or Alt+Home) to return to today's view. Adding a new entry also automatically switches you back to today's view. Reports ======= At the end of the day you can send off a daily report by choosing Report... in the menu. You can select a date and a date range (day/week/month) and preview the report directly in the gtimelog window before sending it. (Actual sending requires a working local MTA, such as Postfix, to be installed and configured, which is outside the scope of this document.) Correcting mistakes =================== If you make a mistake and type in the wrong activity name, don't worry. GTimeLog stores the time log in a simple plain text file. You can edit it by choosing Edit log from the menu (or pressing Ctrl-E). Every line contains a timestamp and the name of the activity that was finished at the time. All other lines are ignored, so you can add comments if you want to -- just make sure no comment begins with a timestamp. You don't have to worry about GTimeLog overwriting your changes -- GTimeLog always appends entries at the end of the file, and doesn't keep the log file open all the time. You do have to worry about overwriting changes made by GTimeLog with your editor -- make sure you do not enter any activities in GTimeLog while you have timelog.txt open in a text editor. GTimeLog watches the modification time and automatically reloads timelog.txt if it notices you changed it. Syncing ======= GTimeLog has no built-in sync between multiple machines. You can put its files into Dropbox and create a symlink. .. include:: footer.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/docs/mg.css0000644000175000017500000000211413543636505013306 0ustar00mgmgbody { margin: 1em; color: black; background: white; } h1, h2, h3 { color: #134D73; margin: 0.5em 0 0.5ex 0; } h1.title { font-size: 2em; font-weight: bold; } h2.subtitle { color: #134D73; margin: 0 0.5ex 2em 2ex; font-size: large; font-weight: normal; } h1 { font-size: 1.5em; font-weight: bold; } div.introduction { font-size: small; color: gray; margin: 0 1em 2em 0; } a { color: #869ABF; } a:visited { color: purple; } a:active, a:hover { color: #B22222; } img { border: 0; } tt { font-family: Andale Mono, monospace; } pre { margin-left: 40px; } span.prompt { color: #00cc00; } span.typing { color: #0000cc; } hr { height: 1px; border: none; border-top: 1px dotted gray; margin: 2em 10em 1em 10em; } p.footer { margin-top: 0.5em; font-size: x-small; color: gray; margin: 1em 2em; } /* Nonintrusive hyperlinks */ p.footer a { color: gray; font-weight: normal; text-decoration: none; border-bottom: 1px dotted gray; } p.footer a:visited { color: gray; } p.footer a:active, p.footer a:hover { color: #b22222; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/docs/website.rst0000644000175000017500000000260513543636505014372 0ustar00mgmg======== gtimelog ======== A time tracking app ~~~~~~~~~~~~~~~~~~~ GTimeLog is a small time tracking application for GNOME. Its main goal is to be as unintrusive as possible. .. image:: gtimelog.png :alt: Screenshot Documentation ============= - `Overview `__ - `File formats `__ Download ======== You can get GTimeLog from the `Python Package Index`_:: pip install gtimelog Packages exist in Debian_ and in Ubuntu_, but they're outdated at the moment. .. _Python Package Index: https://pypi.python.org/pypi/gtimelog .. _debian: https://packages.debian.org/gtimelog .. _ubuntu: https://packages.ubuntu.com/gtimelog GTimeLog should in theory also run on Windows and Mac OS X, but I don't have convenient installers. Bugs ==== Please report bugs/feature requests on GitHub__. __ https://github.com/gtimelog/gtimelog/issues Source code =========== The source code lives on GitHub__. Get it with :: git clone https://github.com/gtimelog/gtimelog __ https://github.com/gtimelog/gtimelog Author ====== GTimeLog was written by `Marius Gedminas`_, with contributions from `many others`_. It is released under the terms of the `GNU GPL`_. .. _Marius Gedminas: mailto:marius@gedmin.as .. _many others: https://github.com/gtimelog/gtimelog/blob/master/src/gtimelog/CONTRIBUTORS.rst .. _GNU GPL: https://www.gnu.org/copyleft/gpl.html .. include:: footer.rst ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2720604 gtimelog-0.12.0/flatpak/0000775000175000017500000000000014603245772012667 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/flatpak/org.gtimelog.GTimeLog.yaml0000664000175000017500000000242014515757026017616 0ustar00mgmg--- app-id: org.gtimelog.GTimeLog runtime: org.gnome.Platform runtime-version: "3.38" sdk: org.gnome.Sdk command: gtimelog tags: - nightly modules: - name: gtimelog sources: - type: git url: https://github.com/gtimelog/gtimelog.git no-make-install: true buildsystem: simple build-commands: - make mo-files - pip3 install --prefix=/app --no-deps . - install -D -m 644 gtimelog.desktop /app/share/applications/gtimelog.desktop - install -D -m 644 src/gtimelog/gtimelog-large.png /app/share/icons/hicolor/256x256/apps/gtimelog.png finish-args: # X11 + XShm access - --share=ipc - --socket=x11 # Wayland access - --socket=wayland - --socket=session-bus # dconf access - --filesystem=xdg-run/dconf - --filesystem=~/.config/dconf:ro - --talk-name=ca.desrt.dconf - --env=DCONF_USER_CONFIG_DIR=.config/dconf # network access (for outgoing SMTP and for remote task list downloads) - --share=network # keyring access (for SMTP and for remote task list downloads) - --talk-name=org.freedesktop.secrets # filesystem access to legacy data directory - --filesystem=~/.gtimelog rename-appdata-file: gtimelog.appdata.xml rename-desktop-file: gtimelog.desktop rename-icon: gtimelog desktop-file-name-suffix: ' (Nightly)' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/gtimelog0000775000175000017500000000044314515757026013005 0ustar00mgmg#!/usr/bin/python3 """ Script to run GTimeLog from the source checkout without installing """ import os import sys basedir = os.path.dirname(os.path.realpath(__file__)) pkgdir = os.path.join(basedir, 'src') sys.path.insert(0, pkgdir) from gtimelog.main import main # noqa: E402 main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711446986.0 gtimelog-0.12.0/gtimelog.appdata.xml0000664000175000017500000000231114600515712015173 0ustar00mgmg org.gtimelog.GTimeLog CC0-1.0 GPL-2.0 Time Log Unobtrusively keep track of your time

GTimeLog is a small GNOME app for keeping track of your time. Its main goal is to be as unobtrusive as possible.

https://gtimelog.org/gtimelog.png Time Log developers time tracking logging gtimelog.desktop https://gtimelog.org/ marius@gedmin.as gtimelog gtimelog.desktop
././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/gtimelog.desktop0000644000175000017500000000035113543636505014444 0ustar00mgmg[Desktop Entry] Name=Time Log Name[lt]=Laiko žurnalas Comment=Track and time daily activities Comment[lt]=Sekite kasdieninius darbus Exec=gtimelog Terminal=false Type=Application StartupNotify=true Icon=gtimelog Categories=Utility; ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/gtimelog.desktop.in0000644000175000017500000000026713543636505015057 0ustar00mgmg[Desktop Entry] _Name=Time Log _Comment=Track and time daily activities Exec=gtimelog Terminal=false Type=Application StartupNotify=true Icon=gtimelog Categories=Application;Utility; ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/gtimelog.rst0000664000175000017500000001206314515757026013612 0ustar00mgmg======== gtimelog ======== -------------------------------- minimal time logging application -------------------------------- :Author: Marius Gedminas :Date: 2019-08-05 :Copyright: Marius Gedminas :Version: 0.12 :Manual section: 1 SYNOPSIS ======== **gtimelog** [options] DESCRIPTION =========== ``gtimelog`` provides a time tracking application to allow the user to track what they work on during the day and how long they spend doing it. Here's how it works: every day, when you arrive to work, start up ``gtimelog`` and type "arrived". Then start doing some activity (e.g. reading mail, or working on a task). Whenever you stop doing an activity (either when you have finished it, or when you switch to working on something else), type the name of the activity into the ``gtimelog`` prompt. Try to use the same text if you make several entries for an activity (history helps here — just use the up and down arrow keys). The key principle is to name the activity after you've stopped working on it, and not when you've started. Of course you can type the activity name upfront, and just delay pressing the Enter key until you're done. There are two broad categories of activities: ones that count as work (coding, planning, writing proposals or reports, answering work-related email), and ones that don't (browsing the web for fun, reading personal email, chatting with a friend on the phone for two hours, going out for a lunch break). To indicate which activities are not work related add two asterisks to the activity name:: lunch ** browsing slashdot ** napping on the couch ** If you want some activity (or non-activity) to be completely omitted from the reports, use three asterisks:: break *** ``gtimelog`` displays all the things you've done today, calculates the total time you spent working, and the total time you spent "slacking". It also advises you how much time you still have to work today to get 8 hours of work done, and how much time is left just to have spent a workday at the office (the number of hours in a day is configurable). There are three basic views: one shows all the activities in chronological order, with starting and ending times; another groups all entries with the same title into one activity and just shows the total duration; and a third one groups all entries from the same categories into one line with the total duration. At the end of the day you can send off a daily report by choosing ``Report...`` from the menu. You can select a date and a date range (day/week/month) and preview the report directly in the gtimelog window before sending it. (Actual sending requires a working local MTA, such as Postfix, to be installed and configured, which is outside the scope of this document.) If you make a mistake and type in the wrong activity name, or just forget to enter an activity, don't worry. ``gtimelog`` stores the time log in a simple plain text file ``~/.gtimelog/timelog.txt`` (or ``~/.local/share/gtimelog/timelog.txt``). Every line contains a timestamp and the name of the activity that was finished at the time. All other lines are ignored, so you can add comments if you want to — just make sure no comment begins with a timestamp. You do not have to worry about ``gtimelog`` overwriting your changes — ``gtimelog`` always appends entries at the end of the file, and does not keep the log file open all the time. You do have to worry about overwriting changes made by ``gtimelog`` with your editor — make sure you do not enter any activities in ``gtimelog`` while you have ``timelog.txt`` open in a text editor. OPTIONS ======= --version Show program's version number and exit. -h, --help Show this help message and exit. --debug Show debug information. --prefs Open the preferences window. --email-prefs Open the preferences window on the email page. FILES ===== gtimelog uses XDG-compliant config and data directories by default (~/.config/gtimelog, ~/.local/share/gtimelog). For backwards compatibility, if ~/.gtimelog exists, it will be used instead. | **~/.gtimelog/timelog.txt** | **~/.local/share/gtimelog/timelog.txt** Activity log file. Each line contains an ISO-8601 timestamp (YYYY-MM-DD HH:MM:SS) followed by a ":" and a space, followed by the activity name. Lines are sorted chronologically. Blank lines separate days. Lines starting with ``#`` are comments. | **~/.gtimelog/tasks.txt** | **~/.local/share/gtimelog/tasks.txt** Tasks to be shown in the task pane. Each line is either "task name" or "category: task name", lines starting with a ``#`` are comments. | **~/.gtimelog/sentreports.log** | **~/.local/share/gtimelog/sentreports.log** A CSV file listing reports that have been sent. The columns are: timestamp, report kind (daily/weekly/monthly), report date, recipient's email address. | **~/.gtimelog/gtimelogrc** | **~/.config/gtimelog/gtimelogrc** Legacy configuration file for gtimelog 0.10 and older. If it exists when gtimelog 0.11 or newer starts for the first time, settings from it will be migrated to gsettings. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/other-requirements.txt0000664000175000017500000000062414515757026015654 0ustar00mgmg# runtime dependencies python-gobject python-gi-cairo gir1.2-gtk-3.0 gir1.2-soup-3.0 gir1.2-secret-1 # build dependencies python-docutils # for rst2man libglib2.0-bin # for glib-compile-schemas gettext # for msgfmt # test dependencies (in a clean VM, so you have GTK+ themes etc.) dbus-x11 gnome-themes-standard gnome-icon-theme-full gnome-icon-theme-symbolic libcanberra-gtk3-module gedit ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147422.0 gtimelog-0.12.0/release.mk0000664000175000017500000001273714603245736013230 0ustar00mgmg# release.mk version 2.1 (2021-04-19) # # Helpful Makefile rules for releasing Python packages. # https://github.com/mgedmin/python-project-skel # You might want to change these FILE_WITH_VERSION ?= setup.py FILE_WITH_CHANGELOG ?= CHANGES.rst CHANGELOG_DATE_FORMAT ?= %Y-%m-%d CHANGELOG_FORMAT ?= $(changelog_ver) ($(changelog_date)) DISTCHECK_DIFF_OPTS ?= $(DISTCHECK_DIFF_DEFAULT_OPTS) # These should be fine PYTHON ?= python3 PYPI_PUBLISH ?= rm -rf dist && $(PYTHON) setup.py -q sdist bdist_wheel && twine check dist/* && twine upload dist/* LATEST_RELEASE_MK_URL = https://raw.githubusercontent.com/mgedmin/python-project-skel/master/release.mk DISTCHECK_DIFF_DEFAULT_OPTS = -x PKG-INFO -x setup.cfg -x '*.egg-info' -x .github -I'^\#' # These should be fine, as long as you use Git VCS_GET_LATEST ?= git pull VCS_STATUS ?= git status --porcelain VCS_EXPORT ?= git archive --format=tar --prefix=tmp/tree/ HEAD | tar -xf - VCS_TAG ?= git tag -s $(changelog_ver) -m \"Release $(changelog_ver)\" VCS_COMMIT_AND_PUSH ?= git commit -av -m "Post-release version bump" && git push && git push --tags # These are internal implementation details changelog_ver = `$(PYTHON) setup.py --version` changelog_date = `LC_ALL=C date +'$(CHANGELOG_DATE_FORMAT)'` # Tweaking the look of 'make help'; most of these are awk literals and need the quotes HELP_INDENT = "" HELP_PREFIX = "make " HELP_WIDTH = 24 HELP_SEPARATOR = " \# " HELP_SECTION_SEP = "\n" .PHONY: help help: @grep -Eh -e '^[a-zA-Z0-9_ -]+:.*?##: .*$$' -e '^##:' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = "(^|:[^#]*)##: "; section=""}; \ /^##:/ {printf "%s%s\n%s", section, $$2, $(HELP_SECTION_SEP); section=$(HELP_SECTION_SEP)} \ /^[^#]/ {printf "%s\033[36m%-$(HELP_WIDTH)s\033[0m%s%s\n", \ $(HELP_INDENT), $(HELP_PREFIX) $$1, $(HELP_SEPARATOR), $$2}' .PHONY: dist dist: $(PYTHON) setup.py -q sdist bdist_wheel # Provide a default 'make check' to be the same as 'make test', since that's # what 80% of my projects use, but make it possible to override. Now # overriding Make rules is painful, so instead of a regular rule definition # you'll have to override the check_recipe macro. .PHONY: check check: $(check_recipe) ifndef check_recipe define check_recipe = @$(MAKE) test endef endif .PHONY: distcheck distcheck: distcheck-vcs distcheck-sdist .PHONY: distcheck-vcs distcheck-vcs: ifndef FORCE # Bit of a chicken-and-egg here, but if the tree is unclean, make # distcheck-sdist will fail. @test -z "`$(VCS_STATUS) 2>&1`" || { echo; echo "Your working tree is not clean:" 1>&2; $(VCS_STATUS) 1>&2; exit 1; } endif # NB: do not use $(MAKE) in rules with multiple shell commands joined by && # because then make -n distcheck will actually run those instead of just # printing what it does # TBH this could (and probably should) be replaced by check-manifest .PHONY: distcheck-sdist distcheck-sdist: dist pkg_and_version=`$(PYTHON) setup.py --name`-`$(PYTHON) setup.py --version` && \ rm -rf tmp && \ mkdir tmp && \ $(VCS_EXPORT) && \ cd tmp && \ tar -xzf ../dist/$$pkg_and_version.tar.gz && \ diff -ur $$pkg_and_version tree $(DISTCHECK_DIFF_OPTS) && \ cd $$pkg_and_version && \ make dist check && \ cd .. && \ mkdir one two && \ cd one && \ tar -xzf ../../dist/$$pkg_and_version.tar.gz && \ cd ../two/ && \ tar -xzf ../$$pkg_and_version/dist/$$pkg_and_version.tar.gz && \ cd .. && \ diff -ur one two -x SOURCES.txt -I'^#:' && \ cd .. && \ rm -rf tmp && \ echo "sdist seems to be ok" .PHONY: check-latest-rules check-latest-rules: ifndef FORCE @curl -s $(LATEST_RELEASE_MK_URL) | cmp -s release.mk || { printf "\nYour release.mk does not match the latest version at\n$(LATEST_RELEASE_MK_URL)\n\n" 1>&2; exit 1; } endif .PHONY: check-latest-version check-latest-version: $(VCS_GET_LATEST) .PHONY: check-version-number check-version-number: @$(PYTHON) setup.py --version | grep -qv dev || { \ echo "Please remove the 'dev' suffix from the version number in $(FILE_WITH_VERSION)"; exit 1; } .PHONY: check-long-description check-long-description: @$(PYTHON) setup.py --long-description | rst2html --exit-status=2 > /dev/null .PHONY: check-changelog check-changelog: @ver_and_date="$(CHANGELOG_FORMAT)" && \ grep -q "^$$ver_and_date$$" $(FILE_WITH_CHANGELOG) || { \ echo "$(FILE_WITH_CHANGELOG) has no entry for $$ver_and_date"; exit 1; } # NB: the Makefile that includes release.mk may want to add additional # dependencies to the releasechecklist target, but I want 'make distcheck' to # happen last, so that's why I put it into the recipe and not at the end of the # list of dependencies. .PHONY: releasechecklist releasechecklist: check-latest-rules check-latest-version check-version-number check-long-description check-changelog $(MAKE) distcheck .PHONY: release release: releasechecklist do-release ##: prepare a new PyPI release .PHONY: do-release do-release: $(release_recipe) define default_release_recipe_publish_and_tag = # I'm chicken so I won't actually do these things yet @echo "Please run" @echo @echo " $(PYPI_PUBLISH)" @echo " $(VCS_TAG)" @echo endef define default_release_recipe_increment_and_push = @echo "Please increment the version number in $(FILE_WITH_VERSION)" @echo "and add a new empty entry at the top of the changelog in $(FILE_WITH_CHANGELOG), then" @echo @echo ' $(VCS_COMMIT_AND_PUSH)' @echo endef ifndef release_recipe define release_recipe = $(default_release_recipe_publish_and_tag) $(default_release_recipe_increment_and_push) endef endif ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/runtests0000775000175000017500000000035314515757026013065 0ustar00mgmg#!/usr/bin/env python3 """ Script to run GTimeLog's tests from the source checkout """ import os import sys pkgdir = os.path.join(os.path.dirname(__file__), 'src') sys.path.insert(0, pkgdir) from gtimelog.tests import main main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2720604 gtimelog-0.12.0/scripts/0000775000175000017500000000000014603245772012734 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1560382518.0 gtimelog-0.12.0/scripts/README.rst0000644000175000017500000000247713500306066014420 0ustar00mgmgOld scripts =========== These old scripts are kept for sentimental value. timelog.py is an earlier, less powerful text-mode version of gtimelog. You type in activity names, and timelog writes them down into timelog.txt with timestamps prepended. today.py can generate a daily report from timelog.txt. It does not group activities with the same name, and it does not spawn a mail client. You can also specify the date on the command line -- generating reports for earlier days is not yet possible with GTimeLog. sum.py can help you consolidate daily reports. It is designed to work as a filter: it reads lines from the standard input, extracts durations from those lines (formatted as "N hours, M min" at the end of the line, and separated by at least two spaces from other text), sums them and prints the total. If you use vim for editing daily reports, you can select a bunch of lines and pipe them through sum.py. difftime.py is a hacky interactive calculator that I used to generate daily reports from timelog.txt before today.py and gtimelog.py could automate the task. The biggest feature of difftime.py (it's raison d'etre if you will) is the ability to calculate the duration between two timestamps. export-my-calendar.py uses the gtimelog internal APIs to produce an iCalendar file of the log. It has some hardcoded dates. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/scripts/difftime.py0000755000175000017500000000133013543636505015073 0ustar00mgmg#!/usr/bin/python import readline # noqa def parse_time(s): h, m = map(int, s.strip().split(':')) return h * 60 + m def fmt_delta(mins): sign = mins < 0 and "-" or "" mins = abs(mins) if mins >= 60: h = mins / 60 m = mins % 60 return "%s%d min (%d hr, %d min)" % (sign, mins, h, m) else: return "%s%d min" % (sign, mins) while True: try: what = raw_input("start, end> ") except EOFError: print break try: if ',' in what: t1, t2 = map(parse_time, what.split(',')) else: t1, t2 = map(parse_time, what.split()) print fmt_delta(t2 - t1) except ValueError: print eval(what) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160762.0 gtimelog-0.12.0/scripts/export-my-calendar.py0000664000175000017500000000123214515760172017015 0ustar00mgmg#!/usr/bin/python2.3 """ Experimental script to export GTimeLog data to iCalendar file. """ import os import datetime import gtimelog # Hardcoded date range and output file d1 = datetime.datetime(2005, 2, 1) d2 = datetime.datetime.now() outputfile = 'calendar.ics' settings = gtimelog.Settings() configdir = settings.get_config_dir() datadir = settings.get_data_dir() settings_file = settings.get_config_file() if os.path.exists(settings_file): settings.load(settings_file) timelog = gtimelog.TimeLog(settings.get_timelog_file(), settings.virtual_midnight) window = timelog.window_for(d1, d2) window.icalendar(open(outputfile, 'w')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1602138973.0 gtimelog-0.12.0/scripts/sum.py0000775000175000017500000000147113737531535014122 0ustar00mgmg#!/usr/bin/python import sys import re time_rx = re.compile(r'(\d+) hours?,? (\d+) min$' r'|(\d+) hours?$' r'|(\d+) min$') def parse_time(s): m = time_rx.match(s) if not m: return None h1, m1, h2, m2 = m.groups() return int(h1 or h2 or '0') * 60 + int(m1 or m2 or '0') def format_time(t): h, m = divmod(t, 60) if h and m: return '%d hour%s, %d min' % (h, h != 1 and "s" or "", m) elif h: return '%d hour%s' % (h, h != 1 and "s" or "") else: return '%d min' % m total = 0 for line in sys.stdin: if ' ' not in line: continue time = parse_time(line.split(' ')[-1].strip()) if time is None: continue print line.rstrip() total += time print "** Total: %s" % format_time(total) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1569668421.0 gtimelog-0.12.0/scripts/timelog.py0000755000175000017500000000064113543636505014750 0ustar00mgmg#!/usr/bin/python import datetime import readline # noqa: make raw_input() friendlier f = open("timelog.txt", "a") print >> f f.close() while True: try: what = raw_input("> ") except EOFError: print break ts = datetime.datetime.now() line = "%s: %s" % (ts.strftime("%Y-%m-%d %H:%M"), what) print line f = open("timelog.txt", "a") print >> f, line f.close() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1602138973.0 gtimelog-0.12.0/scripts/today.py0000775000175000017500000001017413737531535014436 0ustar00mgmg#!/usr/bin/python import re import os import sys import getopt import datetime def read_timelog(filename): return file(filename) def todays_entries(today, lines): # assume "day turnover" at 2 am min = datetime.datetime.combine(today, datetime.time(2, 0)) max = min + datetime.timedelta(1) for line in lines: time = line.split(': ', 1)[0] try: time = parse_datetime(time) except ValueError: pass else: if min <= time < max: yield line def parse_date(dt): m = re.match(r'^(\d+)-(\d+)-(\d+)$', dt) if not m: raise ValueError('bad date: ', dt) year, month, day = map(int, m.groups()) return datetime.date(year, month, day) def parse_datetime(dt): m = re.match(r'^(\d+)-(\d+)-(\d+) (\d+):(\d+)$', dt) if not m: raise ValueError('bad date time: ', dt) year, month, day, hour, min = map(int, m.groups()) return datetime.datetime(year, month, day, hour, min) def calculate_diffs(lines): last_time = None for line in lines: time, action = line.split(': ', 1) time = parse_datetime(time) if last_time is None: delta = None else: delta = time - last_time yield last_time, time, delta, action.strip() last_time = time def format_time(t): h, m = divmod(t, 60) if h and m: return '%d hour%s %d min' % (h, h != 1 and "s" or "", m) elif h: return '%d hour%s' % (h, h != 1 and "s" or "") else: return '%d min' % m def print_diff(last_time, time, delta, action): time = time.strftime('%H:%M') if delta is None: delta = "" else: delta = format_time(delta.seconds / 60) # format 1 # print "%s%15s %s" % (time, delta, action) # format 2 action = action[:1].title() + action[1:] if not delta: print "%s at %s\n" % (action, time) else: print "%-62s %s" % (action, delta) def print_diffs(iter): first_time = None time = None total_time = total_slack = datetime.timedelta(0) for last_time, time, delta, action in iter: if first_time is None: first_time = time print_diff(last_time, time, delta, action) if delta is not None: if '**' in action: total_slack += delta else: total_time += delta return first_time, time, total_time, total_slack def main(argv=sys.argv): filename = 'timelog.txt' opts, args = getopt.getopt(argv[1:], 'hf:', ['help']) for k, v in opts: if k == '-f': filename = v if len(args) > 1: print >> sys.stderr, "too many arguments" elif len(args) == 1: if args[0] == 'yesterday': today = datetime.date.today() - datetime.timedelta(1) else: today = parse_date(args[0]) else: if os.path.basename(argv[0]).replace('.py', '') == 'yesterday': today = datetime.date.today() - datetime.timedelta(1) else: today = datetime.date.today() title = "Today, %s" % today.strftime('%Y-%m-%d') print title print "-" * len(title) chain = read_timelog(filename) chain = todays_entries(today, chain) chain = calculate_diffs(chain) first_time, last_time, total_time, total_slack = print_diffs(chain) now = datetime.datetime.now() print "" print "Total work done: %s" % format_time(total_time.seconds / 60) print "Time spent slacking: %s" % format_time(total_slack.seconds / 60) print "" print "Time now: %s" % now.strftime('%H:%M') if last_time is not None: delta = now - last_time print "Time since last entry: %s" % format_time(delta.seconds / 60) delta = now - first_time print "Time since first entry: %s" % format_time(delta.seconds / 60) est_end_of_work = last_time + datetime.timedelta(hours=8) - total_time delta = est_end_of_work - now print "Time left at work: %s (til %s)" % ( format_time(delta.seconds / 60), est_end_of_work.strftime("%H:%M")) if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1560382518.0 gtimelog-0.12.0/scripts/workdays.py0000755000175000017500000000266413500306066015147 0ustar00mgmg#!/usr/bin/python """ Given a constraint to do at least 7 hours of work per day, how many actual days of work you did in a given week? """ import fileinput import re time_rx = re.compile(r'(\d+) hours?,? (\d+) min$' r'|(\d+) hours?$' r'|(\d+) min$') def parse_time(s): m = time_rx.match(s) if not m: return None h1, m1, h2, m2 = m.groups() return int(h1 or h2 or '0') * 60 + int(m1 or m2 or '0') def format_time(t): h, m = divmod(t, 60) if h and m: return '%d hour%s, %d min' % (h, h != 1 and "s" or "", m) elif h: return '%d hour%s' % (h, h != 1 and "s" or "") else: return '%d min' % m def main(): for line in fileinput.input(): if line.startswith('Total work done this week:'): work_in_minutes = parse_time(line.split(':', 1)[1].strip()) assert work_in_minutes is not None print line, break else: return work_days = 5.0 days_off = 0 while True: avg_day_len = work_in_minutes / work_days if avg_day_len >= 6 * 60 + 50: break days_off += 0.5 work_days -= 0.5 def fmt(f): return ("%.1f" % f).replace(".0", "") print " Days off: %s" % fmt(days_off) print " Work days: %s" % fmt(work_days) print " Average day length: %s" % format_time(avg_day_len) if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2760603 gtimelog-0.12.0/setup.cfg0000664000175000017500000000123314603245772013065 0ustar00mgmg[flake8] doctests = yes exclude = .git,.tox,build,__pycache__ ignore = E121,E123,E126,E133,E226,E241,E242,E704,E501,E301,E261,E127,E128,W391,W503,E402 [isort] multi_line_output = 3 include_trailing_comma = true lines_after_imports = 2 reverse_relative = true default_section = THIRDPARTY known_first_party = gtimelog [tool:pytest] norecursedirs = .* *.egg-info dist build tmp scripts python_files = tests.py python_functions = !test_suite addopts = --doctest-modules --ignore=setup.py doctest_optionflags = NORMALIZE_WHITESPACE [bdist_wheel] universal = 1 [zest.releaser] python-file-with-version = src/gtimelog/__init__.py [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/setup.py0000775000175000017500000000500314515757026012762 0ustar00mgmg#!/usr/bin/env python import ast import os import re import sys from setuptools import find_packages, setup here = os.path.dirname(__file__) def read(filename): with open(os.path.join(here, filename), encoding='utf-8') as f: return f.read() metadata = { k: ast.literal_eval(v) for k, v in re.findall( '^(__version__|__author__|__url__|__licence__) = (.*)$', read('src/gtimelog/__init__.py'), flags=re.MULTILINE, ) } version = metadata['__version__'] changes = read('CHANGES.rst').split('\n\n\n') changes_in_latest_versions = '\n\n\n'.join(changes[:3]) older_changes = ''' Older versions ~~~~~~~~~~~~~~ See the `full changelog`_. .. _full changelog: https://github.com/gtimelog/gtimelog/blob/master/CHANGES.rst ''' short_description = 'A Gtk+ time tracking application' long_description = ''.join([ read('README.rst'), '\n\n', changes_in_latest_versions, '\n\n', older_changes, ]) tests_require = ['freezegun'] if sys.version_info < (3, 6, 0): sys.exit("Python 3.6 is the minimum required version") setup( name='gtimelog', version=version, author='Marius Gedminas', author_email='marius@gedmin.as', url='https://gtimelog.org/', description=short_description, long_description=long_description, long_description_content_type='text/x-rst', license='GPL', keywords='time log logging timesheets gnome gtk', classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: X11 Applications :: GTK', 'License :: OSI Approved :: GNU General Public License (GPL)', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Office/Business', ], python_requires='>= 3.7', packages=find_packages('src'), package_dir={'': 'src'}, include_package_data=True, package_data={'': ['locale/*/LC_MESSAGES/gtimelog.mo']}, test_suite='gtimelog.tests', tests_require=tests_require, extras_require={ 'test': [ 'freezegun', ], }, zip_safe=False, entry_points=""" [gui_scripts] gtimelog = gtimelog.main:main """, install_requires=['PyGObject'], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2680602 gtimelog-0.12.0/src/0000775000175000017500000000000014603245772012034 5ustar00mgmg././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2720604 gtimelog-0.12.0/src/gtimelog/0000775000175000017500000000000014603245772013643 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/CONTRIBUTORS.rst0000664000175000017500000000211714556177605016341 0ustar00mgmgContributors ============ In alphabetic order: - "ijk" - Adomas Paltanavičius - Barry Warsaw - Chris Beaven - Christian Theune - Dafydd Harries - Daniel Kraft - Danielle Madeley - Eduardo Habkost - Emanuele Aina - Eric Lavarde - Gaute Amundsen - Gintautas Miliauskas - Harald Friessnegger - Heimen Stoffels - Holger Brandhorst - Ignas Mikalajūnas - Jamu Kakar - Jean Jordaan - Jeroen Langeveld - Jonatan Cloutier - Jonathan Snyder - Kees Cook - Lars Wirzenius - Laurynas Speičys - Martin Pitt - Michael Vogt - Michael Howitz - Nathan Pratta Teodosio - Olivier Crête - Patrick Gerken - Radek Muzatko - Rodrigo Daunoravicius - Rohan Mitchell - Shirish Agarwal शिरीष अग्रवाल - Stéphane Mangin - Thom May - Till Hofmann - Tomaz Canabrava - Vikas Yadav - Živilė Gedminaitė Their contributions include patches (including those that didn't make it into the mainline), helpful suggestions, icons, configuration tips for integration with other software, offers for co-maintainership. Apologies to anyone I may have omitted. If you drop me a note, I'll correct the omission. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712146174.0 gtimelog-0.12.0/src/gtimelog/__init__.py0000664000175000017500000000006014603243376015746 0ustar00mgmg# The gtimelog package. __version__ = '0.12.0' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/about.ui0000664000175000017500000000437014556177605015326 0ustar00mgmg False True Time Log (placeholder) Copyright © 2004–2024 Marius Gedminas and contributors. A time tracking application https://gtimelog.org/ Marius Gedminas Adomas Paltanavičius Barry Warsaw Chris Beaven Christian Theune Dafydd Harries Daniel Kraft Danielle Madeley Eduardo Habkost Emanuele Aina Gaute Amundsen Gintautas Miliauskas Harald Friessnegger Ignas Mikalajūnas Jamu Kakar Jean Jordaan Jeroen Langeveld Jonatan Cloutier Kees Cook Lars Wirzenius Laurynas Speičys Martin Pitt Michael Vogt Olivier Crête Patrick Gerken Radek Muzatko Rodrigo Daunoravicius Rohan Mitchell Thom May Tomaz Canabrava "ijk" Živilė Gedminaitė gtimelog.png gpl-2-0 False vertical 2 False end False False 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2720604 gtimelog-0.12.0/src/gtimelog/data/0000775000175000017500000000000014603245772014554 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711446986.0 gtimelog-0.12.0/src/gtimelog/data/gschemas.compiled0000664000175000017500000000345614600515712020063 0ustar00mgmgGVariantX(XLX\S.p\ Hhorg.gtimelog(  v zzL|\2 v6|$ vvӗvCEC vP{'U{ vЮ8vA)V v0vz!v(-;- v8S-Svhwbw vuaM vv߄v}~ v .* v (n(v@E}'E vXF |vv9 v.log-orderstart-timeestart-timenamedurationtask-list (s(yau))     smtp-username(s)list-emailactivity@example.com(s)hours @r8@(d(y(dd)))office-hours"@r8@(d(y(dd)))mail-protocolSMTPeSMTPSMTPSSMTP (StartTLS)(s(yau))settings-migrated(b)task-list-url(s)gtk-completion(b)remote-task-list(b)smtp-portr(i(y(ii)))virtual-midnight((ii))window-sizeR&((ii))smtp-serverlocalhost(s)show-task-pane(b)window-position((ii)).path/org/gtimelog/stask-pane-positionX(i)task-list-edit-url(s)detail-levelchronologicalechronologicalgroupedsummary(s(yau))nameAnonymous(s)senderAnonymous (s)report-styleplaincplaincategorized(s(yau))././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711446986.0 gtimelog-0.12.0/src/gtimelog/data/org.gtimelog.gschema.xml0000664000175000017500000001475114600515712021300 0ustar00mgmg false Settings migrated Set to true after copying old settings from a gtimelogrc file. If false, and if a gtimelogrc exists, settings from the gtimelogrc will be copied into GSettings. "chronological" Detail level Detail level to show in the main pane. "start-time" Log Tasks/Groups order Order of tasks and groups in Log view false Show task pane If true, the sidebar with a list of tasks is shown. 600 Task pane position The width of the time log area to the left of the task pane. (850, 550) Window size Size of the application window (width and height). (-1, -1) Window position Position of the application window (X and Y). 8 Work hours Target hours of work, to be used for estimating time left to work. 9 Office hours Target hours of work (including breaks), to be used for estimating time left at the office. (2, 0) Virtual midnight Hour and minute that say when a work day ends and another begins. "Anonymous" Name Your name in activity reports. "Anonymous <me@example.com>" Sender email Sender email for activity reports. "SMTP" Email protocol Mechanism for sending outgoing mail. "localhost" SMTP server SMTP server hostname for outgoing email. 0 SMTP port SMTP server port for outgoing email (0 = default: 25 for SMTP, 465 for SMTPS). "" SMTP username Username for SMTP authentication. "activity@example.com" Recipient email Email to send activity reports to. "plain" Report style Report style. false Use remote task list If true, the task sidebar will show tasks fetched from a specified task list URL. "" Task list URL URL for fetching tasks for the task pane. Expects a plain text response with one task name per line, with an optional category in front (delimited with a ':'). "" Task list edit URL URL for editing tasks for the task pane. Will be opened in a browser window if the user asks to edit tasks. true Use completion If true, the task entry will use the standard GTK+ completion. If false, it'll only use the custom prefix completion on PageUp/PageDown. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/debian-paths.py0000664000175000017500000000073114515757026016557 0ustar00mgmg""" Resource locations for running out of Debian package installs """ UI_FILE = '/usr/share/gtimelog/gtimelog.ui' PREFERENCES_UI_FILE = '/usr/share/gtimelog/preferences.ui' ABOUT_DIALOG_UI_FILE = '/usr/share/gtimelog/about.ui' SHORTCUTS_UI_FILE = '/usr/share/gtimelog/shortcuts.ui' MENUS_UI_FILE = '/usr/share/gtimelog/menus.ui' CSS_FILE = '/usr/share/gtimelog/gtimelog.css' LOCALE_DIR = '/usr/share/locale' CONTRIBUTORS_FILE = '/usr/share/doc/gtimelog/CONTRIBUTORS.rst' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1602138973.0 gtimelog-0.12.0/src/gtimelog/gtimelog-large.png0000664000175000017500000040740613737531535017265 0ustar00mgmgPNG  IHDR,,y}u pHYs   MiCCPPhotoshop ICC profilexڝSwX>eVBl"#Ya@Ņ VHUĂ H(gAZU\8ܧ}zy&j9R<:OHɽH gyx~t?op.$P&W " R.TSd ly|B" I>ةآ(G$@`UR,@".Y2GvX@`B, 8C L0ҿ_pH˕͗K3w!lBa)f "#HL 8?flŢko">!N_puk[Vh]3 Z zy8@P< %b0>3o~@zq@qanvRB1n#Dž)4\,XP"MyRD!ɕ2 w ONl~Xv@~- g42y@+͗\LD*A aD@ $<B AT:18 \p` Aa!:b""aH4 Q"rBj]H#-r9\@ 2G1Qu@Ơst4]k=Kut}c1fa\E`X&cX5V5cX7va$^lGXLXC%#W 1'"O%zxb:XF&!!%^'_H$ɒN !%2I IkHH-S>iL&m O:ňL $RJ5e?2BQͩ:ZImvP/S4u%͛Cˤ-Кigih/t ݃EЗkw Hb(k{/LӗT02goUX**|:V~TUsU?y TU^V}FUP թU6RwRPQ__c FHTc!2eXBrV,kMb[Lvv/{LSCsfffqƱ9ٜJ! {--?-jf~7zھbrup@,:m:u 6Qu>cy Gm7046l18c̐ckihhI'&g5x>fob4ekVyVV׬I\,mWlPW :˶vm))Sn1 9a%m;t;|rtuvlp4éĩWggs5KvSmnz˕ҵܭm=}M.]=AXq㝧/^v^Y^O&0m[{`:>=e>>z"=#~~~;yN`k5/ >B Yroc3g,Z0&L~oL̶Gli})*2.QStqt,֬Yg񏩌;jrvgjlRlc웸xEt$ =sl3Ttcܢ˞w|/%ҟ3gAMA|Q cHRMz%u0`:o_F#IDATxw$uމ/lF,o}??).EQ gJWKZեD  7c{ڻi[]]҇U3=Ӏ|2+2**#y{#R߷}s}~m}Nn7^z[Bx/JHł_J1ߟ0oFӭZպrn6Pi*+U.D 㕕zf}]+aDsʥe?MU$*2%R>{T0L_ j"'+aV,Jg3! @$x2l=Ͱ je0T( JnЍj-8$(n7KIК+|5_zzp~4z V>d\nZvU Z=`u-hWN. ٭n@ӣM+>7ѐ5$qBZt\!z*BAXEj<-0uH*$0uPY`&-G 1ipY N0L0uIβ0sN8Jq0.m|KgXoF1it3^BMO9s_QQvx4renMCD%,f:Ն/.PgnJL |߇S| j,ϑ4#|ץP+!L |fR TX\hW8AHt*ChlMVT$ B t f89IT!IRv]ti& "T^D4=MHD(%$cR<&$Is@:p|_%VwJ ߎzzky7{fĿF#[~FNo|7bIV #\>1tj.v}cJB4N(HÀT)4M#R'Im>iP(P(R}/zr⧂A@1<4HJ/_VЉ|ʅa4h,-l`x:Jjt6JH2JIDXҠX(h( Ta,kAai&u0"1sb? #ЈUhfK&NWg~&U[e`,G{;`~o*=#/sE]´r"v]rvbd c*f"B r⫴]T`Vr TBV)P\C*X]bǶLlL6A(Lt) HҀ4N3"H|0m7\nvm#8zqI54]-'O)P, %B˱0P)X!\6axMAbeL$ "<òM4M $iK@)HSIDIؤa J20 0B7 R9(H2Q$Hc;g~5M&^Ow X"z'"o`TfpWJ_>CtwReS'D+V%$RӰvI(j]&kˍ9Μ>EZeav] 8_@R(qp?CCLnJ6E%]Dm4Yϳ|q1&Rjip %*# 100FX'abW,;M' 3 ͬgemqe=j11N>GBǤYMCZQ#M"T NbBt;.'_B p.*U:*IQBsWMZFɟ韾nE*zIo `}-;^~*Vg/\ږ r޻Is \ Pt= f8LU'q}8WB}w2:EUլ% "A+:_cy/i7 4Y}jz[(o*mKUF&X7i)6nu,SgeyХFD$(5RA.# BT0AH4nR>a`6Ab A8Bm躎ضnDqB8|3KH~jf;M]/pwn$rnO{?Ч_ye}va-ncGfbk a.Q.i2o"iYExa78s R.NvDaH'^D"<ϧhJB uclز#=JF "A"Ϟbgqm$x!4 Mf$Rh٣̢%)3ӵ8Uzfa}(TJ$IL$k-5BOnb+q/^戂.X^ M  0 HSXM @~) Nben1&HSp]YkN@juM\VS?SM ku?ZAnnFo?}7nwr"o0c+slg|ʑoDi8 Jc,D =020.X6F@0u=_9G,4)-Μ>@09G"2Fat* vmBc&af:Ϝ]Q` LabYi&fdU!RhIܹ3jl޼ ф$ISL^ODqDĄaDGDaDQ P,ۡZdjN6FC|"FS!NSND 0iu2ӧi&JDQB8Ӵ;9a!aB BQ*% IJL̜ MBV ʹ4MgMɟܬb֭4|' ۨݒ'>SZ;} }G8xe\Ci1Acqji03GRd]laK +177Ck>e+J\xO/7s$bdx^oڋ.,@GDda"'Oܙc4Vz׈'u,"!8ض RI!a! lܴjaضaj5/q)>p]HISE_}={< yu]|u] Bus c][$ Ve&Ӡ:=R$1 ij$G^HMT.!$ì*2vAJA((DaF"3Kxi$gP@sLΝ8s@+OOz7^ixǷOÑta|yǞݏ|R]j}Ì#D0_gyιqɏyEBףP c ⦊MVcz#'](ZM\ NP&̞?H=xw+]%4.$Q:~sՓ|\8wn[s ضiAաj290M'O>_Weyq#G?crv.ja:$IʯڿC4 |_b~~ciLVy=rzvMbvv8N=NC;?Rj}lپAxnO鬠bnxnkTj}w<S~HR0-t:Qe0Mn#_,\+!I"xMC% i87a5%?5͍DY7ZA-i9qu:^GλN#X9e0o{91{5 fvrS0"cLn\];285N %3sTKejF%,_^v lZMġu:L"Au]>2v._svL:2_O_dqBx g(+TSȗXJy^ L(wq7+e>ć>$M.Cu*mSߢ^_' Mt=Ҧ6SdÆ c&S`llO "Aq J {q30_8w4M0tZ8TjJyl;GTa-W<'ؽ{7֭cxx|^_ѣsϽaRTH;)M$iJ<{<SK[lahhӴX$^²,'!$CCüLm | =]lkͼOybia gO!T$nP |<0%V *D~n2uvT&6EAFDarO8̡BHӄi=RRL;J 0$}̜CnA]*`,/LS-WpQ/^i},;# w컏¥%ΞƸ_gg 'H]7d$e?0@ MTatxMSرm;?$aSOq]w12<٪}mlڴ &yciD ²u(BJ |44M";BTD)Oh4MT*{CR^@(-[Hòࡃg>jv {`狴.C.o.BjQ4tLBq>P)tȕʤqL$qL.gP)N!۶PJBWQeDaAG9EՃ۷^#uyxo[XǏW`u#QԍO0i2w1ZKܶm K e^$:Z(WtU쾍 )mΟ#tqh5+<補vI#|}?=OqYJ+/OzDi 29IjiLDݻdmlٴ,ۿo?N.I Y\BJ3A!BiYנ !W6!%{&HSE* Y[i\xNÞ={P*mt3g^Eo4M*Ie2 #̯\>GfeR1/3E|iB$X7 %n(H\X0@ adѓˑND)c#heG%9ƴFFGY)bwСC};E(6ºQT uM&FgN028Dq@2Mm(1pS"LFMl >| b@,EdjjN@>g3='$E}a0پ}>\8D4 >so4 `[_7A?bv;@JK}lߺq,Ă)J+4tMC $zW(&J"E*L aZ$Y%5AgU4͢(!XVlÐ'qVJ._fiioԩ,--cN|#4ݪ3s,} NavhZ89v]tB׳Yia[6a($<8D%if`lH8s8;&5850IJt) c(N|8 ☄ZmGfnhC羟i{|37u>uÿx 1T)nd++lܳ71T9&xЫ};XYY,a yZni!Qs) 0 9S[6 !Fx n䉳̭4ő/'߹x( L[O_uZ Cflt1FG8q$ȏr_])qEV*k'iJ^tou^|ٞ0XQTb)t8gt:.JeIr+++qBT0L\-iI Qz4M}Y^ye~8IJr_}o|w߃ȱC=6hza 8w8nkkv,3EmZH! R$ i,:ND*H%€8+g"aD\B0B`lg>e B\ Tr4'֍wc}#o%sX~-w4%|7`^#ġ]ct4?A6I[|'&Yv0Пgvf0<9B2P-S[C/ !r@ma42+ s_^h%)*5Xamw}/pk,t/OYLLgۨ7;~/gΝܹM73n=}}:nvP"KDYBj= uMiR1<駿ʼn'x3ȃ>L\& LS\|_k_+Μ9C__u&1 4;U/GQK$att^ t'O`tt[S,<\#IbZ\dtdR%M}j}e|#dv EK4 rz"eR`^&CeI֛y'.ė>%F+&16.Dy QݹW{ b~Ea16Ns%<Ņ3ٰ~Cqγ2ї#lܸNI6 ;%M<怜4?AvTLn@VctdJ̕=z){?7m%Ф@ ITJ2dY9U*E [W,΢zܓ:W ;nG}EFsVhj/#8yL _< hZ\pgyg}qm{ %kΣ@Jql(2SS]zѓpع`Z GQ' 3@r_?d.XfIL& }Jfu]iv[ـ|$M=g_XBa;a]4cRM\7 zDqBkybqG:m7 {X7z[XX[=xPc/9ҥ9:K;wW l&cT:1S+c9L! CAXP(Wh6ؖ.$J3bZ6а7GtH%q(eH!Y5RHNZ(:thÇ{_^@}ި7[9[s??al+}i^,ٶi#f!xOH7NW_\'|x";oT,1 &Cr,++u:6 >i*R\ZU/.j]ضűcGR/ٰ~= xmsq<uY^n`[炐$P$ C ibJ2S˦Uo:Xtٴ(&Ic "ͥz4|D1/Y&$ ӱ2^4,K绝N0q᩽{{덲JO}M[OZ-{'G'Huj4\p~e{6bϽal: ˢY!%Չ~DSos9rjvC7ubhx!Fǿwͱ˜M~fI{Rk:b'y^0E%Y䖪IS?'o8IT=?B>_DE^gaaaVVVGiZ$Iʮ]_74u4V^[OmK(JV: 6}6mIܮK$܅iJLJ?wu9qߢZ<YhRs򙻩KE+- "KsX3R0tPmaY_J R%|*Hsq؎Cqs3Nw;?v+iԭO FĤG.۶ma}T T*vG0vƅ 1 DQ!ro +_ ?w~|Wxx˼od6&Mٸi3{]:6IU:6IY=a @)hz4A5|Ü>}KH)9sUPs}4+t{066Ӝ9s)% qwBzdpBy,¦M[k4 >VV}s[Ӷcii |JRuD>}4 س{/;vZ _Sp LMMs'ذa=~I&\L _s>ogY^|f.]biq@2b0[O}Z={q344c>O>G?==:tlah48xO>ii(Dρ4C}IN@Kc׮݄a6m:lŽ{504̥4*4]J){V6YĨW$fzzm۶e\&дL7{Uvr`#%fW"j B^:T axm;HaYVeev)5Ha@\Y!B iL;#"IL N~н KuÇE- ~GI[V?O^%&}*w'fP\9>moT.q:+ νzHNlcTL\(AJ8TU /@^_-..kg?KI5*͈$?rqfggHSÇXn7n&,-,SOz%SuάJ{'urfN`۶kјl6r [7oRRVs\9ƉM=T<,0iZ>JQIJBq 9 UiUmZzFoPDB(TJH>nNj>޳gO„QGDoX}KYKyhc (X&6OeJ2NrLDم*r]jlݱN7p?3ssZ0F! ˱c'^᱇GYA!W>?M;X\Ybqiax`gltdv 4|;w;;`M 'u8&)J$C$qjBX 9x a̡Cؽ{Dmyos 9Iv ZtVT!3&C0$"8%c8ᮻf9{,3Y@h(p>F6nB|֭[͛xg}Mu3g!>`VASibSkZJx9x +V#|J"'|yzM)6n侻^j*(fV.'8~f'0iOkzR8GF56oB~@1_V1}ne6Ln1%Gb<#U ˲1L#kh)RMӈ8㠺.FiF*ti(!0EBx&Ydyw\fBDJTnw^I˶BևxyϞ=ލ'-Q ){'܈HSө4@↤*4!1JyܳVIΞ W Ð$I2MP,//o|`=ټy3|1ǨVػwQ"e&TUiRZhlC*6sss< wyr ?4v^J{CQ"%zSUz͵ IӐO~kkܹrڳ"2bxx! 0Np5֯_N96NPRI89IghSdQsEH$GQE\#4- aHe!I$ð7u;M EGMM'J+yvn>r={#%m-ycJx3K) +S (5EwXV ț:f!UTK%"'.\8pR]7Hz4MH *C} P](az$f z|361/?`bb=/X`z osڵݻWb׮]LMMR,{IJZs3P*!MJd P&3뒴'52c2>>NFܱ۷aj#SU\|jד&iO>wlgbb_|na(xWظq#7n$ 3Sd=B)y~{;vvXYYf߾={}OIEQ.^[X6BgA^CJIErN(E/LHEVe||I&Ns0ObN*mnxYt*t))*$Q8ӂď0m頲&rMH8؏a_BRt~Ɓ5Lv.36&0>E{0gϞPe[,zh/}^}p084/}_aӦ, SiC2M{]. ZqMJ] ,ݴ/?2}%.}~N?w2}˱3tSǞg lrDX`td;nr9CI{_D*אW\o2f!PaAb߼u+O<$==(>|q>ץsٶm;xW{f}!nCsw0<4 ɽ<OcvЇx TBcppIVjvz}gU:-־5Rޢ6KW&u(DtG;yyɿ)lˤ\*gC&ek):%:S;ömyx뫐$ a,--> [a % p$]7{B 2WPhR,n!$ E$T*>n4вȑ#wᄀ]<&J"bm[y&Yn=C= REX[Z#گ {61=xRB5)Ś5;_ ^k/H9֭[OXά*j0MZE1ƚBEAiٳ{֭=FG)רݳR y8|0DQߟ󉶆 +NI&2{vmwx8(V4uL+m5jL("P=s8=Am9F)qd(zW4HD(A$n3oGF0P1v\AJ-Diw&VWv=~DW׳֭W}c`iutЈ>}G )ED& +."g,..RQ*|y(KRvɩӧ_~$9}qxqf_bjr3Q/c|gaan+R cm(L7];L0D 4u 0R+1li$A1# B"?)4PHR7<ϱ~jݷZdž ٱc;5LSGlA*Ϊ`j1 t= !QfͲM/q|b@yvR׀*p5>&V/kHًV'MTD͚iF߯Yr,s[si۽df aaJ(`Ϟ=e W^X̿a1HtSψm !RGt 4 4@3-4i322ʮ]$cN~NIK CǰL4Fh\f}MqS,P(.~c:&:!tF(@C7mlve*.ףjIehM Uk|n7[p'n8<~0.1Opgٵq X caBR*Ã.Pa& |.aDH!6^SQ9HiZoS,9w)pe"Lɱm4Z R##C^!jHiF=BGHHk"UwUHj7usev:NiP,f󖭬 M \s?ENRZu%W+KҵTTߋĔ@$k<\\Qdʵ"Ad۵0 3MV@YW0<<Oj"$F:BiV/Bk:HMhNaaqiX~= /vۼrabbbؤtC4,t&6i4VP<+:b]PiϜ0HU6'PY9FVda Z)4X[g7i '>Q`zBF7V |7fh$"I嘝_Ay.nKRW2m#'0>1et:Νa9ax>Fh0ŜMż3 ?zCۼχgϣŸ}s{ L S0P'S.,)=O]ыb4raҘY`ߝwpqf P*9aHbXK6oD޴s^49|#2=.9^8s|K:?Go[lb gPv[Wġk߱e6 @&tU%ޠU^ʔ4S)P:{ћXӖe@t-Gכ"$=)$7Ek묓(5;V~|_\^x9x!n*%I&@I֫ 6LMcei0 ٿ.ܮ@'NR&7l4ӗ8z[ _e:Fsi+\HXe|F9EN!54]bjYV]L3֛DImh@ZowܛǁH4f[A>|];/n R!żEf1hE(@X*VAwu'ӝ%֍4¹eAּ1Rgpb6\vEab$sx}G'O]!&7K|G/024Ķmk8NVV&N_ȯگYCh?;?Ino~A v"9'>p?q!DdP b}Į6#,"4=X2+S]%tX+{7) 5mVfi6x]wߍmgDB;r5Zݖ$1a311q~=yťYnvq &cJ,,̠0 M&ubs è'H*I QfXDAaHB)4t ¶ 4 q6Q{-U)8A1FzѣGڵ#7s|,u--=ř9.G{(K2?=M%hEU +Ɔֱ\>~?)Fv~*}ԘIS)>QQ_Z0㤁˥W3?Ď'ָxixq؋عё "J)kluUhZݓyu+3*"snE Dia=}U}ժDU{_5Yx]E* /j(z#5k'Y5[m RmHkǮ U,-[ nspzR!@Rl&T3W/8Ky4H)(8Ν{SNy64Mgiq+ Ej躞 dn!'PnXE\A U(pUXhVvV4R_C"$ NAhb:+WX/2:6/o~JvUop~C_`#}8T*# St @|.q>v晋t] B;@ N\HZCZ^K`hh ż#R]frfW`aMP(ٺcJ[lg˶mT՞T\[ِP˶9y+/| rǴ K_pr6B)TI'8qӗ.SOc~nEa湤  %,3&5@Z5fRxZoP)5=MٵQ* ]CyMqubf'zUv0Lػw/ԳVzRWEZ #åi`ݰ0 ]DQ<綽G_D Xnf8 I$kR qh *N4 ӶiĮ 0,+u"?sRD J1-iD( 'n+}H1e ,4s6K|kRo2_vr}Fm]|AH i|ϥe Rk}z8ef{5zn@cB9q03Wصc0(U ؖƹK:vor 'sE>s |7e.]8=Ο䊑ݩskݻ=wΗ\5ŋ?c}i1M${ 5*|5)%ZOt]4FAX@PTgϞ۷T  *)J4EF}C3(UJQ3HUJ\X<*LJQ9Ƕ=}}5:! d'ohiQ |o4=|o:nUP*8Ⱦ.biP/ItC2==Ⱥ D( G!mg/)6Kx#H]cnv$[n;ogll 0l0F 4t|_]nhN|^ory{(I|~XYYnFqOc0X%oFQ@(GJAX`->}z/ ÀӧNq)>OQpΝ<<LNN,gr^JӫmEkT,T6>ūMz/J@$zQWZ}=K13R~+~];vx~z<)5LAj$RT4%iiSX\wCE[nen~.QJ&R/ 86aat[Mr8$i6v1rH]CJ|Tv2̚Z[X^\A2!D$wwc?#?wf߲*{Vp ).+8DORd"CHS{e"u?~k[nvr"[6n,a3-λ _tX\B+^0,~C?Kԛ+ yǽ 216Ξ={ذaB2IC7JܳWL{-2T" Èa￝8Qi%im;+%վ~ʕ2b '_@zJDQR̲={;*HHp%yq)(ckJ`oU~7g^^ fWlDUkebJ)'3J8i 344ifv&q:*Ǐ6P╗_FbRG4M8xn)zeJ"H!QiNzzm.ڤk+{ bpp; H%Μ9K/?ϩSz/}K|_eΝ<(U4^=v]mؾ*8M8.5j ,lES_-(ECCC߿?3v}Ԫ$I5}_2>.&ضXv6]JS׾ӧdxxjwM5Z|\kX#Z`]jlxku3l22;{6jjtk4M~/}Z q!HU"6M38x%k58jAHZ%"ř 8 r|6BjHRN׼SI4t,M&Iv;$3 ]veXsKc,@*ÈZzfjG.ڵkfc-wmzsL4wb?Ʋl<)JYl22:mYU+^8V=;vqz6l+"FB.[)TK)U] 2?O}n Z&ahɲ}rś<'Qe@`_ㅱ `_^ ƤQ HDar9\V:qTݺ-j$}SݪS<h4un8!S*I$YyHӔo5.v l(|o=]>mCXkiZzTJdݘB c9*U4# C ,7 ㈽{p~iՋL {FFw~ P{.T\om,dvͰE AyuݐOGxṿ{GHAi׸t"Ӹ׿{cNfV*exq.]8x;A~w;1e+wǟw=6e:(J9z'V ??ǏöMXfL177CG(a1W155vnMRfwDk:& #|*M,W*-hIUzH"IQTi5[X$K3ne nd І]%dim$) ([bَy[ױ%$$Fv|HhQYFő|RVne۸GҪ72Mƴ:l?|kn7f+/'ubd &Iŵ`]C$r *8xcѩ81m =_|珿F6q7%df<Ǘp o}ܭ32?E on6c^ӏSUBi6-a l5s-7]Q{@Z߸=nz1hr,M./|Fre`!2Ҹ|.{$R2-ITRG].TIRMEqn7ku\&k+ke&ff\ʥ:H@Ę:Z 4i67PJ:҂H,\zщU%G鰮cW^3 tA99u<""R*Uӄ \ V+0:4Jux-3uhȬ3T WV]oYX"D9~ãX]s'y 7ME@7n59rIvIehR$rvt=іPov?1qFLm1uv,pWwW~u˔ꏇW 7ZkZS,=RZO?[$\> OZ7W4z>?=hdM%89 SadIw=L067%vnRc::+/u:6MŤ…,.]a^03 Wqk(*zoΠgfvB=j<ƣu_u4nHR?[nEJc=~}xpn>֧([g#Tβw,V[Bl3 s Ҙm~[UQ00dЀ""is.;vquRv82j w bg)"St%t0X[Ң{DIj|Pla960vDR)>n @nO%` }CCGzE}fRf};ygXp/33=(/h5ZڹU<=;t}7::j|evMO}8vG^l1:5.X~qtRx>SaF!ƒm+ﲸx/~sRC%vZlI<6,-@ >JwR7:CC!Y^\)mfa2J)J28,4!%Ξ;͎)+DI(hery"Z/Ύ؄Q28D;X$LR( xB+pPP-^^ae[vn8 8kR}:ɰmz}M˥^R>[׼*şHi`4*},Y1{7mǩH+^:&C$f.iݔ9hmSJ!aO27?c{FVj 4Wf&ϱqqQIݻ#O4Ndjn;+onclltK?P6WXMw v0#Qk>lvj[>~h܉SN*L_D:N̓>{S'OX 8* 'ixP(`LތK{ /ʀHg 6YT4Th00nrayʥKR)ܫZ+nNT+LO2>1M QR6?다qLZF A)\&lvt  W,@ҬhwZD*E*N٤jF]d֩ YC#.x8VNO^kssŅ'l1ρ}(d rQk46!X_}G?Q\|7;kGL[61ZT^$&#^klWu}{a^O~\|۱7G0f`$mǃBs4R#=iȜ\,sLOM/&KX;}yţycbzA%6vlCdddK% C-N@(m6YR* *I)[Ml) dv\פRxҲ%PIBgXmiF'4JBJix a8:S~Gy7q]~#*5Gn jzr6u iC*OsCa,!kc5Zkkחi댓ԇ>+xe&Y_omb,F3}iZk EPynFv+C^J{s,4d<?ƿ?oƯ ب-v+xuuc;ؼ5ToG.%dQ6U!^CZMQw!dqiOضq_}{sw]6fUo8_;Ab0(Nm|_ϱѠR gu|2BK)Vinc<ǣVX*TJmuV8 MUf;͍ p^B-Lvݼ86Q?. C+ %\!vms٨tE W?l zƅ֛4-Vic#a VJQQT^cxd`j:VfS'|Sⱓ<|ݔvͲx"^5 m%NO`Y6cS8˾m se0avnIIR ϝallB@Rehh}* lLmvV\PxcA@恪`%&A|:jLכibit;l)q\ Q̫T <6|ݙVsC5D]ߊ2ƫC(%qZ9T'eF-chm\\8ϗ>9…$ 4#v혣h;#x0ZhkK$a!HJn'x Y>uځmĝ.kKOLzSTeVxJ[  Ṛ~"|% #nno+\rbhF-en9`m=>~#G-]LLLp=֜pSNZiLe*J•mzdE:tg( x% YXXsKE\xXd*4*Sh[;͈kW843Nb&Z B4u 7Z.Xrn_<2,%Ir/&Њ4?_X"V3wc8HiYXC*Lie#Pފ;_4F:ʍTULdFbmۙ6$oS ˅ʓxaltZuJaf<199J"4NsƖS`|rV:y P}6.. _dR,c֠$-8{_gy≧iɭ6O ?( ) c+wۄ*}.شHU.1-$q#LPfق )@ %t^0TR.7p%~grWy]x^@I7l`˘zի1MK^ T6T)0@a[~0g1SE[ޜteIW<>g- C{H!f iwo ]°Ʌ gazzqŲ (IFefΕiQcKZak,,ӸG =XT*%nȨ>n%ƥ+$޷$d l"23BxĎGy1 1=kJEHt"TSbYG061NihՕUgwtw:2(3۸xeQwQ nW¡ؽ{/ف9,7T'asǗTΛ!6Ux^^%KS}sM`u>o7y_ϿwSNDiwJ|WB=R@$Jdv"S8{IpDڷmsE+L L o};o~g.ppk(niie$pxg@)leLN/$+CTaxxX##\0+ _9pO)oh6Xw~w1=5Eݹ-֡WIm6Yb]_[gd("“.k^]cS_"Z].U'(WenEmqad^Ͼ׿i2׽i~//yp=wdTht:DQlҔ$J)kQjk؞kF E`;رF?6eG[s=W8p G&ss;$L bK%S,C$٦m%(*,+t Kb;.a;Zi,"3^i+%,%bTi%!X਌N$.EEGYE5ccK˼N–aV!,!0*(mij_/|>2SIűaҨS/ĉN@r)>3m,G~ҝE>x ~ckq2Rtcn7SS=2BW(鴺eiZDu,/,gn<ēwiLe]^YX,>bs.-77+g{Nj///j78xpR'?o<:.A+Nu5k @Hz,'"1v?#oEJj,cG.(?fswb0ϜO?2x^f:^="ip&a{+Ryr} Q4m:KEwsa ,,, D{4MX'N~le=jM nlٶJ6aHe㺂nMj%&$8?NQ;mViFե69e66y-#X̾ݻ(W|Ξ:NPevnN4ی1nD/m378xӍ4֌(jkxqqyN3Z8a2 n嚑%M66(Rc8@&˿Hmmu9u$??˗I(]߉8aźzl7ԷRKag%]YxTTv@IT+2 ,NI4L+)dӻk_sOs_~[1um|?Ñcxۈ$QHk!ٖmqE[|82`@d})(Տ"C  =Eּo;Pkk+xk_J"V]-$V[…N" 놤q=4EZfdYy[;=qg?iy޴wE6cXRbq!h%- ٳY^^bt|VWIS,K~ޥAM\Evxn@H% Th7'Yq4l/@ 4MpQ;gy"VFi>䫻1?oWJog/W"{ǭЍyKl,rw0kP Qr]wPl'6”ɱ)6:QZbxxg}i۾4Ͷ{^."}Pc.b-$A ?QN~y2oFH+dlӍ:mwgYxmӘج4Mi[]aH7SV9sށ/L`bb jk)5 (~Un8`.FeQNJlc,O?N*y'ozzvCN:Χl߱o~ӛ" )-3~iY +g+q#t:-w0<2J R,& z22`K*BTǸ|02>E j؞ iSO܉ij6Z:q!˱]+FO̵B tb`W@%(3:W& [}9n>p :Up%X 0AC$Ix/7팍BKT[qy8&Inv;˿Le]fnvNjmqKUJ(H9_ 7K:N?O,˨V9F m9q_-o}kni?(}:-Wot#? ϝdO;g TP_[^'<+&h[I1] 7f[)}/_|+NO[dԹ<q)?>W_ŝmscgr V}*Q}'5m:ߴ||4Cvwٵc'iZH77Y^^u]ob=$lǔ7aTQ.ٷeXkP,,XDaLet:-Ivxn!TF <\uZ :k.8v^TPrl sR¶myk^wfwէ1_JsZ 7LDrB95B/K)T)xg'СC oŒ^Z9B|,QGȏѓJ\P4FFG(s߱T9]i E{tuX؃kt:b$ΐMel S_" ζM^_e<82>D7 )$9T'ħ> Jٹk7oR(J_1)aM-}ݶ|c>sL}[M0Z>,":-IzKfmulTJ !St-Kqf-|lE3է8D^,`*n!@Hai4k74iw߰tB* †yO?g푙zI&cVs>vQ Oatz'R񊸁˙ǩ U[+؞C&x2E¦^oR!eL1m ;t0OK}.sֺRHץT*sQAxq (>[;E18k|ׯ,LvK }n}v"I4",mQ-yUUU`efsOď1Y_1xsW%)Ru]م~s*ҼU,S{[Bnvmb]~~#io{;u.PB9xNW?`C;~5ӳ7+Mtyxʥ"i$QMfYwϡ<29%Y@˱ȺI7$l5*bIK^9je6q7o Rjk+I,U(? Kkطw4[֙DIIRf!]@Ҡ<\  mZmͳѪC*{_Ek3:`3,z_K7ݙez>$gTxqYA1c}F1Mof8OڋӒR+/w~y'X^^ҥ VTeqo;:2J5!=v@i5/VZ8KA 7BdIitKX9%CdqBk&{>:Pi *EMhQ'$lǰ q,,dU(M0{$ S_w%85JA⎖XXRʹK4;mqS8 il4 AP gTn A= u}%O}}sX=ˍodmuFٿawD*"SdWu5Eykpl 5H`W$9Մ >z߯h4Moz3"(Ɠ]􊡩pBX+%>GMva[QtoiQ>R! 5#T1:r$mR ul,۰,8R l*B eȳ#\FX@[( Ryq\c,X{,KMwD dV.Kһ^R30{O?^`{>F];稔$6B?]8$hCYո|"vFAs_(2\r|2bjwR.DQ-w߇ԯ,l&WYR E8"nwH`dr !+KKDaد9^7w)[)J`"6=ï]/ꂿ%5T2ad4xZyVNܷ|vm(Jb_*\W }[N!O~ KZ%ʽ܋6QE03.V>}Q Sڥm\ /~5X=t0ʞ,Ǟ)ߒ(4bzwhl-o~T.>n&Ҷ)~~/KdYڷECn6:*'*RbX[JCRbCiَ8Zz}L-;CZE3DiIjxUI@!IX3Y?)KR2OyZLadm uJG>Cm 5"m^;uvD*5Μf# q]BҐj Fʔ &Fܩ3=7 Œ'Ϟc23ssFqxxmla3:2N+ vx$Qk"\ǡ\,.[NWQW^_ `GtȬWx~_?I5.H ɒ˖OINc7 Bx6BZ:/p,R 6<fl-T,͹] HJeb8 ԧȱ,aLO =Icygϣ'zW{{?):zZZi|3nv*VW5*tVꔊզhPp=\e[/=Av:l:4: ^xE\ZZ *B}tO=4XnctdɱQ,ۡYo0r] :ʇ>cb)Lfy3eD-/4J:HBtKgaenZM44G>A>]y722ۿ32^OGNωbcg2A$5}ees+)^6i˿KJ)-!$x=,)Rsa*}Wczz=w$_|_|Zml5^ciou3aZ \!c,ǣX(t:؞Bd!$at$qTFEH-,ȰsJ m>Y"dQDed*EtBmij=T)k '};r'IW\X@G]._@erV;妛o*143SX98ďahdSOY^3wbh~/}KLTXl\_a… X;y";oӌnʢZ.s1bISEP h6rc#Ma%mْrJfXœO>SO>ŻYnAsQÏ[> _z B@o$TP@Ѝ#:Q׳,V譅i '־p`t,5u1T54Iofiq?J:2ʉ_o4~ 'PDjM&2 2ByŁʿMWV&^W*XXXgƲ,nVwknʕ *ۈe' ַ+ Y5/mC ȟ2Z0{o8h ۮG Mj8"Kؾ%%F ?(v4 \m{f' JEJ*nl,iN}e%%:KGWf `c睗vŝ$M=,Sq^QG>2uaitHe1c*+ E _ĎmӬ,-sbek&ff$TK˸ h-O7IvBmuQVV-Mm.>hi i7p:_g!F9u,\=S/s%zIB 8W.೟ c|w=aw H~u:}|1~E~}Uٞυ}1ZuPPnePt.eZ|<5%%ccx_;÷|˷O~7>vrNgpɟv*os7C #*!eq8V&qs0eB rwZedڼOAPs,#(+}׽q]LMSу!wi-<|o_gΜ7vl6VjfcMv N67r (eʅl ():YAҒV XMgiuvSȗ*se(IDe|ch%1&0(mcV\U*'gEE#br&vC,fkQX7 %*e癜{ s+KuZ'CE1b/}j& \6>L.IA}{`>''ScLLOѨi5)-oέRҾ UjZ&-mFrGݻWϽ%X~)o4MsSϷ,VW386%)<us\wux;XX[~iC܎w}{ qwǓ??Xx؉IIur~ x*E Th6ʯ ;MQgzz7-w ^:BΝQE'gpeҤ7u lcCuy?mKVx By*$Lϣh8 &Of|b7dd"s ũ3<ʩ̈́=M< ^*:fg K-zHݕ"d,Zx|wϑ#"B(bǎ]ݷ8N 6"?lI5 VrMgm _f('-jmǶ,E|^x'|o?ǿ喛ndrJI-TCe6!9(e!0R\Wc_ W=o| SSL -c*{z㠧)=ExA`A-l~7PJ1>>wFCl݄F 3&t8mh0I9f B nV0…K3<{}l߾bFI^G箬j۶RdܳrMZgxGygh e;︓]vS*Vk=R0;vOqy,X,񶷽x-fx|!-Ξ9MDr1\${ #KlH)˶H\G)E"Yiw)TJ`YxkkMnk۾[( >RX(l,6cFf:;)H:c<Ze>9SB%@ZzKFaJK, &oT.Z?uR`+0>?o,1FyxŅ\WQߨ1^,Q6J#uFFexh;sZ{j]$c04R+yꩧioy{ct]iz0A?J&ϥ8ʊ.,(2l} Nc{Ϊ7;7Mq$[F45gsN;ʥyyg( 391(B@P%Ij6v;,.-*q.ܹ}0??OP=J cZ+d/Hh#ǿ̧?g,//csQM#s4Fb!,eBh}$ïX]Ƕm@kc~PVkdYm|%)r[8h :x B٨5ﰦw_r%FZOƦrhi}q7%dR2B/.K+ǘ si7:GRbEcpJWK4-G,Ck<[p.7[؈CVjKaG23VXfdf$N\Qq=6W3ߵn$V3::8GL&JEFh"SNghvHc8@Uf:"_ v[[GsjqGt1@cYސLNMQ.aϞ=Zm:0ܠǑqz9MRZR.WS㠅BX~},R* 1XNGo##cܱ˶t$J) B59"SI߹mL.p\$! h1Q)59qҙy-~p\,5EĒ6Qb[R>nmْ$ae99q*u.,/6VZLLM66hSAwP5}<~Bf[R.< ~-y \| 7r YATP]3'q-ICuЬ0YGwH2;iEQ11=K U}Μ;ޝ8},3vV1^)SF.ѣǸ5E)MPƶ-ZJbÇyy;y]ytߍʯHAkErdij,^ +h ;E Z0[qL PVlW6uR*o+}t]'|4MrW5˲ml.˲Lx/7JF)R"YnR}nEII"(fhnקiʹ gvZdʘy~Th4W:n)-%qa;%;ʉTFG a0*2JvKHsI aK]NwM뒤)X)͘iYs IMeyFl2ϜfﮃTFoQFb+4HVynTa.sҕy0z<4MK:0hpqÇEiŶm;طQ "Bv2P趺8_Ūe$jjjlc4e~"G,\ab#lH?Nyzvm=w/P͂Iڝ6A@JYuc|f8o/=J¥Khngn&s/094,ϜQ_c}2P8qRt[uJe0 %TGZ|/7~3~o= Y2'OP]q|;0*3D?2dfR/SB|r6 5hismV_iW3Qb{_yJu{l0l/wϮczMNVj P7A%.e_ ^R @ΙyCjW Jﺟob||):*K%ISvDwd:/`&O@ ׶1Qv[i{,/ ~PD B:c; GD%^mК( qâϲ,KJSR8GB#vIVj"pT "Fť:? uH|Dg3D-xT{ť`!PB,Kl86_eYDmYB e}ymc=iϫn{KRWf.\^@+e<{^d397%(-ې+K}})1=/N5vY[nԨV**rc_ehvNMqTgci8kLNO}..?OX<ȾndiyW/U8"Mren5ODRNFk}˟%SMX0Yޕdc|e̶lׄG(PYb:,r]@٢n&1MȲ wɒ$aHxH*s~wv^'vX(+v˻nv0 "1!҂*Ct !L綌i[BAE(y^+n!W=^&βFAQ&fꍖjW[Fd!6Gc!M`ȝ;ti"}3e8QXV[gAA$ARq(W./B!M mEX8gFd)Y7 3Zͮqb@IeHqB܉b;1;NBX &,c6QQ˱Piax,KLů6ka gB];癛G DV PBI©cdQiԸr"bnP`tCA?S8{8iM\@[6fj];q 6>T4MΜ8bf'mbDit2Q6㓓U90 t&}׶mN:ŕ˗y^GE[$MصsbFŭ?%I3 ]NEud$MnInQJ}k-+VAJW)j%Uos ^yޒILL2ŪAAKo 77o%;YĀ7<@->،$i{:Cc古q,S' 4K3gm?3vU[ :Mh"]n8 NBԄf43DIlh TuUuJǻuqYE.<}>>[oh!ha]@` Qheg:)a |CPpB;|lq_8ocQO2u=km #{aBC E]0Q۶6K.Ҳ,6^d#j:=98>EQ>vΦxK!3$Ʊf)^'0RU5/|x?LH,Xʪ$p}fgR{.e)ګQ0|E`tXaQ3`0ؾ7ZD[p8evMRt]fYJ&3ܠIj6Y6FY\Z"R6ɠ\\.ݸJ`qin.G{w?U⸉A6-j4u,dI޸ѐ͝ml%y^`;>m/]VVX[[5n~ReWߣ_4~:zE*̨1Rsvԩɘ]#zl8uv7tȉ3Zy>|;N>wJ~oo(J*Nv&qʎA՘Kh` _C?_x{ y^ՌLRI*EQENyA](Ox9ʶ\C9Қ0NRsp_8Kl2AX\QmQ k$B,2*v}\ϰ>:>(+550 E'X:g2uئ4mob3.\|GJ_smnxӉbl2kynd:ǼwڸdǞcR1I7Z nz^dnptD7kZp:w7QkVB/|0~ su]O},K'%!@ST'{㿯SVU X&Og K=-p<qM0RFcu{'Ą³]Ĝ_=^j]c9*+" qFJ,Ʋlp\NgXf)ռs؎*)q]aUMضJT-q,%|yIV =Ȳ?` ෞH*|qa5sp/.|- J@W #vll]\č'2%i5 ]J0<اwhb F@;%??N|)mJYr滬/晧rYhQȒBeV/k'iqO8xI믿kOe<CJ;Kk*GHmmS#o6}޹6=~d15.sc cNjU91qzr$\T([c)`BSxywm^~%>|d2=ѳٶlL7|;.ԖKDmV괣sld) IB:Dĩ؞JD1ȯ#mh84Ob¡,Lt0](21J|ק(l6"jȋҠɕF+r,A`<Pz3u\,Ǚ \F'aMgek[iW[t0e}2GG; xzRrTRrMeW.^B{>xRmyMVx㍷ji:a:=֖V*p ns]lM1՘q d,Ͱ*h]9q3dfҿz-QFo1 Vח--3Nq0e8v㘥h|OpI˧ffیcf4`>[ y!Fs]]q gyL.8﫮yt}r~ygv]g._$g[3C믿ʿ7'N]!4ݻw_?,Rj0>QiMYVfcFu-pNotFܚ43Vm]k ]E86ܫA@W|UÑ;c/oAOŧe< #ʒq,p8_7ׯ_7-RYH8~ZZJKKW*6!tKUU1 N)>3bzD{\9?,P='ZǏ=\ѯ8sx=]7˿ko:vӯt.t()*M](Y+ 'PRޠ[Ŷ|"tEsUr#fIAH)9::~ si~x?'`žm&|-vOvooKqv6š5W<7@IIU*?'%3JYRixV="t0|*A*:TErčČeELT ( ~* ,BԚt&s\rp,^`TH Gx3O(  m̌?ܶpm(}FjRF"RfXa/#u]?$}yeRrÔE*•:6I^qo!o>"dG2c6F ^0+L \~ Y*D?H{qBv!z -aye)%'(ʕOC,4(j#Ƈ$#j`,S*1 ?(3y"pɜ⁏ƚK'?)+I>[6B0 (c'q*8ST3Zs]Zc_qRj>,:NѳY/NG&Jݲl o毓3q6_򯲿뺆Yﷸv?k̦3, FW * ,"Od%AKesd9&-fTY}\_UėىDE^m9TuEY'6۶ &"ضd:s45F~aStQm,N=˿l=jda͈!͵l~-!׮_g6㻂8( cd&iH&VnbkhueM j hK+oq1VB ,-2Ma C6?Ǎ7{a#`;oѵ[J/+KcJ,~57.?ǧ>LS:YQz>EY5h_1D>/O{;&o1>L(qO4\ GG9S/8G=[`NQ?yv|\X>)y>"ce,KϿW'RR5;,//~OSog$֯YQ33+RJVJ)|F#ӂ`mhB!4[˫ >w"€l5aaAɲ 4-eOۡR^86GC)v6Q ВO|JcYFg{{ܹs}sT ͈ulU9Mq(iF#ZTu}FŲpR\sṡǿ?->n>?Qs(>:?g٤Y,Ҍ8jtBTDtҹiُBxTDtӚ^Ϭ,Ǯ$S ?>k׸pnҨ%@i 5iRJ9tk7?X)7F:λVӱٖEb91~hplGC&G6e%plA^VVXAm "I'dFP碔, DZ(Km ;ԚF#6FVtMUR$l:~OA0Yܠz.A* €OT?؂}"Wf{?$H9Zפi^Z>?˟W^{=&#84Ugau,ֺLC4@V̸umڭfMl !Bd6smF)f\I4flDln<6QL(e&a ߀έ=EQL sԜ$0 G<3&B>ćҏҜpN,˘cD<,,J欠5ٰғB!*ùTyN]קΩGOe NRBUI~ ~?KD`:e)B+h%^|NR*ʪʕ,--EͿ]$WfTeFLijb\%@+jQ#vPRRs˯5y8gB u2Ff/)x!RtD^ؖ=רә2Ӏg@1b(>UYbOêtͻǧ?12-v;C?8I-tIlt:\Z9Ul'ص&207#CeO~YQ*:kخpONJAyODŽIb6tZcͽ^5F>EUctшTD[/.OQ2Nog.^Ň|ZeguX'oKqkgoA}uScP`&sN:ӯy z.NQs-86Bd$F¥'22/~KԴ $"#VzZ65&!.hmvq,hGs;4―PťE-~t4@dTbw~ |<sռ;]~1{R}F叨O;3Z&U֛'2sRZ+-L@i9e JJ*)Ju}ypk66RE!q](INf1<+( (<r>eŔZ*7l4 "ˌjvA)d!Fa`wt:GTR"l繦Yb.)ʒZ]:` lmhwpH=`{/y/~e[|{ZbG!E&)mz݅, =tUkt 0*$qNY\hpN9W}(֏yo5pxxߧh$ `UE)+,7otQfqI:!wǶz,}"jxL-K "\ۢ2|Gq\/Q`t0@ eN!Gf/ʢ oslF=GWuDFZ +\FP4>Cߣ jDZOo>ۥ\}/ M6i5,ǡ^GB6xyQF K Y8Y|"m̍V(pR? &gt;W`Xԕ2E J45.EYYymsxtifIӟa3PRӈb>G쏳zw=h31ei*'l \bҔG4}:S`ŅEOBEu,,wq}eYGy^}vs~gn|TXϯ5O?ӐL׏w|7H}jEJMJ#,˒;&_q#¸ &ӣ-F]l56j8FX8&OgTs臄Q%±gYx &Ϩ-MH,Ķ-y8nǞYNJX[&@biTXu̴8W>](N4F ]kĄat2(ӑ[J؞K̦3Tx*ˆp4G< jq=\LO9i$PW@E \lWP3dGRR`kqٽ{F&*vxEٸsr:be<18Lq>yY12lK0IZ,_`f~$oCͷ,*Yʒ0ɋkO=4<ߵ{Ʈmv"?b80d8lVQp4PT9f$Tpyi{RX+Tךؼ3 i8C<^}Uw/oN\GtV``BDy.A{|3?о)>D@k.sx=.\Zg{gY+47Qu4pw@CX̲"%9?!7#._̎ɗ~qin<Lυu;YNqĤg4nx6٬Et 7W ?(r hQ,+% Ud1.#Th ֞XvvJ1Mi'1фȋLjwR~,%L&XSf kLU[5[vʻߦ5<*',9˫deFquY6u9:<|,,E>R+AbAwOu|mK#}%,FE;x~g?HVJNJIvvtت59w/01L08W.ONGiE#IZ#sy;7<9Mn{ky'71e0p0'qrg6RYI@Y,i[41JdyIm찉凤ipMta2.A.٬j4ȋZQ)aQҌzq4:MT|d.JVl֊8Ȋ C-VZbپ,BiJDTYA% 00)U!Cl2A5Z0B+&Q(%$&Q@)tXje+=,Q$%Gؕ$7y萶vq'qll4"'teMv䩤 Uဲd1TJF %N3JPYŭw:$^[K$ic=fYJQ,-Jxf4९K}_A@`|8&]^2t;?at*" /o0tY7F!i^0.xd!CXF0M$Q_Ac=< %IM|qq++_eumJ>bs)R$I³>ݻw<e=}Uig"GZYuN E^3?җ~wҺfs??=Rw Ð$Ih6,--saW^s=9|O}||]uf,u)#2ea-,bV'|p5UҢ60287| Zm)Qdj88#NYI¶ѵ*sz*ZUQs M W:7Rc \5c<"3Ч*rcu\DŽcHLJ K`lJǠeӶ$IWW"s*YsSRg$wpH4[ Qh ] =}ۯyPeIU,P䈢Sxwŧ*\"ii4ZTTl=xH7lBҥK<7$Jbdu[,+MHde;;+n{!j(3I*Ë~KYZZwpL>c-Y~vġtb01Oh r9::g=養"':.UU>uN>~bcuf#?Y~w7gaqq -18ЏYw]eYm1xwr&h.,ɏG[qYY^gիW)bA׵rjt)+e|fU,+ĉNȬ(Gld6! CB<\"f³i$t>RV_ɘFMj-=n!~Hgleme1(CQh4"ixit0R/p,RKFL:KSEژ+ 9rR'rcz2*|?$Fx0xc:.JXܽKj$ĵ,ȥiR)4"Kp4e0ҌLFSJʜB#xԴ6E)~0?7ǿ^5׶(b*{<|ߧ5 !y3OY\X xop,=VBV:mMfZH<2n!^auTï?ޑ)}oz<쳼|_W_sc}4_!dμSZ} fE$|lh4<鼎О~?AHOO9J%E'x7IHӧqڶ'LCƻw)&j]RJt@U^уDxXB[0( }y(DT9ABm4[ KS37j* lƃe 55eQ!+e"|ψQF[3ȪD۲ 9tYpB%U]#lӆRVS6xLp?q->m~?s?'_غ߄aCql6ug6Ep8ޛ'ɟd-ݻ믿 ȈL99kMhb:5Ej6"$&~k׮O~??kRZ<0J%*ázˋEG~cKx8Lӌ`;o")јlm pjN%҈[io<}ŵm,یQP%{h[ɳ)X)KQ(]fٴ:| \O L\)bmжmf` 㹸b0E>66y"0JP u}q'Q5_ʗkK*UP~hQ2VV)5WSB1ɏؿGgq<0 UbDDvHU[-v6Y]Z1jk 9aOxaB4"m,e{s$i_|7g>,^@UnnQQ3'_ /$nXQD:}V/bp@&$AXQ3& yjJxxހel bd5V t-Yb'_*+K&/OV(WW $p)be?;W^G歷4A;%Ms8sq\C[~Q g8!$p"v,px6v*Ņ Yj5]=,UtAKZE: lmorڽ g: +Ik4G1<[M: TZP)EUQ ܁4fqieWN nlyF Ey4ARV)-vH1n+u<~7^?}(0>ƽ{w}/w%U+˗O9Rx|޾#޿>~~o *%,*]jDzCҤEÛmĹwJ*"I|3?h4ѣK/qm& Zh,_by8>ʒ7;l߻U2M݀VҠpIKx%YXZbZP ̈NGkK^TU m 'Բpoݭ,Z{{;DqDGmmjFLf3 K|{+W tL%4[mC 4U^ ._5nR6}W@݋Ʊ,,Q֐ ,kƣMʲ*͞%!_% "IENXGWH-|;??'mkHYׯ)fDžҥ|矧h)*_(2viceQѧEsasӲmYܽ{~,KG"R./r\ \n o}7b7@5>a{Դzmjm̋/#G IE۲loﱽ|@YxW~}[:d &VJ r֯hKv<pQȲ( IqQzuʙ2E%2g GJeUzU燎'4FOQ}yjYRHyzQTJ&_/?C,PZRQUIjJmQmЊط6S_[L&SKbsSb:c.^pci.B#8!iv(f|Rdae9YV`{h׎rوQZDIDQXB`I}خEӤ2〮UݟB\l j-AԸMf v&jI:648D']?H'gGãUP$b6N,Fx9(j.EQv DZ,x.k ,Rb?Q6%Eia[dYdE:* ;-ڬK 5]yc䓴zGIu<++l!(J6Qe.@(2|(kt<#{jm!˜ Jk0r qVO`{wU^ vp v-ll( E/]m^l:^R LGYʅu AII$fumFKbt6q}"iH%IZm^\tF q8 wn`%eU:5y0. /Jl"IsTF[[ba?͛>^LeM87yqyCS=-Z$V aq}^{5.\㸔U5cI N\|\ ]ЁvB!_w`x4@!,8˦cyEz>Ʉ0ɳ7tg3dU1 뱿cV\4*%籿Ou0lmE嘯AnX͍MٔF32R_Zv,λ{!eg)>(*5g0g3dJ]K\[PS__|vHӌ퍇خ/`Y6EYaו*CUN0&Yx^&CiH󔽃].^0 Y/^j|㥗t:cyy-˲²3Si u5b:(eTfe:(o|, Ptlkkh-y饗t:&(BjAZ KkDX+hFDQK:0v-ll$X@tl*90̦4Mc22{#+1Jj, u {1O)ˌ,M4%c@hA`+e8c`a`5EIs8^0f4Ͽ{^ x"p=Dimҥ:h6UySW'X$O|$k}?E%M+yS XKhb[}0&i88#JyY,¶kҴDi&JB=BV,͐eI6Iv*yq`uLؾE`G C%&*1 RiClc[(m@㠴qm 'ȳa-ĩT=U`|ѵTUnM``eklWw$!kSU9t EZI3`wdL1R԰BKz< G`4d{ckxKȺFXI#"KQTKsxO$qTJbaz.&y^+V6.;Tӊ]\e6O-ˊ`0LÄfg}C&1gqmFxxիWiؖ؎Ci7׾lMDhᠵCfI.j|? 4pm̈́(j$1ݥ͗f6r%dY͙fA|}Ȩ~++TF(ZɊeUc1EQ1kU"}UZl6wT eDadiZNY,_^cr8f#u}4r(uM1 XeaTkvb{eam%j( (4(]3 q|VI6JNxQ, Ñ\/,JU!JTP._c}jUImz KntIjP@+X]]'""LT׾3O 4N)sJ`QL\^N|~(KQU#B-) E>K l )uM4nz!ѵGCX[]AWJkJ:`Q!e_u&ׯ]gRDq뺼t V@r./-QT9}%W%WZ ܈Vg%,KSSͽ[q曼kv|Vh0,A*dY$IuGqpHUX xxYC{o9,RkCe熀E:0[qao 4ϐ s]f|h͹8IfBJ,lFF<%$/s8A9EaRZ0]dmsf)TA,MCPy/.dzg ٫>[osd |8F)M${f_k&1zW.\ hŔEGtYY]'|f29Ķ]#Vf6m"dxܽ{ F^ٻtaN5텕yQ4Fkk̦4cyvF[ФEAi.lADlqE&ntd6cppxO3<8B.)2ͩ2Xh܀ˊR  ls!k֑4۪daGg|+Ë/|(PUZSU~4ywYYFjA!-Bl/Tm#{RJR Uiʬ`o!|51p=,/hlCNtmF x1_2ܿur㙫dmfPKԈJ!`| )Q+s ,MT)˱PV DI)QךF0>kIDa"Cn& rHgSj3ײٖE]|β44 VjϹkpRyOX[won{E": ,7>yB8>+kd"52奢r=ڝ."HUS)xܼu>" JT B]Xa0QR;dBfߏN -%,QUpcM{mZaa&`7Cʢ ]++t{mnQRÛxS.^yAZhT5Qz V֖Uqol d-LOU[S,`DNPMX,v(txy4K"A1v]̗FeZJrwlHY!l us#˱7-[O<3o ޿yZV)4p?DDODI:$qVq8])2-!5A08`Y 2=Ķ\l7U{>y^vwn?p8C՜hEܻ{sAiv kܶZ.6;8K~`0%lGQ͘~*[XOJ 4{d ר-5\9ђl!*J{g!4c01z*c A{6/J*%i;5`;a<ڥ-4.kTTJP(A~E.!RYd&WɕEo%,DzJ-E%s,QUGoM{ܽ7}n-C"% Kč$d:ayF$kğ$dgc ;mfә)f2>ރ{l>b8<إj3h(i6j>۔Ux:`2q ZJ(]D MSZ&ؘqlYIj~J@'YSۢwTO`xg't? + i2?D Y9ȣ l\jIYrI>Bi&fDe#les촴ij\Vx>]Q?"[[: \X^P>Ve9Hmq#~eY+רulIb<X;#vwi4[dYFdF0K'dYEp}îou[XZ0M h|H;iOkolwhv;1X\Zd6iwi$=&x+WrIHtp\,Y׶ͨ`ؖe&PZXu]H4-*iV<U| jy-kЪF QLnjL wo2$UaF4Ϩep\`qH(u365Cc5X ?Y\(U p%I,G5"EF*Ӎf9y^IgC|?D!>TRU%`XwgJܽAp< }^DZ1M`m2X98أ,$v[,2:QkWFՊܢny$_OJM|x`~͔4/i#t`Ќ[p";;tz=,cpt$kH6`K.CK^Wq-; 40q=< 0<2A=_* ,lJ޻Ept yEM iuC3eY`0:8fuq"vt.Ujѐe ㇡}TINh%S6EUT86Nn&fg4C$F3h1O]{P['l{a uȋd4EM1H lǦa9Ux4q+ } W\|4fwvGءI3WNvYQtL hxt:Mf6;[C}cruTcD ܿ˵aay FH踌$9K2Nj/~/pl}nsq}{z ;]6$QB^ضeQbҁ]ePe*1$e9c2RkI'SF!6$1~a96<$Seh~5 ܀1ј(X Cla1 i6;{=pW/."ױBt[X/*V{ІQd8"B 6οKF,ီY\/e7ob= lD >= p(hLHŧ|٬r 3R8!Id Rxv9G5RBw|H8 c!9P9mhlF]62_" zS$QH$$MrLoP ٌ4wAw6g E ؛yrdWt톫kNo)]zk>mW=kDlgM(czӛp}uJ3SW[65QfER rڦ7&{}mn<}63|KH0mZ&=&{L{^'QHf*+`ڲcLYbλ{rjӚhDx}6f[21UISWa|1'iXģI(O AU5"$  &#W+I]F3}Ĝ1Y f !R `Ht}Ov .k$yۧ8oAhW̎3 ġD0g]I^쯾կOK/^>nW{Dq ei)+'l3=T\_^sqv}ASW$MIv[sT^\i;'#f9o1 ;|xu{G\]]0NpBP ./ٟN)uPmJn8%Pf@0Y,fD2`<R]38:_^/`ts09''L9?{2BȐ' 8>8˫K,I`-p||`8d҈*Ӕ7u?x@^$JrxGhͶdƂ8J_ѵem& 像"E?Oֶ'CbdY>Db8x1M ǣ }Ef8;{xoDSd<_uԧ쒢W5M-qH Bdi5Y麖 5 ΁g 7M vbZAHk[q|{fA0^[#^}V6 Y9(Tw&_+ b6Pwb>/3(4uYǩłhH]цS7a€EY&g ")RUr c;<ۙZk4i;>:>/T> n?QwdYpotGĴ}’g>.-r5gH󔲪H␮>98894-rtzC Jc˕7; c`+$$kZm "F׺IӚ dEǼ{3SnM?|T|/2e]YD$:do KS] K~|xJ]5diΓO7F̮INknZohkcV>:p4ea~=WЦB qb5aҷ-o5(Ef zf4P7%uVqYNRqݓ,g~yr09`^%H5c5Qrt|r~̓py>kV%W+Fƽ7ɳIx-1tmޔ(A _Ȕ ٖ+6ՌH8:8+.gO|O麎_ b$k;EADaʠȐp,͘[Z Et:aEL{@v5y>`<3ݟԵS :k{=; ׋%f;:7Лwn3 k̮8'6iyNg̯FSnKZ %IR} 4^QTO22b.$WއIzI>1@EFIWazz[=ơ ]*У ^^1'IVTeY 5[>k؜+o+7Ow2 &1'N N?o{4gh-:Fň'tk8yHӶ,ίXO<"/خ7MIS5lQsy9c|$)٠z`\7Iq|(8{%٠ Ehp8уX3V%y0̮QH |mfW|E+B)Y qV ea9_)rp24e:MR׿_wY.M#T 4r-/yAZZ8uJ` Q3Qha8'&8,w#ء$UYG M]Z-I,┦ηA%g4}_xiiGW7K"%T8hpRE Ċ<;](n{bz!I}ĖPmkھA(/EC*\爓H|`Ѷ$*$* >3?~领{SP$)4dYsx|Wxwy[w"E ܾKmi˚_{b8sF1br1}۞lN68 I2 .Οrջm !iQuqzl^.(!˫k7H i]3[/Y/<{#fW4w8!_J5Tu䀮+ LPDqƓZ{ݣ'|hK:q3„ KvєMMpik-IR &B7=eUvuZ!.;,$jCskI|)'^~7xׯ2;1>~BHnrN 5E!?"1XmJ""4v!sy݌Msz;$kb8!/2Tfҗl~M1J)o)k!'GNӌCP,[fX>I1yQ&!ْڒeRAdjEHnYGӴ 2 ubDהzxP\]1!e,$NNo35w_E"v"TP7Isux).}Ľ7ٳGLXVDЌ&9v)}|k7yٮ7W;K6=yN>ft~p:!rzrziڊAQ__f36|'S8^iX&b4". և*$PJ<0Ғ9my1Xжo= ybh:&0fK۶ I]o)&8hs`Eѽ ho k;WbP*8qa?]- ޽{FSWo=i>E, kn߾G֫],H€! =xce+r]d{ԋA}UD-XK<0!ִe~=mZa+&yx2ꂲ0AIGfooB/8U{[8nJuE߶hrzrղFcg4mIi$M!f\`M@ܺsr]0>,е-qt[oG FwqD( mmx2 '`{V-B*(_ذrʒG?_)M-.3,gWܺsbfYh^y5TWi7*'R`z-Y-6ܼ;8Z,xbquAla3^y lB(uxr}zEqAo8X R7%{dQ`@24eC]4 &L"tw *H8DӫK{EIh jEI,sL}WEmO27-wHktt] !=q'???;,k}ރ[GYkm fIe;deTH1~UUBV?|̝ۧ<9Nmq6t޲-ɳG7 a޹M6h];_{[7Oxջ$QsNNNIE]$qB lJa$JPU#I"n|.K;CH&\\rAŤaL^ Om|]z[Zrq[y)qR!eAŜ/¯P9>d4 V#T[Mh[G1) sJ3c~v|6ٳ9:wJb,kl0˖mb$Sd0%@kC۶^A$K8`\y K6] m)T|W(m)٧Pοկ|/]Ug=S7W|Ob;Kz㈪\ ,$qB>i>1eP *dSmȓƼY[#QlyMƻ m;Ͽ1$~K\V0\5 k{Sn>]mȆKMZ Ŝ$W_x@'썦4r35Y15m+&1fͶ!7Oypc6-xyliݛ'οRH҈|NMҤ`- \k8<9⚛n#Dx̭Wg9=F w&MoR)5y6/}VPۀ_/!pc||]]3=gz8OQ"#p0 zCݥ\~{,+D2q~~s$qi65dkж(')Ϟ ClDY.}rPc5_M"kn3=%|ra98:ElM֑ }ף(@I/ǠduPeuG۔ OZu'ޫ֥~AH)7[ڮ&LSo/+rCcp!B֫% gv0 އ(Ba 96? ޽{|M}y8sJDg2̉F?՝?)!r'dT!w_=jΫ7_a`jh4&#gW :tBΞ>lb?&n!JB,6w5 # ˚iL)dG4Mv9XE $OlҏSTєRO4QG_~w!ꍿ~L30 &6m a2 UY^/ p\\_S QN`%Z75yRP5mӴ :No`AXE7X iCj̖sy4Op ]Qd#|Pд=]2, Tbz`8bٰ0e8` YNSU>,Y-MS-8qD myRF T!JF҄#<~F6\Z./Ι.(IY-LFGUW?GX-*bE:]q={`0a8\,0͘M0g>[0_qtW1ϽA|;DX1L'8!n5 ]|/W;9Fkp0ao\F $ʉã4KmKY.ؖ n咶o3B A׷IBY~ξ\ȇ*Z:hmeRMJڲk|%(K@[Z{Q$IZ([ھ5H*㓰ޢ{3ރ `/^$mxBs^:*8 s_ܻw|*֟T~F&%خg4>GϟՊvMhoNJs9ɋ⊾  Ӕ\Q"Y/Y"8Gi=}FW`s1?f^s+H1;o:?`!upz&YV ל%N3zKń lюU]羈5;BfWc"}5ф%UU^躖#6koff9_PW-QR57-kl6WKܵԍFWlʲJNXlT5N@12(X{xlA/pZM׶`4MS/pRn<-q0Y/TpdyAf^ZKU5DI1lƠDDŘiCF[FRx`@JAY\J &U|×W/{|?eWÂ(N)фzGçE`:f)B(B[N[ub~ly,ܹ{E>!$tB1l[& ֜=}w_}(}Pߵk5Ř,0wc ۵5pMM"UI׵XC4m kyOu0"@IE{xSPai[&gó8wEw慾R;-\f%N":T?-޿1!#o GSW8JHtȰnBl˗| sH%]W~S?'gF8XoMOnn7kۆ)g+"Q^֌'N`7&ENiMFtmG(XJ(=ISp-w_˦Z|NkOư7AJgY$i2db>8G]Xzj׵4zt㎶>I]p6f ==k[B%)`|$+ XmoO$\_hrMVl7^ku}yE1*@ ؖ+<%vޔ7&<}V/98:,vqW.x쌯W):rl6#p0O ILXP8fBH>&I8jo$EM[(YVXgH>߽,,ɘ|IK/nӶ5JI(gƇcq]7Y,! !+6%Jb8bDaLUnvi9P JEIC9> &cٮ!3aq|jQ$PhbI1ȩ YA"FƂ,[ hCi;>aijR޼۶]R1*<5ɋk5Q mقt$Q)!’gcًf2q\/PRktαK1;<ÒD17GFGE)QF*")YT^N MOX+mp2mq'ƧwOQu=1tXk;;ԭS,h,?ӷZT{H!YV=!.!k֋5y?׀dQmۚ#ќ3uH L{DdXP̳aDuWa8ڬ]1B4.0V8fb[zU`>f㨪4{8F*849Q\^3_1M؟ܠnXsqq'(sz/໾ILSK6%Ώuј4NEp0 K%NDȀa6f;_ v/JMLUd`2YdE^- `JYW}K]{B@wP u_՚^w`eU1=8"VctO8%QzΓqCTBךzFWEhoDݔTychii6v%㯅X؏ "g2Ѵ[^w_c6ct osttLe8qzDJM"ެ;<m+L6 b6 9{c-Y6dP$2u)BvKܾ&8ج6>%7mMnLfSt@D^#8~AH0q$i N{^(MߵY10A p#JѻCD-rkٗ߳/:0ӄ{Fn Q k@ AݒRϛ%}Yg$iFlQqaZ]Vzix˻[yuΛ vQ ;w ևoַ_ s GbMլAL_$WK.^2 e]^mLl̮fdY=f}Pn3hʚ<㔣cD(imϳGϹx,]2N)[ږ<-7l eUcbdXך// 2k:kg;XZwۊlh{JSoC:zC$dِo.7AHpd}+]+wn3NXoK`4ɈW_pg,36BX0Mllk= F,wn8<=!O3$oYݒCΧ V, edoJT)[m?;GS7/l˒,P+OH5}zpD$B*nѺCI5nc ۺm;pk:cnW:^^p8IycqVfxL&#"] ,\=cgQ*h{J Mִy>7^s1Ld9,E1^ED1I RJL;o mz֋%\onm4'`:ƃmo>{\_>u]${ZsI]-c&#DžPzkTE t];bm%gg<~NmOoz)}ߓGֆz9W'Ak ;xcfO>[o?NOsY?Fzg]%9 ބke6qyu5q1G[b:p&mr~hn ,$i+IFQG\R l[*ITj5's9]Ӷu`:{ʲvagg$Y9dms%#Ai0ʺlѦNhMkIZ;lHbj7FmGkP"lf4ː2R [zF7O7PKfVd/-9"PaۋI4XgxƩDEO*[G(/g`*_X4Eݫ~?7'7D(=AX,nrS21 8>|jFy^HGʒh65qe |G1A|-vj]Ҵ5GejFb[nyYhl2=`]3ft] q8V(Ÿ srzOZއخ6Xk5 E?yG*֏ʪDŊX%mCdqA]Bbd,%EN}a\ީw2gGH*设[oBI~p4w|3ٞ yN()2|,X3Zc$51LY"ج+T# %Uv"MBN6v!iK0ݻԍjn0leMURLs"R ZXXEmPBLq̓'HyE5ĩJcLOϽ{c @g$aD,cg8<h K4G10'^/JkaI +ETI1ze dLj쐪q$} w4M"4`,MǑ8JNH7Q a@¯&‚ w^ګ[zWH1&$Ib0Nb$AwE;C٬|a>-\" u><?MD>BH-0j8'v.4M}p< 4][AQ 2H YQ$"#bUqE MDkM]SOk/i21.t"c?r9Gj'wmvm^v-fQ.ՅH~DeAw|Xhcj !p0 ;*K` H"$cʳ܍_OX-;^Xhq+,_e.,W6KN4"G 7sh85@JrE(-tƱ3ޙ΂6ffl&BMwwTۚfz{6-U]w#<:v@V5Z[@&O=jjDJ#c}\bO)jK/%]!T^YQ/@ )A P01㦙^[o W@R9`'$F{UB`01N=()'1]"v;^x 1i 2/S㻀߮HZx뭷?˴BnvӬnJ'q딶7}I7NOjѮg-I(N}[o;8XC:zM'm 89h$JH$ >BR3T՞B Z:mSCGڶm 4DjIIX ,_а8 i˅ $QRD8O8}WF2THcƢ(D^Ȁ(01ai +p;ݖ:(1Z("z]塞'v~pz9gYD̯UIbNn!DvÑdڶl7mQ2d$ˎYgX6-Y.sYqWʒ8kM J)4cYw=^V='{t]K u]_އ$1ypv̮8a EH~;Kn>? 8`5<_i>ws V&gB\Dg@(ٍ_>H(g hn7V:4GBu7, 8|aѴ9#J,Z/\(Mp$7_u B![oÉFP3TGoVvWv񻓎ܷ.h4z8[^EmWz16^P.fsF!ն%#шrC'(%;:f9syuM1b\PWwd8Y]-ƔuO'Ri9,\/){W nzd:3GO7Ѷpj!Tu|4cj?֚/#źܿjI@DuE;4uT;3hK(NA z'~1> Fj'sȗm#Z(C2ܾ<$vK_>T:_ȤHBЪXi`"P$aͩPwAL=<{r0{28d]GޭP0^##w}֠hw@}Ke nI4ۆ8 7=:pmNBc 8?YOd$2i7[䟶0loz焩b@QiAjQ/3;KF $7(4 )N8 u+1yF*wqy*z`i[|R 3="4 .aSm r{#v{)͚j$C|fju{/+Z^B*s'Kԭx-JLs;h^` ;v~nyy ;]T }1v'kvIG/[+ȢaPĂQ*dӐAȲh{I*3eDR$VCg`<݌P !*LBU(B<|5BɝK/?coݻ'a4տoo˪`$j zeY&14r `H1Y-䙏 B^|iz19Qhrخ6L'#A(}Y7#TCEzʀVП$MrrKG^]J*zmYi2[kljs8I?|99_h2ukK PwQ*wy8bù?;Z{;=C-Iv>J @IC0b\GBȀÉhR )Pq{$o3EjrюU)͉mG!aӯ[/U;'yIn=I5Tv UUe ֘Z{FE]YlpAʈ"BIN'4ImXwuy`6o[AdGO||+_yo\W ۺB}%!FBbs6e %E#$ڮ|8*kcv莾<cզkʲai֌{IQAvRtMUS%ѐ"HIEe*;oY,K-9ǃ˖_z5᪶axzͶe(s9 X{{Rc; ,JTRIP YĦtY8z Hcciöj"U 3E˝DݡD?I%q¬rtIp-!mС&#)?\ҭ;^?q׻x#)K͓y|;s_D# Rpc/bg)O ()xt'$"i{).V?W0G޲T`.N{cXTLe#DZ8{w02(B38NbXF$QLbe!y$ DC nnjbA)C|=P2Xlav; "&lZ0ܝF,klSj0"B IaRd[#R \l,}uG v\-z!"$ N"V\.:`ټޝ1Q>"s09DB%_d yp>2|gEDaYlWl4zF{Нa7$S1^JE:r a(TR0 %Nww2q]jFyLAUy;aOidpa&Ar9l R*1Mk錣l5I.D;{ o̹}#e ںٜ72$Rq*CSp8!ڂ,}4Fw^ C/[oR1D Ϭ@~wV +TyLo(]Z>J*Y7U`Tж c㈪I Dų 88Vlʚ8^9cZ>Y0 uOG,yspxD4ۆ*F0f9[q|t̳)5q#CAofmG8Hⅵαz٬s'lE$䊽1ō7yܢ-( "6zP9  }1lZ^ ADI7=mkCI ޒeBy}ibs"SLgc-UkXm4xIA ")֖UK5QD  lkC?MBP $ #dXo5I3bL 'n%vboĊŦm,Is΄IG_zDU#z?d\d$QDj8h!$x@`(.akYR]RaX~&Oݻg 4+XT$ s__>{%pc h\.nF>y0զzyٛ? :NvH~K,E`<E^2\` PeUЉ;wz4̧ Nb1cowLYDIHjviX*E+|R7vy2Vcyk7g<gK 2(gxի$IhZL?B8D,*[Tc#H0"jAG'R-dcii YGT֤`V݀nD(I>NL ;FeT:8 XYYDA6$tC (Y_0El2YMMXG"$gxr%KNK;:!˽'XϿ:N8Z'd>ё֭t JRuK 1~_:+M0LGخAnzuT:~W/I`y<}_+5={n'ҢBMSVĝ;;yF NLJqj0P75e]X(O4(2O DɄ6i@,-1N"MghjX]c2.c@tӘl'NIL]Qxwz_QOkÀŢ"KݐqIe^rHa@g<Pe3iYm+P+!ҲWQ фBЉ%fo\1DZ9\놔ePqf)AN2HK71VDkN,BK0"!/ cF(iHPn'fyqf}sx7x]E'-è Ӕ4`gRO9%%i2F4 ( ,DgMQ@g<g OMCh ;m̛ҷ44udVV{gN< +_=4Ŀ@P"˯:kzTu2'$I!Vud2²74^h4&張B5,_4Rd%a>[pK~YUz/iFu)!&I"y$R>^)ʈk do2'ϧ RūS@V8A ݈jxEP6m ObPZORH+DR EkGa њA=1Yհ9s](R,czϑirh&k/&Vd#+4 #MKFaT3HZyoZf +:P+$GƱ(K#>vWuڋ$ Mi-0kTЋ NC4da;Adv6ȲIY_]FG I4Bԏ9e{3'ВnͳΧtQIcoa iT]Y^ ҁ!yP%5)RWY{zL VpSd)j01!Sև Cle c}SFU}mWUtK)! CZ:LUry^3[ z $$a +! CV#(fym,{% ^O_.dCRP;.Ȳ4":qT28Œic-D IMHfeM)P0,Jx^⌣DqDTT6n`ww$Hwi␵AJc, FihYVggfqM@K"²3ə 4 c b6ω:)e^ﯿ?yzGG[m` E[|-hLfӉ.E4?RSXق)1?Y5UF6@jMzT!NbYIm:$MBfKclb-d6[0hlMe&(@!D I'M [ K=qOcy[颦ln@t19v&u~V8 P6~'$N(<8'Y[J`"IcV.t"ІN; %ġo8 f YMa4IEgd!<'V1H D*J #*z)`4^3IZ_#qO]8ҜYN-\ߚe5ֽt+U1*t '$̲"P'vY] ٛ䋊1ޞFTWW<˗//ou i>_/_x"_' Rsoe4{>bkpRy.{'XaE Gjṿ4nIui9wfdZ9^{X]|;GSWGmO^|y_;-E4YQ P-ʚhB$:`:++CRƁ_ UMP,tJ %˫C(.9D`cyǍ8X^]b>+3G-QzɃM'\2Ϝ!egE{qH^9essNw0세ag^bdb`{U AJH$K]xI +( )+K^VA7 PQ֨՛܋A5q Yd5qL0Ҥiu@}Vdf^;hH,!Ɣoq*#!AYJޜdy:, %ݛd>Týo'Ѽ {_~oxk+-oUWftVTw~w LFb{ ^"M;tҘ()h. 9USǑN4MVD)p8D+M6-PSU4>;O<}ĝ@iŖL' 3oQR4!/3rIB^%USƔk}?u9ܙ.U+V)R-!{3XF@4f4 I͠䙷IY]MB$Q47!^Jh@Ify8'c1ZitQgd&MZJ!N@8uHziH,Xᨙw&;S1(K,3.Dup+%z :ix]0HasB9qg6ΧV4'ƍX7OuNNN>76۩azcf8Iҿ3/|[g EYP>)"DFK!O\8t6( p$i%zzGJĄڛi {#!N0wus8IGrI%4n-UQ2wȲԵeo'%)lc k{@ bmeHz=Ly&SaЉԵ 1a eX`NDGIHҚi NZiR߫G[Sw $%/jKW,8DY^+Hy NA$DRQem9O;jogA8'E7(-8$CD7cD?3o(<`_|Ns۩ۿ}"qnso|n :Ppw? 1qg%QM%}[F!J{(kTME%yi 3:l],AmDo풦 IF`5nS$&*}$qۼq {=n>^ˋ P?)ƒ9DqDH32he~˴0[Qҿ*8ʲB)v}"u] 8@iQ@48IDSQ5u;EqUMX0)iLuݠd)jǻ.ት׷&pn,oA@&$ %yi*Aca^'dcy 턤q$(jC7M5(Q85LS#$ x^F9i)NH:(*$7CoiAT&>r Y^Dڷ֢#BLOs"^p ;Zsk7cckm,~>YZ-!w,D8A'V>QhG)c_|zr$~ugX(_g=pR$# R~y*˽KeS{bQ|;/˿IuUżm:PTeM]Ui6#ZXP}-c|F"€Zւ0[%^Z7 Hӈ:$|*uQ愑&B|y'0zH!=F(&ɬ oNp.B!+{Rrv5 IN^Yct(֖n7A!}jULg-ў-;wJIC ֞~KckCỦEx4MCc,i*IVU7j0Bm sqΑFH BV8 y$iۉ@8>Ͽsd @Y'XyJ\$ `}_>l?-楗^+W6x<gtkH΢Q@-ϥ7SP"#ʲԵOtVZg^ DI|:# :i'DXb^x2ESAlW$UYzI(x[\83O O\[pflSZG(96$ 4;)AC8Sb餚$mZ3fYR[kYt@(Ӓy^I#"Rk9ݎ0X;L]W߹ᅫf PR5 a""ϽaW{OiW',INX7*0nܚ `y)x}i7iL^&FH0v?pg,ֹ6[XXN;V7k }j/}݀븺:{^zio=dYEQ:С*+0YK6/<$OmXkPZsԕA}^K!P#,v㼗RS#4t\c)ʂ8 _(:YTJ.{ӊwEiIhs\zqPfcePAt~a(Y^! RնRIhE?i!_=[&8I(j ULcH-I,G8_] 0ΠMc F(aN&~  ;)uӰ5ZPN$I$! MO6i3 .sカy(O>H ^=2:Nuj@+k+sܱs';(zg[>ن!ټ*B(A]iϳs>K8lEB +B ni8@KUv}CU~e ;##}s $WlQO7[h)vV&q,wwЁ"M"bhńBU, Sci`:T~IcqJY~xǖxk溍N dejK7t:1AJUo㻓~yۣ++]5,8QD?J*LZUדG} #R@^4%B0\勢F %LJzxPz>KX)0- QH*8`c${+KPynm{yn qFaaDV[r % ! Gd}mp̙nhsu˿qPqAHGa}?7sA@8@/7ji ZHyǡ I9R%Xq]S,f9IܹLYV,+W0iaӺD8-&DA@ԍ!bP4?Hvc`SIg$׉1boR!RMxYɠmm2QAU9ziTtBg I"ñ4(k7€4 0MEEt҈ 6}ZbjAm7wnf}‘a/ /kYdK7 u"oe(|Ɓ^7߉ֱ;ckwu4QPBD$A$c<+HڟG-ʼnMD pa@ҿídp/_9,t$#~s;Rt%6}/o^j+c},/6&fzBJYF B* #phl X$b'QFc akK#eZLt:D.*<0֐v"o*0푦]u~Oޗ@ƍ14ታ}EuIXA.,#,4hז٢HȪN.@ ޜ FWJa-hcRhX(RG xuG@[:PH@EJʪ HCEBW7([0JK4@bP0 I}U9LJĵ"JxR{Oz(~'^[;k8_|(0qn`L>}kAwON/}o$jֶ@:4I XHĸkhpE1 ) ?G~3w2#_f1ٜIλ~`gⓂfYތ4v#;)XSdJ/-|JPi"M{#/F5OnMcv$'MT`, :1^&[$ Y i7oP+P^`*4(kC+uwX^6 Dau@/h;3Vɑl'Jpsk(#R^ ^O!k7|Բ}Z9It5~s(d| ':5g?{~m:yo~{2hdGONNxY(4KRG@:GMɳ,#IC>)c<UZ52GQX_I'7ĴXEV<2+ÈYiyYF 8 %RiήX:#_KS$Zak E*BsXAT`%M":zq:2?7~[0q"% 5QDIlcͳS9}EVaoRPV A(Z j`ץeΜ,G Xv$1 k nޯ? hl VxQ)!()3bC\FK.>`r(UW;Xڔ3]/B̟t7/Vu愞6S_F?PR08-\¡U ,lAU $eU~8UįZPT%åe"G)-(KpM3X "NҔ YA'\:c -Jf5Me&YA],x;#dЅ^cZI&EV!qqBXzx6[;3i@}دܞrskJc N^tٍ5E7G,^' "sQ@a4Js%[a@Ic٬`U$$!i5; icޤ !g aٞdll))c$Mk:id^r}{F>qHk'X/ BxQ{@$ꟹr[^ڎ7򿃍˗/# }*$@a{竗_~y|\Q],?^Z1ƐgY+?8,MS(LYINUռi4(D2 `8(0"/rT譍76Ǭp~K}ʐ$ ڛS7 i[($c3N@je>WQ*VD'(JGVslQ$G//o ':x[8Ǎ olLqqvjH lm!ޛ^/]|ۆ=.`uj}@vAu:f$O)ktYZg矹tK$QtB@~*K$b2!)+uH ٌrl-M ֖:Nlx֗[{c1\X&XR%YuMc<ϸz˞]2y^3aıf}wC "˹vk¢hX]pnGx着+{gS:約fe8e%a8E`6/Cn&H U`B:B"q`Ah(ߍ0!+"oW|[ۣEuSU8wbGVι__[`u?`N>OG ZwKϜgi/Jaۍ1 C0kZ2hbişy?Sg dq^;eo2™!kK jn,O3N+0TN5@iMoJFq0& 8 $ь[[IKW/OFsnJpq}R?Nc,;ӜMmYtxL8TU`(̳4Ypv;Lc0B57Ʋee CuL N'mcAJ^ R| kSնr3ޛ䷮m]ݘl[۳Y^4RZs8X_|ߜ$(vЏm{=z 1. N?}Gx/ ??O[͖|Ԁu嵛ƢkO\<:royj& a`+A^.P2ºrwO)7weʰdε#VPbʜAb %g{`ZRhBt^R5+K]Rq2ь82mPՆcLSVW:Ee1\ݞrk{N$nҰ6LN7X%cə.R+nS1pkg>J(mHKWA8غ!ZXP h05y;+w[{ڭ^U&/f1%DӼyI|IN2{`uj-PuPuJn|A: 8ֿ"~ ?bً +قI fc{Ki9q,i+tu4c^;q3؞6wU1staU VXQJlv70'skAku Q~ԍM`swZ/f̲ z 0ɜ+#BxܐA/AP9AXF{sdKq(=%+].~2'Cƭա糤H!1N;ew4'4=ki(`{{8JPW'XSrsk3 }vlgtXvq_vllOO>LOIo:(٢`goP3K=$`1/9pv٥.~`nh!]2m,Ұ9 ™D'E|"' %+=ixPAW/âhYiSt|C(gyQgӼ^OxVdQ˺nN֐=='?I{GQXZsϽ NWZBxgǕ=Op)=gNW|si?OMe]% 5.c0_K |گʯ'EX`[`uJA \Gg~xG>_-7lO Is5ۣy+ևf5nMK?}[]ٌX՛cEڠrP53*[[ǾUs"gcwMÐk=:}\ٚ7icD[*VaE$H姪" EY<&yVOFx>gdk/Uu~-r[W?ORU&X`=!:*u>՞~gg߲'C_ e(c<8,nlN8=lS!h$/}y3OpLcA^%;c{wN?# `h1UDZԡMeܚgׇı1[ 4@)iRg-c}h0eY5tQj:tooe۳EQ5U1,/O|iUVa~WeT ^'bg;;oɳ4/yM)W{ FAR4ll-B͹>4btތ֬͝&^pPѢ׋'bƠe49a])-1jk5XlI+ikfqE٘(˷w{nn.emlA '>VVa8;DQ&?>TqoEzZO~?:=(kqcs( PqucDeH`ʂ( vk+Gtw^ZanZ@YUl,X5_^%oɋնubMc}DU7 %I.Ӵc?knTXκi\Wtlvskx̫)>gNN%x; >^{R^'TG}s{xio+?G)6؞;wbʢbg` CTPαk^$+9>qmg,`ckAV{ކ@&.)Xᜮ˪΋[Wn6"N|T =2/yqޭڿ~`u\UiLV؝|BKkÞS :S_ZǓg'97,."D tf7<Đ'S [8~ @8o8/ܔb|׽l ٺuuW6&ݍf k:?>.`uu;h5|IjNC!~W-3-p%~|-w,ϸzcBiW\4y7ǼzeϜaR5VJ-QR{G@ QBVQMbWwo^ߘL'qA5w@}yй,Mf[W7&׾$6ɬ(e yɁ0UGRaA0|qTwNRdzT`z lC@=W՟1꯾w[s~=穊[3VV:<#!qԍҧ4kIdE=_1;Z^;BEr4 O7>яV!O9`Zi Ozzx?뭢?j+_ǫKO^Z U_9pįDJl@R H/KpDiy;ܚn&xogoͳ΋yc҇o^x_uXzd-AuQ:=|P:*=/|R[q xv|~?KXEV~~`!AU7B*tYg]Shh~kwO՛{;ۻ<ˋƾ P'v!pv:-dZoMz} o7T?tAyw4< w@W+_W1R|'ί>ˮ gs-A9-Y5M;i64Jxzx`sm X=r*/҃\39qBh0m/4$8\ gֆ?Ի\=rƙ4iLb(v&|tsk76^;ޛR6@ Px3 MgGV:>NuuQk^T':~_|wsa걙n7ڼƵ+nmnlOow T^p} ^x{-?u7^NEޏ|?VVz h_/k? E%?s?w~}|W^2z7NmeG:izA%{8,`=*//}KCqsǢo>Ho%//ۣIc3%߅y7ma$a$Rh/}Lk#RYOjjꖿGjW{xC>EP vn;[^2Gdx/HSsc[ VoHO}VCWR'DuzKgiz[q= [[^>zZG%s wۿocC6>jѾڪʴ en|)8'+;5}B NBnD1Du~N 9/8#Ⓚޞ4H}f Znî jw~[> pWx㎲xJ({MTC:g.k۾m%u /K۷NWt xn ""$Nc4r~w~巫VoVU{%PR>>j #DI߭ں{uu" @0I[:>osojVn_~{@sRx$*ǭn?"iIn봫?K9%}훵@ /#"VUuTn?ҝYqvQpXwr^!+oWY?TIU-ZkG?jzXy!{4Zst uXY1Ο}c*o^;#+Ы~^\IO-UmK n?` c~Hu?: ` dܮvᇗTv|/rQEV}\@Gúu> .A[tx~[Wyd*4xh~=&O9J{IVUo?un[};I"8uǟVտ*˶UҾ6j$~m6PDUurxjo?n{:@BvlUm@5j[I,X_? | \8VQM7W͏}cWUu uiQc]aYmmnUZûa0>a+!-/ۧy?P}cW? xSu'XPzQqǖúuPpq=R B'fN7rvjq[?,7(٫;A$aiIw{-=.&G;<)I_T 5k}ۯcެ6H=J:NUvusE铌;%Y>)~|_}?s{մ?۟mo7A^NN RM=N@u*mNRA9Ɖdx/"`rU qX`뼎_[yv˕ifF{{lqR'PRǭ&P%|m?.ugx72AvUz?P:B%?}B[巵kE k_ZJzʯ}kӻ=(0=He-= ؜Pt- i]ģ~N_$d[QM^x/Bs@tҕI: euy}^$}>o{y1pIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/gtimelog.css0000664000175000017500000000005214515757026016163 0ustar00mgmg/* We don't have any custom CSS today! */ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1602138973.0 gtimelog-0.12.0/src/gtimelog/gtimelog.png0000664000175000017500000001654713737531535016177 0ustar00mgmgPNG  IHDR00W pHYs   MiCCPPhotoshop ICC profilexڝSwX>eVBl"#Ya@Ņ VHUĂ H(gAZU\8ܧ}zy&j9R<:OHɽH gyx~t?op.$P&W " R.TSd ly|B" I>ةآ(G$@`UR,@".Y2GvX@`B, 8C L0ҿ_pH˕͗K3w!lBa)f "#HL 8?flŢko">!N_puk[Vh]3 Z zy8@P< %b0>3o~@zq@qanvRB1n#Dž)4\,XP"MyRD!ɕ2 w ONl~Xv@~- g42y@+͗\LD*A aD@ $<B AT:18 \p` Aa!:b""aH4 Q"rBj]H#-r9\@ 2G1Qu@Ơst4]k=Kut}c1fa\E`X&cX5V5cX7va$^lGXLXC%#W 1'"O%zxb:XF&!!%^'_H$ɒN !%2I IkHH-S>iL&m O:ňL $RJ5e?2BQͩ:ZImvP/S4u%͛Cˤ-Кigih/t ݃EЗkw Hb(k{/LӗT02goUX**|:V~TUsU?y TU^V}FUP թU6RwRPQ__c FHTc!2eXBrV,kMb[Lvv/{LSCsfffqƱ9ٜJ! {--?-jf~7zھbrup@,:m:u 6Qu>cy Gm7046l18c̐ckihhI'&g5x>fob4ekVyVV׬I\,mWlPW :˶vm))Sn1 9a%m;t;|rtuvlp4éĩWggs5KvSmnz˕ҵܭm=}M.]=AXq㝧/^v^Y^O&0m[{`:>=e>>z"=#~~~;yN`k5/ >B Yroc3g,Z0&L~oL̶Gli})*2.QStqt,֬Yg񏩌;jrvgjlRlc웸xEt$ =sl3Ttcܢ˞w|/%ҟ3gAMA|Q cHRMz%u0`:o_FIDATxڼky2;3{x(юhIYI-JGqm4qp> ֭kj|HM8 uQ[v4vb]\[$J)Rxuvvgv潝[?P]kv`1ٹ]jJLhGgm=UxoPR:9>~w=Ia8YκckY` {g~}lb0XJ ia*KDQD!w( ! ׶גx{YqEYl%cĶtvue;ι7 \ĿOnT50NχI90 [w4#89}5@ēhmxG8q'N0JQf:a`#V+Xtw0%5'('糟 >>Oeǎ^ţ˿qml}zN݌L (p⤶}|ħ~ #{1aB=Lh)7wGAH֐R{orix}f}ٶm36:]PT<vعӈh&f`FO?$TBWVWa86:,ZIN,}t]}<_ܶw/ssyǞwzn )%qqyޱg@]@JEXK0h0::J NI>{;dttyx'333ǡCeUbE8B$aj$Eik#xZ4#cڜjx7pz7P!$~͔{&#M66<@AGw,-/c;nw'c I#{&PkiKpzgRħ?k?||32A,HRgϭXh4q^GeTUpo~zA~k-Y!a"p8g0^+ZʲDJyk3@ Le2_i$iq31>Bx|[AĔ%/:uc{/ 2X0* ptY$BHGq|pR)!^#ş0FzR!Cky0yN.^s}~ Tu֯\1jrs&8쳬QJ!]˜qҴR  q!xJ)Dqv!E:1:J7]$y#c#dYKץ( 0_¿fffm4ߣDcI|Q:|I$J)3gλeN AJwy& 5!( fo6m68fggFӧȋ"@ Nzgp]a hj1eYM75Ͽx5c,Z,B*R?3,d[Y^$I=g(PKSTRkH*BکS|hkPIvX|ͻ8 8t. ZsVC#& τ[\nc hP!/6WϰweɵEw8k1`eϞ=|Mdo'xQ)|8t|A(58{P9!#BvoPEIUi(bc x:RJ>*R)i=vJ fJh"ŠSa֍!$eYe5;ue %s_۩] k,QT#CʲD``.jIΑ6RaƋ6c,d,pYF Zkp,äx0ÿ;E›yN` ˲@A` $I(ʂ^xz2ը,4I-4y?Ǻ thm)ˊu(e9pF$IJhc,{'6׀h(|hoPbpJ8[>`ݻ^e|bT$g5|u~gjf[o1*T^Q9^iaeEE烶mdP9()(כ~K=rAkܓ $)Xc@jVC5Ν>N5kԚ#LOQOS!Ѻg9=BaQJRYI)DqLFTe5(Lk&@eW/[i6EQalI)tZ0eHŁ>g^[X1,kJBʳ{6d$QAecEqPxxUk7n 7F[;f4 aT#Ij" _+M{GL2Q̷K,g!K@2ڈ"E?͔ׄmr_$M(ʊ"[^ι02䏮*/fgf&r+Z_:gh<9g$IёQJ)>UeIӔO^vH0^"Df*PldPhfr_{..m~,2?1{vH=7IVLO6Grf=(- SzGLK_ѣGz/ ì|-T]Fzw()ʒ#wT`SwGȉB֎јH?}3~pv~ix+%G4ygQZSFTG]svQQ0YgpRR=IZn.,}?ԓ+++˼{:"R ё4g'kZH!@x[Z͗痺 ]>{Rjo hid` n@0wnO*À#._;~j;Ƹrc0> 8s{G>w W_=xKNֽnoA:7x;1{Wہ&BS㣫k]m)0VΨp{X?U?rL`Y(:Pu\_? @7daf`?: lk]kx,v`k( : w? ]?IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/gtimelog.ui0000664000175000017500000010212014515757026016007 0ustar00mgmg True False go-previous-symbolic True False go-next-symbolic True False go-home-symbolic True False open-menu-symbolic True False view-dual-symbolic True False view-grid-symbolic False center 850 550 gtimelog.png True False True False vertical True True 600 True True False vertical 50 True True False True True edit-find-symbolic False False False True 0 True True True True 2 False word 6 6 True True 1 True False False vertical 50 True True True True True True 1 True False info True False 6 end False False 0 False 16 True False Downloading tasks. False True 0 False False 0 False True 2 False False True True 0 True False 6 6 True False 12:00 task_entry False True 0 True True True True True 6 1 Add True True True True False win.add-entry False False True 2 False True 6 end 1 True False False True 2 entry report True False vertical True False center 6 6 6 6 False start Daily True True True win.time-range "day" True True True 0 Weekly True True True win.time-range "week" True True 1 Monthly True True True win.time-range "month" True True 2 False True 0 True False 6 vertical 6 True False 12 6 12 True False Sender sender_entry 1 False True 0 True True Your Name <youremail@example.com> True True 1 False True 1 True False 12 6 12 True False Recipient recipient_entry 1 False True 0 True True email@example.com True True 1 False True 2 True False 12 6 12 True False Subject subject_entry 1 False True 0 True True False True True 1 False True 3 False True 1 True False False True 4 True True False True 2 True False 6 6 True True 5 True False error True False 6 end False False 0 False 16 True False True Something happened. False True 0 False False 0 False True 3 report 1 True False Time Log Wednesday, 2015-09-02 (week 36) True True False True True True win.go-back image1 False True 0 True True True win.go-forward image2 False True 1 True True True win.go-home image3 False True 2 Cancel False False win.cancel-report end 2 Send True True win.send-report end 1 True True True image6 end 3 True True True win.show-task-pane image5 end 2 True True True menu_image end 1 GTK_SIZE_GROUP_HORIZONTAL ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711446986.0 gtimelog-0.12.0/src/gtimelog/main.py0000664000175000017500000024420314600515712015135 0ustar00mgmg"""An application for keeping track of your time.""" import sys import time DEBUG = '--debug' in sys.argv if DEBUG: def mark_time(what=None, _prev=[0, 0]): t = time.time() if what: print("{:.3f} ({:+.3f}) {}".format(t - _prev[1], t - _prev[0], what)) else: print() _prev[1] = t _prev[0] = t else: def mark_time(what=None): pass mark_time() mark_time("in script") import collections import datetime import email import email.header import email.mime.text import functools import gettext import locale import logging import os import re import signal import smtplib from contextlib import closing from email.utils import formataddr, parseaddr from gettext import gettext as _ from io import StringIO mark_time("Python imports done") if DEBUG: os.environ['G_ENABLE_DIAGNOSTIC'] = '1' # The gtimelog.paths import has important side effects and must be done before # importing 'gi'. from .paths import ( ABOUT_DIALOG_UI_FILE, CONTRIBUTORS_FILE, CSS_FILE, LOCALE_DIR, MENUS_UI_FILE, PREFERENCES_UI_FILE, SHORTCUTS_UI_FILE, UI_FILE, ) from .utils import require_version require_version('Gtk', '3.0') require_version('Gdk', '3.0') require_version('Soup', '3.0') import gi from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Soup mark_time("Gtk imports done") from gtimelog import __version__ from gtimelog.secrets import ( Authenticator, set_smtp_password, start_smtp_password_lookup, ) from gtimelog.settings import Settings from gtimelog.timelog import ( ReportRecord, Reports, TaskList, TimeLog, as_minutes, different_days, next_month, parse_time, prev_month, uniq, virtual_day, ) mark_time("gtimelog imports done") log = logging.getLogger('gtimelog') MailProtocol = collections.namedtuple('MailProtocol', 'factory, startssl') MAIL_PROTOCOLS = { 'SMTP': MailProtocol(smtplib.SMTP, False), 'SMTPS': MailProtocol(smtplib.SMTP_SSL, False), 'SMTP (StartTLS)': MailProtocol(smtplib.SMTP, True), } class EmailError(Exception): pass def format_duration(duration): """Format a datetime.timedelta with minute precision. The difference from gtimelog.timelog.format_duration() is that this one is internationalized. """ h, m = divmod(as_minutes(duration), 60) return _('{0} h {1} min').format(h, m) def isascii(s): return all(0 <= ord(c) <= 127 for c in s) def address_header(name_and_address): if isascii(name_and_address): return name_and_address name, addr = parseaddr(name_and_address) name = str(email.header.Header(name, 'UTF-8')) return formataddr((name, addr)) def subject_header(header): if isascii(header): return header return email.header.Header(header, 'UTF-8') def prepare_message(sender, recipient, subject, body): if isascii(body): msg = email.mime.text.MIMEText(body) else: msg = email.mime.text.MIMEText(body, _charset="UTF-8") if sender: msg["From"] = address_header(sender) msg["To"] = address_header(recipient) msg["Subject"] = subject_header(subject) msg["User-Agent"] = "gtimelog/{}".format(__version__) return msg def make_option(long_name, short_name=None, flags=0, arg=GLib.OptionArg.NONE, arg_data=None, description=None, arg_description=None): # surely something like this should exist inside PyGObject itself?! option = GLib.OptionEntry() option.long_name = long_name.lstrip('-') option.short_name = 0 if not short_name else short_name.lstrip('-') option.flags = flags # Not 100% sure about the int(), but it fixes a warning from PyGI option.arg = int(arg) option.arg_data = arg_data option.description = description option.arg_description = arg_description return option soup_session = Soup.Session() authenticator = Authenticator() class Application(Gtk.Application): class Actions(object): actions = [ 'preferences', 'shortcuts', 'about', 'quit', 'edit-log', 'edit-tasks', 'refresh-tasks', ] def __init__(self, app): for action_name in self.actions: action = Gio.SimpleAction.new(action_name, None) action.connect('activate', getattr(app, 'on_' + action_name.replace('-', '_'))) app.add_action(action) setattr(self, action_name.replace('-', '_'), action) self.shortcuts.set_enabled(hasattr(Gtk, 'ShortcutsWindow')) def __init__(self): super(Application, self).__init__( application_id='org.gtimelog', flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, ) GLib.set_application_name(_("Time Log")) GLib.set_prgname('gtimelog') self.add_main_option_entries([ make_option("--version", description=_("Show version number and exit")), make_option("--debug", description=_("Show debug information on the console")), make_option("--prefs", description=_("Open the preferences dialog")), make_option("--email-prefs", description=_("Open the preferences dialog on the email page")), ]) def check_schema(self): schema_source = Gio.SettingsSchemaSource.get_default() if schema_source.lookup("org.gtimelog", False) is None: sys.exit(_("\nWARNING: GSettings schema for org.gtimelog is missing! If you're running from a source checkout, be sure to run 'make'.")) def create_data_directory(self): data_dir = Settings().get_data_dir() if not os.path.exists(data_dir): try: os.makedirs(data_dir) except OSError as e: log.error(_("Could not create {directory}: {error}").format(directory=data_dir, error=e), file=sys.stderr) else: log.info(_("Created {directory}").format(directory=data_dir)) def do_handle_local_options(self, options): if options.contains('version'): print(_('GTimeLog version: {}').format(__version__)) print(_('Python version: {}').format(sys.version.replace('\n', ''))) print(_('GTK+ version: {}.{}.{}').format(Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION, Gtk.MICRO_VERSION)) print(_('PyGI version: {}').format(gi.__version__)) print(_('Data directory: {}').format(Settings().get_data_dir())) print(_('Legacy config directory: {}').format(Settings().get_config_dir())) self.check_schema() gsettings = Gio.Settings.new("org.gtimelog") if not gsettings.get_boolean('settings-migrated'): print(_('Settings will be migrated to GSettings (org.gtimelog) on first launch')) else: print(_('Settings already migrated to GSettings (org.gtimelog)')) return 0 return -1 # send the args to the remote instance for processing def do_command_line(self, command_line): self.do_activate() options = command_line.get_options_dict() if options.contains('email-prefs'): self.on_preferences(page="email") elif options.contains('prefs'): self.on_preferences() return 0 def do_startup(self): mark_time("in app startup") self.check_schema() self.create_data_directory() Gtk.Application.do_startup(self) mark_time("basic app startup done") css = Gtk.CssProvider() css.load_from_path(CSS_FILE) screen = Gdk.Screen.get_default() Gtk.StyleContext.add_provider_for_screen( screen, css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) mark_time("CSS loaded") if Gtk.Settings.get_default().get_property('gtk-shell-shows-app-menu'): builder = Gtk.Builder.new_from_file(MENUS_UI_FILE) self.set_app_menu(builder.get_object('app_menu')) mark_time("menus loaded") self.actions = self.Actions(self) self.set_accels_for_action("win.detail-level::chronological", ["1"]) self.set_accels_for_action("win.detail-level::grouped", ["2"]) self.set_accels_for_action("win.detail-level::summary", ["3"]) self.set_accels_for_action("win.time-range::day", ["4"]) self.set_accels_for_action("win.time-range::week", ["5"]) self.set_accels_for_action("win.time-range::month", ["6"]) self.set_accels_for_action("win.log-order::start-time", ["7"]) self.set_accels_for_action("win.log-order::name", ["8"]) self.set_accels_for_action("win.log-order::duration", ["9"]) self.set_accels_for_action("win.log-order::task-list", ["0"]) self.set_accels_for_action("win.show-task-pane", ["F9"]) self.set_accels_for_action("win.show-menu", ["F10"]) self.set_accels_for_action("win.show-search-bar", ["F"]) self.set_accels_for_action("win.go-back", ["Left"]) self.set_accels_for_action("win.go-forward", ["Right"]) self.set_accels_for_action("win.go-home", ["Home"]) self.set_accels_for_action("win.focus-task-entry", ["L"]) self.set_accels_for_action("win.edit-last-entry", ["BackSpace"]) self.set_accels_for_action("app.edit-log", ["E"]) self.set_accels_for_action("app.edit-tasks", ["T"]) self.set_accels_for_action("app.shortcuts", ["question"]) self.set_accels_for_action("app.preferences", ["P"]) self.set_accels_for_action("app.quit", ["Q"]) self.set_accels_for_action("win.report", ["D"]) self.set_accels_for_action("win.cancel-report", ["Escape"]) self.set_accels_for_action("win.send-report", ["Return"]) mark_time("app startup done") def on_quit(self, action, parameter): self.quit() def open_in_editor(self, filename): self.create_if_missing(filename) if os.name == 'nt': os.startfile(filename) else: uri = GLib.filename_to_uri(filename, None) Gtk.show_uri(None, uri, Gdk.CURRENT_TIME) def on_edit_log(self, action, parameter): filename = Settings().get_timelog_file() self.open_in_editor(filename) def on_edit_tasks(self, action, parameter): gsettings = Gio.Settings.new("org.gtimelog") if gsettings.get_boolean('remote-task-list'): uri = gsettings.get_string('task-list-edit-url') if self.get_active_window() is not None: self.get_active_window().editing_remote_tasks = True Gtk.show_uri(None, uri, Gdk.CURRENT_TIME) else: filename = Settings().get_task_list_file() self.open_in_editor(filename) def on_refresh_tasks(self, action, parameter): gsettings = Gio.Settings.new("org.gtimelog") if gsettings.get_boolean('remote-task-list'): if self.get_active_window() is not None: self.get_active_window().download_tasks() def create_if_missing(self, filename): if not os.path.exists(filename): open(filename, 'a').close() def on_shortcuts(self, action, parameter): builder = Gtk.Builder.new_from_file(SHORTCUTS_UI_FILE) shortcuts_window = builder.get_object('shortcuts_window') shortcuts_window.set_transient_for(self.get_active_window()) shortcuts_window.show_all() def get_contributors(self): contributors = [] with open(CONTRIBUTORS_FILE, encoding='utf-8') as f: for line in f: if line.startswith('- '): contributors.append(line[2:].strip()) return sorted(contributors) def on_about(self, action, parameter): # Note: must create a new dialog (which means a new Gtk.Builder) # on every invocation. builder = Gtk.Builder.new_from_file(ABOUT_DIALOG_UI_FILE) about_dialog = builder.get_object('about_dialog') about_dialog.set_version(__version__) about_dialog.set_authors(self.get_contributors()) about_dialog.set_transient_for(self.get_active_window()) about_dialog.connect("response", lambda *args: about_dialog.destroy()) about_dialog.show() def on_preferences(self, action=None, parameter=None, page=None): if self.are_there_any_modals(): # Don't let a user invoke this recursively via gtimelog --prefs return preferences = PreferencesDialog(self.get_active_window(), page=page) preferences.connect("response", lambda *args: preferences.destroy()) preferences.run() def are_there_any_modals(self): # Fix for https://github.com/gtimelog/gtimelog/issues/127 return any(window.get_modal() for window in Gtk.Window.list_toplevels()) def do_activate(self): mark_time("in app activate") window = self.get_active_window() if window is not None: # window.present() doesn't work on wayland: # https://gitlab.gnome.org/GNOME/gtk/issues/624#note_119092 window.present_with_time(GLib.get_monotonic_time() // 1000) # the above workaround stopped working on gnome-shell 44, but maybe # window.present() will be fixed one day? window.present() return window = Window(self) mark_time("have window") self.add_window(window) mark_time("added window") window.show() mark_time("showed window") GLib.idle_add(mark_time, "in main loop") mark_time("app activate done") def copy_properties(src, dest): blacklist = ( 'events', 'child', 'parent', 'input-hints', 'buffer', 'tabs', 'completion', 'model', 'type', 'progress-', 'primary-icon-', 'secondary-icon-', ) RW = GObject.ParamFlags.READWRITE for prop in src.props: if prop.flags & GObject.ParamFlags.DEPRECATED != 0: continue if prop.flags & RW != RW: continue if prop.name.startswith(blacklist): continue setattr(dest.props, prop.name, getattr(src.props, prop.name)) def swap_widget(builder, name, replacement): original = builder.get_object(name) copy_properties(original, replacement) parent = original.get_parent() if isinstance(parent, Gtk.Box): expand, fill, padding, pack_type = parent.query_child_packing(original) position = parent.get_children().index(original) parent.remove(original) parent.add(replacement) if isinstance(parent, Gtk.Box): parent.set_child_packing(replacement, expand, fill, padding, pack_type) parent.reorder_child(replacement, position) original.destroy() REPORT_KINDS = { # map time_range values to report_kind values 'day': ReportRecord.DAILY, 'week': ReportRecord.WEEKLY, 'month': ReportRecord.MONTHLY, } class Window(Gtk.ApplicationWindow): timelog = GObject.Property( type=object, default=None, nick='Time log', blurb='Time log object') tasks = GObject.Property( type=object, default=None, nick='Tasks', blurb='Task list object') date = GObject.Property( type=object, default=None, nick='Date', blurb='Date to show (None tracks today)') detail_level = GObject.Property( type=str, default='chronological', nick='Detail level', blurb='Detail level to show (chronological/grouped/summary)') time_range = GObject.Property( type=str, default='day', nick='Time range', blurb='Time range to show (day/week/month)') log_order = GObject.Property( type=str, default='start-time', nick='Log Order', blurb='Log order for Tasks/Groups (start-time/name/duration/task-list)') filter_text = GObject.Property( type=str, default='', nick='Filter text', blurb='Show only tasks matching this substring') class Actions(object): simple_actions = [ 'go-back', 'go-forward', 'go-home', 'focus-task-entry', 'add-entry', 'edit-last-entry', 'report', 'send-report', 'cancel-report', ] def __init__(self, win): PropertyAction = Gio.PropertyAction self.detail_level = PropertyAction.new("detail-level", win, "detail-level") win.add_action(self.detail_level) self.time_range = PropertyAction.new("time-range", win, "time-range") win.add_action(self.time_range) self.log_order = PropertyAction.new("log-order", win, "log-order") win.add_action(self.log_order) self.show_view_menu = PropertyAction.new("show-view-menu", win.view_button, "active") win.add_action(self.show_view_menu) self.show_task_pane = PropertyAction.new("show-task-pane", win.task_pane, "visible") win.add_action(self.show_task_pane) self.show_menu = PropertyAction.new("show-menu", win.menu_button, "active") win.add_action(self.show_menu) self.show_search_bar = PropertyAction.new("show-search-bar", win.search_bar, "search-mode-enabled") win.add_action(self.show_search_bar) for action_name in self.simple_actions: action = Gio.SimpleAction.new(action_name, None) action.connect('activate', getattr(win, 'on_' + action_name.replace('-', '_'))) win.add_action(action) setattr(self, action_name.replace('-', '_'), action) def __init__(self, app): Gtk.ApplicationWindow.__init__(self, application=app, icon_name='gtimelog') self._watches = {} self._download = None self._date = None self._showing_today = None self._window_size_update_timeout = None self.editing_remote_tasks = False self.timelog = None self.tasks = None self.app = app mark_time("loading ui") builder = Gtk.Builder.new_from_file(UI_FILE) mark_time("main ui loaded") builder.add_from_file(MENUS_UI_FILE) mark_time("menus loaded") # I want to use a custom Gtk.ApplicationWindow subclass, but I # also want to be able to edit the .ui file with Glade. So I use # a regular ApplicationWindow in the .ui file, then steal its # children and add them into my custom window instance. main_window = builder.get_object('main_window') main_stack = builder.get_object('main_stack') headerbar = builder.get_object('headerbar') copy_properties(main_window, self) main_window.set_titlebar(None) main_window.remove(main_stack) self.add(main_stack) self.set_titlebar(headerbar) # Cannot store these in the same .ui file nor hook them up in the # .ui because glade doesn't support that and strips both the # and the menu-model property on save. self.view_button = builder.get_object("view_button") self.menu_button = builder.get_object("menu_button") self.menu_button.set_menu_model(builder.get_object('window_menu')) self.view_button.set_menu_model(builder.get_object('view_menu')) self.main_stack = main_stack self.paned = builder.get_object("paned") self.task_pane_button = builder.get_object("task_pane_button") self.back_button = builder.get_object("back_button") self.forward_button = builder.get_object("forward_button") self.today_button = builder.get_object("today_button") self.send_report_button = builder.get_object("send_report_button") self.cancel_report_button = builder.get_object("cancel_report_button") self.sender_entry = builder.get_object("sender_entry") self.recipient_entry = builder.get_object("recipient_entry") self.subject_entry = builder.get_object("subject_entry") self.tasks_infobar = builder.get_object("tasks_infobar") self.tasks_infobar_label = builder.get_object("tasks_infobar_label") self.infobar = builder.get_object("report_infobar") self.infobar.connect('response', lambda *args: self.infobar.hide()) self.infobar_label = builder.get_object("infobar_label") self.headerbar = builder.get_object('headerbar') self.time_label = builder.get_object('time_label') self.task_entry = TaskEntry() swap_widget(builder, 'task_entry', self.task_entry) self.task_entry.grab_focus() # I specified this in the .ui file but it gets ignored self.add_button = builder.get_object('add_button') self.add_button.grab_default() # I specified this in the .ui file but it gets ignored self.log_view = LogView() swap_widget(builder, 'log_view', self.log_view) self.bind_property('timelog', self.task_entry, 'timelog', GObject.BindingFlags.DEFAULT) self.bind_property('timelog', self.log_view, 'timelog', GObject.BindingFlags.DEFAULT) self.bind_property('showing_today', self.log_view, 'showing_today', GObject.BindingFlags.DEFAULT) self.bind_property('date', self.log_view, 'date', GObject.BindingFlags.DEFAULT) self.bind_property('detail_level', self.log_view, 'detail_level', GObject.BindingFlags.SYNC_CREATE) self.bind_property('time_range', self.log_view, 'time_range', GObject.BindingFlags.SYNC_CREATE) self.bind_property('log_order', self.log_view, 'log_order', GObject.BindingFlags.SYNC_CREATE) self.task_entry.bind_property('text', self.log_view, 'current_task', GObject.BindingFlags.DEFAULT) self.bind_property('subtitle', self.headerbar, 'subtitle', GObject.BindingFlags.DEFAULT) self.bind_property('filter_text', self.log_view, 'filter_text', GObject.BindingFlags.DEFAULT) self.bind_property('tasks', self.log_view, 'tasks', GObject.BindingFlags.DEFAULT) self.search_bar = builder.get_object("search_bar") self.search_entry = builder.get_object("search_entry") self.search_entry.connect('search-changed', self.on_search_changed) self.task_pane = builder.get_object("task_pane") self.task_list = TaskListView() swap_widget(builder, 'task_list', self.task_list) self.task_list.connect('row-activated', self.task_list_row_activated) self.bind_property('tasks', self.task_list, 'tasks', GObject.BindingFlags.DEFAULT) self.actions = self.Actions(self) self.actions.add_entry.set_enabled(False) self.actions.send_report.set_enabled(False) self.report_view = ReportView() swap_widget(builder, 'report_view', self.report_view) self.bind_property('timelog', self.report_view, 'timelog', GObject.BindingFlags.DEFAULT) self.bind_property('date', self.report_view, 'date', GObject.BindingFlags.DEFAULT) self.bind_property('time_range', self.report_view, 'time_range', GObject.BindingFlags.SYNC_CREATE) self.sender_entry.bind_property('text', self.report_view, 'sender', GObject.BindingFlags.SYNC_CREATE) self.recipient_entry.bind_property('text', self.report_view, 'recipient', GObject.BindingFlags.SYNC_CREATE) self.report_view.bind_property('subject', self.subject_entry, 'text', GObject.BindingFlags.DEFAULT) self.report_view.connect('notify::recipient', self.update_send_report_availability) self.report_view.connect('notify::body', self.update_send_report_availability) self.report_view.connect('notify::report-status', self.update_already_sent_indication) self.update_send_report_availability() mark_time('window created') self.load_settings() self.date = None # initialize today's date self.task_entry.connect('changed', self.task_entry_changed) self.connect('notify::detail-level', self.detail_level_changed) self.connect('notify::time-range', self.time_range_changed) self.connect('focus-in-event', self.gained_focus) mark_time('window ready') GLib.idle_add(self.load_log) GLib.idle_add(self.load_tasks) self.tick(True) # In theory we could wake up once every 60 seconds. Shame that # there's no timeout_add_minutes. I don't want to use # timeout_add_seconds(60) because that wouldn't be aligned to a # minute boundary, so we would delay updating the current time # unnecessarily. GLib.timeout_add_seconds(1, self.tick) def load_settings(self): self.gsettings = Gio.Settings.new("org.gtimelog") self.gsettings.bind('detail-level', self, 'detail-level', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('log-order', self, 'log-order', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('show-task-pane', self.task_pane, 'visible', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('hours', self.log_view, 'hours', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('office-hours', self.log_view, 'office-hours', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('name', self.report_view, 'name', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('sender', self.sender_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('list-email', self.recipient_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('report-style', self.report_view, 'report-style', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('remote-task-list', self.app.actions.refresh_tasks, 'enabled', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('gtk-completion', self.task_entry, 'gtk-completion-enabled', Gio.SettingsBindFlags.DEFAULT) self.gsettings.connect('changed::remote-task-list', self.load_tasks) self.gsettings.connect('changed::task-list-url', self.load_tasks) self.gsettings.connect('changed::task-list-edit-url', self.update_edit_tasks_availability) self.gsettings.connect('changed::virtual-midnight', self.virtual_midnight_changed) self.update_edit_tasks_availability() x, y = self.gsettings.get_value('window-position') w, h = self.gsettings.get_value('window-size') tpp = self.gsettings.get_int('task-pane-position') self.resize(w, h) if (x, y) != (-1, -1): self.move(x, y) self.paned.set_position(tpp) self.paned.connect('notify::position', self.delay_store_window_size) self.connect("configure-event", self.delay_store_window_size) if not self.gsettings.get_boolean('settings-migrated'): old_settings = Settings() loaded_files = old_settings.load() if old_settings.summary_view: self.gsettings.set_string('detail-level', 'summary') elif old_settings.chronological: self.gsettings.set_string('detail-level', 'chronological') else: self.gsettings.set_string('detail-level', 'grouped') self.gsettings.set_boolean('show-task-pane', old_settings.show_tasks) self.gsettings.set_double('hours', old_settings.hours) self.gsettings.set_double('office-hours', old_settings.office_hours) self.gsettings.set_string('name', old_settings.name) self.gsettings.set_string('sender', old_settings.sender) self.gsettings.set_string('list-email', old_settings.email) self.gsettings.set_string('report-style', old_settings.report_style) self.gsettings.set_string('task-list-url', old_settings.task_list_url) self.gsettings.set_boolean('remote-task-list', bool(old_settings.task_list_url)) for arg in old_settings.edit_task_list_cmd.split(): if arg.startswith(('http://', 'https://')): self.gsettings.set_string('task-list-edit-url', arg) vm = old_settings.virtual_midnight self.gsettings.set_value('virtual-midnight', GLib.Variant('(ii)', (vm.hour, vm.minute))) self.gsettings.set_boolean('gtk-completion', bool(old_settings.enable_gtk_completion)) self.gsettings.set_boolean('settings-migrated', True) if loaded_files: log.info(_('Settings from {filename} migrated to GSettings (org.gtimelog)').format(filename=old_settings.get_config_file())) mark_time('settings loaded') def load_log(self): mark_time("loading timelog") timelog = TimeLog(Settings().get_timelog_file(), self.get_virtual_midnight()) mark_time("timelog loaded") self.timelog = timelog self.tick(True) self.enable_add_entry() mark_time("timelog presented") self.watch_file(self.timelog.filename, self.on_timelog_file_changed) def load_tasks(self, *args): mark_time("loading tasks") if self.gsettings.get_boolean('remote-task-list'): filename = Settings().get_task_list_cache_file() tasks = TaskList(filename) self.download_tasks() else: filename = Settings().get_task_list_file() tasks = TaskList(filename) self.tasks_infobar.hide() mark_time("tasks loaded") if self.tasks: self.unwatch_file(self.tasks.filename) self.tasks = tasks mark_time("tasks presented") self.watch_file(self.tasks.filename, self.on_tasks_file_changed) self.update_edit_tasks_availability() def update_edit_tasks_availability(self, *args): if self.gsettings.get_boolean('remote-task-list'): can_edit_tasks = bool(self.gsettings.get_string('task-list-edit-url')) else: can_edit_tasks = True self.app.actions.edit_tasks.set_enabled(can_edit_tasks) def update_send_report_availability(self, *args): if self.main_stack.get_visible_child_name() == 'report': can_send = bool(self.report_view.recipient and self.report_view.body) else: can_send = False self.actions.send_report.set_enabled(can_send) def update_already_sent_indication(self, *args): if self.report_view.report_status == 'sent': self.infobar_label.set_text(_("Report already sent")) self.infobar.show() # https://github.com/gtimelog/gtimelog/issues/89 self.infobar.queue_resize() elif self.report_view.report_status == 'sent-elsewhere': self.infobar_label.set_text( _("Report already sent (to {})").format( self.report_view.report_sent_to)) self.infobar.show() # https://github.com/gtimelog/gtimelog/issues/89 self.infobar.queue_resize() else: self.infobar.hide() def cancel_tasks_download(self, hide=True): if self._download: self.cancellable.cancel() self._download = None if hide: self.tasks_infobar.hide() def download_tasks(self): # hide=False and queue_resize() are needed to work around # this bug: https://github.com/gtimelog/gtimelog/issues/89 self.cancel_tasks_download(hide=False) url = self.gsettings.get_string('task-list-url') if not url: log.debug("Not downloading tasks: URL not specified") return cache_filename = Settings().get_task_list_cache_file() self.tasks_infobar.set_message_type(Gtk.MessageType.INFO) self.tasks_infobar_label.set_text(_("Downloading tasks...")) self.cancellable = Gio.Cancellable() self.tasks_infobar.connect('response', lambda *args: self.cancel_tasks_download()) self.tasks_infobar.show() self.tasks_infobar.queue_resize() log.debug("Downloading tasks from %s", url) message = Soup.Message.new('GET', url) self._download = (message, url) message.connect('authenticate', authenticator.http_auth_cb) soup_session.send_and_read_async( message, GLib.PRIORITY_DEFAULT, self.cancellable, self.tasks_downloaded, cache_filename ) def tasks_downloaded(self, session, result, cache_filename): message = soup_session.get_async_result_message(result) status_code = message.get_status() if status_code != Soup.Status.OK: url = message.get_uri().to_string() log.error("Failed to download tasks from %s: %d %s", url, status_code, message.get_reason_phrase()) self.tasks_infobar.set_message_type(Gtk.MessageType.ERROR) self.tasks_infobar_label.set_text(_("Download failed.")) self.tasks_infobar.connect('response', lambda *args: self.tasks_infobar.hide()) self.tasks_infobar.show() else: content = soup_session.send_and_read_finish(result).get_data().decode() log.debug("Successfully downloaded tasks:\n %s", content.replace('\n', '\n ')) with open(cache_filename, 'w') as f: f.write(content) self.check_reload_tasks() self.tasks_infobar.hide() self._download = None def gained_focus(self, *args): if self.editing_remote_tasks: self.download_tasks() self.editing_remote_tasks = False # In case inotify magic fails, let's allow the user to refresh by # switching focus. self.check_reload() self.check_reload_tasks() def virtual_midnight_changed(self, *args): if self.timelog: # This is only partially correct: we're not reloading old logs. # (Reloading old logs would also be partially incorrect.) self.timelog.virtual_midnight = self.get_virtual_midnight() def delay_store_window_size(self, *args): # Delaying the save to avoid performance problems that gnome-music had # (see https://bugzilla.gnome.org/show_bug.cgi?id=745651) if self._window_size_update_timeout is None: self._window_size_update_timeout = GLib.timeout_add(500, self.store_window_size) def _store_window_size(self): position = self.get_position() size = self.get_size() tpp = self.paned.get_position() old_position = self.gsettings.get_value('window-position') old_size = self.gsettings.get_value('window-size') old_tpp = self.gsettings.get_int('task-pane-position') if tuple(size) != tuple(old_size): self.gsettings.set_value('window-size', GLib.Variant('(ii)', size)) if tuple(position) != tuple(old_position): self.gsettings.set_value('window-position', GLib.Variant('(ii)', position)) if tpp != old_tpp: self.gsettings.set_int('task-pane-position', tpp) def store_window_size(self): if self.props.window is not None and not self.is_maximized_in_any_way(): self._store_window_size() GLib.source_remove(self._window_size_update_timeout) self._window_size_update_timeout = None return False def is_maximized_in_any_way(self): # NB: This fails to catch horizontally maximized windows because # GDK ignores the _NET_WM_STATE_MAXIMIZED_HORZ atom when it's not # accompanied by _NET_WM_STATE_MAXIMIZED_VERT. We only catch # vertically maximized windows because GDK thinks they're tiled. assert self.props.window is not None MAXIMIZED_IN_ANY_WAY = (Gdk.WindowState.MAXIMIZED | Gdk.WindowState.TILED | Gdk.WindowState.FULLSCREEN) return (self.props.window.get_state() & MAXIMIZED_IN_ANY_WAY) != 0 def watch_file(self, filename, callback): log.debug('adding watch on %s', filename) gf = Gio.File.new_for_path(filename) gfm = gf.monitor_file(Gio.FileMonitorFlags.NONE, None) gfm.connect('changed', callback) self._watches[filename] = (gfm, None) # keep a reference so it doesn't get garbage collected if os.path.islink(filename): realpath = os.path.join(os.path.dirname(filename), os.readlink(filename)) log.debug('%s is a symlink, adding a watch on %s', filename, realpath) self._watches[filename] = (gfm, realpath) if realpath not in self._watches: # protect against infinite loops self.watch_file(realpath, callback) def unwatch_file(self, filename): # watch_file(a_symlink, callback) creates multiple watches, so be sure to unwatch them all while filename in self._watches: log.debug('removing watch on %s', filename) filename = self._watches.pop(filename)[1] def get_last_time(self): if self.timelog is None: return None return self.timelog.window.last_time() def get_time_window(self): if self.time_range == 'day': return self.timelog.window_for_day(self.date) elif self.time_range == 'week': return self.timelog.window_for_week(self.date) elif self.time_range == 'month': return self.timelog.window_for_month(self.date) def get_now(self): return datetime.datetime.now().replace(second=0, microsecond=0) def get_virtual_midnight(self): h, m = self.gsettings.get_value('virtual-midnight') return datetime.time(h, m) def get_today(self): return virtual_day(datetime.datetime.now(), self.get_virtual_midnight()) def get_current_task(self): """Return the current task entry text (as Unicode).""" entry = self.task_entry.get_text() if isinstance(entry, bytes): entry = entry.decode('UTF-8') return entry.strip() @date.getter def date(self): return self._date @date.setter def date(self, new_date): # Enforce strict typing if new_date is not None and not isinstance(new_date, datetime.date): new_date = None # Going back to today is the same as going home today = self.get_today() if new_date is None or new_date >= today: new_date = today old_date = self._date old_showing_today = self._showing_today self._date = new_date if new_date == today: self._showing_today = True self.actions.go_home.set_enabled(False) self.actions.go_forward.set_enabled(False) else: self._showing_today = False self.actions.go_home.set_enabled(True) self.actions.go_forward.set_enabled(True) if old_showing_today != self._showing_today: self.notify('showing_today') if old_date != self._date: self.notify('subtitle') @GObject.Property( type=bool, default=True, nick='Showing today', blurb='Currently visible time range includes today') def showing_today(self): return self._showing_today @GObject.Property( type=str, nick='Subtitle', blurb='Description of the visible time range') def subtitle(self): date = self.date if not date: return '' if self.time_range == 'day': return _("{0:%A, %Y-%m-%d} (week {1:0>2})").format( date, date.isocalendar()[1]) elif self.time_range == 'week': monday = date - datetime.timedelta(date.weekday()) sunday = monday + datetime.timedelta(6) isoyear, isoweek = date.isocalendar()[:2] return _("{0}, week {1} ({2:%B %-d}-{3:%-d})").format( isoyear, isoweek, monday, sunday) elif self.time_range == 'month': return _("{0:%B %Y}").format(date) def detail_level_changed(self, obj, param_spec): assert self.detail_level in {'chronological', 'grouped', 'summary'} self.notify('subtitle') def time_range_changed(self, obj, param_spec): assert self.time_range in {'day', 'week', 'month'} self.notify('subtitle') def on_search_changed(self, *args): self.filter_text = self.search_entry.get_text() def on_go_back(self, action, parameter): if self.time_range == 'day': self.date -= datetime.timedelta(1) elif self.time_range == 'week': self.date -= datetime.timedelta(7) elif self.time_range == 'month': self.date = prev_month(self.date) def on_go_forward(self, action, parameter): if self.time_range == 'day': self.date += datetime.timedelta(1) elif self.time_range == 'week': self.date += datetime.timedelta(7) elif self.time_range == 'month': self.date = next_month(self.date) def on_go_home(self, action, parameter): self.date = None def on_focus_task_entry(self, action, parameter): self.task_entry.grab_focus() def on_edit_last_entry(self, action, parameter): text = self.timelog.remove_last_entry() if text is not None: self.date = None self.notify('timelog') self.tick(True) self.task_entry.set_text(text) self.task_entry.grab_focus() self.task_entry.select_region(-1, -1) def on_add_entry(self, action, parameter): mark_time() mark_time("on_add_entry") entry = self.get_current_task() entry, now = self.timelog.parse_correction(entry) if not entry: return mark_time("adding the entry") if not self.showing_today: self.date = None # jump to today mark_time("jumped to today") previous_day = self.timelog.day self.timelog.append(entry, now) mark_time("appended") same_day = self.timelog.day == previous_day self.log_view.entry_added(same_day) mark_time("log_view updated") self.task_entry.entry_added() self.task_entry.set_text('') self.task_entry.grab_focus() mark_time("focus grabbed") self.tick(True) mark_time("label updated") def on_report(self, action, parameter): if self.main_stack.get_visible_child_name() == 'report': self.on_cancel_report() else: self.saved_date = self.date self.saved_time_range = self.time_range self.main_stack.set_visible_child_name('report') self.view_button.hide() self.task_pane_button.hide() self.menu_button.hide() self.cancel_report_button.show() self.send_report_button.show() self.report_view.show() self.headerbar.set_show_close_button(False) self.set_title(_("Report")) self.update_send_report_availability() def on_send_report(self, action, parameter): if self.main_stack.get_visible_child_name() != 'report': log.debug("Not sending report: not in report mode") return sender = self.report_view.sender recipient = self.report_view.recipient subject = self.report_view.subject body = self.report_view.body if not body: log.debug("Not sending report: no body") return if not recipient: log.debug("Not sending report: no destination") return try: self.send_email(sender, recipient, subject, body) except EmailError as e: self.infobar_label.set_text( _("Couldn't send email to {}: {}.").format(recipient, e)) self.infobar.show() else: self.record_sent_email(self.report_view.time_range, self.report_view.date, recipient) self.on_cancel_report() def send_email(self, sender, recipient, subject, body): smtp_server = self.gsettings.get_string('smtp-server') smtp_username = self.gsettings.get_string('smtp-username') callback = functools.partial(self._send_email, sender, recipient, subject, body) if smtp_username: start_smtp_password_lookup(smtp_server, smtp_username, callback) else: callback('') def _send_email(self, sender, recipient, subject, body, smtp_password): smtp_server = self.gsettings.get_string('smtp-server') smtp_port = self.gsettings.get_int('smtp-port') smtp_username = self.gsettings.get_string('smtp-username') sender_name, sender_address = parseaddr(sender) recipient_name, recipient_address = parseaddr(recipient) msg = prepare_message(sender, recipient, subject, body) mail_protocol = self.gsettings.get_string('mail-protocol') factory, starttls = MAIL_PROTOCOLS[mail_protocol] try: log.debug('Connecting to %s port %s', smtp_server, smtp_port or '(default)') with closing(factory(smtp_server, smtp_port)) as smtp: if DEBUG: smtp.set_debuglevel(1) if starttls: log.debug('Issuing STARTTLS') smtp.starttls() if smtp_username: log.debug('Logging in as %s', smtp_username) smtp.login(smtp_username, smtp_password) log.debug('Sending email from %s to %s', sender_address, recipient_address) smtp.sendmail(sender_address, [recipient_address], msg.as_string()) log.debug('Closing SMTP connection') except (OSError, smtplib.SMTPException) as e: log.error(_("Couldn't send mail: %s"), e) raise EmailError(e) else: log.debug('Email sent!') def record_sent_email(self, time_range, date, recipient): record = self.report_view.record try: report_kind = REPORT_KINDS[time_range] record.record(report_kind, date, recipient) except IOError as e: log.error(_("Couldn't append to {}: {}").format(record.filename, e)) def on_cancel_report(self, action=None, parameter=None): if self.main_stack.get_visible_child_name() != 'report': self.search_bar.set_search_mode(False) self.filter_text = '' return self.main_stack.set_visible_child_name('entry') self.view_button.show() self.task_pane_button.show() self.menu_button.show() self.cancel_report_button.hide() self.send_report_button.hide() self.report_view.hide() self.infobar.hide() self.headerbar.set_show_close_button(True) self.set_title(_("Time Log")) self.date = self.saved_date self.time_range = self.saved_time_range self.update_send_report_availability() self.add_button.grab_default() # huh def on_timelog_file_changed(self, monitor, file, other_file, event_type): # When I edit timelog.txt with vim, I get a series of notifications: # - Gio.FileMonitorEvent.DELETED # - Gio.FileMonitorEvent.CREATED # - Gio.FileMonitorEvent.CHANGED # - Gio.FileMonitorEvent.CHANGED # - Gio.FileMonitorEvent.CHANGES_DONE_HINT # - Gio.FileMonitorEvent.ATTRIBUTE_CHANGED # So, plan: react to CHANGES_DONE_HINT at once, but in case some # systems/OSes don't ever send it, react to other events after a # short delay, so we wouldn't have to reload the file more than # once. log.debug('watch on %s reports %s', file.get_path(), event_type.value_nick.upper()) if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT: self.check_reload() else: GLib.timeout_add_seconds(1, self.check_reload) def on_tasks_file_changed(self, monitor, file, other_file, event_type): log.debug('watch on %s reports %s', file.get_path(), event_type.value_nick.upper()) if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT: self.check_reload_tasks() else: GLib.timeout_add_seconds(1, self.check_reload_tasks) def check_reload(self): if self.timelog and self.timelog.check_reload(): self.notify('timelog') self.tick(True) def check_reload_tasks(self): if self.tasks and self.tasks.check_reload(): self.notify('tasks') def enable_add_entry(self): enabled = self.timelog is not None and self.get_current_task() self.actions.add_entry.set_enabled(enabled) def task_entry_changed(self, widget): self.enable_add_entry() def task_list_row_activated(self, treeview, path, view_column): task = self.task_list.get_task_for_row(path) self.task_entry.set_text(task) # There's a race here: sometimes the GDK_2BUTTON_PRESS signal is # handled _after_ row-activated, which makes the tree control steal # the focus back from the task entry. To avoid this, wait until all # the events have been handled. GLib.idle_add(self._focus_task_entry) def _focus_task_entry(self): self.task_entry.grab_focus() self.task_entry.set_position(-1) def tick(self, force_update=False): now = self.get_now() if not force_update and now == self.last_tick: # Do not eat CPU unnecessarily: update the time ticker only when # the minute changes. return True self.last_tick = now last_time = self.get_last_time() if last_time is None: self.time_label.set_text(now.strftime(_('%H:%M'))) else: self.time_label.set_text(format_duration(now - last_time)) self.log_view.now = now if self.showing_today and virtual_day(now, self.get_virtual_midnight()) != self.date: self.date = None return True class TaskEntry(Gtk.Entry): timelog = GObject.Property( type=object, default=None, nick='Time log', blurb='Time log object') completion_limit = GObject.Property( type=int, default=1000, nick='Completion limit', blurb='Maximum number of items in the completion popup') gtk_completion_enabled = GObject.Property( type=bool, default=True, nick='Completion enabled', blurb='GTK+ completion enabled?') def __init__(self): Gtk.Entry.__init__(self) self.set_up_history() self.set_up_completion() self.connect('notify::timelog', self.timelog_changed) self.connect('notify::completion-limit', self.timelog_changed) self.connect('changed', self.on_changed) self.connect('notify::gtk-completion-enabled', self.gtk_completion_enabled_changed) def set_up_history(self): self.history = [] self.filtered_history = [] self.history_pos = 0 self.history_undo = '' def set_up_completion(self): completion = self.gtk_completion = Gtk.EntryCompletion() self.completion_choices = Gtk.ListStore(str) self.completion_choices_as_set = set() completion.set_model(self.completion_choices) completion.set_text_column(0) completion.set_match_func( self.completion_match_func, self.completion_choices) if self.gtk_completion_enabled: self.set_completion(completion) def completion_match_func(self, completion, search_text, tree_iter, data): entry = data.get_value(tree_iter, 0).lower() pos = 0 for char in search_text: new_pos = entry.find(char, pos) if new_pos == -1: return False else: pos = new_pos return True def gtk_completion_enabled_changed(self, *args): if self.gtk_completion_enabled: self.set_completion(self.gtk_completion) else: self.set_completion(None) def timelog_changed(self, *args): mark_time('about to initialize history completion') self.completion_choices_as_set.clear() self.completion_choices.clear() if self.timelog is None: mark_time('no history') return self.history = [item[1] for item in self.timelog.items] mark_time('history prepared') # if there are duplicate entries, we want to keep the last one # e.g. if timelog.items contains [a, b, a, c], we want # self.completion_choices to be [b, a, c]. entries = [] for entry in reversed(self.history): if entry not in self.completion_choices_as_set: entries.append(entry) self.completion_choices_as_set.add(entry) mark_time('unique items selected') for entry in reversed(entries[:self.completion_limit]): self.completion_choices.append([entry]) mark_time('history completion initialized') def entry_added(self): if self.timelog is None: return entry = self.timelog.last_entry().entry self.history.append(entry) self.history_pos = 0 if entry not in self.completion_choices_as_set: self.completion_choices.append([entry]) self.completion_choices_as_set.add(entry) def on_changed(self, widget): self.history_pos = 0 def do_key_press_event(self, event): if event.keyval == Gdk.keyval_from_name('Prior'): self._do_history(1) return True if event.keyval == Gdk.keyval_from_name('Next'): self._do_history(-1) return True return Gtk.Entry.do_key_press_event(self, event) def _do_history(self, delta): """Handle movement in history.""" if not self.history: return if self.history_pos == 0: self.history_undo = self.get_text() self.filtered_history = uniq([ entry for entry in self.history if entry.startswith(self.history_undo) ]) history = self.filtered_history new_pos = max(0, min(self.history_pos + delta, len(history))) if new_pos == 0: self.set_text(self.history_undo) self.set_position(-1) else: self.set_text(history[-new_pos]) self.select_region(len(self.history_undo), -1) # Do this after on_changed reset history_pos to 0 self.history_pos = new_pos class LogView(Gtk.TextView): timelog = GObject.Property( type=object, default=None, nick='Time log', blurb='Time log object') date = GObject.Property( type=object, default=None, nick='Date', blurb='Date to show (None tracks today)') showing_today = GObject.Property( type=bool, default=True, nick='Showing today', blurb='Currently visible time range includes today') detail_level = GObject.Property( type=str, default='chronological', nick='Detail level', blurb='Detail level to show (chronological/grouped/summary)') time_range = GObject.Property( type=str, default='day', nick='Time range', blurb='Time range to show (day/week/month)') log_order = GObject.Property( type=str, default='start-time', nick='Log order', blurb='Log order of tasks/groups (start-time/name/duration/task-list)') hours = GObject.Property( type=float, default=0, nick='Hours', blurb='Target number of work hours per day') office_hours = GObject.Property( type=float, default=0, nick='Office Hours', blurb='Target number of office hours per day') current_task = GObject.Property( type=str, nick='Current task', blurb='Current task in progress') now = GObject.Property( type=object, default=None, nick='Now', blurb='Current date and time') filter_text = GObject.Property( type=str, default='', nick='Filter text', blurb='Show only tasks matching this substring') tasks = GObject.Property( type=object, nick='Tasks', blurb='The task list (an instance of TaskList)') def __init__(self): Gtk.TextView.__init__(self) self._extended_footer = False self._footer_mark = None self._update_pending = False self._footer_update_pending = False self.set_up_tabs() self.set_up_tags() self.connect('notify::timelog', self.queue_update) self.connect('notify::date', self.queue_update) self.connect('notify::showing-today', self.queue_update) self.connect('notify::detail-level', self.queue_update) self.connect('notify::time-range', self.queue_update) self.connect('notify::log-order', self.queue_update) self.connect('notify::hours', self.queue_footer_update) self.connect('notify::office-hours', self.queue_footer_update) self.connect('notify::current-task', self.queue_footer_update) self.connect('notify::now', self.queue_footer_update) self.connect('notify::filter-text', self.queue_update) self.connect('notify::tasks', self.queue_update) def queue_update(self, *args): if not self._update_pending: self._update_pending = True GLib.idle_add(self.populate_log) def queue_footer_update(self, *args): if not self._footer_update_pending: self._footer_update_pending = True GLib.idle_add(self.update_footer) def set_up_tabs(self): pango_context = self.get_pango_context() em = pango_context.get_font_description().get_size() tabs = Pango.TabArray.new(2, False) tabs.set_tab(0, Pango.TabAlign.LEFT, 9 * em) tabs.set_tab(1, Pango.TabAlign.LEFT, 12.5 * em) self.set_tabs(tabs) def set_up_tags(self): buffer = self.get_buffer() buffer.create_tag('today', foreground='#204a87') # Tango dark blue buffer.create_tag('duration', foreground='#ce5c00') # Tango dark orange buffer.create_tag('time', foreground='#4e9a06') # Tango dark green buffer.create_tag('highlight', foreground='#4e9a06') # Tango dark green buffer.create_tag('slacking', foreground='gray') def get_time_window(self): assert self.timelog is not None if self.time_range == 'day': return self.timelog.window_for_day(self.date) elif self.time_range == 'week': return self.timelog.window_for_week(self.date) elif self.time_range == 'month': return self.timelog.window_for_month(self.date) def get_last_time(self): assert self.timelog is not None return self.timelog.window.last_time() def get_current_task_time(self): last_time = self.get_last_time() if last_time is None: return datetime.timedelta(0) else: return self.now - last_time def get_current_task_work_time(self): if '**' in self.current_task: return datetime.timedelta(0) else: return self.get_current_task_time() def time_left_at_work(self, total_work): total_time = total_work + self.get_current_task_work_time() return datetime.timedelta(hours=self.hours) - total_time def populate_log(self): self._update_pending = False self.get_buffer().set_text('') if self.timelog is None: return # not loaded yet window = self.get_time_window() total = datetime.timedelta(0) if self.detail_level == 'chronological': prev = None for item in window.all_entries(): first_of_day = prev is None or different_days(prev, item.start, self.timelog.virtual_midnight) if first_of_day and prev is not None: self.w("\n") if self.time_range != 'day' and first_of_day: self.w(_("{0:%A, %Y-%m-%d}\n").format(item.start)) if self.filter_text in item.entry: self.write_item(item) total += item.duration prev = item.start elif self.detail_level == 'grouped': work, slack = window.grouped_entries(sorted_by=self.log_order, sorted_tasks=self.tasks) for start, entry, duration in work + slack: if self.filter_text in entry: self.write_group(entry, duration) total += duration elif self.detail_level == 'summary': entries, totals = window.categorized_work_entries() no_cat = totals.pop(None, None) categories = sorted(totals.items()) if no_cat is not None: categories = [('no category', no_cat)] + categories for category, duration in categories: if self.filter_text in category: self.write_group(category, duration) total += duration else: return # bug! if self.filter_text: self.w('\n') args = [ (self.filter_text, 'highlight'), (format_duration(total), 'duration'), ] if self.time_range != 'day': work_days = window.count_days() or 1 per_diem = total / work_days args.append((format_duration(per_diem), 'duration')) self.wfmt(_('Total for {0}: {1} ({2} per day)'), *args) else: weekly_window = self.timelog.window_for_week(self.date) work_days_in_week = weekly_window.count_days() or 1 week_work, week_slacking = weekly_window.totals( filter_text=self.filter_text) week_total = week_work + week_slacking args.append((format_duration(week_total), 'duration')) per_diem = week_total / work_days_in_week args.append((format_duration(per_diem), 'duration')) self.wfmt(_('Total for {0}: {1} ({2} this week, {3} per day)'), *args) self.w('\n') self.reposition_cursor() self.add_footer() self.scroll_to_end() def entry_added(self, same_day): if (self.detail_level == 'chronological' and same_day and not self.filter_text): self.delete_footer() self.write_item(self.timelog.last_entry()) self.add_footer() self.scroll_to_end() else: self.populate_log() def reposition_cursor(self): where = self.get_buffer().get_end_iter() where.backward_cursor_position() self.get_buffer().place_cursor(where) def scroll_to_end(self): # If I do the scrolling immediately, it won't scroll to the end, usually. # If I delay the scrolling, it works every time. # I only wish I knew how to disable the scroll animation. GLib.idle_add(self._scroll_to_end) def _scroll_to_end(self): buffer = self.get_buffer() self.scroll_to_iter(buffer.get_end_iter(), 0, False, 0, 0) def write_item(self, item): self.w(format_duration(item.duration), 'duration') self.w('\t') period = _('({0:%H:%M}-{1:%H:%M})').format(item.start, item.stop) self.w(period, 'time') self.w('\t') tag = ('slacking' if '**' in item.entry else None) self.w(item.entry + '\n', tag) def write_group(self, entry, duration): self.w(format_duration(duration), 'duration') tag = ('slacking' if '**' in entry else None) self.w('\t' + entry + '\n', tag) def w(self, text, tag=None): """Write some text at the end of the log buffer.""" buffer = self.get_buffer() if tag: buffer.insert_with_tags_by_name(buffer.get_end_iter(), text, tag) else: buffer.insert(buffer.get_end_iter(), text) def wfmt(self, fmt, *args): """Write formatted text at the end of the log buffer. Accepts the same kind of format string as Python's str.format(), e.g. "Hello, {0}". Each argument should be a tuple (value, tag_name). """ for bit in re.split(r'({\d+(?::[^}]*)?})', fmt): if bit.startswith('{'): spec = bit[1:-1] idx, colon, fmt = spec.partition(':') value, tag = args[int(idx)] if fmt: value = format(value, fmt) self.w(value, tag) else: self.w(bit) def should_have_extended_footer(self): return self.showing_today and self.time_range == 'day' def update_footer(self): self._footer_update_pending = False if self._footer_mark is None: return if self._extended_footer or self.should_have_extended_footer(): # Update "time left to work"/"at office today" self.delete_footer() self.add_footer() def delete_footer(self): buffer = self.get_buffer() buffer.delete( buffer.get_iter_at_mark(self._footer_mark), buffer.get_end_iter()) buffer.delete_mark(self._footer_mark) self._footer_mark = None def add_footer(self): buffer = self.get_buffer() self._footer_mark = buffer.create_mark( 'footer', buffer.get_end_iter(), True) window = self.get_time_window() total_work, total_slacking = window.totals() self.w('\n') if self.time_range == 'day': fmt1 = _('Total work done: {0} ({1} this week, {2} per day)') fmt2 = _('Total work done: {0} ({1} this week)') elif self.time_range == 'week': fmt1 = _('Total work done this week: {0} ({1} per day)') fmt2 = _('Total work done this week: {0}') elif self.time_range == 'month': fmt1 = _('Total work done this month: {0} ({1} per day)') fmt2 = _('Total work done this month: {0}') args = [(format_duration(total_work), 'duration')] if self.time_range == 'day': weekly_window = self.timelog.window_for_week(self.date) week_total_work, week_total_slacking = weekly_window.totals() work_days = weekly_window.count_days() args.append((format_duration(week_total_work), 'duration')) per_diem = week_total_work / max(1, work_days) else: work_days = window.count_days() per_diem = total_work / max(1, work_days) if work_days: args.append((format_duration(per_diem), 'duration')) self.wfmt(fmt1, *args) else: self.wfmt(fmt2, *args) self.w('\n') if self.time_range == 'day': fmt1 = _('Total slacking: {0} ({1} this week, {2} per day)') fmt2 = _('Total slacking: {0} ({1} this week)') elif self.time_range == 'week': fmt1 = _('Total slacking this week: {0} ({1} per day)') fmt2 = _('Total slacking this week: {0}') elif self.time_range == 'month': fmt1 = _('Total slacking this month: {0} ({1} per day)') fmt2 = _('Total slacking this month: {0}') args = [(format_duration(total_slacking), 'duration')] if self.time_range == 'day': args.append((format_duration(week_total_slacking), 'duration')) per_diem = week_total_slacking / max(1, work_days) else: per_diem = total_slacking / max(1, work_days) if work_days: args.append((format_duration(per_diem), 'duration')) self.wfmt(fmt1, *args) else: self.wfmt(fmt2, *args) if not self.should_have_extended_footer(): self._extended_footer = False return self._extended_footer = True if self.hours: self.w('\n') time_left = self.time_left_at_work(total_work) time_to_leave = self.now + time_left if time_left < datetime.timedelta(0): fmt = _("Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} until now)") real_time_left = datetime.timedelta(0) self.wfmt( fmt, (format_duration(real_time_left), 'duration'), (time_to_leave, 'time'), (format_duration(-time_left), 'duration'), ) else: fmt = _('Time left at work: {0} (till {1:%H:%M})') self.wfmt( fmt, (format_duration(time_left), 'duration'), (time_to_leave, 'time'), ) if self.office_hours: self.w('\n') hours = datetime.timedelta(hours=self.office_hours) total = total_slacking + total_work total += self.get_current_task_time() if total > hours: self.wfmt( _('At office today: {0} ({1} overtime)'), (format_duration(total), 'duration'), (format_duration(total - hours), 'duration'), ) else: self.wfmt( _('At office today: {0} ({1} left)'), (format_duration(total), 'duration'), (format_duration(hours - total), 'duration'), ) class ReportView(Gtk.TextView): timelog = GObject.Property( type=object, default=None, nick='Time log', blurb='Time log object') name = GObject.Property( type=str, nick='Name', blurb='Name of report sender') sender = GObject.Property( type=str, nick='Sender email', blurb='Email of the report sender') recipient = GObject.Property( type=str, nick='Recipient email', blurb='Email of the report recipient') date = GObject.Property( type=object, default=None, nick='Date', blurb='Date to show (None tracks today)') time_range = GObject.Property( type=str, default='day', nick='Time range', blurb='Time range to show (day/week/month)') report_style = GObject.Property( type=str, nick='Report style', blurb='Style of the report (plain/categorized)') body = GObject.Property( type=str, nick='Report body', blurb='Report body text') report_status = GObject.Property( type=str, default='not-sent', nick='Report status', blurb='Status of this particular report (not-sent/sent/sent-elsewhere)') report_sent_to = GObject.Property( type=str, nick='Report was sent to', blurb='Who already received this report (other than the current recipient?)') def __init__(self): Gtk.TextView.__init__(self) self._update_pending = False self._subject = '' self.connect('notify::timelog', self.queue_update) self.connect('notify::name', self.update_subject) self.connect('notify::date', self.queue_update) self.connect('notify::time-range', self.queue_update) self.connect('notify::report-style', self.queue_update) self.connect('notify::visible', self.queue_update) self.connect('notify::recipient', self.update_already_sent_indication) self.bind_property('body', self.get_buffer(), 'text', GObject.BindingFlags.BIDIRECTIONAL) # GTK+ themes other than Adwaita ignore the 'monospace' property and # use a proportional font for text widgets. self.override_font(Pango.FontDescription.from_string("Monospace")) filename = Settings().get_report_log_file() self.record = ReportRecord(filename) def queue_update(self, *args): if not self._update_pending: self._update_pending = True GLib.idle_add(self.populate_report) def get_time_window(self): assert self.timelog is not None if self.time_range == 'day': return self.timelog.window_for_day(self.date) elif self.time_range == 'week': return self.timelog.window_for_week(self.date) elif self.time_range == 'month': return self.timelog.window_for_month(self.date) @GObject.Property(type=str, nick='Name', blurb='Report subject') def subject(self): return self._subject def update_subject(self, *args): self._subject = '' if self.timelog is None or not self.get_visible(): self.notify('subject') return # not loaded yet window = self.get_time_window() reports = Reports(window) name = self.name if self.time_range == 'day': self._subject = reports.daily_report_subject(name) elif self.time_range == 'week': self._subject = reports.weekly_report_subject(name) elif self.time_range == 'month': self._subject = reports.monthly_report_subject(name) self.notify('subject') def populate_report(self): self._update_pending = False self.update_subject() if self.timelog is None or not self.get_visible(): self.get_buffer().set_text('') return # not loaded yet window = self.get_time_window() reports = Reports(window, email_headers=False, style=self.report_style) output = StringIO() recipient = self.recipient name = self.name if self.time_range == 'day': reports.daily_report(output, recipient, name) elif self.time_range == 'week': reports.weekly_report(output, recipient, name) elif self.time_range == 'month': reports.monthly_report(output, recipient, name) textbuf = self.get_buffer() textbuf.set_text(output.getvalue()) textbuf.place_cursor(textbuf.get_start_iter()) self.update_already_sent_indication() def update_already_sent_indication(self, *args): if not self.date: return report_kind = REPORT_KINDS[self.time_range] recipients = self.record.get_recipients(report_kind, self.date) self.report_sent_to = ', '.join(sorted(set(recipients) - {self.recipient})) if not recipients: self.report_status = 'not-sent' elif self.recipient in recipients: self.report_status = 'sent' else: self.report_status = 'sent-elsewhere' class TaskListView(Gtk.TreeView): tasks = GObject.Property( type=object, nick='Tasks', blurb='The task list (an instance of TaskList)') def __init__(self): Gtk.TreeView.__init__(self) self.task_store = Gtk.TreeStore(str, str) self.set_model(self.task_store) column = Gtk.TreeViewColumn(_('Tasks'), Gtk.CellRendererText(), text=0) self.append_column(column) self.connect('notify::tasks', self.tasks_changed) def get_task_for_row(self, path): return self.task_store[path][1] def tasks_changed(self, *args): mark_time('loading task list') self.task_store.clear() if self.tasks is None: mark_time('task list empty') return for group_name, group_items in self.tasks.groups: if group_name == self.tasks.other_title: t = self.task_store.append(None, [_("Other"), ""]) else: t = self.task_store.append(None, [group_name, group_name + ': ']) for item in group_items: if group_name == self.tasks.other_title: task = item else: task = group_name + ': ' + item self.task_store.append(t, [item, task]) self.expand_all() mark_time('task list loaded') class PreferencesDialog(Gtk.Dialog): use_header_bar = hasattr(Gtk.DialogFlags, 'USE_HEADER_BAR') def __init__(self, transient_for, page=None): kwargs = {} if self.use_header_bar: kwargs['use_header_bar'] = True Gtk.Dialog.__init__(self, transient_for=transient_for, title=_("Preferences"), **kwargs) self.set_default_size(500, 0) if not self.use_header_bar: self.add_button(_("Close"), Gtk.ResponseType.CLOSE) self.set_default_response(Gtk.ResponseType.CLOSE) else: # can't do it now, it doesn't have window decorations yet! GLib.idle_add(self.make_enter_close_the_dialog) builder = Gtk.Builder.new_from_file(PREFERENCES_UI_FILE) stack = builder.get_object('dialog_stack') self.get_content_area().add(stack) stack_switcher = Gtk.StackSwitcher(stack=stack) self.get_header_bar().set_custom_title(stack_switcher) stack_switcher.show() if page: stack.set_visible_child_name(page) virtual_midnight_entry = builder.get_object('virtual_midnight_entry') self.virtual_midnight_entry = virtual_midnight_entry hours_entry = builder.get_object('hours_entry') office_hours_entry = builder.get_object('office_hours_entry') name_entry = builder.get_object('name_entry') sender_entry = builder.get_object('sender_entry') recipient_entry = builder.get_object('recipient_entry') protocol_combo = builder.get_object('protocol_combo') server_entry = builder.get_object('server_entry') port_entry = builder.get_object('port_entry') self.port_entry = port_entry self.username_entry = builder.get_object('username_entry') self.password_entry = builder.get_object('password_entry') self.gsettings = Gio.Settings.new("org.gtimelog") self.gsettings.bind('hours', hours_entry, 'value', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('office-hours', office_hours_entry, 'value', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('name', name_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('sender', sender_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('list-email', recipient_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.gsettings.connect('changed::virtual-midnight', self.virtual_midnight_changed) self.virtual_midnight_changed() self.virtual_midnight_entry.connect('focus-out-event', self.virtual_midnight_set) self.gsettings.bind('mail-protocol', protocol_combo, 'active-id', Gio.SettingsBindFlags.DEFAULT) self.gsettings.bind('smtp-server', server_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.gsettings.connect('changed::smtp-port', self.smtp_port_changed) self.gsettings.connect('changed::mail-protocol', self.smtp_port_changed) self.smtp_port_changed() port_entry.connect('focus-out-event', self.smtp_port_set) self.gsettings.bind('smtp-username', self.username_entry, 'text', Gio.SettingsBindFlags.DEFAULT) self.refresh_password_field() server_entry.connect('focus-out-event', self.refresh_password_field) self.username_entry.connect('focus-out-event', self.refresh_password_field) self.password_entry.connect('focus-out-event', self.update_password) def make_enter_close_the_dialog(self): hb = self.get_header_bar() hb.forall(self._traverse_headerbar_children, None) def _traverse_headerbar_children(self, widget, user_data): if isinstance(widget, Gtk.Box): widget.forall(self._traverse_headerbar_children, None) elif isinstance(widget, Gtk.Button): if widget.get_style_context().has_class('close'): widget.set_can_default(True) widget.grab_default() def virtual_midnight_changed(self, *args): h, m = self.gsettings.get_value('virtual-midnight') self.virtual_midnight_entry.set_text('{:d}:{:02d}'.format(h, m)) def virtual_midnight_set(self, *args): try: vm = parse_time(self.virtual_midnight_entry.get_text()) except ValueError: self.virtual_midnight_changed() else: h, m = self.gsettings.get_value('virtual-midnight') if (h, m) != (vm.hour, vm.minute): self.gsettings.set_value('virtual-midnight', GLib.Variant('(ii)', (vm.hour, vm.minute))) def smtp_port_changed(self, *args): port = self.gsettings.get_int('smtp-port') if port == 0: mail_protocol = self.gsettings.get_string('mail-protocol') default_port = MAIL_PROTOCOLS[mail_protocol].factory.default_port self.port_entry.set_text('auto (%d)' % default_port) else: self.port_entry.set_text(str(port)) def smtp_port_set(self, *args): port = self.port_entry.get_text() if not port or port.lower().startswith("auto"): port = 0 try: port = int(port) if not 0 <= port <= 65535: raise ValueError('value out of range') except ValueError: self.smtp_port_changed() else: self.gsettings.set_int('smtp-port', port) def refresh_password_field(self, *args): server = self.gsettings.get_string("smtp-server") username = self.gsettings.get_string("smtp-username") def callback(password): # In theory the user could've focused the password field # and started typing in a new password, in which case we shouldn't # overwrite it! self.password_entry.set_text(password) if username: start_smtp_password_lookup(server, username, callback) else: self.password_entry.set_text("") def update_password(self, *args): server = self.gsettings.get_string("smtp-server") username = self.gsettings.get_string("smtp-username") password = self.password_entry.get_text() if username: set_smtp_password(server, username, password) def main(): mark_time("in main()") root_logger = logging.getLogger() root_logger.addHandler(logging.StreamHandler()) if DEBUG: root_logger.setLevel(logging.DEBUG) else: root_logger.setLevel(logging.INFO) # Tell Python's gettext.gettext() to use our translations gettext.bindtextdomain('gtimelog', LOCALE_DIR) gettext.textdomain('gtimelog') # Tell GTK+ to use out translations if hasattr(locale, 'bindtextdomain'): locale.bindtextdomain('gtimelog', LOCALE_DIR) locale.textdomain('gtimelog') else: # pragma: nocover # https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 # locale.bindtextdomain is missing on Windows! log.error(_("Unable to configure translations: no locale.bindtextdomain()")) # Make ^C terminate the process signal.signal(signal.SIGINT, signal.SIG_DFL) # Run the app app = Application() mark_time("app created") try: sys.exit(app.run(sys.argv)) finally: mark_time("exiting") if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/menus.ui0000664000175000017500000001140114556177605015334 0ustar00mgmg
Preferences app.preferences Keyboard Shortcuts app.shortcuts About app.about Quit app.quit
Edit log app.edit-log Edit last item app.edit-last-item Edit tasks app.edit-tasks Refresh tasks app.refresh-tasks action-disabled
Report... win.report
Preferences app.preferences Keyboard Shortcuts app.shortcuts About Time Log app.about
Detail level Chronological win.detail-level chronological Grouped win.detail-level grouped Summary win.detail-level summary
Time range Day win.time-range day Week win.time-range week Month win.time-range month
Sorting By start time win.log-order start-time By name win.log-order name By duration win.log-order duration By task list order win.log-order task-list
Filter win.show-search-bar
././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/paths.py0000664000175000017500000000311714515757026015340 0ustar00mgmg""" Resource locations for running out of source checkouts and pip installs """ import os import subprocess import sys here = os.path.dirname(__file__) SCHEMA_DIR = os.path.join(here, 'data') if SCHEMA_DIR and not os.environ.get('GSETTINGS_SCHEMA_DIR'): # Have to do this before importing 'gi'. # Note: it has been brought to my attention that I can do # source = Gio.SettingsSchemaSource.new_from_directory(SCHEMA_DIR, # Gio.SettingsSchemaSource.get_default(), False) # schema = source.lookup('...', False) # to load schemas from any location os.environ['GSETTINGS_SCHEMA_DIR'] = SCHEMA_DIR if not os.path.exists(os.path.join(SCHEMA_DIR, 'gschemas.compiled')): # This, too, I have to do before importing 'gi'. print("Compiling GSettings schema") glib_compile_schemas = os.path.join(sys.prefix, 'lib', 'site-packages', 'gnome', 'glib-compile-schemas.exe') if not os.path.exists(glib_compile_schemas): glib_compile_schemas = 'glib-compile-schemas' try: subprocess.call([glib_compile_schemas, SCHEMA_DIR]) except OSError as e: print("Failed: %s" % e) ui_dir = here UI_FILE = os.path.join(ui_dir, 'gtimelog.ui') PREFERENCES_UI_FILE = os.path.join(ui_dir, 'preferences.ui') ABOUT_DIALOG_UI_FILE = os.path.join(ui_dir, 'about.ui') SHORTCUTS_UI_FILE = os.path.join(ui_dir, 'shortcuts.ui') MENUS_UI_FILE = os.path.join(ui_dir, 'menus.ui') CSS_FILE = os.path.join(ui_dir, 'gtimelog.css') LOCALE_DIR = os.path.join(here, 'locale') CONTRIBUTORS_FILE = os.path.join(here, 'CONTRIBUTORS.rst') ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2760603 gtimelog-0.12.0/src/gtimelog/po/0000775000175000017500000000000014603245772014261 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/po/POTFILES.in0000664000175000017500000000034214515757026016037 0ustar00mgmg[encoding: UTF-8] ../../gtimelog.desktop.in main.py secrets.py [type: gettext/glade]about.ui [type: gettext/glade]gtimelog.ui [type: gettext/glade]preferences.ui [type: gettext/glade]menus.ui [type: gettext/glade]shortcuts.ui ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/po/en.po0000664000175000017500000002026714556177605015240 0ustar00mgmg# GTimeLog translation to English # Copyright (C) 2015 Marius Gedminas # This file is distributed under the same license as the GTimeLog package. # Marius Gedminas , 2015. # # msgid "" msgstr "" "Project-Id-Version: gtimelog 0.10.dev0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-30 14:15+0200\n" "PO-Revision-Date: 2015-09-04 10:53+0300\n" "Last-Translator: Marius Gedminas \n" "Language-Team: \n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" msgid "Time Log" msgstr "" msgid "Track and time daily activities" msgstr "" #, python-brace-format msgid "{0} h {1} min" msgstr "" msgid "Show version number and exit" msgstr "" msgid "Show debug information on the console" msgstr "" msgid "Open the preferences dialog" msgstr "" msgid "Open the preferences dialog on the email page" msgstr "" msgid "" "\n" "WARNING: GSettings schema for org.gtimelog is missing! If you're running " "from a source checkout, be sure to run 'make'." msgstr "" #, python-brace-format msgid "Could not create {directory}: {error}" msgstr "" #, python-brace-format msgid "Created {directory}" msgstr "" msgid "GTimeLog version: {}" msgstr "" msgid "Python version: {}" msgstr "" msgid "GTK+ version: {}.{}.{}" msgstr "" msgid "PyGI version: {}" msgstr "" msgid "Data directory: {}" msgstr "" msgid "Legacy config directory: {}" msgstr "" msgid "Settings will be migrated to GSettings (org.gtimelog) on first launch" msgstr "" msgid "Settings already migrated to GSettings (org.gtimelog)" msgstr "" #, python-brace-format msgid "Settings from {filename} migrated to GSettings (org.gtimelog)" msgstr "" msgid "Report already sent" msgstr "" msgid "Report already sent (to {})" msgstr "" msgid "Downloading tasks..." msgstr "" msgid "Download failed." msgstr "" msgid "{0:%A, %Y-%m-%d} (week {1:0>2})" msgstr "" msgid "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgstr "{0}, week {1} ({2:%B %-d}–{3:%-d})" msgid "{0:%B %Y}" msgstr "" msgid "Report" msgstr "" msgid "Couldn't send email to {}: {}." msgstr "" #, python-format msgid "Couldn't send mail: %s" msgstr "" msgid "Couldn't append to {}: {}" msgstr "" msgid "%H:%M" msgstr "" msgid "{0:%A, %Y-%m-%d}\n" msgstr "" #, python-brace-format msgid "Total for {0}: {1} ({2} per day)" msgstr "" #, python-brace-format msgid "Total for {0}: {1} ({2} this week, {3} per day)" msgstr "" msgid "({0:%H:%M}-{1:%H:%M})" msgstr "({0:%H:%M}–{1:%H:%M})" #, python-brace-format msgid "Total work done: {0} ({1} this week, {2} per day)" msgstr "" #, python-brace-format msgid "Total work done: {0} ({1} this week)" msgstr "" #, python-brace-format msgid "Total work done this week: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total work done this week: {0}" msgstr "" #, python-brace-format msgid "Total work done this month: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total work done this month: {0}" msgstr "" #, python-brace-format msgid "Total slacking: {0} ({1} this week, {2} per day)" msgstr "" #, python-brace-format msgid "Total slacking: {0} ({1} this week)" msgstr "" #, python-brace-format msgid "Total slacking this week: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total slacking this week: {0}" msgstr "" #, python-brace-format msgid "Total slacking this month: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total slacking this month: {0}" msgstr "" msgid "" "Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} " "until now)" msgstr "" msgid "Time left at work: {0} (till {1:%H:%M})" msgstr "" #, python-brace-format msgid "At office today: {0} ({1} overtime)" msgstr "" #, python-brace-format msgid "At office today: {0} ({1} left)" msgstr "" msgid "Tasks" msgstr "" msgid "Other" msgstr "" msgid "Preferences" msgstr "" msgid "Close" msgstr "" #. pragma: nocover #. https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 #. locale.bindtextdomain is missing on Windows! msgid "Unable to configure translations: no locale.bindtextdomain()" msgstr "" msgid "Failed to store SMTP password in the keyring." msgstr "" msgid "Failed to store HTTP password in the keyring." msgstr "" #, python-format msgid "" "Authentication is required for \"%s\"\n" "You need a username and a password to access %s" msgstr "" msgid "Copyright © 2004–2024 Marius Gedminas and contributors." msgstr "" msgid "A time tracking application" msgstr "" msgid "Add" msgstr "" msgid "Daily" msgstr "" msgid "Weekly" msgstr "" msgid "Monthly" msgstr "" msgid "Sender" msgstr "" msgid "Your Name " msgstr "" msgid "Recipient" msgstr "" msgid "email@example.com" msgstr "" msgid "Subject" msgstr "" msgid "Cancel" msgstr "" msgid "Send" msgstr "" msgid "Data entry" msgstr "" msgid "Virtual midnight" msgstr "" msgid "Goals" msgstr "" msgid "Work hours" msgstr "" msgid "Office hours" msgstr "" msgid "per day" msgstr "" msgid "(including lunch break)" msgstr "" msgid "Entry" msgstr "" msgid "Reports by email" msgstr "" msgid "Name" msgstr "" msgid "From" msgstr "" msgid "To" msgstr "" msgid "Nickname used in the Subject line" msgstr "" msgid "SMTP server" msgstr "" msgid "Server" msgstr "" msgid "Port" msgstr "" msgid "Security" msgstr "" msgid "None" msgstr "" msgid "TLS" msgstr "" msgid "StartTLS" msgstr "" msgid "Username" msgstr "" msgid "Password" msgstr "" msgid "Email" msgstr "" msgid "Keyboard Shortcuts" msgstr "" msgid "About" msgstr "" msgid "Quit" msgstr "" msgid "Edit log" msgstr "" msgid "Edit last item" msgstr "" msgid "Edit tasks" msgstr "" msgid "Refresh tasks" msgstr "" msgid "Report..." msgstr "" msgid "About Time Log" msgstr "" msgid "Detail level" msgstr "" msgid "Chronological" msgstr "" msgid "Grouped" msgstr "" msgid "Summary" msgstr "" msgid "Time range" msgstr "" msgid "Day" msgstr "" msgid "Week" msgstr "" msgid "Month" msgstr "" msgid "Sorting" msgstr "" msgid "By start time" msgstr "" msgid "By name" msgstr "" msgid "By duration" msgstr "" msgid "By task list order" msgstr "" msgid "Filter" msgstr "" msgctxt "shortcuts window" msgid "Entry" msgstr "" msgctxt "shortcut window" msgid "Detail level" msgstr "" msgctxt "shortcut window" msgid "Full chronological list" msgstr "" msgctxt "shortcut window" msgid "Group by description" msgstr "" msgctxt "shortcut window" msgid "Group by category" msgstr "" msgctxt "shortcut window" msgid "Time range" msgstr "" msgctxt "shortcut window" msgid "Day view" msgstr "" msgctxt "shortcut window" msgid "Week view" msgstr "" msgctxt "shortcut window" msgid "Month view" msgstr "" msgctxt "shortcut window" msgid "Sort order" msgstr "" msgctxt "shortcut window" msgid "By start time" msgstr "" msgctxt "shortcut window" msgid "By name" msgstr "" msgctxt "shortcut window" msgid "By duration" msgstr "" msgctxt "shortcut window" msgid "By task list order" msgstr "" msgctxt "shortcut window" msgid "Time navigation" msgstr "" msgctxt "shortcut window" msgid "Go back in time" msgstr "" msgctxt "shortcut window" msgid "Go forward in time" msgstr "" msgctxt "shortcut window" msgid "Go back to today" msgstr "" msgctxt "shortcut window" msgid "General" msgstr "" msgctxt "shortcut window" msgid "Menu" msgstr "" msgctxt "shortcut window" msgid "Focus the task entry" msgstr "" msgctxt "shortcut window" msgid "Edit last task entry" msgstr "" msgctxt "shortcut window" msgid "Keyboard shortcuts" msgstr "" msgctxt "shortcut window" msgid "Toggle search bar" msgstr "" msgctxt "shortcut window" msgid "Edit task log" msgstr "" msgctxt "shortcut window" msgid "Preferences" msgstr "" msgctxt "shortcut window" msgid "Quit" msgstr "" msgctxt "shortcut window" msgid "Task pane" msgstr "" msgctxt "shortcut window" msgid "Toggle task pane" msgstr "" msgctxt "shortcut window" msgid "Edit task list" msgstr "" msgctxt "shortcuts window" msgid "Reports" msgstr "" msgctxt "shortcut window" msgid "Reporting" msgstr "" msgctxt "shortcut window" msgid "Switch to report mode" msgstr "" msgctxt "shortcut window" msgid "Send report via email" msgstr "" msgctxt "shortcut window" msgid "Return to task entry mode" msgstr "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/po/fr.po0000664000175000017500000003110714556177605015240 0ustar00mgmg# GTimeLog translation to French # Copyright (C) 2020 Stéphane Mangin # This file is distributed under the same license as the GTimeLog package. # Marius Gedminas , 2020. # # msgid "" msgstr "" "Project-Id-Version: gtimelog 0.12.0.dev0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-30 14:15+0200\n" "PO-Revision-Date: 2020-08-08 15:52+0200\n" "Last-Translator: Stéphane Mangin \n" "Language-Team: \n" "Language: fr_FR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "(n%100<10 || n%100>=20) ? 1 : 2);\n" msgid "Time Log" msgstr "Gestion du temps" msgid "Track and time daily activities" msgstr "Gérer ses activités quotidiennes" #, python-brace-format msgid "{0} h {1} min" msgstr "{0} h {1} min" msgid "Show version number and exit" msgstr "Afficher le numéro de version et quitter" msgid "Show debug information on the console" msgstr "Afficher les informations de débogage sur le console" msgid "Open the preferences dialog" msgstr "Ouvrir les préférences" msgid "Open the preferences dialog on the email page" msgstr "Ouvrir les préférences dans la page email" msgid "" "\n" "WARNING: GSettings schema for org.gtimelog is missing! If you're running " "from a source checkout, be sure to run 'make'." msgstr "" "\n" "DĖMESIO: Le schéma GSettings de org.gtimelog est manquant ! Si vous l'avez " "lancé depuis une source git, assurez-vous d'avoir lancer 'make' " "préalablement." #, python-brace-format msgid "Could not create {directory}: {error}" msgstr "Impossible de créer le répertoire {directory}: {error}" #, python-brace-format msgid "Created {directory}" msgstr "Répertoire {directory} créé" msgid "GTimeLog version: {}" msgstr "GTimeLog version: {}" msgid "Python version: {}" msgstr "Python version: {}" msgid "GTK+ version: {}.{}.{}" msgstr "GTK+ version: {}.{}.{}" msgid "PyGI version: {}" msgstr "PyGI version: {}" msgid "Data directory: {}" msgstr "Répertoire de données: {}" msgid "Legacy config directory: {}" msgstr "Répertoire historique de configuration: {}" msgid "Settings will be migrated to GSettings (org.gtimelog) on first launch" msgstr "" "La configuration va être migrée vers GSettings (org.gtimelog) au premier " "lancement" msgid "Settings already migrated to GSettings (org.gtimelog)" msgstr "La configuration a déjà été migrée vers GSettings (org.gtimelog)" #, python-brace-format msgid "Settings from {filename} migrated to GSettings (org.gtimelog)" msgstr "" "Le fichier de configuration {filename} a été migrée vers GSettings (org." "gtimelog)" msgid "Report already sent" msgstr "Alerte déjà envoyée" msgid "Report already sent (to {})" msgstr "Alerte déjà envoyée (à {})" msgid "Downloading tasks..." msgstr "Téléchargement des tâches..." msgid "Download failed." msgstr "Téléchargement impossible." msgid "{0:%A, %Y-%m-%d} (week {1:0>2})" msgstr "{0:%A, %d/%m/%Y} (semaine {1:0>2})" msgid "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgstr "{0}, semaine {1} ({2:%-d} au {3:%-d %B})" msgid "{0:%B %Y}" msgstr "{0:%Y %B}" msgid "Report" msgstr "Alerter" msgid "Couldn't send email to {}: {}." msgstr "Impossible d'envoyer l'email à {}: {}." #, python-format msgid "Couldn't send mail: %s" msgstr "Impossible d'envoyer l'email: %s" msgid "Couldn't append to {}: {}" msgstr "Ajout impossible dans {}: {}" msgid "%H:%M" msgstr "%H:%M" msgid "{0:%A, %Y-%m-%d}\n" msgstr "{0:%A, %d/%m/%Y}\n" #, python-brace-format msgid "Total for {0}: {1} ({2} per day)" msgstr "Total pour {0}: {1} ({2} par jour)" #, python-brace-format msgid "Total for {0}: {1} ({2} this week, {3} per day)" msgstr "Total pour {0}: {1} (cette semaine {2}, {3} par jour)" msgid "({0:%H:%M}-{1:%H:%M})" msgstr "({0:%H:%M}–{1:%H:%M})" #, python-brace-format msgid "Total work done: {0} ({1} this week, {2} per day)" msgstr "Travail total effectué: {0} (cette semaine {1}, {2} par jour)" #, python-brace-format msgid "Total work done: {0} ({1} this week)" msgstr "Travail total effectué: {0} (cette semaine {1})" #, python-brace-format msgid "Total work done this week: {0} ({1} per day)" msgstr "Travail total effectué cette semaine: {0} ({1} par jour)" #, python-brace-format msgid "Total work done this week: {0}" msgstr "Travail total effectué cette semaine: {0}" #, python-brace-format msgid "Total work done this month: {0} ({1} per day)" msgstr "Travail total effectué ce mois: {0} ({1} par jour)" #, python-brace-format msgid "Total work done this month: {0}" msgstr "Travail total effectué ce mois: {0}" #, python-brace-format msgid "Total slacking: {0} ({1} this week, {2} per day)" msgstr "Total de temps de pause: {0} (cette semaine {1}, {2} par jour)" #, python-brace-format msgid "Total slacking: {0} ({1} this week)" msgstr "Total de temps de pause: {0} (cette semaine {1})" #, python-brace-format msgid "Total slacking this week: {0} ({1} per day)" msgstr "Total de temps de pause cette semaine: {0} ({1} par jour)" #, python-brace-format msgid "Total slacking this week: {0}" msgstr "Total de temps de pause cette semaine: {0}" #, python-brace-format msgid "Total slacking this month: {0} ({1} per day)" msgstr "Total de temps de pause ce mois: {0} ({1} par jour)" #, python-brace-format msgid "Total slacking this month: {0}" msgstr "Total de temps de pause ce mois: {0}" msgid "" "Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} " "until now)" msgstr "" "Temps restant au travail: {0} (fin estimée {1:%H:%M}, dépassement horaire de " "{2})" msgid "Time left at work: {0} (till {1:%H:%M})" msgstr "Temps restant au travail: {0} (jusqu'à {1:%H:%M})" #, python-brace-format msgid "At office today: {0} ({1} overtime)" msgstr "Au bureau aujourd'hui: {0} ({1} de dépassement)" #, python-brace-format msgid "At office today: {0} ({1} left)" msgstr "Au bureau aujourd'hui: {0} ({1} restante)" msgid "Tasks" msgstr "Tâches" msgid "Other" msgstr "Autre" msgid "Preferences" msgstr "Préférences" msgid "Close" msgstr "Fermer" #. pragma: nocover #. https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 #. locale.bindtextdomain is missing on Windows! msgid "Unable to configure translations: no locale.bindtextdomain()" msgstr "" "Impossible de configurer les traductions: pas de locale.bindtextdomain()" msgid "Failed to store SMTP password in the keyring." msgstr "Impossible de sauvegarder le mot de passe SMTP dans le portefeuille." msgid "Failed to store HTTP password in the keyring." msgstr "Impossible de sauvegarder le mot de passe HTTP dans le portefeuille." #, python-format msgid "" "Authentication is required for \"%s\"\n" "You need a username and a password to access %s" msgstr "" "L'authentification est requise pour \"%s\"\n" "Vous avez besoin d'un identifiant et d'un mot de passe pour accéder à %s" msgid "Copyright © 2004–2024 Marius Gedminas and contributors." msgstr "Tous droits réservés © 2004–2024 Marius Gedminas et contributeurs." msgid "A time tracking application" msgstr "Une application de gestion de temps" msgid "Add" msgstr "Ajouter" msgid "Daily" msgstr "Quotidien" msgid "Weekly" msgstr "Hebdomadaire" msgid "Monthly" msgstr "Mensuel" msgid "Sender" msgstr "Expéditeurs" msgid "Your Name " msgstr "Votre nom " msgid "Recipient" msgstr "Destinataire" msgid "email@example.com" msgstr "emailas@example.com" msgid "Subject" msgstr "Sujet" msgid "Cancel" msgstr "Annuler" msgid "Send" msgstr "Envoyer" msgid "Data entry" msgstr "Entrée des données" msgid "Virtual midnight" msgstr "Minuit virtuel" msgid "Goals" msgstr "Objectifs" msgid "Work hours" msgstr "Heures de travail" msgid "Office hours" msgstr "Heures de bureau" msgid "per day" msgstr "par jour" msgid "(including lunch break)" msgstr "(pause déjeuner incluse)" msgid "Entry" msgstr "Entrée" msgid "Reports by email" msgstr "Alerter par email" msgid "Name" msgstr "Nom" msgid "From" msgstr "De" msgid "To" msgstr "à" msgid "Nickname used in the Subject line" msgstr "Surnom utilisé dans le sujet" msgid "SMTP server" msgstr "Serveur SMTP" msgid "Server" msgstr "Serveur" msgid "Port" msgstr "Port" msgid "Security" msgstr "Sécurité" msgid "None" msgstr "Aucun" msgid "TLS" msgstr "TLS" msgid "StartTLS" msgstr "StartTLS" msgid "Username" msgstr "Identifiant" msgid "Password" msgstr "Mot de passe" msgid "Email" msgstr "Email" msgid "Keyboard Shortcuts" msgstr "Raccourcis claviers" msgid "About" msgstr "À propos" msgid "Quit" msgstr "Quitter" msgid "Edit log" msgstr "Éditer le fichier des entrées" #, fuzzy msgid "Edit last item" msgstr "Editer la liste de tâches" msgid "Edit tasks" msgstr "Éditer le fichier des tâches" msgid "Refresh tasks" msgstr "Rafraîchir les tâches" msgid "Report..." msgstr "Alerter..." msgid "About Time Log" msgstr "À propos de GTimeLog" msgid "Detail level" msgstr "Niveau de détails" msgid "Chronological" msgstr "Chronologique" msgid "Grouped" msgstr "Groupé" msgid "Summary" msgstr "Résumé" msgid "Time range" msgstr "Interval de temps" msgid "Day" msgstr "Jour" msgid "Week" msgstr "Semaine" msgid "Month" msgstr "Mois" #, fuzzy msgid "Sorting" msgstr "Alerter" msgid "By start time" msgstr "" msgid "By name" msgstr "" msgid "By duration" msgstr "" #, fuzzy msgid "By task list order" msgstr "Editer la liste de tâches" msgid "Filter" msgstr "Filtre" msgctxt "shortcuts window" msgid "Entry" msgstr "Entrée" msgctxt "shortcut window" msgid "Detail level" msgstr "Niveau de détails" msgctxt "shortcut window" msgid "Full chronological list" msgstr "Liste chronologique complète" msgctxt "shortcut window" msgid "Group by description" msgstr "Grouper par description" msgctxt "shortcut window" msgid "Group by category" msgstr "Grouper par catégories" msgctxt "shortcut window" msgid "Time range" msgstr "Interval de temps" msgctxt "shortcut window" msgid "Day view" msgstr "Vue journée" msgctxt "shortcut window" msgid "Week view" msgstr "Vue semaine" msgctxt "shortcut window" msgid "Month view" msgstr "Vue mois" msgctxt "shortcut window" msgid "Sort order" msgstr "" msgctxt "shortcut window" msgid "By start time" msgstr "" msgctxt "shortcut window" msgid "By name" msgstr "" msgctxt "shortcut window" msgid "By duration" msgstr "" #, fuzzy msgctxt "shortcut window" msgid "By task list order" msgstr "Editer la liste de tâches" msgctxt "shortcut window" msgid "Time navigation" msgstr "Navigation temporelle" msgctxt "shortcut window" msgid "Go back in time" msgstr "Retour vers le futur" msgctxt "shortcut window" msgid "Go forward in time" msgstr "Avancer dans le temps" msgctxt "shortcut window" msgid "Go back to today" msgstr "Revenir à aujourd'hui" msgctxt "shortcut window" msgid "General" msgstr "Général" msgctxt "shortcut window" msgid "Menu" msgstr "Menu" #, fuzzy msgctxt "shortcut window" msgid "Focus the task entry" msgstr "Retourner en mode edition" #, fuzzy msgctxt "shortcut window" msgid "Edit last task entry" msgstr "Retourner en mode edition" msgctxt "shortcut window" msgid "Keyboard shortcuts" msgstr "Raccourcis claviers" msgctxt "shortcut window" msgid "Toggle search bar" msgstr "Afficher/masquer la barre de recherche" msgctxt "shortcut window" msgid "Edit task log" msgstr "Éditer le fichier des tâches" msgctxt "shortcut window" msgid "Preferences" msgstr "Préférences" msgctxt "shortcut window" msgid "Quit" msgstr "Quitter" msgctxt "shortcut window" msgid "Task pane" msgstr "Nom de tâche" msgctxt "shortcut window" msgid "Toggle task pane" msgstr "Afficher/masquer le panneau des tâches" msgctxt "shortcut window" msgid "Edit task list" msgstr "Editer la liste de tâches" msgctxt "shortcuts window" msgid "Reports" msgstr "Alertes" msgctxt "shortcut window" msgid "Reporting" msgstr "Alerter" msgctxt "shortcut window" msgid "Switch to report mode" msgstr "Changer pour le mode d'alerte" msgctxt "shortcut window" msgid "Send report via email" msgstr "Alerter par email" msgctxt "shortcut window" msgid "Return to task entry mode" msgstr "Retourner en mode edition" #~ msgid "Couldn't execute %s: %s" #~ msgstr "Impossible d'exécuter %s: %s" #~ msgid "Couldn't send email: %s returned code %d" #~ msgstr "Impossible d'envoyer l'email: %s a retourné le code %d" #~ msgid "Help" #~ msgstr "Aide" #~ msgctxt "shortcut window" #~ msgid "Help" #~ msgstr "Aide" #~ msgid "Total for {0}: {1}" #~ msgstr "Total pour {0}: {1}" #~ msgid "J. Random Hacker " #~ msgstr "Vardenis Pavardenis " #~ msgid "JRH" #~ msgstr "JRH" #~ msgid "Custom..." #~ msgstr "Spécifique..." ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/po/gtimelog.pot0000664000175000017500000002006514556177605016625 0ustar00mgmg# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-30 14:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Time Log" msgstr "" msgid "Track and time daily activities" msgstr "" #, python-brace-format msgid "{0} h {1} min" msgstr "" msgid "Show version number and exit" msgstr "" msgid "Show debug information on the console" msgstr "" msgid "Open the preferences dialog" msgstr "" msgid "Open the preferences dialog on the email page" msgstr "" msgid "" "\n" "WARNING: GSettings schema for org.gtimelog is missing! If you're running " "from a source checkout, be sure to run 'make'." msgstr "" #, python-brace-format msgid "Could not create {directory}: {error}" msgstr "" #, python-brace-format msgid "Created {directory}" msgstr "" msgid "GTimeLog version: {}" msgstr "" msgid "Python version: {}" msgstr "" msgid "GTK+ version: {}.{}.{}" msgstr "" msgid "PyGI version: {}" msgstr "" msgid "Data directory: {}" msgstr "" msgid "Legacy config directory: {}" msgstr "" msgid "Settings will be migrated to GSettings (org.gtimelog) on first launch" msgstr "" msgid "Settings already migrated to GSettings (org.gtimelog)" msgstr "" #, python-brace-format msgid "Settings from {filename} migrated to GSettings (org.gtimelog)" msgstr "" msgid "Report already sent" msgstr "" msgid "Report already sent (to {})" msgstr "" msgid "Downloading tasks..." msgstr "" msgid "Download failed." msgstr "" msgid "{0:%A, %Y-%m-%d} (week {1:0>2})" msgstr "" msgid "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgstr "" msgid "{0:%B %Y}" msgstr "" msgid "Report" msgstr "" msgid "Couldn't send email to {}: {}." msgstr "" #, python-format msgid "Couldn't send mail: %s" msgstr "" msgid "Couldn't append to {}: {}" msgstr "" msgid "%H:%M" msgstr "" msgid "{0:%A, %Y-%m-%d}\n" msgstr "" #, python-brace-format msgid "Total for {0}: {1} ({2} per day)" msgstr "" #, python-brace-format msgid "Total for {0}: {1} ({2} this week, {3} per day)" msgstr "" msgid "({0:%H:%M}-{1:%H:%M})" msgstr "" #, python-brace-format msgid "Total work done: {0} ({1} this week, {2} per day)" msgstr "" #, python-brace-format msgid "Total work done: {0} ({1} this week)" msgstr "" #, python-brace-format msgid "Total work done this week: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total work done this week: {0}" msgstr "" #, python-brace-format msgid "Total work done this month: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total work done this month: {0}" msgstr "" #, python-brace-format msgid "Total slacking: {0} ({1} this week, {2} per day)" msgstr "" #, python-brace-format msgid "Total slacking: {0} ({1} this week)" msgstr "" #, python-brace-format msgid "Total slacking this week: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total slacking this week: {0}" msgstr "" #, python-brace-format msgid "Total slacking this month: {0} ({1} per day)" msgstr "" #, python-brace-format msgid "Total slacking this month: {0}" msgstr "" msgid "" "Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} " "until now)" msgstr "" msgid "Time left at work: {0} (till {1:%H:%M})" msgstr "" #, python-brace-format msgid "At office today: {0} ({1} overtime)" msgstr "" #, python-brace-format msgid "At office today: {0} ({1} left)" msgstr "" msgid "Tasks" msgstr "" msgid "Other" msgstr "" msgid "Preferences" msgstr "" msgid "Close" msgstr "" #. pragma: nocover #. https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 #. locale.bindtextdomain is missing on Windows! msgid "Unable to configure translations: no locale.bindtextdomain()" msgstr "" msgid "Failed to store SMTP password in the keyring." msgstr "" msgid "Failed to store HTTP password in the keyring." msgstr "" #, python-format msgid "" "Authentication is required for \"%s\"\n" "You need a username and a password to access %s" msgstr "" msgid "Copyright © 2004–2024 Marius Gedminas and contributors." msgstr "" msgid "A time tracking application" msgstr "" msgid "Add" msgstr "" msgid "Daily" msgstr "" msgid "Weekly" msgstr "" msgid "Monthly" msgstr "" msgid "Sender" msgstr "" msgid "Your Name " msgstr "" msgid "Recipient" msgstr "" msgid "email@example.com" msgstr "" msgid "Subject" msgstr "" msgid "Cancel" msgstr "" msgid "Send" msgstr "" msgid "Data entry" msgstr "" msgid "Virtual midnight" msgstr "" msgid "Goals" msgstr "" msgid "Work hours" msgstr "" msgid "Office hours" msgstr "" msgid "per day" msgstr "" msgid "(including lunch break)" msgstr "" msgid "Entry" msgstr "" msgid "Reports by email" msgstr "" msgid "Name" msgstr "" msgid "From" msgstr "" msgid "To" msgstr "" msgid "Nickname used in the Subject line" msgstr "" msgid "SMTP server" msgstr "" msgid "Server" msgstr "" msgid "Port" msgstr "" msgid "Security" msgstr "" msgid "None" msgstr "" msgid "TLS" msgstr "" msgid "StartTLS" msgstr "" msgid "Username" msgstr "" msgid "Password" msgstr "" msgid "Email" msgstr "" msgid "Keyboard Shortcuts" msgstr "" msgid "About" msgstr "" msgid "Quit" msgstr "" msgid "Edit log" msgstr "" msgid "Edit last item" msgstr "" msgid "Edit tasks" msgstr "" msgid "Refresh tasks" msgstr "" msgid "Report..." msgstr "" msgid "About Time Log" msgstr "" msgid "Detail level" msgstr "" msgid "Chronological" msgstr "" msgid "Grouped" msgstr "" msgid "Summary" msgstr "" msgid "Time range" msgstr "" msgid "Day" msgstr "" msgid "Week" msgstr "" msgid "Month" msgstr "" msgid "Sorting" msgstr "" msgid "By start time" msgstr "" msgid "By name" msgstr "" msgid "By duration" msgstr "" msgid "By task list order" msgstr "" msgid "Filter" msgstr "" msgctxt "shortcuts window" msgid "Entry" msgstr "" msgctxt "shortcut window" msgid "Detail level" msgstr "" msgctxt "shortcut window" msgid "Full chronological list" msgstr "" msgctxt "shortcut window" msgid "Group by description" msgstr "" msgctxt "shortcut window" msgid "Group by category" msgstr "" msgctxt "shortcut window" msgid "Time range" msgstr "" msgctxt "shortcut window" msgid "Day view" msgstr "" msgctxt "shortcut window" msgid "Week view" msgstr "" msgctxt "shortcut window" msgid "Month view" msgstr "" msgctxt "shortcut window" msgid "Sort order" msgstr "" msgctxt "shortcut window" msgid "By start time" msgstr "" msgctxt "shortcut window" msgid "By name" msgstr "" msgctxt "shortcut window" msgid "By duration" msgstr "" msgctxt "shortcut window" msgid "By task list order" msgstr "" msgctxt "shortcut window" msgid "Time navigation" msgstr "" msgctxt "shortcut window" msgid "Go back in time" msgstr "" msgctxt "shortcut window" msgid "Go forward in time" msgstr "" msgctxt "shortcut window" msgid "Go back to today" msgstr "" msgctxt "shortcut window" msgid "General" msgstr "" msgctxt "shortcut window" msgid "Menu" msgstr "" msgctxt "shortcut window" msgid "Focus the task entry" msgstr "" msgctxt "shortcut window" msgid "Edit last task entry" msgstr "" msgctxt "shortcut window" msgid "Keyboard shortcuts" msgstr "" msgctxt "shortcut window" msgid "Toggle search bar" msgstr "" msgctxt "shortcut window" msgid "Edit task log" msgstr "" msgctxt "shortcut window" msgid "Preferences" msgstr "" msgctxt "shortcut window" msgid "Quit" msgstr "" msgctxt "shortcut window" msgid "Task pane" msgstr "" msgctxt "shortcut window" msgid "Toggle task pane" msgstr "" msgctxt "shortcut window" msgid "Edit task list" msgstr "" msgctxt "shortcuts window" msgid "Reports" msgstr "" msgctxt "shortcut window" msgid "Reporting" msgstr "" msgctxt "shortcut window" msgid "Switch to report mode" msgstr "" msgctxt "shortcut window" msgid "Send report via email" msgstr "" msgctxt "shortcut window" msgid "Return to task entry mode" msgstr "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/po/lt.po0000664000175000017500000003037214556177605015253 0ustar00mgmg# GTimeLog translation to Lithuanian # Copyright (C) 2015 Marius Gedminas # This file is distributed under the same license as the GTimeLog package. # Marius Gedminas , 2015. # # msgid "" msgstr "" "Project-Id-Version: gtimelog 0.10.dev0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-30 14:15+0200\n" "PO-Revision-Date: 2015-09-04 10:53+0300\n" "Last-Translator: Marius Gedminas \n" "Language-Team: \n" "Language: lt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "(n%100<10 || n%100>=20) ? 1 : 2);\n" msgid "Time Log" msgstr "Laiko žurnalas" msgid "Track and time daily activities" msgstr "Sekti kasdieninius darbus" #, python-brace-format msgid "{0} h {1} min" msgstr "{0} val {1} min" msgid "Show version number and exit" msgstr "Rodyti programos versiją ir išeiti" msgid "Show debug information on the console" msgstr "Rodyti derinimo informaciją konsolėje" msgid "Open the preferences dialog" msgstr "Atidaryti nustatymų dialogą" msgid "Open the preferences dialog on the email page" msgstr "Atidaryti nustatymų dialogo el. pašto puslapį" msgid "" "\n" "WARNING: GSettings schema for org.gtimelog is missing! If you're running " "from a source checkout, be sure to run 'make'." msgstr "" "\n" "DĖMESIO: trūksta GSettings schemos „org.gtimelog“. Jei leidžiate programą " "iš pradinio kodo katalogo, paleiskite „make“." #, python-brace-format msgid "Could not create {directory}: {error}" msgstr "Nepavyko sukurti {directory}: {error}" #, python-brace-format msgid "Created {directory}" msgstr "Sukurtas {directory}" msgid "GTimeLog version: {}" msgstr "GTimeLog versija: {}" msgid "Python version: {}" msgstr "Python versija: {}" msgid "GTK+ version: {}.{}.{}" msgstr "GTK+ versija: {}.{}.{}" msgid "PyGI version: {}" msgstr "PyGI versija: {}" msgid "Data directory: {}" msgstr "Duomenų katalogas: {}" msgid "Legacy config directory: {}" msgstr "Senų nustatymų katalogas: {}" msgid "Settings will be migrated to GSettings (org.gtimelog) on first launch" msgstr "" "Nustatymai bus perkelti į GSettings (org.gtimelog) pirmo paleidimo metu" msgid "Settings already migrated to GSettings (org.gtimelog)" msgstr "Nustatymai jau buvo perkelti į GSettings (org.gtimelog)" #, python-brace-format msgid "Settings from {filename} migrated to GSettings (org.gtimelog)" msgstr "Nustatymai perkelti iš {filename} į GSettings (org.gtimelog)" msgid "Report already sent" msgstr "Ataskaita jau buvo išsiųsta" msgid "Report already sent (to {})" msgstr "Ataskaita jau buvo išsiųsta kitam gavėjui ({})" msgid "Downloading tasks..." msgstr "Parsiunčiamos užduotys..." msgid "Download failed." msgstr "Nepavyko parsisiųsti užduočių." msgid "{0:%A, %Y-%m-%d} (week {1:0>2})" msgstr "{0:%A, %Y-%m-%d} ({1} savaitė)" msgid "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgstr "{0}, {1} savaitė ({2:%B %-d}–{3:%-d})" msgid "{0:%B %Y}" msgstr "{0:%Y %B} mėn." msgid "Report" msgstr "Ataskaita" msgid "Couldn't send email to {}: {}." msgstr "Nepavyko išsiųsti laiško {}: {}." #, python-format msgid "Couldn't send mail: %s" msgstr "Nepavyko išsiųsti laiško: %s." msgid "Couldn't append to {}: {}" msgstr "Nepavyko rašyti į {}: {}" msgid "%H:%M" msgstr "%H:%M" msgid "{0:%A, %Y-%m-%d}\n" msgstr "{0:%A, %Y-%m-%d}\n" #, python-brace-format msgid "Total for {0}: {1} ({2} per day)" msgstr "Iš viso {0}: {1} (po {2} per dieną)" #, python-brace-format msgid "Total for {0}: {1} ({2} this week, {3} per day)" msgstr "Iš viso {0}: {1} (šią savaitę {2}, po {3} per dieną)" msgid "({0:%H:%M}-{1:%H:%M})" msgstr "({0:%H:%M}–{1:%H:%M})" #, python-brace-format msgid "Total work done: {0} ({1} this week, {2} per day)" msgstr "Iš viso dirbta: {0} (šią savaitę {1}, po {2} per dieną)" #, python-brace-format msgid "Total work done: {0} ({1} this week)" msgstr "Iš viso dirbta: {0} (šią savaitę {1})" #, python-brace-format msgid "Total work done this week: {0} ({1} per day)" msgstr "Iš viso dirbta šią savaitę: {0} (po {1} per dieną)" #, python-brace-format msgid "Total work done this week: {0}" msgstr "Iš viso dirbta šią savaitę: {0}" #, python-brace-format msgid "Total work done this month: {0} ({1} per day)" msgstr "Iš viso dirbta šį mėnesį: {0} (po {1} per dieną)" #, python-brace-format msgid "Total work done this month: {0}" msgstr "Iš viso dirbta šį mėnesį: {0}" #, python-brace-format msgid "Total slacking: {0} ({1} this week, {2} per day)" msgstr "Iš viso nedirbta: {0} (šią savaitę {1}, po {2} per dieną)" #, python-brace-format msgid "Total slacking: {0} ({1} this week)" msgstr "Iš viso nedirbta: {0} (šią savaitę {1})" #, python-brace-format msgid "Total slacking this week: {0} ({1} per day)" msgstr "Iš viso nedirbta šią savaitę: {0} (po {1} per dieną)" #, python-brace-format msgid "Total slacking this week: {0}" msgstr "Iš viso nedirbta šią savaitę: {0}" #, python-brace-format msgid "Total slacking this month: {0} ({1} per day)" msgstr "Iš viso nedirbta šį mėnesį: {0} (po {1} per dieną)" #, python-brace-format msgid "Total slacking this month: {0}" msgstr "Iš viso nedirbta šį mėnesį: {0}" msgid "" "Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} " "until now)" msgstr "Liko dirbti: {0} (reikėjo baigti {1:%H:%M}, viršvalandžiai {2})" msgid "Time left at work: {0} (till {1:%H:%M})" msgstr "Liko dirbti: {0} (iki {1:%H:%M})" #, python-brace-format msgid "At office today: {0} ({1} overtime)" msgstr "Šiandien darbe: {0} ({1} per daug)" #, python-brace-format msgid "At office today: {0} ({1} left)" msgstr "Šiandien darbe: {0} (liko {1})" msgid "Tasks" msgstr "Užduotys" msgid "Other" msgstr "Kitos" msgid "Preferences" msgstr "Nustatymai" msgid "Close" msgstr "Uždaryti" #. pragma: nocover #. https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 #. locale.bindtextdomain is missing on Windows! msgid "Unable to configure translations: no locale.bindtextdomain()" msgstr "Nepavyko įjungti vertimų: nėra locale.bindtextdomain()" msgid "Failed to store SMTP password in the keyring." msgstr "Nepavyko išsaugoti SMTP slaptažodžio raktinėje." msgid "Failed to store HTTP password in the keyring." msgstr "Nepavyko išsaugoti HTTP slaptažodžio raktinėje." #, python-format msgid "" "Authentication is required for \"%s\"\n" "You need a username and a password to access %s" msgstr "" "Reikia prisijungti prie \"%s\"\n" "Reikia naudotojo vardo ir slaptažodžio, kad pasiektumėte %s" msgid "Copyright © 2004–2024 Marius Gedminas and contributors." msgstr "Autorinės teisės © 2004–2024 Marius Gedminas ir kiti." msgid "A time tracking application" msgstr "Programa darbo laiko sekimui" msgid "Add" msgstr "Pridėti" msgid "Daily" msgstr "Dienos" msgid "Weekly" msgstr "Savaitės" msgid "Monthly" msgstr "Mėnesio" msgid "Sender" msgstr "Siuntėjas" msgid "Your Name " msgstr "Jūsų Vardas " msgid "Recipient" msgstr "Gavėjas" msgid "email@example.com" msgstr "emailas@example.com" msgid "Subject" msgstr "Tema" msgid "Cancel" msgstr "Atšaukti" msgid "Send" msgstr "Siųsti" msgid "Data entry" msgstr "Duomenų įvedimas" msgid "Virtual midnight" msgstr "Virtualus vidurnaktis" msgid "Goals" msgstr "Tikslai" msgid "Work hours" msgstr "Darbo valandos" msgid "Office hours" msgstr "Valandos darbe" msgid "per day" msgstr "per dieną" msgid "(including lunch break)" msgstr "(įskaitant pietų pertrauką)" msgid "Entry" msgstr "Įvedimas" msgid "Reports by email" msgstr "Ataskaitos paštu" msgid "Name" msgstr "Vardas" msgid "From" msgstr "Nuo" msgid "To" msgstr "Kam" msgid "Nickname used in the Subject line" msgstr "Trumpas vardas temos laukeliui" msgid "SMTP server" msgstr "SMTP serveris" msgid "Server" msgstr "Serveris" msgid "Port" msgstr "Prievadas" msgid "Security" msgstr "Saugumas" msgid "None" msgstr "nėra" msgid "TLS" msgstr "TLS" msgid "StartTLS" msgstr "StartTLS" msgid "Username" msgstr "Naudotojo vardas" msgid "Password" msgstr "Slaptažodis" msgid "Email" msgstr "Paštas" msgid "Keyboard Shortcuts" msgstr "Trumpiniai" msgid "About" msgstr "Apie" msgid "Quit" msgstr "Išeiti" msgid "Edit log" msgstr "Taisyti žurnalą" msgid "Edit last item" msgstr "Taisyti paskutinį įrašą" msgid "Edit tasks" msgstr "Tvarkyti užduotis" msgid "Refresh tasks" msgstr "Atnaujinti užduotis" msgid "Report..." msgstr "Ataskaita..." msgid "About Time Log" msgstr "Apie Laiko žurnalą" msgid "Detail level" msgstr "Detalumas" msgid "Chronological" msgstr "Chronologinis" msgid "Grouped" msgstr "Sugrupuotas" msgid "Summary" msgstr "Apžvalga" msgid "Time range" msgstr "Laiko intervalas" msgid "Day" msgstr "Diena" msgid "Week" msgstr "Savaitė" msgid "Month" msgstr "Mėnuo" msgid "Sorting" msgstr "Rikiavimas" msgid "By start time" msgstr "Pagal pradžios laiką" msgid "By name" msgstr "Pagal pavadinimą" msgid "By duration" msgstr "Pagal trukmę" msgid "By task list order" msgstr "Pagal užduočių sąrašą" msgid "Filter" msgstr "Filtruoti" msgctxt "shortcuts window" msgid "Entry" msgstr "Įvedimas" msgctxt "shortcut window" msgid "Detail level" msgstr "Detalumas" msgctxt "shortcut window" msgid "Full chronological list" msgstr "Chronologinis" msgctxt "shortcut window" msgid "Group by description" msgstr "Grupuoti pagal pavadinimą" msgctxt "shortcut window" msgid "Group by category" msgstr "Grupuoti pagal kategoriją" msgctxt "shortcut window" msgid "Time range" msgstr "Laiko intervalas" msgctxt "shortcut window" msgid "Day view" msgstr "Diena" msgctxt "shortcut window" msgid "Week view" msgstr "Savaitė" msgctxt "shortcut window" msgid "Month view" msgstr "Mėnuo" msgctxt "shortcut window" msgid "Sort order" msgstr "Rikiavimas" msgctxt "shortcut window" msgid "By start time" msgstr "Pagal pradžios laiką" msgctxt "shortcut window" msgid "By name" msgstr "Pagal pavadinimą" msgctxt "shortcut window" msgid "By duration" msgstr "Pagal trukmę" msgctxt "shortcut window" msgid "By task list order" msgstr "Pagal užduočių sąrašą" msgctxt "shortcut window" msgid "Time navigation" msgstr "Navigacija" msgctxt "shortcut window" msgid "Go back in time" msgstr "Eiti atgal laike" msgctxt "shortcut window" msgid "Go forward in time" msgstr "Eiti pirmyn laike" msgctxt "shortcut window" msgid "Go back to today" msgstr "Grįžti į šiandieną" msgctxt "shortcut window" msgid "General" msgstr "Bendri" msgctxt "shortcut window" msgid "Menu" msgstr "Meniu" msgctxt "shortcut window" msgid "Focus the task entry" msgstr "Fokusuoti įvesties lauką" msgctxt "shortcut window" msgid "Edit last task entry" msgstr "Perkelti paskutinį žurnalo įrašą į įvesties lauką" msgctxt "shortcut window" msgid "Keyboard shortcuts" msgstr "Trumpiniai" msgctxt "shortcut window" msgid "Toggle search bar" msgstr "Rodyti/slėpti paieškos lauką" msgctxt "shortcut window" msgid "Edit task log" msgstr "Taisyti žurnalą" msgctxt "shortcut window" msgid "Preferences" msgstr "Nustatymai" msgctxt "shortcut window" msgid "Quit" msgstr "Užverti programą" msgctxt "shortcut window" msgid "Task pane" msgstr "Užduočių skiltis" msgctxt "shortcut window" msgid "Toggle task pane" msgstr "Rodyti/slėpti užduočių skiltį" msgctxt "shortcut window" msgid "Edit task list" msgstr "Tvarkyti užduotis" msgctxt "shortcuts window" msgid "Reports" msgstr "Ataskaitos" msgctxt "shortcut window" msgid "Reporting" msgstr "Ataskaitos" msgctxt "shortcut window" msgid "Switch to report mode" msgstr "Ataskaitų režimas" msgctxt "shortcut window" msgid "Send report via email" msgstr "Siųsti el. paštu" msgctxt "shortcut window" msgid "Return to task entry mode" msgstr "Grįžti į įvedimo režimą" #~ msgid "Couldn't execute %s: %s" #~ msgstr "Nepavyko paleisti %s: %s" #~ msgid "Couldn't send email: %s returned code %d" #~ msgstr "Nepavyko išsiųsti laiško: %s grąžino kodą %d" #~ msgid "Help" #~ msgstr "Žinynas" #~ msgctxt "shortcut window" #~ msgid "Help" #~ msgstr "Žinynas" #~ msgid "Total for {0}: {1}" #~ msgstr "Iš viso {0}: {1}" #~ msgid "J. Random Hacker " #~ msgstr "Vardenis Pavardenis " #~ msgid "JRH" #~ msgstr "Vardenis" #~ msgid "Custom..." #~ msgstr "Kitas..." ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/po/nb.po0000664000175000017500000002675014556177605015240 0ustar00mgmg# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # John Erling Blad , 2022. # msgid "" msgstr "" "Project-Id-Version: unnamed project\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-30 14:15+0200\n" "PO-Revision-Date: 2022-10-07 11:57+0200\n" "Last-Translator: John Erling Blad \n" "Language-Team: \n" "Language: nb\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Gtranslator 42.0\n" "Plural-Forms: nplurals=2; plural=(n!=1)\n" msgid "Time Log" msgstr "Tidslogg" msgid "Track and time daily activities" msgstr "Spor og tidfest daglige aktiviteter" #, python-brace-format msgid "{0} h {1} min" msgstr "{0} t {1} min" msgid "Show version number and exit" msgstr "Vis versjonsnummer og avslutt" msgid "Show debug information on the console" msgstr "Vis feilsøkingsinformasjon i konsollet" msgid "Open the preferences dialog" msgstr "Åpne brukerinnstillinger" msgid "Open the preferences dialog on the email page" msgstr "Åpne brukerinnstillinger for e-postsiden" msgid "" "\n" "WARNING: GSettings schema for org.gtimelog is missing! If you're running " "from a source checkout, be sure to run 'make'." msgstr "" "\n" "ADVARSEL: GSettings-skjema for org.gtimelog mangler! Hvis du kjører etter " "utsjekk av kildekode, så må du kjøre 'make'." #, python-brace-format msgid "Could not create {directory}: {error}" msgstr "Kunne ikke opprette: {directory}: {error}" #, python-brace-format msgid "Created {directory}" msgstr "Opprettet {directory}" msgid "GTimeLog version: {}" msgstr "GTimeLog versjon: {}" msgid "Python version: {}" msgstr "Python versjon: {}" msgid "GTK+ version: {}.{}.{}" msgstr "GTK+ versjon: {}.{}.{}" msgid "PyGI version: {}" msgstr "PyGI versjon: {}" msgid "Data directory: {}" msgstr "Datamappe: {}" msgid "Legacy config directory: {}" msgstr "Avleggs konfigurasjonskatalog: {}" msgid "Settings will be migrated to GSettings (org.gtimelog) on first launch" msgstr "" "Innstillinger blir overført til GSettings (org.gtimelog) ved første kjøring" msgid "Settings already migrated to GSettings (org.gtimelog)" msgstr "Innstillinger er allerede migrert til GSettings (org.gtimelog)" #, python-brace-format msgid "Settings from {filename} migrated to GSettings (org.gtimelog)" msgstr "Innstillinger fra {filename} er migrert til GSettings (org.gtimelog)" msgid "Report already sent" msgstr "Rapport er allerede sendt" msgid "Report already sent (to {})" msgstr "Rapport er allerede sendt (til {})" msgid "Downloading tasks..." msgstr "Laster ned oppgaver..." msgid "Download failed." msgstr "Kunne ikke laste ned." msgid "{0:%A, %Y-%m-%d} (week {1:0>2})" msgstr "{0:%A, %Y-%m-%d} (uke {1:0>2})" msgid "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgstr "{0}, uke {1} ({2:%B %-d}-{3:%-d})" msgid "{0:%B %Y}" msgstr "{0:%B %Y}" msgid "Report" msgstr "Rapport" msgid "Couldn't send email to {}: {}." msgstr "Kunne ikke sende e-post til {}: {}." #, python-format msgid "Couldn't send mail: %s" msgstr "Kunne ikke sende epost: %s" msgid "Couldn't append to {}: {}" msgstr "Kunne ikke legge til {}: {}" msgid "%H:%M" msgstr "%H:%M" msgid "{0:%A, %Y-%m-%d}\n" msgstr "{0:%A, %Y-%m-%d}\n" #, python-brace-format msgid "Total for {0}: {1} ({2} per day)" msgstr "Totalt {0}: {1} ({2} per dag)" #, python-brace-format msgid "Total for {0}: {1} ({2} this week, {3} per day)" msgstr "Totalt {0}: {1} ({2} denne uka, {3} per dag)" msgid "({0:%H:%M}-{1:%H:%M})" msgstr "({0:%H:%M}-{1:%H:%M})" #, python-brace-format msgid "Total work done: {0} ({1} this week, {2} per day)" msgstr "Totalt utført arbeid: {0} ({1} denne uka, {2} per dag)" #, python-brace-format msgid "Total work done: {0} ({1} this week)" msgstr "Totalt utført arbeid: {0} ({1} denne uka)" #, python-brace-format msgid "Total work done this week: {0} ({1} per day)" msgstr "Totalt utført arbeid denne uka: {0} ({1} per dag)" #, python-brace-format msgid "Total work done this week: {0}" msgstr "Totalt utført arbeid denne uka: {0}" #, python-brace-format msgid "Total work done this month: {0} ({1} per day)" msgstr "Totalt utført arbeid denne måneden: {0} ({1} per dag)" #, python-brace-format msgid "Total work done this month: {0}" msgstr "Totalt utført arbeid denne måneden: {0}" #, python-brace-format msgid "Total slacking: {0} ({1} this week, {2} per day)" msgstr "Totalt slacking: {0} ({1} denne uka, {2} per dag)" #, python-brace-format msgid "Total slacking: {0} ({1} this week)" msgstr "Totalt slacking: {0} ({1} denne uka)" #, python-brace-format msgid "Total slacking this week: {0} ({1} per day)" msgstr "Total lediggang denne uka: {0} ({1} per dag)" #, python-brace-format msgid "Total slacking this week: {0}" msgstr "Total lediggang denne uka: {0}" #, python-brace-format msgid "Total slacking this month: {0} ({1} per day)" msgstr "Total lediggang denne måneden: {0} ({1} per dag)" #, python-brace-format msgid "Total slacking this month: {0}" msgstr "Total lediggang denne måneden: {0}" msgid "" "Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} " "until now)" msgstr "" "Gjenstående tid på arbeid: {0} (burde avsluttet {1:%H:%M}, overtid {2} " "inntil nå)" msgid "Time left at work: {0} (till {1:%H:%M})" msgstr "Gjenstående tid på arbeid: {0} (til {1:%H:%M})" #, python-brace-format msgid "At office today: {0} ({1} overtime)" msgstr "På kontoret i dag: {0} ({1} overtid)" #, python-brace-format msgid "At office today: {0} ({1} left)" msgstr "På kontoret i dag: {0} ({1} gjenstående)" msgid "Tasks" msgstr "Oppgaver" msgid "Other" msgstr "Annet" msgid "Preferences" msgstr "Innstillinger" msgid "Close" msgstr "Steng" #. pragma: nocover #. https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 #. locale.bindtextdomain is missing on Windows! msgid "Unable to configure translations: no locale.bindtextdomain()" msgstr "Kan ikke konfigurere oversettelser: ingen locale.bindtextdomain()" msgid "Failed to store SMTP password in the keyring." msgstr "Kunne ikke lagre SMTP-passord i nøkkelringen." msgid "Failed to store HTTP password in the keyring." msgstr "Kunne ikke lagre HTTP-passord i nøkkelringen." #, python-format msgid "" "Authentication is required for \"%s\"\n" "You need a username and a password to access %s" msgstr "" "Autentisering er påkrevd for «%s»\n" "Du trenger et brukernavn og et passord for tilgang %s" msgid "Copyright © 2004–2024 Marius Gedminas and contributors." msgstr "Copyright © 2004–2024 Marius Gedminas og bidragsytere." msgid "A time tracking application" msgstr "En tidssporingsapplikasjon" msgid "Add" msgstr "Legg til" msgid "Daily" msgstr "Daglig" msgid "Weekly" msgstr "Ukentlig" msgid "Monthly" msgstr "Månedlig" msgid "Sender" msgstr "Avsender" msgid "Your Name " msgstr "Ditt navn " msgid "Recipient" msgstr "Mottaker" msgid "email@example.com" msgstr "epost@example.com" msgid "Subject" msgstr "Emne" msgid "Cancel" msgstr "Avbryt" msgid "Send" msgstr "Send" msgid "Data entry" msgstr "Dataoppføring" msgid "Virtual midnight" msgstr "Virtuell midnatt" msgid "Goals" msgstr "Mål" msgid "Work hours" msgstr "Arbeidstimer" msgid "Office hours" msgstr "Kontortimer" msgid "per day" msgstr "per dag" msgid "(including lunch break)" msgstr "(inkludert lunsj)" msgid "Entry" msgstr "Oppføring" msgid "Reports by email" msgstr "Rapporter med e-post" msgid "Name" msgstr "Navn" msgid "From" msgstr "Fra" msgid "To" msgstr "Til" msgid "Nickname used in the Subject line" msgstr "Kallenavn brukt på emnelinjen" msgid "SMTP server" msgstr "SMTP-tjener" msgid "Server" msgstr "Tjener" msgid "Port" msgstr "Port" msgid "Security" msgstr "Sikkerhet" msgid "None" msgstr "Ingen" msgid "TLS" msgstr "TLS" msgid "StartTLS" msgstr "StartTLS" msgid "Username" msgstr "Brukernavn" msgid "Password" msgstr "Passord" msgid "Email" msgstr "E-post" msgid "Keyboard Shortcuts" msgstr "Tastatursnarveier" msgid "About" msgstr "Om" msgid "Quit" msgstr "Avslutt" msgid "Edit log" msgstr "Rediger logg" #, fuzzy msgid "Edit last item" msgstr "Rediger oppgaveliste" msgid "Edit tasks" msgstr "Rediger oppgaver" msgid "Refresh tasks" msgstr "Gjenoppfrisk oppgaver" msgid "Report..." msgstr "Rapport..." msgid "About Time Log" msgstr "Om Tidslogg" msgid "Detail level" msgstr "Detaljnivå" msgid "Chronological" msgstr "Kronologisk" msgid "Grouped" msgstr "Gruppert" msgid "Summary" msgstr "Sammendrag" msgid "Time range" msgstr "Tidsrom" msgid "Day" msgstr "Dag" msgid "Week" msgstr "Uke" msgid "Month" msgstr "Måned" msgid "Sorting" msgstr "Sortering" msgid "By start time" msgstr "Etter starttid" msgid "By name" msgstr "Etter navn" msgid "By duration" msgstr "Etter varighet" msgid "By task list order" msgstr "Etter oppgavelisten" msgid "Filter" msgstr "Filter" msgctxt "shortcuts window" msgid "Entry" msgstr "Oppføring" msgctxt "shortcut window" msgid "Detail level" msgstr "Detaljnivå" msgctxt "shortcut window" msgid "Full chronological list" msgstr "Full kronologisk liste" msgctxt "shortcut window" msgid "Group by description" msgstr "Grupper etter beskrivelse" msgctxt "shortcut window" msgid "Group by category" msgstr "Grupper etter kategori" msgctxt "shortcut window" msgid "Time range" msgstr "Tidsområde" msgctxt "shortcut window" msgid "Day view" msgstr "Dagsvisning" msgctxt "shortcut window" msgid "Week view" msgstr "Ukesvisning" msgctxt "shortcut window" msgid "Month view" msgstr "Månedsvisning" msgctxt "shortcut window" msgid "Sort order" msgstr "Sorteringsorden" msgctxt "shortcut window" msgid "By start time" msgstr "Etter starttid" msgctxt "shortcut window" msgid "By name" msgstr "Etter navn" msgctxt "shortcut window" msgid "By duration" msgstr "Etter varighet" msgctxt "shortcut window" msgid "By task list order" msgstr "Etter oppgavelisten" msgctxt "shortcut window" msgid "Time navigation" msgstr "Navigering i tid" msgctxt "shortcut window" msgid "Go back in time" msgstr "Gå tilbake i tid" msgctxt "shortcut window" msgid "Go forward in time" msgstr "Gå forover i tid" msgctxt "shortcut window" msgid "Go back to today" msgstr "Gå tilbake til i dag" msgctxt "shortcut window" msgid "General" msgstr "Generelt" msgctxt "shortcut window" msgid "Menu" msgstr "Meny" msgctxt "shortcut window" msgid "Focus the task entry" msgstr "Tilbake til oppgaveinnlegging" #, fuzzy msgctxt "shortcut window" msgid "Edit last task entry" msgstr "Tilbake til oppgaveinnlegging" msgctxt "shortcut window" msgid "Keyboard shortcuts" msgstr "Tastatursnarveier" msgctxt "shortcut window" msgid "Toggle search bar" msgstr "Bytt søkefeltet" msgctxt "shortcut window" msgid "Edit task log" msgstr "Rediger oppgavelista" msgctxt "shortcut window" msgid "Preferences" msgstr "Innstillinger" msgctxt "shortcut window" msgid "Quit" msgstr "Avslutt" msgctxt "shortcut window" msgid "Task pane" msgstr "Oppgaverute" msgctxt "shortcut window" msgid "Toggle task pane" msgstr "Bytt oppgaverute" msgctxt "shortcut window" msgid "Edit task list" msgstr "Rediger oppgaveliste" msgctxt "shortcuts window" msgid "Reports" msgstr "Rapporter" msgctxt "shortcut window" msgid "Reporting" msgstr "Rapporterer" msgctxt "shortcut window" msgid "Switch to report mode" msgstr "Bytt til rappportmodus" msgctxt "shortcut window" msgid "Send report via email" msgstr "Send rapport via e-post" msgctxt "shortcut window" msgid "Return to task entry mode" msgstr "Tilbake til modus for oppgaveinnlegging" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/po/nl.po0000664000175000017500000002717314556177605015252 0ustar00mgmg# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # # SPDX-FileCopyrightText: 2023 Heimen Stoffels msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-30 14:15+0200\n" "PO-Revision-Date: 2023-09-04 20:10+0200\n" "Last-Translator: Heimen Stoffels \n" "Language-Team: Dutch\n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.3.2\n" msgid "Time Log" msgstr "Tijdlogboek" msgid "Track and time daily activities" msgstr "Houd bij hoeveel tijd u besteedt aan dagelijkse activiteiten" #, python-brace-format msgid "{0} h {1} min" msgstr "{0} uur, {1} min." msgid "Show version number and exit" msgstr "Toon het versienummer en sluit af" msgid "Show debug information on the console" msgstr "Toon foutopsporingsberichten op de opdrachtregel" msgid "Open the preferences dialog" msgstr "Open het voorkeurenvenster" msgid "Open the preferences dialog on the email page" msgstr "Open het voorkeurenvenster (e-mailpagina)" msgid "" "\n" "WARNING: GSettings schema for org.gtimelog is missing! If you're running " "from a source checkout, be sure to run 'make'." msgstr "" "\n" "WAARSCHUWING: Het GSettings-schema van org.gtimelog ontbreekt! Als u de " "toepassing zelf gecompileerd hebt, voer dan ‘make’ uit." #, python-brace-format msgid "Could not create {directory}: {error}" msgstr "‘{directory}’ kan niet worden aangemaakt: {error}" #, python-brace-format msgid "Created {directory}" msgstr "{directory} is aangemaakt" msgid "GTimeLog version: {}" msgstr "GTimeLog-versie: {}" msgid "Python version: {}" msgstr "Python-versie: {}" msgid "GTK+ version: {}.{}.{}" msgstr "GTK+-versie: {}.{}.{}" msgid "PyGI version: {}" msgstr "PyGI-versie: {}" msgid "Data directory: {}" msgstr "Gegevensmap: {}" msgid "Legacy config directory: {}" msgstr "Oude voorkeurenmap: {}" msgid "Settings will be migrated to GSettings (org.gtimelog) on first launch" msgstr "" "Na de eerste keer opstarten worden de voorkeuren gemigreerd naar GSettings " "(org.gtimelog)" msgid "Settings already migrated to GSettings (org.gtimelog)" msgstr "De voorkeuren zijn al gemigreerd naar GSettings (org.gtimelog)" #, python-brace-format msgid "Settings from {filename} migrated to GSettings (org.gtimelog)" msgstr "" "De voorkeuren uit ‘{filename}’ zijn gemigreerd naar GSettings (org.gtimelog)" msgid "Report already sent" msgstr "Dit verslag is al verstuurd" msgid "Report already sent (to {})" msgstr "Dit verslag is al verstuurd (aan {})" msgid "Downloading tasks..." msgstr "Bezig met ophalen van taken…" msgid "Download failed." msgstr "Het ophalen is mislukt." msgid "{0:%A, %Y-%m-%d} (week {1:0>2})" msgstr "{0:%A, %Y-%m-%d} (week {1:0>2})" msgid "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgstr "{0}, week {1} ({2:%B %-d}-{3:%-d})" msgid "{0:%B %Y}" msgstr "{0:%B %Y}" msgid "Report" msgstr "Verslag" msgid "Couldn't send email to {}: {}." msgstr "Er kan geen e-mail worden verstuurd aan {}: {}." #, python-format msgid "Couldn't send mail: %s" msgstr "Er kan geen e-mail worden verstuurd: %s" msgid "Couldn't append to {}: {}" msgstr "Het toevoegen aan ‘{}’ is mislukt: {}" msgid "%H:%M" msgstr "%H:%M" msgid "{0:%A, %Y-%m-%d}\n" msgstr "{0:%A, %Y-%m-%d}\n" #, python-brace-format msgid "Total for {0}: {1} ({2} per day)" msgstr "Totaal van {0}: {1} ({2} per dag)" #, python-brace-format msgid "Total for {0}: {1} ({2} this week, {3} per day)" msgstr "Totaal van {0}: {1} ({2} deze week; {3} per dag)" msgid "({0:%H:%M}-{1:%H:%M})" msgstr "({0:%H:%M}-{1:%H:%M})" #, python-brace-format msgid "Total work done: {0} ({1} this week, {2} per day)" msgstr "Totale werktijd: {0} ({1} deze week; {2} per dag)" #, python-brace-format msgid "Total work done: {0} ({1} this week)" msgstr "Totale werktijd: {0} ({1} deze week)" #, python-brace-format msgid "Total work done this week: {0} ({1} per day)" msgstr "Totale werktijd deze week: {0} ({1} per dag)" #, python-brace-format msgid "Total work done this week: {0}" msgstr "Totale werktijd deze week: {0}" #, python-brace-format msgid "Total work done this month: {0} ({1} per day)" msgstr "Totale werktijd deze maand: {0} ({1} per dag)" #, python-brace-format msgid "Total work done this month: {0}" msgstr "Totale werktijd deze maand: {0}" #, python-brace-format msgid "Total slacking: {0} ({1} this week, {2} per day)" msgstr "Totale lanterfanttijd: {0} ({1} deze week; {2} per dag)" #, python-brace-format msgid "Total slacking: {0} ({1} this week)" msgstr "Totale lanterfanttijd: {0} ({1} deze week)" #, python-brace-format msgid "Total slacking this week: {0} ({1} per day)" msgstr "Totale lanterfanttijd deze week: {0} ({1} per dag)" #, python-brace-format msgid "Total slacking this week: {0}" msgstr "Totale lanterfanttijd deze week: {0}" #, python-brace-format msgid "Total slacking this month: {0} ({1} per day)" msgstr "Totale lanterfanttijd deze maand: {0} ({1} per dag)" #, python-brace-format msgid "Total slacking this month: {0}" msgstr "Totale lanterfanttijd deze maand: {0}" msgid "" "Time left at work: {0} (should've finished at {1:%H:%M}, overtime of {2} " "until now)" msgstr "" "Resterende werktijd: {0} (u had klaar moeten zijn om {1:%H:%M}, maar werkt nu " "{2} over)" msgid "Time left at work: {0} (till {1:%H:%M})" msgstr "Resterende werktijd: {0} (tot {1:%H:%M})" #, python-brace-format msgid "At office today: {0} ({1} overtime)" msgstr "Totale kantoortijd vandaag: {0} ({1} overtijd)" #, python-brace-format msgid "At office today: {0} ({1} left)" msgstr "Totale kantoortijd vandaag: {0} ({1} resterend)" msgid "Tasks" msgstr "Taken" msgid "Other" msgstr "Overig" msgid "Preferences" msgstr "Voorkeuren" msgid "Close" msgstr "Sluiten" #. pragma: nocover #. https://github.com/gtimelog/gtimelog/issues/95#issuecomment-252299266 #. locale.bindtextdomain is missing on Windows! msgid "Unable to configure translations: no locale.bindtextdomain()" msgstr "" "De vertalingen kunnen niet worden ingesteld: geen locale.bindtextdomain()" msgid "Failed to store SMTP password in the keyring." msgstr "Het smtp-wachtwoord kan niet worden bewaard in de sleutelbos." msgid "Failed to store HTTP password in the keyring." msgstr "Het http-wachtwoord kan niet worden bewaard in de sleutelbos." #, python-format msgid "" "Authentication is required for \"%s\"\n" "You need a username and a password to access %s" msgstr "" "Authenticatie vereist voor ‘%s’.\n" "Log in met de gebruikersnaam en het wachtwoord van %s" msgid "Copyright © 2004–2024 Marius Gedminas and contributors." msgstr "Auteursrecht © 2004–2024 Marius Gedminas en bijdragers." msgid "A time tracking application" msgstr "Een tijdregistratietoepassing" msgid "Add" msgstr "Toevoegen" msgid "Daily" msgstr "Iedere dag" msgid "Weekly" msgstr "Iedere week" msgid "Monthly" msgstr "Iedere maand" msgid "Sender" msgstr "Afzender" msgid "Your Name " msgstr "Mijn naam " msgid "Recipient" msgstr "Ontvanger" msgid "email@example.com" msgstr "e-mailadres@voorbeeld.nl" msgid "Subject" msgstr "Onderwerp" msgid "Cancel" msgstr "Annuleren" msgid "Send" msgstr "Versturen" msgid "Data entry" msgstr "Gegevensinvoer" msgid "Virtual midnight" msgstr "Virtuele middernacht" msgid "Goals" msgstr "Doelen" msgid "Work hours" msgstr "Werkuren" msgid "Office hours" msgstr "Kantooruren" msgid "per day" msgstr "per dag" msgid "(including lunch break)" msgstr "(inclusief lunchpauze)" msgid "Entry" msgstr "Item" msgid "Reports by email" msgstr "Verslagen via e-mail" msgid "Name" msgstr "Naam" msgid "From" msgstr "Van" msgid "To" msgstr "Aan" msgid "Nickname used in the Subject line" msgstr "Bijnaam van de onderwerpregel" msgid "SMTP server" msgstr "Smtp-server" msgid "Server" msgstr "Server" msgid "Port" msgstr "Poort" msgid "Security" msgstr "Beveiliging" msgid "None" msgstr "Geen" msgid "TLS" msgstr "Tls" msgid "StartTLS" msgstr "StartTLS" msgid "Username" msgstr "Gebruikersnaam" msgid "Password" msgstr "Wachtwoord" msgid "Email" msgstr "E-mailadres" msgid "Keyboard Shortcuts" msgstr "Sneltoetsen" msgid "About" msgstr "Over" msgid "Quit" msgstr "Afsluiten" msgid "Edit log" msgstr "Logboek bewerken" #, fuzzy msgid "Edit last item" msgstr "Takenlijst bewerken" msgid "Edit tasks" msgstr "Taken bewerken" msgid "Refresh tasks" msgstr "Taken herladen" msgid "Report..." msgstr "Verslag opmaken…" msgid "About Time Log" msgstr "Over Tijdlogboek" msgid "Detail level" msgstr "Detailniveau" msgid "Chronological" msgstr "Chronologisch" msgid "Grouped" msgstr "Gegroepeerd" msgid "Summary" msgstr "Samenvatting" msgid "Time range" msgstr "Tijdspanne" msgid "Day" msgstr "Dag" msgid "Week" msgstr "Week" msgid "Month" msgstr "Maand" msgid "Sorting" msgstr "Sorteren" msgid "By start time" msgstr "Op begintijd" msgid "By name" msgstr "Op naam" msgid "By duration" msgstr "Op duur" msgid "By task list order" msgstr "Op takenlijstvolgorde" msgid "Filter" msgstr "Filteren" msgctxt "shortcuts window" msgid "Entry" msgstr "Item" msgctxt "shortcut window" msgid "Detail level" msgstr "Detailniveau" msgctxt "shortcut window" msgid "Full chronological list" msgstr "Volledige chronologische lijst" msgctxt "shortcut window" msgid "Group by description" msgstr "Groeperen op beschrijving" msgctxt "shortcut window" msgid "Group by category" msgstr "Groeperen op categorie" msgctxt "shortcut window" msgid "Time range" msgstr "Tijdspanne" msgctxt "shortcut window" msgid "Day view" msgstr "Dagweergave" msgctxt "shortcut window" msgid "Week view" msgstr "Weekweergave" msgctxt "shortcut window" msgid "Month view" msgstr "Maandweergave" msgctxt "shortcut window" msgid "Sort order" msgstr "Sorteervolgorde" msgctxt "shortcut window" msgid "By start time" msgstr "Op begintijd" msgctxt "shortcut window" msgid "By name" msgstr "Op naam" msgctxt "shortcut window" msgid "By duration" msgstr "Op duur" msgctxt "shortcut window" msgid "By task list order" msgstr "Op takenlijstvolgorde" msgctxt "shortcut window" msgid "Time navigation" msgstr "Tijdmachine" msgctxt "shortcut window" msgid "Go back in time" msgstr "Ga terug in de tijd" msgctxt "shortcut window" msgid "Go forward in time" msgstr "Ga vooruit in de tijd" msgctxt "shortcut window" msgid "Go back to today" msgstr "Ga naar vandaag" msgctxt "shortcut window" msgid "General" msgstr "Algemeen" msgctxt "shortcut window" msgid "Menu" msgstr "Menu" msgctxt "shortcut window" msgid "Focus the task entry" msgstr "Taakitem focussen" #, fuzzy msgctxt "shortcut window" msgid "Edit last task entry" msgstr "Taakitem focussen" msgctxt "shortcut window" msgid "Keyboard shortcuts" msgstr "Sneltoetsen" msgctxt "shortcut window" msgid "Toggle search bar" msgstr "Zoekbalk tonen/verbergen" msgctxt "shortcut window" msgid "Edit task log" msgstr "Taaklogboek bewerken" msgctxt "shortcut window" msgid "Preferences" msgstr "Voorkeuren" msgctxt "shortcut window" msgid "Quit" msgstr "Afsluiten" msgctxt "shortcut window" msgid "Task pane" msgstr "Takenpaneel" msgctxt "shortcut window" msgid "Toggle task pane" msgstr "Takenpaneel tonen/verbergen" msgctxt "shortcut window" msgid "Edit task list" msgstr "Takenlijst bewerken" msgctxt "shortcuts window" msgid "Reports" msgstr "Verslagen" msgctxt "shortcut window" msgid "Reporting" msgstr "Verslagen" msgctxt "shortcut window" msgid "Switch to report mode" msgstr "Verslagmodus inschakelen" msgctxt "shortcut window" msgid "Send report via email" msgstr "Verslag versturen per e-mail" msgctxt "shortcut window" msgid "Return to task entry mode" msgstr "Terug naar taakitemmodus" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/preferences.ui0000664000175000017500000006333414515757026016516 0ustar00mgmg 24 0.5 1 24 0.5 1 True False 18 18 18 18 True True False False vertical 18 True False vertical 6 True False Data entry 0 False True 0 True False 12 6 12 True False Virtual midnight 1 0 0 True True 5 5 02:00 1 True 1 0 False True 1 False True 0 True False vertical 6 True False Goals 0 False True 0 True False 12 6 12 True False Work hours 1 0 0 True False Office hours end 1 0 1 True True 1 5 5 8 1 hours_adjustment True 1 0 True True 1 5 5 9 1 office_hours_adjustment True 1 1 True False per day 0.5 0 2 0 True False (including lunch break) 0 2 1 False True 1 False True 1 entry 0 Entry True False False vertical 18 True False vertical 6 True False Reports by email 0 False True 2 True False 12 6 12 True False Name 1 0 2 True False From 1 0 0 True False To 1 0 1 True True True activity@example.com email@example.com True 1 1 True True True J. Random Hacker <jrh@example.com> Your Name <youremail@example.com> True 1 0 True True True JRH True Nickname used in the Subject line 1 2 False True 3 False True 0 True False vertical 6 True False SMTP server 0 False True 0 True False 12 6 12 True False Server 1 0 0 True True True localhost True 1 0 True False Port 1 2 0 True True 9 9 0 digits True 3 0 True False Security 1 0 1 True True None TLS StartTLS 1 1 3 True False Username 1 0 2 True True True True 1 2 3 True False Password 1 0 3 True True True False True 1 3 3 False True 3 False True 1 email Email 1 GTK_SIZE_GROUP_HORIZONTAL GTK_SIZE_GROUP_HORIZONTAL ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/secrets.py0000664000175000017500000002106314515757026015671 0ustar00mgmg""" Keyring and secrets """ import functools import logging from gettext import gettext as _ from .utils import require_version require_version('Gtk', '3.0') require_version('Secret', '1') from gi.repository import Gio, GObject, Gtk, Secret log = logging.getLogger('gtimelog.secrets') def start_smtp_password_lookup(server, username, callback): schema = Secret.get_schema(Secret.SchemaType.COMPAT_NETWORK) attrs = dict(user=username, server=server, protocol='smtp') def password_callback(source, result): password = Secret.password_lookup_finish(result) if password: log.debug("Found the SMTP password in the keyring.") else: log.debug("Did not find the SMTP password in the keyring.") callback(password or '') log.debug("Looking up the SMTP password for %s@%s in the keyring.", username, server) Secret.password_lookup(schema, attrs, cancellable=None, callback=password_callback) def set_smtp_password(server, username, password): schema = Secret.get_schema(Secret.SchemaType.COMPAT_NETWORK) attrs = dict(user=username, server=server, protocol='smtp') label = '{user}@{server}'.format_map(attrs) def callback(source, result): if not Secret.password_store_finish(result): log.error(_("Failed to store SMTP password in the keyring.")) else: log.debug("SMTP password stored in the keyring.") log.debug("Storing the SMTP password for %s in the keyring.", label) Secret.password_store(schema, attrs, collection=Secret.COLLECTION_DEFAULT, label=label, password=password, cancellable=None, callback=callback) class Authenticator(object): def __init__(self): self.pending = [] self.lookup_in_progress = False self.username = None self.password = None def find_in_keyring(self, uri, callback): """ Attempt to load a username and password from the keyring. If the keyring is not available, return the last username and password entered, if any. """ # NB: we cannot use the simpler Secret.password_lookup_sync() because # it won't give us access to the username! Secret.Service.get( Secret.ServiceFlags.OPEN_SESSION | Secret.ServiceFlags.LOAD_COLLECTIONS, cancellable=None, callback=functools.partial(self._find_in_keyring, uri, callback), ) def _find_in_keyring(self, uri, callback, source, result): service = Secret.Service.get_finish(result) schema = Secret.get_schema(Secret.SchemaType.COMPAT_NETWORK) attrs = dict(server=uri.get_host(), protocol=uri.get_scheme(), port=str(uri.get_port())) flags = (Secret.SearchFlags.UNLOCK | Secret.SearchFlags.LOAD_SECRETS) def search_callback(source, result): items = service.search_finish(result) if items: # Note: the search will give us the most recently used password # if several ones match (e.g. the user tried several different # usernames). This is good because if the wrong username gets # picked and is rejected by the server, we'll ask the user # again and then remember the more recently provided answer. # This is bad only if multiple sets of credentials are valid # but cause different data to be returned -- the user will # be forced to launch Seahorse and remove the saved credentials # manually to log in into a different account. log.debug("Found the HTTP password for %s in the keyring.", items[0].get_label()) username = items[0].get_attributes()['user'] password = items[0].get_secret().get_text() if len(items) > 1: # This will never happen. If you want this to happen, # add Secret.SearchFlags.ALL to the search flags. log.debug("Ignoring the other %d found passwords.", len(items) - 1) else: log.debug("Did not find any HTTP passwords for %s://%s:%s" " in the keyring.", uri.get_scheme(), uri.get_host(), uri.get_port()) username = self.username password = self.password callback(username, password) service.search(schema, attrs, flags, cancellable=None, callback=search_callback) def save_to_keyring(self, uri, username, password): schema = Secret.get_schema(Secret.SchemaType.COMPAT_NETWORK) attrs = dict(server=uri.get_host(), protocol=uri.get_scheme(), port=str(uri.get_port()), user=username) label = '{user}@{server}:{port}'.format_map(attrs) def password_stored_callback(source, result): if not Secret.password_store_finish(result): log.error(_("Failed to store HTTP password in the keyring.")) else: log.debug("HTTP password stored in the keyring.") log.debug("Storing the HTTP password for %s in the keyring.", label) Secret.password_store( schema, attrs, collection=Secret.COLLECTION_DEFAULT, label=label, password=password, cancellable=None, callback=password_stored_callback, ) def ask_the_user(self, auth, uri, callback): """ Pop up a username/password dialog for uri """ mountoperation = Gtk.MountOperation.new() def on_reply(m, r): if r == Gio.MountOperationResult.HANDLED: username = m.get_username() password = m.get_password() if username and password: if m.get_password_save() == Gio.PasswordSave.PERMANENTLY: # NB: we're saving the password before even testing if # it actually works! This is not too big of a problem: # if it fails to work, the user will get another prompt # and we will store the other password. self.save_to_keyring(uri, username, password) elif m.get_password_save() == Gio.PasswordSave.FOR_SESSION: self.username = username self.password = password else: username = None password = None callback(username, password) flags = (Gio.AskPasswordFlags.NEED_PASSWORD | Gio.AskPasswordFlags.NEED_USERNAME | Gio.AskPasswordFlags.SAVING_SUPPORTED) mountoperation.connect('reply', on_reply) mountoperation.set_password_save(Gio.PasswordSave.PERMANENTLY) mountoperation.do_ask_password(mountoperation, _('Authentication is required for "%s"\n' 'You need a username and a password to access %s') % ( auth.get_realm(), uri.get_host()), '', auth.get_realm(), flags) def find_password(self, auth, uri, retrying, callback): def keyring_callback(username, password): # If not found, ask the user for it if username is None or retrying: GObject.idle_add(lambda: self.ask_the_user(auth, uri, callback)) else: callback(username, password) self.find_in_keyring(uri, keyring_callback) def http_auth_cb(self, message, auth, retrying, *args): self.pending.insert(0, (message, auth, retrying)) self.maybe_pop_queue() return True def maybe_pop_queue(self): # I don't think we need any locking, because GIL. if self.lookup_in_progress: return try: (message, auth, retrying) = self.pending.pop() except IndexError: pass else: self.lookup_in_progress = True uri = message.get_uri() self.find_password(auth, uri, retrying, callback=functools.partial( self.http_auth_finish, message, auth)) def http_auth_finish(self, message, auth, username, password): if username and password: auth.authenticate(username, password) else: auth.cancel() self.lookup_in_progress = False self.maybe_pop_queue() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/settings.py0000664000175000017500000001372514515757026016067 0ustar00mgmg""" Settings for GTimeLog """ import datetime import locale import os from configparser import RawConfigParser from gtimelog.timelog import parse_time legacy_default_home = os.path.normpath('~/.gtimelog') default_config_home = os.path.normpath('~/.config') default_data_home = os.path.normpath('~/.local/share') class Settings(object): """Configurable settings for GTimeLog.""" # Apparently locale.getpreferredencoding() might be blank on Mac OS X _encoding = locale.getpreferredencoding() or 'UTF-8' # Insane defaults email = 'activity-list@example.com' name = 'Anonymous' sender = '' editor = 'xdg-open' mailer = 'x-terminal-emulator -e "mutt -H %s"' spreadsheet = 'xdg-open %s' chronological = True summary_view = False show_tasks = True enable_gtk_completion = True # False enables gvim-style completion hours = 8 office_hours = 9 virtual_midnight = datetime.time(2, 0) task_list_url = '' edit_task_list_cmd = '' show_office_hours = True show_tray_icon = False prefer_app_indicator = True start_in_tray = False report_style = 'plain' def check_legacy_config(self): envar_home = os.environ.get('GTIMELOG_HOME') if envar_home is not None: return os.path.expanduser(envar_home) if os.path.isdir(os.path.expanduser(legacy_default_home)): return os.path.expanduser(legacy_default_home) return None # https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html def get_config_dir(self): legacy = self.check_legacy_config() if legacy: return legacy xdg = os.environ.get('XDG_CONFIG_HOME') or default_config_home return os.path.join(os.path.expanduser(xdg), 'gtimelog') def get_data_dir(self): legacy = self.check_legacy_config() if legacy: return legacy xdg = os.environ.get('XDG_DATA_HOME') or default_data_home return os.path.join(os.path.expanduser(xdg), 'gtimelog') def get_config_file(self): return os.path.join(self.get_config_dir(), 'gtimelogrc') def get_timelog_file(self): return os.path.join(self.get_data_dir(), 'timelog.txt') def get_report_log_file(self): return os.path.join(self.get_data_dir(), 'sentreports.log') def get_task_list_file(self): return os.path.join(self.get_data_dir(), 'tasks.txt') def get_task_list_cache_file(self): return os.path.join(self.get_data_dir(), 'remote-tasks.txt') def _config(self): config = RawConfigParser() config.add_section('gtimelog') config.set('gtimelog', 'list-email', self.email) config.set('gtimelog', 'name', self.name) config.set('gtimelog', 'sender', self.sender) config.set('gtimelog', 'editor', self.editor) config.set('gtimelog', 'mailer', self.mailer) config.set('gtimelog', 'spreadsheet', self.spreadsheet) config.set('gtimelog', 'chronological', str(self.chronological)) config.set('gtimelog', 'summary_view', str(self.summary_view)) config.set('gtimelog', 'show_tasks', str(self.show_tasks)) config.set('gtimelog', 'gtk-completion', str(self.enable_gtk_completion)) config.set('gtimelog', 'hours', str(self.hours)) config.set('gtimelog', 'office-hours', str(self.office_hours)) config.set('gtimelog', 'virtual_midnight', self.virtual_midnight.strftime('%H:%M')) config.set('gtimelog', 'task_list_url', self.task_list_url) config.set('gtimelog', 'edit_task_list_cmd', self.edit_task_list_cmd) config.set('gtimelog', 'show_office_hours', str(self.show_office_hours)) config.set('gtimelog', 'show_tray_icon', str(self.show_tray_icon)) config.set('gtimelog', 'prefer_app_indicator', str(self.prefer_app_indicator)) config.set('gtimelog', 'report_style', str(self.report_style)) config.set('gtimelog', 'start_in_tray', str(self.start_in_tray)) return config def load(self, filename=None): if filename is None: filename = self.get_config_file() config = self._config() loaded_files = config.read([filename]) self.email = config.get('gtimelog', 'list-email') self.name = config.get('gtimelog', 'name') self.sender = config.get('gtimelog', 'sender') self.editor = config.get('gtimelog', 'editor') self.mailer = config.get('gtimelog', 'mailer') self.spreadsheet = config.get('gtimelog', 'spreadsheet') self.chronological = config.getboolean('gtimelog', 'chronological') self.summary_view = config.getboolean('gtimelog', 'summary_view') self.show_tasks = config.getboolean('gtimelog', 'show_tasks') self.enable_gtk_completion = config.getboolean('gtimelog', 'gtk-completion') self.hours = config.getfloat('gtimelog', 'hours') self.office_hours = config.getfloat('gtimelog', 'office-hours') self.virtual_midnight = parse_time(config.get('gtimelog', 'virtual_midnight')) self.task_list_url = config.get('gtimelog', 'task_list_url') self.edit_task_list_cmd = config.get('gtimelog', 'edit_task_list_cmd') self.show_office_hours = config.getboolean('gtimelog', 'show_office_hours') self.show_tray_icon = config.getboolean('gtimelog', 'show_tray_icon') self.prefer_app_indicator = config.getboolean('gtimelog', 'prefer_app_indicator') self.report_style = config.get('gtimelog', 'report_style') self.start_in_tray = config.getboolean('gtimelog', 'start_in_tray') return loaded_files def save(self, filename): config = self._config() with open(filename, 'w') as f: config.write(f) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/shortcuts.ui0000664000175000017500000003416414556177605016256 0ustar00mgmg True True entry Entry True Detail level True <alt>1 Full chronological list True <alt>2 Group by description True <alt>3 Group by category True Time range True <alt>4 Day view True <alt>5 Week view True <alt>6 Month view True Sort order True <alt>7 By start time True <alt>8 By name True <alt>9 By duration True <alt>0 By task list order True Time navigation True <alt>Left Go back in time True <alt>Right Go forward in time True <alt>Home Go back to today True General True F10 Menu True <primary>L Focus the task entry True <primary><shift>BackSpace Edit last task entry True <primary>question Keyboard shortcuts True <primary>F Toggle search bar True <primary>E Edit task log True <primary>P Preferences True <primary>Q Quit True Task pane True F9 Toggle task pane True <primary>T Edit task list True report Reports True Time range True <alt>4 Day view True <alt>5 Week view True <alt>6 Month view True Time navigation True <alt>Left Go back in time True <alt>Right Go forward in time True <alt>Home Go back to today True Reporting True <primary>D Switch to report mode True <primary>Return Send report via email True Escape Return to task entry mode ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2760603 gtimelog-0.12.0/src/gtimelog/tests/0000775000175000017500000000000014603245772015005 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/tests/__init__.py0000664000175000017500000000054114515757026017120 0ustar00mgmg"""Tests for gtimelog""" import unittest from gtimelog.tests import test_main, test_settings, test_timelog def test_suite(): return unittest.TestSuite([ test_timelog.test_suite(), test_settings.test_suite(), test_main.test_suite(), ]) def main(): unittest.main(module='gtimelog.tests', defaultTest='test_suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1602138956.0 gtimelog-0.12.0/src/gtimelog/tests/__main__.py0000664000175000017500000000011013737531514017066 0ustar00mgmgfrom gtimelog.tests import main if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/tests/test_main.py0000664000175000017500000000464314515757026017353 0ustar00mgmg# -*- coding: utf-8 -*- """Tests for gtimelog.main""" import textwrap import unittest from unittest import mock gi = mock.MagicMock() gi.repository.Gtk.MAJOR_VERSION = 3 gi.repository.Gtk.MINOR_VERSION = 18 mock_gi = mock.patch.dict('sys.modules', {'gi': gi, 'gi.repository': gi.repository}) @mock_gi class TestEmail(unittest.TestCase): def test_prepare_message_ascii(self): from gtimelog.main import __version__, prepare_message msg = prepare_message( sender='ASCII Name ', recipient='activity@example.com', subject='Report for Mr. Plain', body='These are the activities done by Mr. Plain:\n...\n', ) self.assertEqual("ASCII Name ", msg["From"]) self.assertEqual("activity@example.com", msg["To"]) self.assertEqual("Report for Mr. Plain", msg["Subject"]) expected = textwrap.dedent('''\ Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit From: ASCII Name To: activity@example.com Subject: Report for Mr. Plain User-Agent: gtimelog/0.11.dev0 These are the activities done by Mr. Plain: ... ''').replace('0.11.dev0', __version__) self.assertEqual(expected, msg.as_string()) def test_prepare_message_unicode(self): from gtimelog.main import __version__, prepare_message msg = prepare_message( sender='Ünicødę Name ', recipient='Anöther nąme ', subject='Report for Mr. ☃', body='These are the activities done by Mr. ☃:\n...\n', ) expected = textwrap.dedent('''\ MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64 From: =?utf-8?b?w5xuaWPDuGTEmSBOYW1l?= To: =?utf-8?b?QW7DtnRoZXIgbsSFbWU=?= Subject: =?utf-8?b?UmVwb3J0IGZvciBNci4g4piD?= User-Agent: gtimelog/0.11.dev0 VGhlc2UgYXJlIHRoZSBhY3Rpdml0aWVzIGRvbmUgYnkgTXIuIOKYgzoKLi4uCg== ''').replace('0.11.dev0', __version__) self.assertEqual(expected, msg.as_string()) def test_suite(): return unittest.defaultTestLoader.loadTestsFromName(__name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/tests/test_settings.py0000664000175000017500000001266214515757026020267 0ustar00mgmg"""Tests for gtimelog.settings""" import os import shutil import tempfile import unittest from gtimelog.settings import Settings class TestSettings(unittest.TestCase): def setUp(self): self.settings = Settings() self.real_isdir = os.path.isdir self.tempdir = None self.old_home = os.environ.get('HOME') self.old_userprofile = os.environ.get('USERPROFILE') self.old_gtimelog_home = os.environ.get('GTIMELOG_HOME') self.old_xdg_config_home = os.environ.get('XDG_CONFIG_HOME') self.old_xdg_data_home = os.environ.get('XDG_DATA_HOME') os.environ['HOME'] = os.path.normpath('/tmp/home') os.environ['USERPROFILE'] = os.path.normpath('/tmp/home') os.environ.pop('GTIMELOG_HOME', None) os.environ.pop('XDG_CONFIG_HOME', None) os.environ.pop('XDG_DATA_HOME', None) def tearDown(self): os.path.isdir = self.real_isdir if self.tempdir: shutil.rmtree(self.tempdir) self.restore_env('HOME', self.old_home) self.restore_env('USERPROFILE', self.old_userprofile) self.restore_env('GTIMELOG_HOME', self.old_gtimelog_home) self.restore_env('XDG_CONFIG_HOME', self.old_xdg_config_home) self.restore_env('XDG_DATA_HOME', self.old_xdg_data_home) def restore_env(self, envvar, value): if value is not None: os.environ[envvar] = value else: os.environ.pop(envvar, None) def mkdtemp(self): if self.tempdir is None: self.tempdir = tempfile.mkdtemp(prefix='gtimelog-test-') return self.tempdir def test_get_config_dir_1(self): # Case 1: GTIMELOG_HOME is present in the environment os.environ['GTIMELOG_HOME'] = os.path.normpath('~/.gt') self.assertEqual(self.settings.get_config_dir(), os.path.normpath('/tmp/home/.gt')) def test_get_config_dir_2(self): # Case 2: ~/.gtimelog exists os.path.isdir = lambda dir: True self.assertEqual(self.settings.get_config_dir(), os.path.normpath('/tmp/home/.gtimelog')) def test_get_config_dir_3(self): # Case 3: ~/.gtimelog does not exist, so we use XDG os.path.isdir = lambda dir: False self.assertEqual(self.settings.get_config_dir(), os.path.normpath('/tmp/home/.config/gtimelog')) def test_get_config_dir_4(self): # Case 4: XDG_CONFIG_HOME is present in the environment os.environ['XDG_CONFIG_HOME'] = os.path.normpath('~/.conf') self.assertEqual(self.settings.get_config_dir(), os.path.normpath('/tmp/home/.conf/gtimelog')) def test_get_data_dir_1(self): # Case 1: GTIMELOG_HOME is present in the environment os.environ['GTIMELOG_HOME'] = os.path.normpath('~/.gt') self.assertEqual(self.settings.get_data_dir(), os.path.normpath('/tmp/home/.gt')) def test_get_data_dir_2(self): # Case 2: ~/.gtimelog exists os.path.isdir = lambda dir: True self.assertEqual(self.settings.get_data_dir(), os.path.normpath('/tmp/home/.gtimelog')) def test_get_data_dir_3(self): # Case 3: ~/.gtimelog does not exist, so we use XDG os.path.isdir = lambda dir: False self.assertEqual(self.settings.get_data_dir(), os.path.normpath('/tmp/home/.local/share/gtimelog')) def test_get_data_dir_4(self): # Case 4: XDG_CONFIG_HOME is present in the environment os.environ['XDG_DATA_HOME'] = os.path.normpath('~/.data') self.assertEqual(self.settings.get_data_dir(), os.path.normpath('/tmp/home/.data/gtimelog')) def test_get_config_file(self): self.settings.get_config_dir = lambda: os.path.normpath('~/.config/gtimelog') self.assertEqual(self.settings.get_config_file(), os.path.normpath('~/.config/gtimelog/gtimelogrc')) def test_get_timelog_file(self): self.settings.get_data_dir = lambda: os.path.normpath('~/.local/share/gtimelog') self.assertEqual(self.settings.get_timelog_file(), os.path.normpath('~/.local/share/gtimelog/timelog.txt')) def test_get_report_log_file(self): self.settings.get_data_dir = lambda: os.path.normpath('~/.local/share/gtimelog') self.assertEqual(self.settings.get_report_log_file(), os.path.normpath('~/.local/share/gtimelog/sentreports.log')) def test_get_task_list_file(self): self.settings.get_data_dir = lambda: os.path.normpath('~/.local/share/gtimelog') self.assertEqual(self.settings.get_task_list_file(), os.path.normpath('~/.local/share/gtimelog/tasks.txt')) def test_get_task_list_cache_file(self): self.settings.get_data_dir = lambda: os.path.normpath('~/.local/share/gtimelog') self.assertEqual(self.settings.get_task_list_cache_file(), os.path.normpath('~/.local/share/gtimelog/remote-tasks.txt')) def test_load(self): self.settings.load('/dev/null') self.assertEqual(self.settings.name, 'Anonymous') def test_load_default_file(self): self.settings.load() def test_save(self): tempdir = self.mkdtemp() self.settings.save(os.path.join(tempdir, 'config')) def test_suite(): return unittest.defaultTestLoader.loadTestsFromName(__name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/tests/test_timelog.py0000664000175000017500000017327114556177605020077 0ustar00mgmg"""Tests for gtimelog.timelog""" import datetime import doctest import os import re import shutil import sys import tempfile import textwrap import time import unittest from io import StringIO from unittest import mock import freezegun from gtimelog.timelog import ( Exports, ReportRecord, Reports, TaskList, TimeCollection, TimeLog, ) class Checker(doctest.OutputChecker): """Doctest output checker that can deal with unicode literals.""" def check_output(self, want, got, optionflags): # u'...' -> '...'; u"..." -> "..." got = re.sub(r'''\bu('[^']*'|"[^"]*")''', r'\1', got) # Python 3.7: datetime.timedelta(seconds=1860) -> # Python < 3.7: datetime.timedelta(0, 1860) got = re.sub(r'datetime[.]timedelta[(]seconds=(\d+)[)]', r'datetime.timedelta(0, \1)', got) return doctest.OutputChecker.check_output(self, want, got, optionflags) def doctest_as_hours(): """Tests for as_hours >>> from gtimelog.timelog import as_hours >>> from datetime import timedelta >>> as_hours(timedelta(0)) 0.0 >>> as_hours(timedelta(minutes=30)) 0.5 >>> as_hours(timedelta(minutes=60)) 1.0 >>> as_hours(timedelta(days=2)) 48.0 """ def doctest_format_duration(): """Tests for format_duration. >>> from gtimelog.timelog import format_duration >>> from datetime import timedelta >>> format_duration(timedelta(0)) '0 h 0 min' >>> format_duration(timedelta(minutes=1)) '0 h 1 min' >>> format_duration(timedelta(minutes=60)) '1 h 0 min' """ def doctest_format_short(): """Tests for format_duration_short. >>> from gtimelog.timelog import format_duration_short >>> from datetime import timedelta >>> format_duration_short(timedelta(0)) '0:00' >>> format_duration_short(timedelta(minutes=1)) '0:01' >>> format_duration_short(timedelta(minutes=59)) '0:59' >>> format_duration_short(timedelta(minutes=60)) '1:00' >>> format_duration_short(timedelta(days=1, hours=2, minutes=3)) '26:03' """ def doctest_format_duration_long(): """Tests for format_duration_long. >>> from gtimelog.timelog import format_duration_long >>> from datetime import timedelta >>> format_duration_long(timedelta(0)) '0 min' >>> format_duration_long(timedelta(minutes=1)) '1 min' >>> format_duration_long(timedelta(minutes=60)) '1 hour' >>> format_duration_long(timedelta(minutes=65)) '1 hour 5 min' >>> format_duration_long(timedelta(hours=2)) '2 hours' >>> format_duration_long(timedelta(hours=2, minutes=1)) '2 hours 1 min' """ def doctest_parse_datetime(): """Tests for parse_datetime >>> from gtimelog.timelog import parse_datetime >>> parse_datetime('2005-02-03 02:13') datetime.datetime(2005, 2, 3, 2, 13) >>> parse_datetime('xyzzy') Traceback (most recent call last): ... ValueError: bad date time: 'xyzzy' >>> parse_datetime('YYYY-MM-DD HH:MM') Traceback (most recent call last): ... ValueError: bad date time: 'YYYY-MM-DD HH:MM' """ def doctest_parse_time(): """Tests for parse_time >>> from gtimelog.timelog import parse_time >>> parse_time('02:13') datetime.time(2, 13) >>> parse_time('xyzzy') Traceback (most recent call last): ... ValueError: bad time: 'xyzzy' """ def doctest_virtual_day(): """Tests for virtual_day >>> from datetime import datetime, time >>> from gtimelog.timelog import virtual_day Virtual midnight >>> vm = time(2, 0) The tests themselves: >>> virtual_day(datetime(2005, 2, 3, 1, 15), vm) datetime.date(2005, 2, 2) >>> virtual_day(datetime(2005, 2, 3, 1, 59), vm) datetime.date(2005, 2, 2) >>> virtual_day(datetime(2005, 2, 3, 2, 0), vm) datetime.date(2005, 2, 3) >>> virtual_day(datetime(2005, 2, 3, 12, 0), vm) datetime.date(2005, 2, 3) >>> virtual_day(datetime(2005, 2, 3, 23, 59), vm) datetime.date(2005, 2, 3) """ def doctest_different_days(): """Tests for different_days >>> from datetime import datetime, time >>> from gtimelog.timelog import different_days Virtual midnight >>> vm = time(2, 0) The tests themselves: >>> different_days(datetime(2005, 2, 3, 1, 15), ... datetime(2005, 2, 3, 2, 15), vm) True >>> different_days(datetime(2005, 2, 3, 11, 15), ... datetime(2005, 2, 3, 12, 15), vm) False """ def doctest_first_of_month(): """Tests for first_of_month >>> from gtimelog.timelog import first_of_month >>> from datetime import date, timedelta >>> first_of_month(date(2007, 1, 1)) datetime.date(2007, 1, 1) >>> first_of_month(date(2007, 1, 7)) datetime.date(2007, 1, 1) >>> first_of_month(date(2007, 1, 31)) datetime.date(2007, 1, 1) >>> first_of_month(date(2007, 2, 1)) datetime.date(2007, 2, 1) >>> first_of_month(date(2007, 2, 28)) datetime.date(2007, 2, 1) >>> first_of_month(date(2007, 3, 1)) datetime.date(2007, 3, 1) Why not test extensively? >>> d = date(2000, 1, 1) >>> while d < date(2005, 1, 1): ... f = first_of_month(d) ... if (f.year, f.month, f.day) != (d.year, d.month, 1): ... print("WRONG: first_of_month(%r) returned %r" % (d, f)) ... d += timedelta(1) """ def doctest_prev_month(): """Tests for prev_month >>> from gtimelog.timelog import prev_month >>> from datetime import date, timedelta >>> prev_month(date(2007, 3, 1)) datetime.date(2007, 2, 1) >>> prev_month(date(2007, 3, 7)) datetime.date(2007, 2, 1) >>> prev_month(date(2007, 3, 31)) datetime.date(2007, 2, 1) >>> prev_month(date(2007, 4, 1)) datetime.date(2007, 3, 1) >>> prev_month(date(2007, 2, 28)) datetime.date(2007, 1, 1) >>> prev_month(date(2007, 4, 1)) datetime.date(2007, 3, 1) Why not test extensively? >>> d = date(2000, 1, 1) >>> while d < date(2005, 1, 1): ... f = prev_month(d) ... next = f + timedelta(31) ... if f.day != 1 or (next.year, next.month) != (d.year, d.month): ... print("WRONG: prev_month(%r) returned %r" % (d, f)) ... d += timedelta(1) """ def doctest_next_month(): """Tests for next_month >>> from gtimelog.timelog import next_month >>> from datetime import date, timedelta >>> next_month(date(2007, 1, 1)) datetime.date(2007, 2, 1) >>> next_month(date(2007, 1, 7)) datetime.date(2007, 2, 1) >>> next_month(date(2007, 1, 31)) datetime.date(2007, 2, 1) >>> next_month(date(2007, 2, 1)) datetime.date(2007, 3, 1) >>> next_month(date(2007, 2, 28)) datetime.date(2007, 3, 1) >>> next_month(date(2007, 3, 1)) datetime.date(2007, 4, 1) Why not test extensively? >>> d = date(2000, 1, 1) >>> while d < date(2005, 1, 1): ... f = next_month(d) ... prev = f - timedelta(1) ... if f.day != 1 or (prev.year, prev.month) != (d.year, d.month): ... print("WRONG: next_month(%r) returned %r" % (d, f)) ... d += timedelta(1) """ def doctest_uniq(): """Tests for uniq >>> from gtimelog.timelog import uniq >>> uniq(['a', 'b', 'b', 'c', 'd', 'b', 'd']) ['a', 'b', 'c', 'd', 'b', 'd'] >>> uniq(['a']) ['a'] >>> uniq([]) [] """ def make_time_window(file=None, min=None, max=None, vm=datetime.time(2)): if file is None: file = StringIO() return TimeLog(file, vm).window_for(min, max) def doctest_TimeWindow_repr(): """Test for TimeWindow.__repr__ >>> from datetime import datetime, time >>> min = datetime(2013, 12, 3) >>> max = datetime(2013, 12, 4) >>> vm = time(2, 0) >>> make_time_window(min=min, max=max, vm=vm) """ def doctest_TimeWindow_reread_no_file(): """Test for TimeWindow.reread >>> window = make_time_window('/nosuchfile') There's no error. >>> len(window.items) 0 >>> window.last_time() """ def doctest_TimeWindow_reread_bad_timestamp(): """Test for TimeWindow.reread >>> from datetime import datetime, time >>> min = datetime(2013, 12, 4) >>> max = datetime(2013, 12, 5) >>> vm = time(2, 0) >>> sampledata = StringIO(''' ... 2013-12-04 09:00: start ** ... # hey: this is not a timestamp ... 2013-12-04 09:14: gtimelog: write some tests ... ''') >>> window = make_time_window(sampledata, min, max, vm) There's no error, the line with a bad timestamp is silently skipped. >>> len(window.items) 2 """ def doctest_TimeWindow_reread_bad_ordering(): """Test for TimeWindow.reread >>> from datetime import datetime >>> min = datetime(2013, 12, 4) >>> max = datetime(2013, 12, 5) >>> sampledata = StringIO(''' ... 2013-12-04 09:00: start ** ... 2013-12-04 09:14: gtimelog: write some tests ... 2013-12-04 09:10: gtimelog: whoops clock got all confused ... 2013-12-04 09:10: gtimelog: so this will need to be fixed ... ''') >>> window = make_time_window(sampledata, min, max) There's no error, the timestamps have been reordered, but note that order was preserved for events with the same timestamp >>> for t, e in window.items: ... print("%s: %s" % (t.strftime('%H:%M'), e)) 09:00: start ** 09:10: gtimelog: whoops clock got all confused 09:10: gtimelog: so this will need to be fixed 09:14: gtimelog: write some tests >>> window.last_time() datetime.datetime(2013, 12, 4, 9, 14) """ def doctest_TimeWindow_count_days(): """Test for TimeWindow.count_days >>> from datetime import datetime, time >>> min = datetime(2013, 12, 2) >>> max = datetime(2013, 12, 9) >>> vm = time(2, 0) >>> sampledata = StringIO(''' ... 2013-12-04 09:00: start ** ... 2013-12-04 09:14: gtimelog: write some tests ... 2013-12-04 09:10: gtimelog: whoops clock got all confused ... 2013-12-04 09:10: gtimelog: so this will need to be fixed ... ... 2013-12-05 22:30: some fictional late night work ** ... 2013-12-06 00:30: frobnicate the widgets ... ... 2013-12-08 09:00: work ** ... 2013-12-08 09:01: and stuff ... ''') >>> window = make_time_window(sampledata, min, max, vm) >>> window.count_days() 3 """ def doctest_TimeWindow_last_entry(): """Test for TimeWindow.last_entry >>> from datetime import datetime >>> window = make_time_window() Case #1: no items >>> window.items = [] >>> window.last_entry() Case #2: single item >>> window.items = [ ... (datetime(2013, 12, 4, 9, 0), 'started **'), ... ] >>> start, stop, duration, tags, entry = window.last_entry() >>> start == stop == datetime(2013, 12, 4, 9, 0) True >>> duration datetime.timedelta(0) >>> entry 'started **' Case #3: single item at start of new day >>> window.items = [ ... (datetime(2013, 12, 3, 12, 0), 'stuff'), ... (datetime(2013, 12, 4, 9, 0), 'started **'), ... ] >>> start, stop, duration, tags, entry = window.last_entry() >>> start == stop == datetime(2013, 12, 4, 9, 0) True >>> duration datetime.timedelta(0) >>> entry 'started **' Case #4: several items >>> window.items = [ ... (datetime(2013, 12, 4, 9, 0), 'started **'), ... (datetime(2013, 12, 4, 9, 31), 'gtimelog: tests'), ... ] >>> start, stop, duration, tags, entry = window.last_entry() >>> start datetime.datetime(2013, 12, 4, 9, 0) >>> stop datetime.datetime(2013, 12, 4, 9, 31) >>> duration datetime.timedelta(0, 1860) >>> entry 'gtimelog: tests' """ def doctest_Exports_to_csv_complete(): r"""Tests for Exports.to_csv_complete >>> from datetime import datetime, time >>> min = datetime(2008, 6, 1) >>> max = datetime(2008, 7, 1) >>> vm = time(2, 0) >>> sampledata = StringIO(''' ... 2008-06-03 12:45: start ... 2008-06-03 13:00: something ... 2008-06-03 14:45: something else ... 2008-06-03 15:45: etc ... 2008-06-05 12:45: start ... 2008-06-05 13:15: something ... 2008-06-05 14:15: rest ** ... 2008-06-05 16:15: let's not mention this ever again *** ... ''') >>> window = make_time_window(sampledata, min, max, vm) >>> Exports(window).to_csv_complete(sys.stdout) task,time (minutes) etc,60 something,45 something else,105 """ def doctest_Exports_to_csv_daily(): r"""Tests for Exports.to_csv_daily >>> from datetime import datetime, time >>> min = datetime(2008, 6, 1) >>> max = datetime(2008, 7, 1) >>> vm = time(2, 0) >>> sampledata = StringIO(''' ... 2008-06-03 12:45: start ... 2008-06-03 13:00: something ... 2008-06-03 14:45: something else ... 2008-06-03 15:45: etc ... 2008-06-05 12:45: start ... 2008-06-05 13:15: something ... 2008-06-05 14:15: rest ** ... ''') >>> window = make_time_window(sampledata, min, max, vm) >>> Exports(window).to_csv_daily(sys.stdout) date,day-start (hours),slacking (hours),work (hours) 2008-06-03,12.75,0.0,3.0 2008-06-04,0.0,0.0,0.0 2008-06-05,12.75,1.0,0.5 """ def doctest_Exports_icalendar(): r"""Tests for Exports.icalendar >>> from datetime import datetime, time >>> min = datetime(2008, 6, 1) >>> max = datetime(2008, 7, 1) >>> vm = time(2, 0) >>> sampledata = StringIO(r''' ... 2008-06-03 12:45: start ** ... 2008-06-03 13:00: something ... 2008-06-03 15:45: something, else; with special\chars ... 2008-06-05 12:45: start ** ... 2008-06-05 13:15: something ... 2008-06-05 14:15: rest ** ... ''') >>> window = make_time_window(sampledata, min, max, vm) >>> with freezegun.freeze_time("2015-05-18 15:40"): ... with mock.patch('socket.getfqdn') as mock_getfqdn: ... mock_getfqdn.return_value = 'localhost' ... Exports(window).icalendar(sys.stdout) ... # doctest: +REPORT_NDIFF BEGIN:VCALENDAR PRODID:-//gtimelog.org/NONSGML GTimeLog//EN VERSION:2.0 BEGIN:VEVENT UID:be5f9be205c2308f7f1a30d6c399d6bd@localhost SUMMARY:start ** DTSTART:20080603T124500 DTEND:20080603T124500 DTSTAMP:20150518T154000Z END:VEVENT BEGIN:VEVENT UID:33c7e212fed11eda71d5acd4bd22119b@localhost SUMMARY:something DTSTART:20080603T124500 DTEND:20080603T130000 DTSTAMP:20150518T154000Z END:VEVENT BEGIN:VEVENT UID:b10c11beaf91df16964a46b4c87420b1@localhost SUMMARY:something\, else\; with special\\chars DTSTART:20080603T130000 DTEND:20080603T154500 DTSTAMP:20150518T154000Z END:VEVENT BEGIN:VEVENT UID:04964eef67ec22178d74fe4c0f06aa2a@localhost SUMMARY:start ** DTSTART:20080605T124500 DTEND:20080605T124500 DTSTAMP:20150518T154000Z END:VEVENT BEGIN:VEVENT UID:2b51ea6d1c26f02d58051a691657068d@localhost SUMMARY:something DTSTART:20080605T124500 DTEND:20080605T131500 DTSTAMP:20150518T154000Z END:VEVENT BEGIN:VEVENT UID:bd6bfd401333dbbf34fec941567d5d06@localhost SUMMARY:rest ** DTSTART:20080605T131500 DTEND:20080605T141500 DTSTAMP:20150518T154000Z END:VEVENT END:VCALENDAR """ def doctest_Reports_weekly_report_categorized(): r"""Tests for Reports.weekly_report_categorized >>> from datetime import datetime >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> window = make_time_window(min=min, max=max) >>> reports = Reports(window) >>> reports.weekly_report_categorized(sys.stdout, 'foo@bar.com', ... 'Bob Jones') To: foo@bar.com Subject: Weekly report for Bob Jones (week 04) No work done this week. >>> fh = StringIO(textwrap.dedent(''' ... 2010-01-30 09:00: start ** ... 2010-01-30 09:23: Bing: stuff ... 2010-01-30 12:54: Bong: other stuff ... 2010-01-30 13:32: lunch ** ... 2010-01-30 23:46: misc: blah ... ''')) >>> window = make_time_window(fh, min, max) >>> reports = Reports(window) >>> reports.weekly_report_categorized(sys.stdout, 'foo@bar.com', ... 'Bob Jones') To: foo@bar.com Subject: Weekly report for Bob Jones (week 04) time Bing: Stuff 0:23 ---------------------------------------------------------------------- 0:23 Bong: Other stuff 3:31 ---------------------------------------------------------------------- 3:31 misc: Blah 10:14 ---------------------------------------------------------------------- 10:14 Total work done this week: 14:08 Categories by time spent: misc 10:14 Bong 3:31 Bing 0:23 """ def doctest_Reports_monthly_report_categorized(): r"""Tests for Reports.monthly_report_categorized >>> from datetime import datetime, time >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> window = make_time_window(min=min, max=max) >>> reports = Reports(window) >>> reports.monthly_report_categorized(sys.stdout, 'foo@bar.com', ... 'Bob Jones') To: foo@bar.com Subject: Monthly report for Bob Jones (2010/01) No work done this month. >>> fh = StringIO(textwrap.dedent(''' ... 2010-01-28 09:00: start ... 2010-01-28 09:23: give up *** ... ... 2010-01-30 09:00: start ... 2010-01-30 09:23: Bing: stuff ... 2010-01-30 12:54: Bong: other stuff ... 2010-01-30 13:32: lunch ** ... 2010-01-30 23:46: misc ... ''')) >>> window = make_time_window(fh, min, max, vm) >>> reports = Reports(window) >>> reports.monthly_report_categorized(sys.stdout, 'foo@bar.com', ... 'Bob Jones') To: foo@bar.com Subject: Monthly report for Bob Jones (2010/01) time Bing: Stuff 0:23 ---------------------------------------------------------------------- 0:23 Bong: Other stuff 3:31 ---------------------------------------------------------------------- 3:31 No category: Misc 10:14 ---------------------------------------------------------------------- 10:14 Total work done this month: 14:08 Categories by time spent: No category 10:14 Bong 3:31 Bing 0:23 """ def doctest_Reports_report_categories(): r"""Tests for Reports._report_categories >>> from datetime import datetime, time, timedelta >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> categories = { ... 'Bing': timedelta(2), ... None: timedelta(1)} >>> window = make_time_window(StringIO(), min, max, vm) >>> reports = Reports(window) >>> reports._report_categories(sys.stdout, categories) By category: Bing 48 hours (none) 24 hours """ def doctest_Reports_daily_report(): r"""Tests for Reports.daily_report >>> from datetime import datetime, time >>> vm = time(2, 0) >>> min = datetime(2010, 1, 30) >>> max = datetime(2010, 1, 31) >>> window = make_time_window(StringIO(), min, max, vm) >>> reports = Reports(window) >>> reports.daily_report(sys.stdout, 'foo@bar.com', 'Bob Jones') To: foo@bar.com Subject: 2010-01-30 report for Bob Jones (Sat, week 04) No work done today. >>> fh = StringIO('\n'.join([ ... '2010-01-30 09:00: start', ... '2010-01-30 09:23: Bing: stuff', ... '2010-01-30 12:54: Bong: other stuff', ... '2010-01-30 13:32: lunch **', ... '2010-01-30 15:46: misc', ... ''])) >>> window = make_time_window(fh, min, max, vm) >>> reports = Reports(window) >>> reports.daily_report(sys.stdout, 'foo@bar.com', 'Bob Jones') To: foo@bar.com Subject: 2010-01-30 report for Bob Jones (Sat, week 04) Start at 09:00 Bing: stuff 23 min Bong: other stuff 3 hours 31 min Misc 2 hours 14 min Total work done: 6 hours 8 min By category: Bing 23 min Bong 3 hours 31 min (none) 2 hours 14 min Slacking: Lunch ** 38 min Time spent slacking: 38 min """ def doctest_Reports_weekly_report_plain(): r"""Tests for Reports.weekly_report_plain >>> from datetime import datetime, time >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> window = make_time_window(StringIO(), min, max, vm) >>> reports = Reports(window) >>> reports.weekly_report_plain(sys.stdout, 'foo@bar.com', 'Bob Jones') To: foo@bar.com Subject: Weekly report for Bob Jones (week 04) No work done this week. >>> fh = StringIO(textwrap.dedent(''' ... 2010-01-28 09:00: start ... 2010-01-28 09:23: give up *** ... ... 2010-01-30 09:00: start ... 2010-01-30 09:23: Bing: stuff ... 2010-01-30 12:54: Bong: other stuff ... 2010-01-30 13:32: lunch ** ... 2010-01-30 15:46: misc ... ''')) >>> window = make_time_window(fh, min, max, vm) >>> reports = Reports(window) >>> reports.weekly_report_plain(sys.stdout, 'foo@bar.com', 'Bob Jones') To: foo@bar.com Subject: Weekly report for Bob Jones (week 04) time Bing: stuff 23 min Bong: other stuff 3 hours 31 min Misc 2 hours 14 min Total work done this week: 6 hours 8 min By category: Bing 23 min Bong 3 hours 31 min (none) 2 hours 14 min """ def doctest_Reports_monthly_report_plain(): r"""Tests for Reports.monthly_report_plain >>> from datetime import datetime, time >>> vm = time(2, 0) >>> min = datetime(2007, 9, 1) >>> max = datetime(2007, 10, 1) >>> window = make_time_window(StringIO(), min, max, vm) >>> reports = Reports(window) >>> reports.monthly_report_plain(sys.stdout, 'foo@bar.com', 'Bob Jones') To: foo@bar.com Subject: Monthly report for Bob Jones (2007/09) No work done this month. >>> fh = StringIO('\n'.join([ ... '2007-09-30 09:00: start', ... '2007-09-30 09:23: Bing: stuff', ... '2007-09-30 12:54: Bong: other stuff', ... '2007-09-30 13:32: lunch **', ... '2007-09-30 15:46: misc', ... ''])) >>> window = make_time_window(fh, min, max, vm) >>> reports = Reports(window) >>> reports.monthly_report_plain(sys.stdout, 'foo@bar.com', 'Bob Jones') To: foo@bar.com Subject: Monthly report for Bob Jones (2007/09) time Bing: stuff 23 min Bong: other stuff 3 hours 31 min Misc 2 hours 14 min Total work done this month: 6 hours 8 min By category: Bing 23 min Bong 3 hours 31 min (none) 2 hours 14 min """ def doctest_Reports_custom_range_report_categorized(): r"""Tests for Reports.custom_range_report_categorized >>> from datetime import datetime, time >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 2, 1) >>> window = make_time_window(StringIO(), min, max, vm) >>> reports = Reports(window) >>> reports.custom_range_report_categorized(sys.stdout, 'foo@bar.com', ... 'Bob Jones') To: foo@bar.com Subject: Custom date range report for Bob Jones (2010-01-25 - 2010-01-31) No work done this custom range. >>> fh = StringIO('\n'.join([ ... '2010-01-20 09:00: arrived', ... '2010-01-20 09:30: asdf', ... '2010-01-20 10:00: Bar: Foo', ... '' ... '2010-01-30 09:00: arrived', ... '2010-01-30 09:23: Bing: stuff', ... '2010-01-30 12:54: Bong: other stuff', ... '2010-01-30 13:32: lunch **', ... '2010-01-30 23:46: misc', ... ''])) >>> window = make_time_window(fh, min, max, vm) >>> reports = Reports(window) >>> reports.custom_range_report_categorized(sys.stdout, 'foo@bar.com', ... 'Bob Jones') To: foo@bar.com Subject: Custom date range report for Bob Jones (2010-01-25 - 2010-01-31) time Bing: Stuff 0:23 ---------------------------------------------------------------------- 0:23 Bong: Other stuff 3:31 ---------------------------------------------------------------------- 3:31 No category: Misc 10:14 ---------------------------------------------------------------------- 10:14 Total work done this custom range: 14:08 Categories by time spent: No category 10:14 Bong 3:31 Bing 0:23 """ class Mixins(object): tempdir = None def mkdtemp(self): if self.tempdir is None: self.tempdir = tempfile.mkdtemp(prefix='gtimelog-test-') self.addCleanup(shutil.rmtree, self.tempdir) return self.tempdir def tempfile(self, filename='timelog.txt'): return os.path.join(self.mkdtemp(), filename) def write_file(self, filename, content): filename = os.path.join(self.mkdtemp(), filename) with open(filename, 'w', encoding='utf-8') as f: f.write(content) return filename class TestTimeCollection(Mixins, unittest.TestCase): def test_split_category(self): sp = TimeCollection.split_category self.assertEqual(sp('some task'), (None, 'some task')) self.assertEqual(sp('project: some task'), ('project', 'some task')) self.assertEqual(sp('project: some task: etc'), ('project', 'some task: etc')) def test_split_category_no_task_just_category(self): # Regression test for https://github.com/gtimelog/gtimelog/issues/117 sp = TimeCollection.split_category self.assertEqual(sp('project: '), ('project', '')) self.assertEqual(sp('project:'), ('project', '')) def test_sorted_grouped_time_collection(self): # the unsorted list is nromally a TimeCollection but we fake it unsorted_list = ( # list of (start-time, name, duration) (10_000, 'BBB: b', 20), (30_000, 'CCC: c', 10), (20_000, 'Alone', 12), (40_000, 'AAA: a', 15), ) taskfile = self.write_file('tasks.txt', textwrap.dedent('''\ Alone # comments are skipped BBB: b AAA: a CCC: c ''')) tasklist = TaskList(taskfile) sorted_by = { 'start-time': ('BBB: b', 'Alone', 'CCC: c', 'AAA: a'), 'name': ('AAA: a', 'Alone', 'BBB: b', 'CCC: c'), 'duration': ('CCC: c', 'Alone', 'AAA: a', 'BBB: b'), 'task-list': ('Alone', 'BBB: b', 'AAA: a', 'CCC: c'), } def tc_sorted(method): tc_key = TimeCollection._get_grouped_order_key return tuple(name for start_time, name, duration in sorted(unsorted_list, key=tc_key(method, tasklist))) # we could have a loop but then it wouldn't be clear with which # method the assert fails self.assertEqual(tc_sorted('start-time'), sorted_by['start-time']) self.assertEqual(tc_sorted('name'), sorted_by['name']) self.assertEqual(tc_sorted('duration'), sorted_by['duration']) self.assertEqual(tc_sorted('task-list'), sorted_by['task-list']) class TestTaskList(Mixins, unittest.TestCase): def test_missing_file(self): tasklist = TaskList('/nosuchfile') self.assertFalse(tasklist.check_reload()) tasklist.reload() # no crash def test_parsing_and_ordering(self): taskfile = self.write_file('tasks.txt', textwrap.dedent('''\ # comments are skipped some task other task project: do it project:fix bugs misc: paperwork ''')) tasklist = TaskList(taskfile) self.assertEqual(tasklist.groups, [ ('project', ['do it', 'fix bugs']), ('misc', ['paperwork']), ('Other', ['some task', 'other task']), ]) # also test that the order function works as foreseen self.assertEqual(tasklist.order('project: fix bugs'), 4) self.assertEqual(tasklist.order('unknown task'), sys.maxsize) def test_unicode(self): taskfile = self.write_file('tasks.txt', '\N{SNOWMAN}') tasklist = TaskList(taskfile) self.assertEqual(tasklist.groups, [ ('Other', ['\N{SNOWMAN}']), ]) def test_reloading(self): taskfile = self.write_file('tasks.txt', 'some tasks\n') couple_seconds_ago = time.time() - 2 os.utime(taskfile, (couple_seconds_ago, couple_seconds_ago)) tasklist = TaskList(taskfile) self.assertEqual(tasklist.groups, [ ('Other', ['some tasks']), ]) self.assertFalse(tasklist.check_reload()) with open(taskfile, 'w') as f: f.write('new tasks\n') self.assertTrue(tasklist.check_reload()) self.assertEqual(tasklist.groups, [ ('Other', ['new tasks']), ]) class TestTimeLog(Mixins, unittest.TestCase): def test_reloading(self): logfile = self.tempfile() timelog = TimeLog(logfile, datetime.time(2, 0)) # No file - nothing to reload self.assertFalse(timelog.check_reload()) # Create a file - it should be reloaded, once. open(logfile, 'w').close() self.assertTrue(timelog.check_reload()) self.assertFalse(timelog.check_reload()) # Change the timestamp, somehow st = os.stat(logfile) os.utime(logfile, (st.st_atime, st.st_mtime + 1)) self.assertTrue(timelog.check_reload()) self.assertFalse(timelog.check_reload()) # Disappearance of the file is noticed os.unlink(logfile) self.assertTrue(timelog.check_reload()) self.assertFalse(timelog.check_reload()) def test_window_for_day(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) window = timelog.window_for_day(datetime.date(2015, 9, 17)) self.assertEqual(window.min_timestamp, datetime.datetime(2015, 9, 17, 2, 0)) self.assertEqual(window.max_timestamp, datetime.datetime(2015, 9, 18, 2, 0)) def test_window_for_week(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) for d in range(14, 21): window = timelog.window_for_week(datetime.date(2015, 9, d)) self.assertEqual(window.min_timestamp, datetime.datetime(2015, 9, 14, 2, 0)) self.assertEqual(window.max_timestamp, datetime.datetime(2015, 9, 21, 2, 0)) def test_window_for_month(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) for d in range(1, 31): window = timelog.window_for_month(datetime.date(2015, 9, d)) self.assertEqual(window.min_timestamp, datetime.datetime(2015, 9, 1, 2, 0)) self.assertEqual(window.max_timestamp, datetime.datetime(2015, 10, 1, 2, 0)) def test_window_for_date_range(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) window = timelog.window_for_date_range(datetime.date(2015, 9, 3), datetime.date(2015, 9, 24)) self.assertEqual(window.min_timestamp, datetime.datetime(2015, 9, 3, 2, 0)) self.assertEqual(window.max_timestamp, datetime.datetime(2015, 9, 25, 2, 0)) def test_appending_clears_window_cache(self): # Regression test for https://github.com/gtimelog/gtimelog/issues/28 timelog = TimeLog(self.tempfile(), datetime.time(2, 0)) w = timelog.window_for_day(datetime.date(2014, 11, 12)) self.assertEqual(list(w.all_entries()), []) timelog.append('started **', now=datetime.datetime(2014, 11, 12, 10, 00)) w = timelog.window_for_day(datetime.date(2014, 11, 12)) self.assertEqual(len(list(w.all_entries())), 1) def test_append_adds_blank_line_on_new_day(self): timelog = TimeLog(self.tempfile(), datetime.time(2, 0)) timelog.append('working on sth', now=datetime.datetime(2014, 11, 12, 18, 0)) timelog.append('new day **', now=datetime.datetime(2014, 11, 13, 8, 0)) with open(timelog.filename, 'r') as f: self.assertEqual(f.readlines(), ['2014-11-12 18:00: working on sth\n', '\n', '2014-11-13 08:00: new day **\n']) @freezegun.freeze_time("2015-05-12 16:27:35.115265") def test_append_rounds_the_time(self): timelog = TimeLog(self.tempfile(), datetime.time(2, 0)) timelog.append('now') self.assertEqual(timelog.items[-1][0], datetime.datetime(2015, 5, 12, 16, 27)) @freezegun.freeze_time("2018-12-09 16:27") def test_remove_last_entry(self): TEST_TIMELOG = textwrap.dedent(""" 2018-12-09 08:30: start at home 2018-12-09 08:40: emails # comment 2018-12-09 12:15: coding """) filename = self.tempfile() self.write_file(filename, TEST_TIMELOG) timelog = TimeLog(filename, datetime.time(2, 0)) last_entry = timelog.remove_last_entry() self.assertEqual(last_entry, 'coding') items_after_call = [ (datetime.datetime(2018, 12, 9, 8, 30), 'start at home'), (datetime.datetime(2018, 12, 9, 8, 40), 'emails')] self.assertEqual(timelog.items, items_after_call) self.assertEqual(timelog.window.items, items_after_call) with open(filename) as f: self.assertEqual(f.read(), textwrap.dedent(""" 2018-12-09 08:30: start at home 2018-12-09 08:40: emails # comment ##2018-12-09 12:15: coding """)) last_entry = timelog.remove_last_entry() self.assertEqual(last_entry, 'emails') items_after_call = [ (datetime.datetime(2018, 12, 9, 8, 30), 'start at home')] self.assertEqual(timelog.items, items_after_call) self.assertEqual(timelog.window.items, items_after_call) with open(filename) as f: self.assertEqual(f.read(), textwrap.dedent(""" 2018-12-09 08:30: start at home ##2018-12-09 08:40: emails # comment ##2018-12-09 12:15: coding """)) @freezegun.freeze_time("2018-12-10 10:40") def test_remove_last_entry_start_of_day(self): TEST_TIMELOG = textwrap.dedent(""" 2018-12-09 08:30: start at home 2018-12-09 08:40: emails 2018-12-10 08:30: start at home """) filename = self.tempfile() self.write_file(filename, TEST_TIMELOG) timelog = TimeLog(filename, datetime.time(2, 0)) timelog.reread() last_entry = timelog.remove_last_entry() self.assertEqual(last_entry, 'start at home') items_after_call = [ (datetime.datetime(2018, 12, 9, 8, 30), 'start at home'), (datetime.datetime(2018, 12, 9, 8, 40), 'emails')] self.assertEqual(timelog.items, items_after_call) self.assertEqual(timelog.window.items, []) with open(filename) as f: self.assertEqual(f.read(), textwrap.dedent(""" 2018-12-09 08:30: start at home 2018-12-09 08:40: emails ##2018-12-10 08:30: start at home """)) # no further remove possible at beginning of the day: last_entry = timelog.remove_last_entry() self.assertIsNone(last_entry) @freezegun.freeze_time("2015-05-12 16:27") def test_valid_time_accepts_any_time_in_the_past_when_log_is_empty(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) past = datetime.datetime(2015, 5, 12, 14, 20) self.assertTrue(timelog.valid_time(past)) @freezegun.freeze_time("2015-05-12 16:27") def test_valid_time_rejects_times_in_the_future(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) future = datetime.datetime(2015, 5, 12, 16, 30) self.assertFalse(timelog.valid_time(future)) @freezegun.freeze_time("2015-05-12 16:27") def test_valid_time_rejects_times_before_last_entry(self): timelog = TimeLog(StringIO("2015-05-12 15:00: did stuff"), datetime.time(2, 0)) past = datetime.datetime(2015, 5, 12, 14, 20) self.assertFalse(timelog.valid_time(past)) @freezegun.freeze_time("2015-05-12 16:27") def test_valid_time_accepts_times_between_last_entry_and_now(self): timelog = TimeLog(StringIO("2015-05-12 15:00: did stuff"), datetime.time(2, 0)) past = datetime.datetime(2015, 5, 12, 15, 20) self.assertTrue(timelog.valid_time(past)) def test_parse_correction_leaves_regular_text_alone(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("did stuff"), ("did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_recognizes_absolute_times(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("15:20 did stuff"), ("did stuff", datetime.datetime(2015, 5, 12, 15, 20))) @freezegun.freeze_time("2015-05-13 00:27") def test_parse_correction_handles_virtual_midnight_yesterdays_time(self): # Regression test for https://github.com/gtimelog/gtimelog/issues/33 timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("15:20 did stuff"), ("did stuff", datetime.datetime(2015, 5, 12, 15, 20))) @freezegun.freeze_time("2015-05-13 00:27") def test_parse_correction_handles_virtual_midnight_todays_time(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("00:15 did stuff"), ("did stuff", datetime.datetime(2015, 5, 13, 00, 15))) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_future_absolute_times(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("17:20 did stuff"), ("17:20 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_bad_absolute_times(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("19:60 did stuff"), ("19:60 did stuff", None)) self.assertEqual(timelog.parse_correction("24:00 did stuff"), ("24:00 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_absolute_times_before_last_entry(self): timelog = TimeLog(StringIO("2015-05-12 16:00: stuff"), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("15:20 did stuff"), ("15:20 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_recognizes_negative_relative_times(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("-20 did stuff"), ("did stuff", datetime.datetime(2015, 5, 12, 16, 7))) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_recognizes_positive_relative_times(self): timelog = TimeLog(StringIO("2015-05-12 15:50: stuff"), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("+20 did stuff"), ("did stuff", datetime.datetime(2015, 5, 12, 16, 10))) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_positive_relative_times_without_initial_entry(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("+20 did stuff"), ("+20 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_negative_relative_times_before_last_entry(self): timelog = TimeLog(StringIO("2015-05-12 16:00: stuff"), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("-30 did stuff"), ("-30 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_positive_relative_times_in_the_future(self): timelog = TimeLog(StringIO("2015-05-12 15:50: stuff"), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("+40 did stuff"), ("+40 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_bad_negative_relative_times(self): timelog = TimeLog(StringIO(), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("-200 did stuff"), ("-200 did stuff", None)) @freezegun.freeze_time("2015-05-12 16:27") def test_parse_correction_ignores_bad_positive_relative_times(self): timelog = TimeLog(StringIO("2015-05-12 15:50: stuff"), datetime.time(2, 0)) self.assertEqual(timelog.parse_correction("+200 did stuff"), ("+200 did stuff", None)) class TestTotals(unittest.TestCase): TEST_TIMELOG = textwrap.dedent( """ 2018-12-09 08:30: start at home 2018-12-09 08:40: emails 2018-12-09 09:10: travel to work *** 2018-12-09 09:15: coffee ** 2018-12-09 12:15: coding """) def setUp(self): self.tw = make_time_window( StringIO(self.TEST_TIMELOG), datetime.datetime(2018, 12, 9, 8, 0), datetime.datetime(2018, 12, 9, 23, 59), datetime.time(2, 0), ) def test_TimeWindow_totals(self): work, slack = self.tw.totals() self.assertEqual(work, datetime.timedelta(hours=3, minutes=10)) self.assertEqual(slack, datetime.timedelta(hours=0, minutes=5)) class TestFiltering(unittest.TestCase): TEST_TIMELOG = textwrap.dedent(""" 2014-05-27 10:03: arrived 2014-05-27 10:13: edx: introduce topic to new sysadmins 2014-05-27 10:30: email 2014-05-27 12:11: meeting: how to support new courses? 2014-05-27 15:12: edx: write test procedure for EdX instances 2014-05-27 17:03: cluster: set-up accounts, etc. 2014-05-27 17:14: support: how to run statistics on Hydra? 2014-05-27 17:36: off: pause ** 2014-05-27 17:38: email 2014-05-27 19:06: off: dinner & family ** 2014-05-27 22:19: cluster: fix shmmax-shmall issue """) def setUp(self): self.tw = make_time_window( StringIO(self.TEST_TIMELOG), datetime.datetime(2014, 5, 27, 9, 0), datetime.datetime(2014, 5, 27, 23, 59), datetime.time(2, 0), ) def test_TimeWindow_totals_filtering1(self): work, slack = self.tw.totals(filter_text='support') # matches two items: 1h 41m (10:30--12:11) + 11m (17:03--17:14) self.assertEqual(work, datetime.timedelta(hours=1, minutes=52)) self.assertEqual(slack, datetime.timedelta(0)) def test_TimeWindow_totals_filtering2(self): work, slack = self.tw.totals(filter_text='f') # matches four items: # 3h 1m (12:11--15:12) edx: write test procedure [f]or EdX instances # 3h 13m (19:06--22:19) cluster: [f]ix shmmax-shmall issue # total work: 6h 14m # 22m (17:14--17:36) o[f]f: pause ** # 1h 28m (17:38--19:06) o[f]f: dinner & family ** # total slacking: 1h 50m self.assertEqual(work, datetime.timedelta(hours=6, minutes=14)) self.assertEqual(slack, datetime.timedelta(hours=1, minutes=50)) class TestTagging(unittest.TestCase): TEST_TIMELOG = textwrap.dedent(""" 2014-05-27 10:03: arrived 2014-05-27 10:13: edx: introduce topic to new sysadmins -- edx 2014-05-27 10:30: email 2014-05-27 12:11: meeting: how to support new courses? -- edx meeting 2014-05-27 15:12: edx: write test procedure for EdX instances -- edx sysadmin 2014-05-27 17:03: cluster: set-up accounts, etc. -- sysadmin hpc 2014-05-27 17:14: support: how to run statistics on Hydra? -- support hydra 2014-05-27 17:36: off: pause ** 2014-05-27 17:38: email 2014-05-27 19:06: off: dinner & family ** 2014-05-27 22:19: cluster: fix shmmax-shmall issue -- sysadmin hpc """) def setUp(self): self.tw = make_time_window( StringIO(self.TEST_TIMELOG), datetime.datetime(2014, 5, 27, 9, 0), datetime.datetime(2014, 5, 27, 23, 59), datetime.time(2, 0), ) def test_TimeWindow_set_of_all_tags(self): tags = self.tw.set_of_all_tags() self.assertEqual(tags, {'edx', 'hpc', 'hydra', 'meeting', 'support', 'sysadmin'}) def test_TimeWindow_totals_per_tag1(self): """Test aggregate time per tag, 1 entry only""" result = self.tw.totals('meeting') self.assertEqual(len(result), 2) work, slack = result self.assertEqual(work, # start/end times are manually extracted from the TEST_TIMELOG sample (datetime.timedelta(hours=12, minutes=11) - datetime.timedelta(hours=10, minutes=30)) ) self.assertEqual(slack, datetime.timedelta(0)) def test_TimeWindow_totals_per_tag2(self): """Test aggregate time per tag, several entries""" result = self.tw.totals('hpc') self.assertEqual(len(result), 2) work, slack = result self.assertEqual(work, # start/end times are manually extracted from the TEST_TIMELOG sample (datetime.timedelta(hours=17, minutes=3) - datetime.timedelta(hours=15, minutes=12)) + (datetime.timedelta(hours=22, minutes=19) - datetime.timedelta(hours=19, minutes=6)) ) self.assertEqual(slack, datetime.timedelta(0)) def test_TimeWindow__split_entry_and_tags1(self): """Test `TimeWindow._split_entry_and_tags` with simple entry""" result = self.tw._split_entry_and_tags('email') self.assertEqual(len(result), 2) self.assertEqual(result[0], 'email') self.assertEqual(result[1], set()) def test_TimeWindow__split_entry_and_tags2(self): """Test `TimeWindow._split_entry_and_tags` with simple entry and tags""" result = self.tw._split_entry_and_tags('restart CFEngine server -- sysadmin cfengine issue327') self.assertEqual(len(result), 2) self.assertEqual(result[0], 'restart CFEngine server') self.assertEqual(result[1], {'sysadmin', 'cfengine', 'issue327'}) def test_TimeWindow__split_entry_and_tags3(self): """Test `TimeWindow._split_entry_and_tags` with category, entry, and tags""" result = self.tw._split_entry_and_tags('tooling: tagging support in gtimelog -- tooling gtimelog') self.assertEqual(len(result), 2) self.assertEqual(result[0], 'tooling: tagging support in gtimelog') self.assertEqual(result[1], {'tooling', 'gtimelog'}) def test_TimeWindow__split_entry_and_tags4(self): """Test `TimeWindow._split_entry_and_tags` with slack-type entry""" result = self.tw._split_entry_and_tags('read news -- reading **') self.assertEqual(len(result), 2) self.assertEqual(result[0], 'read news **') self.assertEqual(result[1], {'reading'}) def test_TimeWindow__split_entry_and_tags5(self): """Test `TimeWindow._split_entry_and_tags` with slack-type entry""" result = self.tw._split_entry_and_tags('read news -- reading ***') self.assertEqual(len(result), 2) self.assertEqual(result[0], 'read news ***') self.assertEqual(result[1], {'reading'}) def test_Reports__report_tags(self): rp = Reports(self.tw) txt = StringIO() # use same tags as in tests above, so we know the totals rp._report_tags(txt, ['meeting', 'hpc']) self.assertEqual( txt.getvalue().strip(), textwrap.dedent(""" Time spent in each area: hpc 5:04 meeting 1:41 Note that area totals may not add up to the period totals, as each entry may be belong to multiple areas (or none at all). """).strip()) def test_Reports_daily_report_includes_tags(self): rp = Reports(self.tw) txt = StringIO() rp.daily_report(txt, 'me@example.com', 'me') self.assertIn('Time spent in each area', txt.getvalue()) def test_Reports_weekly_report_includes_tags(self): rp = Reports(self.tw) txt = StringIO() rp.weekly_report(txt, 'me@example.com', 'me') self.assertIn('Time spent in each area', txt.getvalue()) def test_Reports_monthly_report_includes_tags(self): rp = Reports(self.tw) txt = StringIO() rp.monthly_report(txt, 'me@example.com', 'me') self.assertIn('Time spent in each area', txt.getvalue()) def test_Reports_categorized_report_includes_tags(self): rp = Reports(self.tw, style='categorized') txt = StringIO() rp.weekly_report(txt, 'me@example.com', 'me') self.assertIn('Time spent in each area', txt.getvalue()) txt = StringIO() rp.monthly_report(txt, 'me@example.com', 'me') self.assertIn('Time spent in each area', txt.getvalue()) class TestReportRecord(Mixins, unittest.TestCase): def setUp(self): self.filename = self.tempfile('sentreports.log') def load_fixture(self, lines): with open(self.filename, 'w') as f: for line in lines: f.write(line + '\n') def test_get_report_id(self): get_id = ReportRecord.get_report_id self.assertEqual( get_id(ReportRecord.WEEKLY, datetime.date(2016, 1, 1)), '2015/53', ) @freezegun.freeze_time("2016-01-08 09:34:50") def test_record(self): rr = ReportRecord(self.filename) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.com') rr.record(rr.WEEKLY, datetime.date(2016, 1, 6), 'test@example.com') rr.record(rr.MONTHLY, datetime.date(2016, 1, 6), 'test@example.com') with open(self.filename) as f: written = f.read() self.assertEqual( written.splitlines(), [ "2016-01-08 09:34:50,daily,2016-01-06,test@example.com", "2016-01-08 09:34:50,weekly,2016/1,test@example.com", "2016-01-08 09:34:50,monthly,2016-01,test@example.com", ] ) def test_get_recipients(self): self.load_fixture([ "2015-12-21 12:15:11,daily,2015-12-21,test@example.com", "2015-12-21 12:17:35,daily,2015-12-21,marius+test@example.com", "2015-12-21 12:18:21,daily,2015-12-21,marius+test@example.com", "2015-12-21 12:19:06,weekly,2015/46,marius+test@example.com", "2016-01-04 10:35:09,weekly,2015/53,activity@example.com", "2016-01-04 11:00:33,monthly,2015-12,activity@example.com", "2016-01-04 12:59:24,weekly,2015/49,activity@example.com", "2016-01-04 12:59:37,weekly,2015/52,activity@example.com", ]) rr = ReportRecord(self.filename) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), [], ) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2015, 12, 21)), [ "test@example.com", "marius+test@example.com", "marius+test@example.com", ], ) self.assertEqual( rr.get_recipients(rr.WEEKLY, datetime.date(2015, 12, 21)), [ "activity@example.com", ], ) def test_reread_missing_file(self): rr = ReportRecord(self.filename) rr.reread() self.assertEqual(len(rr._records), 0) def test_reread_bad_records_are_ignored(self): self.load_fixture([ "2016-01-08 09:34:50,daily,2016-01-06,test@example.com", "Somebody might edit this file and corrupt it", "2016-01-08 09:34:50,monthly,2016-01,test@example.com", ]) rr = ReportRecord(self.filename) rr.reread() self.assertEqual(len(rr._records), 2) def test_record_then_load_when_empty(self): rr = ReportRecord(self.filename) now = datetime.datetime(2016, 1, 8, 9, 34, 50) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.com', now) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), ['test@example.com'] ) def test_record_then_load_twice_when_empty(self): # Recording twice might not change the mtime because the resolution # is too low; so record() must update the internal data structures # by itself. rr = ReportRecord(self.filename) now = datetime.datetime(2016, 1, 8, 9, 34, 50) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.com', now) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), ['test@example.com'] ) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.org', now) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), ['test@example.com', 'test@example.org'] ) def test_record_then_load_when_nonempty(self): # Since we have lazy-loading, the "let's add the new record internally # and set last_mtime" optimization in record() might trick ReportRecord # into not loading an existing file at all. self.load_fixture([ "2016-01-08 09:34:50,daily,2016-01-06,test@example.com", "2016-01-08 09:34:50,weekly,2016/1,test@example.com", "2016-01-08 09:34:50,monthly,2016-01,test@example.com", ]) rr = ReportRecord(self.filename) now = datetime.datetime(2016, 1, 8, 9, 34, 50) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.org', now) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), ['test@example.com', 'test@example.org'] ) def test_record_then_load_twice_when_nonempty(self): # I'm not sure what I'm protecting against with this test. Probably # pure unnecessary paranoia. self.load_fixture([ "2016-01-08 09:34:50,daily,2016-01-06,test@example.com", "2016-01-08 09:34:50,weekly,2016/1,test@example.com", "2016-01-08 09:34:50,monthly,2016-01,test@example.com", ]) rr = ReportRecord(self.filename) now = datetime.datetime(2016, 1, 8, 9, 34, 50) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.org', now) rr.record(rr.DAILY, datetime.date(2016, 1, 6), 'test@example.net', now) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), ['test@example.com', 'test@example.org', 'test@example.net'] ) def test_automatic_reload(self): rr = ReportRecord(self.filename) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), [] ) self.load_fixture([ "2016-01-08 09:34:50,daily,2016-01-06,test@example.com", "2016-01-08 09:34:50,weekly,2016/1,test@example.com", "2016-01-08 09:34:50,monthly,2016-01,test@example.com", ]) self.assertEqual( rr.get_recipients(rr.DAILY, datetime.date(2016, 1, 6)), ['test@example.com'] ) def additional_tests(): # for setup.py return doctest.DocTestSuite(optionflags=doctest.NORMALIZE_WHITESPACE, checker=Checker()) def test_suite(): return unittest.TestSuite([ unittest.defaultTestLoader.loadTestsFromName(__name__), additional_tests(), ]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706622853.0 gtimelog-0.12.0/src/gtimelog/timelog.py0000664000175000017500000012514014556177605015666 0ustar00mgmg""" Non-GUI bits of gtimelog. """ import collections import csv import datetime import os import re import socket import sys from collections import defaultdict from hashlib import md5 from operator import itemgetter def as_minutes(duration): """Convert a datetime.timedelta to an integer number of minutes.""" return duration.days * 24 * 60 + duration.seconds // 60 def as_hours(duration): """Convert a datetime.timedelta to a float number of hours.""" return duration.days * 24.0 + duration.seconds / (60.0 * 60.0) def format_duration(duration): """Format a datetime.timedelta with minute precision.""" h, m = divmod(as_minutes(duration), 60) return '%d h %d min' % (h, m) def format_duration_short(duration): """Format a datetime.timedelta with minute precision.""" h, m = divmod((duration.days * 24 * 60 + duration.seconds // 60), 60) return '%d:%02d' % (h, m) def format_duration_long(duration): """Format a datetime.timedelta with minute precision, long format.""" h, m = divmod((duration.days * 24 * 60 + duration.seconds // 60), 60) if h and m: return '%d hour%s %d min' % (h, h != 1 and "s" or "", m) elif h: return '%d hour%s' % (h, h != 1 and "s" or "") else: return '%d min' % m def parse_datetime(dt): """Parse a datetime instance from 'YYYY-MM-DD HH:MM' formatted string.""" if len(dt) != 16 or dt[4] != '-' or dt[7] != '-' or dt[10] != ' ' or dt[13] != ':': raise ValueError('bad date time: %r' % dt) try: year = int(dt[:4]) month = int(dt[5:7]) day = int(dt[8:10]) hour = int(dt[11:13]) min = int(dt[14:]) except ValueError: raise ValueError('bad date time: %r' % dt) return datetime.datetime(year, month, day, hour, min) def parse_time(t): """Parse a time instance from 'HH:MM' formatted string.""" m = re.match(r'^(\d+):(\d+)$', t) if not m: raise ValueError('bad time: %r' % t) hour, min = map(int, m.groups()) return datetime.time(hour, min) def virtual_day(dt, virtual_midnight): """Return the "virtual day" of a timestamp. Timestamps between midnight and "virtual midnight" (e.g. 2 am) are assigned to the previous "virtual day". """ if dt.time() < virtual_midnight: # assign to previous day return dt.date() - datetime.timedelta(1) return dt.date() def different_days(dt1, dt2, virtual_midnight): """Check whether dt1 and dt2 are on different "virtual days". See virtual_day(). """ return virtual_day(dt1, virtual_midnight) != virtual_day(dt2, virtual_midnight) def first_of_month(date): """Return the first day of the month for a given date.""" return date.replace(day=1) def prev_month(date): """Return the first day of the previous month.""" if date.month == 1: return datetime.date(date.year - 1, 12, 1) else: return datetime.date(date.year, date.month - 1, 1) def next_month(date): """Return the first day of the next month.""" if date.month == 12: return datetime.date(date.year + 1, 1, 1) else: return datetime.date(date.year, date.month + 1, 1) def uniq(items): """Return list with consecutive duplicates removed.""" result = items[:1] for item in items[1:]: if item != result[-1]: result.append(item) return result def get_mtime(filename): """Return the modification time of a file, if it exists. Returns None if the file doesn't exist. """ # Accept any file-like object instead of a filename (for the benefit of # unit tests). if hasattr(filename, 'read'): return None try: return os.stat(filename).st_mtime except OSError: return None Entry = collections.namedtuple('Entry', 'start stop duration tags entry') class TimeCollection(object): """A collection of timestamped events. self.items is a list of (timestamp, event_title) tuples. Time intervals between events within the time window form entries that have a start time, a stop time, and a duration. Entry title is the title of the event that occurred at the stop time. The first event of each day also creates a special "start" entry of zero duration. Entries that span virtual midnight boundaries are also converted to "start" entries at their end point. """ def __init__(self, virtual_midnight): self.items = [] self.virtual_midnight = virtual_midnight def last_time(self): """Return the time of the last entry. Returns a datetime.datetime instance or None, if the window is empty. """ if not self.items: return None return self.items[-1][0] def last_entry(self): """Return the last entry (or None if there are no events). It is always true that self.last_entry() == list(self.all_entries())[-1] if self.items it not empty. """ if not self.items: return None stop = self.items[-1][0] entry = self.items[-1][1] if len(self.items) == 1: start = stop else: start = self.items[-2][0] if different_days(start, stop, self.virtual_midnight): start = stop duration = stop - start entry, tags = self._split_entry_and_tags(entry) return Entry(start, stop, duration, tags, entry) def all_entries(self): """Iterate over all entries. Yields Entry tuples. The first entry in each day has a duration of 0. """ stop = None for item in self.items: start = stop stop = item[0] entry = item[1] if start is None or different_days(start, stop, self.virtual_midnight): start = stop duration = stop - start entry, tags = self._split_entry_and_tags(entry) yield Entry(start, stop, duration, tags, entry) @staticmethod def _split_entry_and_tags(entry): """ Split the entry title (proper) from the trailing tags. Tags are separated from the title by a `` -- `` marker: anything *before* the marker is the entry title, anything *following* it is the (space-separated) set of tags. Returns a tuple consisting of entry title and set of tags. """ if ' -- ' in entry: entry, tags_bundle = entry.split(' -- ', 1) # there might be spaces preceding ' -- ' entry = entry.rstrip() tags = set(tags_bundle.split()) # put back '**' and '***' if they were in the tags part if '***' in tags: entry += ' ***' tags.remove('***') elif '**' in tags: entry += ' **' tags.remove('**') else: tags = set() return entry, tags @staticmethod def split_category(entry): """Split the entry category from the entry itself. Return a tuple (category, task). """ if ': ' in entry: cat, tsk = entry.split(': ', 1) return cat.strip(), tsk.strip() elif entry.endswith(':'): return entry.partition(':')[0].strip(), '' else: return None, entry def set_of_all_tags(self): """Return the set of all tags mentioned in entries.""" all_tags = set() for entry in self.all_entries(): all_tags.update(entry.tags) return all_tags def count_days(self): """Count days that have entries.""" count = 0 last = None for entry in self.all_entries(): if last is None or different_days(last, entry.start, self.virtual_midnight): last = entry.start count += 1 return count def grouped_entries(self, skip_first=True, sorted_by='start-time', sorted_tasks=None): """Return consolidated entries (grouped by entry title). Returns two lists: work entries and slacking entries. Slacking entries are identified by finding two asterisks in the title. Entry lists are sorted, and contain (start, entry, duration) tuples. """ work = {} slack = {} for start, stop, duration, tags, entry in self.all_entries(): if skip_first: # XXX: in case of for multi-day windows, this should skip # the 1st entry of each day skip_first = False continue if '***' in entry: continue if '**' in entry: entries = slack else: entries = work if entry in entries: old_start, old_entry, old_duration = entries[entry] start = min(start, old_start) duration += old_duration entries[entry] = (start, entry, duration) key_func = self._get_grouped_order_key(sorted_by, sorted_tasks) work = sorted(work.values(), key=key_func) slack = sorted(slack.values(), key=key_func) return work, slack def categorized_work_entries(self, skip_first=True): """Return consolidated work entries grouped by category. Category is a string preceding the first ':' in the entry. Return two dicts: - {: }, where is a category string and is a sorted list that contains tuples (start, entry, duration); entry is stripped of its category prefix. - {: }, where is the total duration of work in the . """ work, slack = self.grouped_entries(skip_first=skip_first) entries = {} totals = {} for start, entry, duration in work: cat, task = self.split_category(entry) entry_list = entries.get(cat, []) entry_list.append((start, task, duration)) entries[cat] = entry_list totals[cat] = totals.get(cat, datetime.timedelta(0)) + duration return entries, totals def totals(self, tag=None, filter_text=None): """Calculate total time of work and slacking entries. If optional argument `tag` is given, only compute totals for entries marked with the given tag. If optional argument `filter_text` is given, only compute totals for entries matching the text. Returns (total_work, total_slacking) tuple. Slacking entries are identified by finding two asterisks in the title. Assuming that total_work, total_slacking = self.totals() work, slacking = self.grouped_entries() It is always true that total_work = sum([duration for start, entry, duration in work]) total_slacking = sum([duration for start, entry, duration in slacking]) (that is, it would be true if sum could operate on timedeltas). """ total_work = total_slacking = datetime.timedelta(0) for start, stop, duration, tags, entry in self.all_entries(): if tag is not None and tag not in tags: continue if filter_text is not None and filter_text not in entry: continue if '***' in entry: continue elif '**' in entry: total_slacking += duration else: total_work += duration return total_work, total_slacking @classmethod def _get_grouped_order_key(cls, sorted_by, sorted_tasks): """ Returns a callable usable as the `key` argument of sorted(). The parameter 'x' to be sorted is deemed a list item as returned by TimeCollection.grouped_entries """ # name is deemed unique as this function is used for grouped entries, # hence sufficient to fully sort a list if sorted_by == 'start-time': return None # hence sort by x elif sorted_by == 'name': return lambda x: x[1] elif sorted_by == 'duration': # return (duration, start-time, name) return lambda x: (x[2], x[0], x[1]) elif sorted_by == 'task-list': # name is also sent to order unknown entries in a stable way return lambda x: (sorted_tasks.order(x[1]), x[1]) class TimeWindow(TimeCollection): """A window into a time log. Includes all events that took place between min_timestamp and max_timestamp. Includes events that took place at min_timestamp, but excludes events that took place at max_timestamp. """ def __init__(self, original, min_timestamp, max_timestamp): super(TimeWindow, self).__init__(original.virtual_midnight) self.min_timestamp = min_timestamp self.max_timestamp = max_timestamp self.items = [item for item in original.items if min_timestamp <= item[0] < max_timestamp] def __repr__(self): return ''.format(self.min_timestamp, self.max_timestamp) class Exports(object): """Exporting of events.""" def __init__(self, window): self.window = window @staticmethod def _hash(start, stop, entry): return md5(("%s%s%s" % (start, stop, entry)).encode('UTF-8')).hexdigest() def icalendar(self, output): """Create an iCalendar file with activities.""" output.write("BEGIN:VCALENDAR\n") output.write("PRODID:-//gtimelog.org/NONSGML GTimeLog//EN\n") output.write("VERSION:2.0\n") idhost = socket.getfqdn() dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") for start, stop, duration, tags, entry in self.window.all_entries(): output.write("BEGIN:VEVENT\n") output.write("UID:%s@%s\n" % (self._hash(start, stop, entry), idhost)) output.write("SUMMARY:%s\n" % (entry.replace('\\', '\\\\')) .replace(';', '\\;') .replace(',', '\\,')) output.write("DTSTART:%s\n" % start.strftime('%Y%m%dT%H%M%S')) output.write("DTEND:%s\n" % stop.strftime('%Y%m%dT%H%M%S')) output.write("DTSTAMP:%s\n" % dtstamp) output.write("END:VEVENT\n") output.write("END:VCALENDAR\n") def to_csv_complete(self, output, title_row=True): """Export work entries to a CSV file. The file has two columns: task title and time (in minutes). """ writer = csv.writer(output) if title_row: writer.writerow(["task", "time (minutes)"]) work, slack = self.window.grouped_entries() work = [(entry, as_minutes(duration)) for start, entry, duration in work if duration] # skip empty "arrival" entries work.sort() writer.writerows(work) def to_csv_daily(self, output, title_row=True): """Export daily work, slacking, and arrival times to a CSV file. The file has four columns: date, time from midnight til arrival at work, slacking, and work (in decimal hours). """ writer = csv.writer(output) if title_row: writer.writerow(["date", "day-start (hours)", "slacking (hours)", "work (hours)"]) # sum timedeltas per date # timelog must be chronological for this to be dependable d0 = datetime.timedelta(0) days = {} # date -> [time_started, slacking, work] dmin = None for start, stop, duration, tags, entry in self.window.all_entries(): if dmin is None: dmin = start.date() day = days.setdefault(start.date(), [datetime.timedelta(minutes=start.minute, hours=start.hour), d0, d0]) if '**' in entry: day[1] += duration else: day[2] += duration if dmin: # fill in missing dates - aka. weekends dmax = start.date() while dmin <= dmax: days.setdefault(dmin, [d0, d0, d0]) dmin += datetime.timedelta(days=1) # convert to hours, and a sortable list items = sorted( (day, as_hours(start), as_hours(slacking), as_hours(work)) for day, (start, slacking, work) in days.items()) writer.writerows(items) class Reports(object): """Generation of reports.""" def __init__(self, window, email_headers=True, style='plain'): self.window = window self.email_headers = email_headers self.style = style def _categorizing_report(self, output, email, who, subject, period_name): """A report that displays entries by category. Writes a report template in RFC-822 format to output. The report looks like | time | Overhead: | Status meeting 43 | Mail 1:50 | -------------------------------- | 2:33 | | Compass: | Compass: hotpatch 2:13 | Call with a client 30 | -------------------------------- | 3:43 | | No category: | SAT roundup 1:00 | -------------------------------- | 1:00 | | Total work done this week: 6:26 | | Categories by time spent: | | Compass 3:43 | Overhead 2:33 | No category 1:00 """ window = self.window if self.email_headers: output.write("To: %(email)s\n" % {'email': email}) output.write("Subject: %s\n" % subject) output.write('\n') items = list(window.all_entries()) if not items: output.write("No work done this %s.\n" % period_name) return output.write(" " * 46) output.write(" time\n") total_work, total_slacking = window.totals() entries, totals = window.categorized_work_entries() if entries: if None in entries: e = entries.pop(None) categories = sorted(entries) categories.append('No category') entries['No category'] = e t = totals.pop(None) totals['No category'] = t else: categories = sorted(entries) for cat in categories: output.write('%s:\n' % cat) work = [(entry, duration) for start, entry, duration in entries[cat]] work.sort() for entry, duration in work: if not duration: continue # skip empty "arrival" entries entry = entry[:1].upper() + entry[1:] output.write(" %-61s %+5s\n" % (entry, format_duration_short(duration))) output.write('-' * 70 + '\n') output.write("%+70s\n" % format_duration_short(totals[cat])) output.write('\n') output.write("Total work done this %s: %s\n" % (period_name, format_duration_short(total_work))) output.write('\n') ordered_by_time = [(time, cat) for cat, time in totals.items()] ordered_by_time.sort(reverse=True) max_cat_length = max([len(cat) for cat in totals.keys()]) line_format = ' %-' + str(max_cat_length + 4) + 's %+5s\n' output.write('Categories by time spent:\n') for time, cat in ordered_by_time: output.write(line_format % (cat, format_duration_short(time))) tags = self.window.set_of_all_tags() if tags: self._report_tags(output, tags) def _report_tags(self, output, tags): """Helper method that lists time spent per tag. Use this to add a section in a report looks similar to this: sysadmin: 2 hours 1 min www: 18 hours 45 min mailserver: 3 hours Note that duration may not add up to the total working time, as a single entry can have multiple or no tags at all! Argument `tags` is a set of tags (string). It is not modified. """ output.write('\n') output.write('Time spent in each area:\n') output.write('\n') # sum work and slacking time per tag; we do not care in this report tags_totals = {} for tag in tags: spent_working, spent_slacking = self.window.totals(tag) tags_totals[tag] = spent_working + spent_slacking # compute width of tag label column max_tag_length = max([len(tag) for tag in tags_totals.keys()]) line_format = ' %-' + str(max_tag_length + 4) + 's %+5s\n' # sort by time spent (descending) for tag, spent in sorted(tags_totals.items(), key=(lambda it: it[1]), reverse=True): output.write(line_format % (tag, format_duration_short(spent))) output.write('\n') output.write( 'Note that area totals may not add up to the period totals,\n' 'as each entry may be belong to multiple areas (or none at all).\n') def _report_categories(self, output, categories): """A helper method that lists time spent per category. Use this to add a section in a report looks similar to this: Administration: 2 hours 1 min Coding: 18 hours 45 min Learning: 3 hours category is a dict of entries (: ). It is not preserved. """ output.write('\n') output.write("By category:\n") output.write('\n') no_cat = categories.pop(None, None) items = sorted(categories.items()) if no_cat is not None: items.append(('(none)', no_cat)) for cat, duration in items: output.write("%-62s %s\n" % ( cat, format_duration_long(duration))) output.write('\n') def _plain_report(self, output, email, who, subject, period_name): """Format a report that does not categorize entries. Writes a report template in RFC-822 format to output. """ window = self.window if self.email_headers: output.write("To: %(email)s\n" % {'email': email}) output.write('Subject: %s\n' % subject) output.write('\n') items = list(window.all_entries()) if not items: output.write("No work done this %s.\n" % period_name) return output.write(" " * 46) output.write(" time\n") work, slack = window.grouped_entries() total_work, total_slacking = window.totals() categories = {} if work: work = [(entry, duration) for start, entry, duration in work] work.sort() for entry, duration in work: if not duration: continue # skip empty "arrival" entries cat, task = TimeCollection.split_category(entry) categories[cat] = categories.get( cat, datetime.timedelta(0)) + duration entry = entry[:1].upper() + entry[1:] output.write("%-62s %s\n" % (entry, format_duration_long(duration))) output.write('\n') output.write("Total work done this %s: %s\n" % (period_name, format_duration_long(total_work))) if categories: self._report_categories(output, categories) tags = self.window.set_of_all_tags() if tags: self._report_tags(output, tags) def weekly_report_subject(self, who): week = self.window.min_timestamp.isocalendar()[1] return 'Weekly report for %s (week %02d)' % (who, week) def weekly_report(self, output, email, who): if self.style == 'categorized': return self.weekly_report_categorized(output, email, who) else: return self.weekly_report_plain(output, email, who) def weekly_report_plain(self, output, email, who): """Format a weekly report.""" subject = self.weekly_report_subject(who) return self._plain_report(output, email, who, subject, period_name='week') def weekly_report_categorized(self, output, email, who): """Format a weekly report with entries displayed under categories.""" subject = self.weekly_report_subject(who) return self._categorizing_report(output, email, who, subject, period_name='week') def monthly_report_subject(self, who): month = self.window.min_timestamp.strftime('%Y/%m') return 'Monthly report for %s (%s)' % (who, month) def monthly_report(self, output, email, who): if self.style == 'categorized': return self.monthly_report_categorized(output, email, who) else: return self.monthly_report_plain(output, email, who) def monthly_report_plain(self, output, email, who): """Format a monthly report .""" subject = self.monthly_report_subject(who) return self._plain_report(output, email, who, subject, period_name='month') def monthly_report_categorized(self, output, email, who): """Format a monthly report with entries displayed under categories.""" subject = self.monthly_report_subject(who) return self._categorizing_report(output, email, who, subject, period_name='month') def custom_range_report_subject(self, who): min = self.window.min_timestamp.strftime('%Y-%m-%d') max = self.window.max_timestamp - datetime.timedelta(1) max = max.strftime('%Y-%m-%d') return 'Custom date range report for %s (%s - %s)' % (who, min, max) def custom_range_report_categorized(self, output, email, who): """Format a custom range report with entries displayed under categories.""" subject = self.custom_range_report_subject(who) return self._categorizing_report(output, email, who, subject, period_name='custom range') def daily_report_subject(self, who): # strftime('%a') would give us translated names, but we want our # reports to be standardized and machine-parseable weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] weekday = weekday_names[self.window.min_timestamp.weekday()] week = self.window.min_timestamp.isocalendar()[1] return ("{0:%Y-%m-%d} report for {who}" " ({weekday}, week {week:0>2})".format( self.window.min_timestamp, who=who, weekday=weekday, week=week)) def daily_report(self, output, email, who): """Format a daily report. Writes a daily report template in RFC-822 format to output. """ window = self.window if self.email_headers: output.write("To: %s\n" % email) output.write("Subject: %s\n" % self.daily_report_subject(who)) output.write('\n') items = list(window.all_entries()) if not items: output.write("No work done today.\n") return start, stop, duration, tags, entry = items[0] entry = entry[:1].upper() + entry[1:] output.write("%s at %s\n" % (entry, start.strftime('%H:%M'))) output.write('\n') work, slack = window.grouped_entries() total_work, total_slacking = window.totals() categories = {} if work: for start, entry, duration in work: entry = entry[:1].upper() + entry[1:] output.write("%-62s %s\n" % (entry, format_duration_long(duration))) cat, task = TimeCollection.split_category(entry) categories[cat] = categories.get( cat, datetime.timedelta(0)) + duration output.write('\n') output.write("Total work done: %s\n" % format_duration_long(total_work)) if categories: self._report_categories(output, categories) output.write('Slacking:\n\n') if slack: for start, entry, duration in slack: entry = entry[:1].upper() + entry[1:] output.write("%-62s %s\n" % (entry, format_duration_long(duration))) output.write('\n') output.write("Time spent slacking: %s\n" % format_duration_long(total_slacking)) tags = self.window.set_of_all_tags() if tags: self._report_tags(output, tags) class ReportRecord(object): """A record of sent reports.""" # Let's be compatible with https://github.com/ProgrammersOfVilnius/gtimesheet DAILY = 'daily' WEEKLY = 'weekly' MONTHLY = 'monthly' def __init__(self, filename): self.filename = filename self.last_mtime = None self._records = defaultdict(list) @classmethod def get_report_id(cls, report_kind, date): if report_kind == cls.DAILY: return date.strftime('%Y-%m-%d') elif report_kind == cls.WEEKLY: # I'd prefer the ISO 8601 format (2015-W31 instead of 2015/31), but # let's be compatible with https://github.com/ProgrammersOfVilnius/gtimesheet return '{}/{}'.format(*date.isocalendar()[:2]) elif report_kind == cls.MONTHLY: return date.strftime('%Y-%m') else: # pragma: nocover raise AssertionError('Bug: unexpected report kind: %r' % report_kind) def record(self, report_kind, report_date, recipient, now=None): """Record that a record has been sent. report_kind is one of DAILY, WEEKLY, MONTHLY. report_date is a date in the report period. recipient is an email address. The intent here is to distinguish real reports sent to activity@yourcompany.example.com from test reports sent to a test address. """ assert report_kind in (self.DAILY, self.WEEKLY, self.MONTHLY) assert isinstance(report_date, datetime.date) if now is None: now = datetime.datetime.now() timestamp = now.strftime('%Y-%m-%d %H:%M:%S') report_id = self.get_report_id(report_kind, report_date) with open(self.filename, 'a') as f: f.write("{},{},{},{}\n".format(timestamp, report_kind, report_id, recipient)) if self.last_mtime is not None: self.last_mtime = get_mtime(self.filename) self._records[report_kind, report_id].append(recipient) def check_reload(self): mtime = get_mtime(self.filename) if mtime != self.last_mtime: self.reread() def reread(self): self.last_mtime = get_mtime(self.filename) self._records.clear() try: with open(self.filename) as f: for line in f: try: timestamp, report_kind, report_id, recipient = line.split(',', 3) except ValueError: continue self._records[report_kind, report_id].append(recipient.strip()) except IOError: pass def get_recipients(self, report_kind, report_date): """Look up who received a particular report. report_kind is one of DAILY, WEEKLY, MONTHLY. report_date is a date in the report period. Returns a list of recipients, in order. """ self.check_reload() report_id = self.get_report_id(report_kind, report_date) return self._records.get((report_kind, report_id), []) class TimeLog(TimeCollection): """Time log. A time log contains a time window for today, and can add new entries at the end. """ def __init__(self, filename, virtual_midnight): super(TimeLog, self).__init__(virtual_midnight) self.filename = filename self.reread() def virtual_today(self): """Return today's date, adjusted for virtual midnight.""" return virtual_day(datetime.datetime.now(), self.virtual_midnight) def check_reload(self): """Look at the mtime of timelog.txt, and reload it if necessary. Returns True if the file was reloaded. """ mtime = get_mtime(self.filename) if mtime != self.last_mtime: self.reread() return True else: return False def reread(self): """Reload the log file.""" self.day = self.virtual_today() self.last_mtime = get_mtime(self.filename) try: if hasattr(self.filename, 'read'): # accept any file-like object # this is a hook for unit tests, really self.filename.seek(0) self.items = self._read(self.filename) else: with open(self.filename, encoding='utf-8') as f: self.items = self._read(f) except IOError: self.items = [] self.window = self.window_for_day(self.day) def _read(self, f): items = [] for line in f: time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue entry = entry.strip() items.append((time, entry)) # There's code that relies on entries being sorted. The entries really # should be already sorted in the file, but sometimes the user edits # timelog.txt directly and introduces errors. # XXX: instead of quietly resorting them we should inform the user if # there are errors # Note that we must preserve the relative order of entries with # the same timestamp: https://bugs.launchpad.net/gtimelog/+bug/708825 items.sort(key=itemgetter(0)) return items def window_for(self, min, max): """Return a TimeWindow for a specified time interval. ``min`` and ``max`` should be datetime.datetime instances. The interval is half-open (inclusive at ``min``, exclusive at ``max``). """ return TimeWindow(self, min, max) def window_for_day(self, date): """Return a TimeWindow for the specified day.""" min = datetime.datetime.combine(date, self.virtual_midnight) max = min + datetime.timedelta(1) return self.window_for(min, max) def window_for_week(self, date): """Return a TimeWindow for the week that contains date.""" monday = date - datetime.timedelta(date.weekday()) min = datetime.datetime.combine(monday, self.virtual_midnight) max = min + datetime.timedelta(7) return self.window_for(min, max) def window_for_month(self, date): """Return a TimeWindow for the month that contains date.""" first_of_this_month = first_of_month(date) first_of_next_month = next_month(date) min = datetime.datetime.combine( first_of_this_month, self.virtual_midnight) max = datetime.datetime.combine( first_of_next_month, self.virtual_midnight) return self.window_for(min, max) def window_for_date_range(self, min, max): """Return a TimeWindow for a specified time interval. ``min`` and ``max`` should be datetime.date instances. The interval is closed. """ min = datetime.datetime.combine(min, self.virtual_midnight) max = datetime.datetime.combine(max, self.virtual_midnight) max = max + datetime.timedelta(1) return self.window_for(min, max) def remove_last_entry(self): self.check_reload() if not self.window.items: # last day's entries list is empty, so nothing to remove return None with open(self.filename, "r", encoding='utf-8') as f: lines = f.readlines() for idx, line in enumerate(reversed(lines), start=1): time, sep, entry = line.partition(': ') if not sep: continue try: time = parse_datetime(time) except ValueError: continue last_entry = entry.strip() break else: # maybe timelog.txt got replaced after we did check_reload() but # before we re-read it? return None # pragma: nocover lines[-idx] = '##' + lines[-idx] with open(self.filename, "w", encoding='utf-8') as f: f.writelines(lines) self.reread() return last_entry def raw_append(self, line, need_space): """Append a line to the time log file.""" with open(self.filename, "a", encoding='utf-8') as f: if need_space: f.write('\n') f.write(line + '\n') self.last_mtime = get_mtime(self.filename) def append(self, entry, now=None): """Append a new entry to the time log.""" if not now: now = datetime.datetime.now().replace(second=0, microsecond=0) self.check_reload() need_space = False last = self.last_time() if last and different_days(now, last, self.virtual_midnight): need_space = True self.items.append((now, entry)) self.window.items.append((now, entry)) line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry) self.raw_append(line, need_space) def valid_time(self, time): """Is this a valid time for a correction? Valid times are those between the last timelog entry and now. """ if time > datetime.datetime.now(): return False last = self.last_time() if last and time < last: return False return True def parse_correction(self, entry): """Recognize a time correction. Corrections are entries that begin with a timestamp (HH:MM) or a relative number of minutes (-MM). Returns a tuple (entry, timestamp). ``timestamp`` will be None if no correction was recognized. ``entry`` will have the leading timestamp stripped. """ now = None date_match = re.match(r'(\d\d):(\d\d)\s+', entry) delta_match = re.match(r'[\-+]([1-9]\d?|1\d\d)\s+', entry) if date_match: h = int(date_match.group(1)) m = int(date_match.group(2)) if 0 <= h < 24 and 0 <= m < 60: now = datetime.datetime.combine(self.virtual_today(), datetime.time(h, m)) if now.time() < self.virtual_midnight: now += datetime.timedelta(1) if self.valid_time(now): entry = entry[date_match.end():] else: now = None if delta_match: seconds = int(delta_match.group()) * 60 # If positive, offset from the end of the last entry. if seconds >= 0: now = self.window.last_time() # Otherwise, offset from the current time. else: now = datetime.datetime.now().replace(second=0, microsecond=0) if now is not None: now += datetime.timedelta(seconds=seconds) if self.valid_time(now): entry = entry[delta_match.end():] else: now = None return entry, now class TaskList(object): """Task list. You can have a list of common tasks in a text file that looks like this Arrived ** Reading mail Project1: do some task Project2: do some other task Project1: do yet another task These tasks are grouped by their common prefix (separated with ':'). Tasks without a ':' are grouped under "Other". A TaskList has an attribute 'groups' which is a list of tuples (group_name, list_of_group_items). It also has an attribute 'task_order' which is a dictionary of task names, potentially prefixed by 'group_name: ', with their value being their original order in the task list. """ other_title = 'Other' loading_callback = None loaded_callback = None error_callback = None def __init__(self, filename): self.filename = filename self.load() def check_reload(self): """Look at the mtime of tasks.txt, and reload it if necessary. Returns True if the file was reloaded. """ mtime = get_mtime(self.filename) if mtime != self.last_mtime: self.load() return True else: return False def load(self): """Load task list from a file named self.filename.""" groups = {} task_order = {} others = [] self.last_mtime = get_mtime(self.filename) try: with open(self.filename, encoding='utf-8') as f: for index, line in enumerate(f): line = line.strip() if not line or line.startswith('#'): continue if ':' in line: # tasks with group prefix group, task = [s.strip() for s in line.split(':', 1)] groups.setdefault(group, []).append(task) task_order[group + ': ' + task] = index else: # "other" tasks others.append(line) task_order[line] = index except IOError: pass # the file's not there, so what? # append the "other" tasks at the end self.groups = list(groups.items()) if others: self.groups.append((self.other_title, others)) self.task_order = task_order def reload(self): """Reload the task list.""" self.load() def order(self, value): """ Return the order index of a value in the task order list If the value isn't in the task order dictionary, it returns a value bigger than any index. """ return self.task_order.get(value, sys.maxsize) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/src/gtimelog/utils.py0000664000175000017500000000105414515757026015357 0ustar00mgmg""" Misc utils """ import sys import gi def require_version(namespace, version): try: gi.require_version(namespace, version) except ValueError: deb_package = "gir1.2-{namespace}-{version}".format( namespace=namespace.lower(), version=version) sys.exit("""Typelib files for {namespace}-{version} are not available. If you're on Ubuntu or another Debian-like distribution, please install them with sudo apt install {deb_package} """.format(namespace=namespace, version=version, deb_package=deb_package)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1712147450.2720604 gtimelog-0.12.0/src/gtimelog.egg-info/0000775000175000017500000000000014603245772015335 5ustar00mgmg././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147450.0 gtimelog-0.12.0/src/gtimelog.egg-info/PKG-INFO0000644000175000017500000001346114603245772016435 0ustar00mgmgMetadata-Version: 2.1 Name: gtimelog Version: 0.12.0 Summary: A Gtk+ time tracking application Home-page: https://gtimelog.org/ Author: Marius Gedminas Author-email: marius@gedmin.as License: GPL Keywords: time log logging timesheets gnome gtk Classifier: Development Status :: 4 - Beta Classifier: Environment :: X11 Applications :: GTK Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Office/Business Requires-Python: >= 3.7 Description-Content-Type: text/x-rst Provides-Extra: test License-File: COPYING GTimeLog ======== GTimeLog is a simple app for keeping track of time. .. image:: https://github.com/gtimelog/gtimelog/workflows/build/badge.svg?branch=master :target: https://github.com/gtimelog/gtimelog/actions :alt: build status .. image:: https://ci.appveyor.com/api/projects/status/github/gtimelog/gtimelog?branch=master&svg=true :target: https://ci.appveyor.com/project/mgedmin/gtimelog :alt: build status (on Windows) .. image:: https://coveralls.io/repos/gtimelog/gtimelog/badge.svg?branch=master :target: https://coveralls.io/r/gtimelog/gtimelog?branch=master :alt: test coverage .. contents:: .. image:: https://raw.github.com/gtimelog/gtimelog/master/docs/gtimelog.png :alt: screenshot Installing ---------- GTimeLog is packaged for Debian and Ubuntu:: sudo apt-get install gtimelog For Ubuntu, sometimes a newer version can usually be found in the PPA: https://launchpad.net/~gtimelog-dev/+archive/ppa Fedora also holds a package of gtimelog to be installed with:: sudo dnf install gtimelog You can fetch the latest released version from PyPI :: $ pip install gtimelog $ gtimelog You can run it from a source checkout (without an explicit installation step):: $ git clone https://github.com/gtimelog/gtimelog $ cd gtimelog $ ./gtimelog System requirements: - Python (3.6+) - PyGObject - gobject-introspection type libraries for Gtk, Gdk, GLib, Gio, GObject, Pango, Soup, Secret - GTK+ 3.18 or newer Documentation ------------- This is work in progress: - `docs/index.rst`_ contains an overview - `docs/formats.rst`_ describes the file formats .. _docs/index.rst: https://github.com/gtimelog/gtimelog/blob/master/docs/index.rst .. _docs/formats.rst: https://github.com/gtimelog/gtimelog/blob/master/docs/formats.rst Resources --------- Website: https://gtimelog.org Mailing list: gtimelog@googlegroups.com (archive at https://groups.google.com/group/gtimelog) IRC: #gtimelog on chat.libera.net Source code: https://github.com/gtimelog/gtimelog Report bugs at https://github.com/gtimelog/gtimelog/issues There's an old bugtracker at https://bugs.launchpad.net/gtimelog I sometimes also browse distribution bugs: - Ubuntu https://bugs.launchpad.net/ubuntu/+source/gtimelog - Debian https://bugs.debian.org/gtimelog Credits ------- GTimeLog was mainly written by Marius Gedminas . Barry Warsaw stepped in as a co-maintainer when Marius burned out. Then Barry got busy and Marius recovered. Many excellent contributors are listed in `CONTRIBUTORS.rst`_ .. _CONTRIBUTORS.rst: https://github.com/gtimelog/gtimelog/blob/master/src/gtimelog/CONTRIBUTORS.rst Changelog --------- 0.12.0 (2024-04-03) ~~~~~~~~~~~~~~~~~~~ - This version talks to an SMTP server instead of relying on /usr/sbin/sendmail for email sending. This should work even in flatpaks. - New command line options: --prefs, --email-prefs. - Use libsecret instead of gnome-keyring. - GTK 3.18 or newer is now required (GH: #131). - Soap 3.0 is now required (GH: #238). - Fixed an AttributeError in the undocumented remote task list feature (GH: #153). - Make the undocumented remote task list feature validate TLS certificates (GH: #214). - Add Python 3.8, 3.9, 3.10, 3.11, and 3.12 support. - Drop Python 2.7, 3.5, and 3.6 support. - Add support for positive time offset syntax in entries. - Focus the task entry on Ctrl+L (GH: #213). - Change entry search to be fuzzy. It is now only required to enter characters of the entry in the correct order to find an entry. - Enforce minimum and maximum size for the task pane (GH: #219). - Task pane now preserves the order of task groups to match the order in tasks.txt (GH: #224). - Grouped task entries can now be sorted by start date, name, duration or according to tasks.txt order (GH: #228). - Add the ability to change the last entry using Ctrl+Shift+BackSpace (GH: #247). 0.11.3 (2019-04-23) ~~~~~~~~~~~~~~~~~~~ - Use a better workaround for window.present() not working on Wayland. - Fix a rare AssertionError on quit. - Fix problem with "Edit log" and "Edit tasks" menu entries on Windows (GH: #133). - Do not include ``***`` entries in slacking total (GH: #138). - Show average time per day spent on filtered tasks (GH: #146). - Drop Python 3.4 support. 0.11.2 (2018-11-03) ~~~~~~~~~~~~~~~~~~~ - Window menu now includes items previously shown only in the app menu: Preferences, About (GH: #126). - Keyboard shortcuts window (press Ctrl+Shift+?). - Dropped the help page (there was only one and it was only listing keyboard shortcuts, and it was also incomplete and had no translations). - Bugfix: if timelog.txt was a symlink, changes to the symlink target would not get noticed automatically (GH: #128). Older versions ~~~~~~~~~~~~~~ See the `full changelog`_. .. _full changelog: https://github.com/gtimelog/gtimelog/blob/master/CHANGES.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147450.0 gtimelog-0.12.0/src/gtimelog.egg-info/SOURCES.txt0000644000175000017500000000324514603245772017223 0ustar00mgmg.coveragerc .gitattributes .gitignore CHANGES.rst CONTRIBUTING.rst CONTRIBUTORS.rst COPYING MANIFEST.in Makefile README.rst TODO.rst appveyor.yml benchmark.py constraints.txt gtimelog gtimelog.appdata.xml gtimelog.desktop gtimelog.desktop.in gtimelog.rst other-requirements.txt release.mk runtests setup.cfg setup.py tox.ini docs/Makefile docs/footer.rst docs/formats.rst docs/gtimelog.png docs/index.rst docs/mg.css docs/website.rst flatpak/org.gtimelog.GTimeLog.yaml scripts/README.rst scripts/difftime.py scripts/export-my-calendar.py scripts/sum.py scripts/timelog.py scripts/today.py scripts/workdays.py src/gtimelog/CONTRIBUTORS.rst src/gtimelog/__init__.py src/gtimelog/about.ui src/gtimelog/debian-paths.py src/gtimelog/gtimelog-large.png src/gtimelog/gtimelog.css src/gtimelog/gtimelog.png src/gtimelog/gtimelog.ui src/gtimelog/main.py src/gtimelog/menus.ui src/gtimelog/paths.py src/gtimelog/preferences.ui src/gtimelog/secrets.py src/gtimelog/settings.py src/gtimelog/shortcuts.ui src/gtimelog/timelog.py src/gtimelog/utils.py src/gtimelog.egg-info/PKG-INFO src/gtimelog.egg-info/SOURCES.txt src/gtimelog.egg-info/dependency_links.txt src/gtimelog.egg-info/entry_points.txt src/gtimelog.egg-info/not-zip-safe src/gtimelog.egg-info/requires.txt src/gtimelog.egg-info/top_level.txt src/gtimelog/data/gschemas.compiled src/gtimelog/data/org.gtimelog.gschema.xml src/gtimelog/po/POTFILES.in src/gtimelog/po/en.po src/gtimelog/po/fr.po src/gtimelog/po/gtimelog.pot src/gtimelog/po/lt.po src/gtimelog/po/nb.po src/gtimelog/po/nl.po src/gtimelog/tests/__init__.py src/gtimelog/tests/__main__.py src/gtimelog/tests/test_main.py src/gtimelog/tests/test_settings.py src/gtimelog/tests/test_timelog.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147450.0 gtimelog-0.12.0/src/gtimelog.egg-info/dependency_links.txt0000644000175000017500000000000114603245772021401 0ustar00mgmg ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147450.0 gtimelog-0.12.0/src/gtimelog.egg-info/entry_points.txt0000644000175000017500000000005414603245772020630 0ustar00mgmg[gui_scripts] gtimelog = gtimelog.main:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1583398830.0 gtimelog-0.12.0/src/gtimelog.egg-info/not-zip-safe0000644000175000017500000000000113630137656017561 0ustar00mgmg ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147450.0 gtimelog-0.12.0/src/gtimelog.egg-info/requires.txt0000644000175000017500000000003414603245772017730 0ustar00mgmgPyGObject [test] freezegun ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1712147450.0 gtimelog-0.12.0/src/gtimelog.egg-info/top_level.txt0000644000175000017500000000001114603245772020055 0ustar00mgmggtimelog ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1698160150.0 gtimelog-0.12.0/tox.ini0000664000175000017500000000214114515757026012560 0ustar00mgmg[tox] envlist = py37,py38,py39,py310,py311,py312,pypy3,flake8 [testenv] setenv = LC_ALL=C deps = freezegun skip_install = true commands_pre = pip install --no-deps -e . --disable-pip-version-check --quiet commands = python -m gtimelog.tests [testenv:coverage] basepython = python3 deps = freezegun coverage -cconstraints.txt skip_install = true commands = pip install --no-deps -e . coverage run {posargs} -m gtimelog.tests coverage report -m --fail-under=100 [testenv:py] deps = freezegun skip_install = true commands = python --version {[testenv]commands} [testenv:flake8] basepython = python3 deps = flake8 skip_install = true commands = flake8 src setup.py gtimelog runtests [testenv:isort] basepython = python3 deps = isort skip_install = true commands = isort {posargs: -c --diff} src setup.py gtimelog runtests benchmark.py [testenv:check-manifest] deps = check-manifest skip_install = true commands = check-manifest {posargs} [testenv:check-python-versions] deps = check-python-versions skip_install = true commands = check-python-versions {posargs}