gtimelog-0.9.1/0000755000175000017500000000000012256037240012361 5ustar mgmg00000000000000gtimelog-0.9.1/TODO.rst0000644000175000017500000000116012247566771013676 0ustar mgmg00000000000000- [ ] 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 gtimelog-0.9.1/CONTRIBUTORS.rst0000644000175000017500000000156512247606777015100 0ustar mgmg00000000000000Contributors ============ In alphabetic order: - "ijk" - 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 - Ž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. gtimelog-0.9.1/COPYING0000644000175000017500000004315212247604142013422 0ustar mgmg00000000000000GNU 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. gtimelog-0.9.1/gtimelog0000755000175000017500000000035512247572171014130 0ustar mgmg00000000000000#!/usr/bin/python """ Script to run GTimeLog from the source checkout without installing """ import os import sys pkgdir = os.path.join(os.path.dirname(__file__), 'src') sys.path.insert(0, pkgdir) from gtimelog.main import main main() gtimelog-0.9.1/.coveragerc0000644000175000017500000000017212247551104014502 0ustar mgmg00000000000000[report] exclude_lines = pragma: nocover if __name__ == '__main__': except ImportError: except NameError: gtimelog-0.9.1/runtests0000755000175000017500000000034712247572040014204 0ustar mgmg00000000000000#!/usr/bin/env python """ 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() gtimelog-0.9.1/MANIFEST.in0000644000175000017500000000047112247607236014131 0ustar mgmg00000000000000include COPYING include *.rst include Makefile include gtimelog include gtimelog.desktop include gtimelogrc.example include runtests include tox.ini include .travis.yml include .coveragerc include .gitignore recursive-include src *.png *.ui recursive-include docs *.png *.rst recursive-include scripts *.py *.rst gtimelog-0.9.1/README.rst0000644000175000017500000000421012247605426014054 0ustar mgmg00000000000000GTimeLog ======== .. image:: https://travis-ci.org/gtimelog/gtimelog.png?branch=master :target: https://travis-ci.org/gtimelog/gtimelog :alt: build status GTimeLog is a simple app for keeping track of time. .. 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, a newer version can usually be found in the PPA: https://launchpad.net/~gtimelog-dev/+archive/ppa 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 (2.6, 2.7 or 3.3) - PyGObject - gobject-introspection type libraries for GTK+, Pango 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: http://mg.pov.lt/gtimelog Mailing list: gtimelog@googlegroups.com (archive at http://groups.google.com/group/gtimelog) IRC: #gtimelog on irc.freenode.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 http://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/CONTRIBUTORS.rst If you want to leave a tip, see https://www.gittip.com/mgedmin/ gtimelog-0.9.1/gtimelogrc.rst0000644000175000017500000002260112256035573015257 0ustar mgmg00000000000000========== gtimelogrc ========== --------------------------- gtimelog configuration file --------------------------- :Author: Marius Gedminas :Date: 2013-12-23 :Copyright: Marius Gedminas :Version: 0.9.1 :Manual section: 5 DESCRIPTION =========== This is the configuration file for **gtimelog**\ (1). It is a pretty standard INI file as recognized by Python's ConfigParser, which means: - it consists of sections, led by a ``[name]`` header - values are specified as ``name = value`` (or ``name: value``) - values can be continued across multiple lines if continuation lines start with whitespace - comments are lines starting with ``#`` or ``;`` - if the same value appears multiple times, new appearances override old ones Only one section is important to gtimelog: ``[gtimelog]``. Within it you can specify the following settings: list-email recipient address for activity reports Example: ``name = activity-reports@example.com`` name your name as it should appear in the reports Example: ``name = Marius`` mailer command to launch your email client. If ``%s`` appears in the command, it will be replaced by the filename of the draft of the email. If ``%s`` doesn't appear, it will be added to the end of the command. Example: ``mailer = gedit`` will just open the report in GEdit, useful if you don't want to send it to anyone. Example: ``mailer = x-terminal-emulator -e mutt -H %s`` (which is the default setting) will open Mutt with the draft in a terminal. ``x-terminal-emulator`` is a Debianism. Example: ``mailer = S='%s'; thunderbird -compose "to='$(cat $S|head -1|sed -e "s/^To: //")',subject='$(cat $S|head -2|tail -1|sed -e "s/^Subject: //")',body='$(cat $S|tail -n +4)'"`` will open Thunderbird with the draft. editor text editor to be used for editing timelog.txt If ``%s`` appears in the command, it will be replaced by the filename of the timelog.txt file. If ``%s`` doesn't appear, it will be added to the end of the command. Example: ``editor = xdg-open`` (the default value) opens whichever program is associated with .txt files on your system. spreasheet program used to display CSV reports If ``%s`` appears in the command, it will be replaced by the filename of the CSV report. If ``%s`` doesn't appear, it will be added to the end of the command. Example: ``spreadsheet = xdg-open`` (the default value) opens whichever program is associated with .csv files on your system. chronological, summary_view select the initial detail level GTimeLog can show you one of three detail levels: - chronological (Alt+1) shows all the entries in order - grouped (Alt+2) shows only work entries, grouped by title - summary (Alt+3) shows only categories of work entries, grouped Example:: # start in chronological view chronological = True summary_view = False Example :: # start in grouped view chronological = False summary_view = False Example :: # start in summary view summary_view = True show_tasks should the task pane be shown on startup? Example: ``show_tasks = True`` enable_gtk_completion should the input box show an autocompletion popup? If set to ``True``, the Up and Down keys navigate the completion popup menu. If set to ``False``, the Up and Down keys trigger prefix-completion in the input box. Note that PageUp and PageDown keys always trigger prefix-completion, so there's no good reason to ever disable this option. Example: ``enable_gtk_completion = True`` hours goal for the number of hours of work in a day This is used to display the "Time left at work" estimate. Example: ``hours = 4`` virtual_midnight hour in the morning when it's safe to assume you're not staying up working any more. Any work done between midnight and "virtual midnight" will be attributed to the previous calendar day. Example: ``virtual_midnight = 2:00`` (the default setting) Warning: changing this setting may mean that old reports can no longer be correctly reconstructed from timelog.txt task_list_url URL for downloading the task list If not set, tasks will be read from a local file (tasks.txt in the gtimelog data directory) If set, tasks will be loaded from the specified URL (but only when you right-click and explicitly ask for a refresh). GTimeLog expects a text/plain response with a list of tasks, one per line. At the time of this writing GTimeLog doesn't show HTTP authentication prompts, so if you need auth, you need to put your username and password into the URL. This feature is mostly useless. Example: ``task_list_url =`` (the default setting) Example: ``task_list_url = https://wiki.example.com/Project/Tasks/raw`` edit_tasklist_cmd command for editing the task list Example: ``edit_tasklist_cmd =`` (the default setting) means that the "Edit task list" command in the popup menu will be disabled. Example: ``edit_tasklist_cmd = xdg-open ~/.local/share/gtimelog/tasks.txt`` Example: ``edit_tasklist_cmd = xdg-open https://wiki.example.com/Project/Tasks/edit`` Bug: this command should support ``%s`` for specifying the full tasks.txt pathname, but it doesn't. show_office_hours whether to show "At office today: NN hours, NN minutes" in the main window Example: ``show_office_hours = True`` show_tray_icon whether to show a notification icon Example: ``show_tray_icon = True`` prefer_app_indicator, prefer_old_tray_icon what kind of tray icon do you prefer? GTimeLog supports three kinds: - Unity application indicator - a standard Gtk+ status icon - ancient EggTrayIcon that shows a ticking clock next to the icon Support for each is conditional on the availability of installed libraries. Example:: # prefer Unity application indicators, then fall back to the Gtk+ # status icon, then fall back to EggTrayIcon. prefer_app_indicator = True Example:: # prefer the ancient EggTrayIcon, then fall back to the Gtk+ # status icon, then fall back to Unity app indicator. prefer_app_indicator = False prefer_old_tray_icon = True Example:: # prefer the Gtk+ status icon, then fall back to the ancient # EggTrayIcon, then fall back to Unity app indicator. prefer_app_indicator = False prefer_old_tray_icon = False start_in_tray whether GTimeLog should start minimized This can also be achieved by running ``gtimelog --tray``, so the option is of little use. This option is ignored if GTimeLog is not using a tray icon (because ``show_tray_icon`` is set to ``False``, or if you're missing all the libraries). Example:: ``start_in_tray = False`` report_style choose one of the available report styles for weekly and monthly reports Example:: ``report_style = plain`` (the default) The report looks like this:: cat1: entry1 N h N min cat1: entry2 N h N min cat2: entry1 N h N min Total work done this week: N hours N min By category: cat1: N hours N min cat2: N hours N min Example:: ``report_style = categorized`` The report looks like this:: category 1: entry1 MM entry2 HH:MM ------------------------------------------------ HH:MM category 2: entry1 MM entry2 HH:MM ------------------------------------------------ HH:MM Total work done this week: HH:MM Categories by time spent: category 1 HH:MM category 2 HH:MM EXAMPLE ======= Example of ``~/.config/gtimelog/gtimelogrc``:: [gtimelog] # Be sure to change these if you want to email the reports somewhere name = Anonymous list-email = activity@example.com # Don't want email? Just look at reports in a text editor mailer = gedit %s # Set a goal for 7 hours and 30 minutes of work per day hours = 7.5 # I'll never stay up working this late virtual_midnight = 06:00 # Disable the tray icon show_tray_icon = no # Hide the Tasks pane on startup show_tasks = no BUGS ==== The config file should not be necessary. GTimeLog should figure out the right programs by looking at your desktop preferences; it should remember the view options from a previous invocation; and it should have a GUI way for specifying things such as your name or the report mailing list. FILES ===== | **~/.gtimelog/gtimelogrc** | **~/.config/gtimelog/gtimelogrc** Configuration file. GTimeLog determines the location for the config file as follows: 1. If the environment variable ``GTIMELOG_HOME`` is set, use ``$GTIMELOG_HOME/gtimelogrc``. 2. If ``~/.gtimelog/`` exists, use ``~/.gtimelog/gtimelogrc``. 3. If the environment variable ``XDG_CONFIG_HOME`` is set, use ``$XDG_CONFIG_HOME/gtimelog/gtimelogrc``. 4. Use ``~/.config/gtimelog/gtimelogrc``. SEE ALSO ======== **gtimelog**\ (1) gtimelog-0.9.1/tox.ini0000644000175000017500000000030612247572171013702 0ustar mgmg00000000000000[tox] envlist = py26,py27,py33 [testenv] deps = commands = python setup.py test -q [testenv:coverage] deps = coverage commands = coverage run -m gtimelog.tests coverage report gtimelog-0.9.1/setup.py0000755000175000017500000000366312247604753014117 0ustar mgmg00000000000000#!/usr/bin/env python import os import re from setuptools import setup here = os.path.dirname(__file__) def read(filename): with open(os.path.join(here, filename)) as f: return f.read() metadata = dict( (k, 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('NEWS.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/NEWS.rst ''' short_description = 'A Gtk+ time tracking application' long_description = ( read('README.rst') + '\n\n' + changes_in_latest_versions + '\n\n' + older_changes ) setup( name='gtimelog', version=version, author='Marius Gedminas', author_email='marius@gedmin.as', url='http://mg.pov.lt/gtimelog/', description=short_description, long_description=long_description, license='GPL', classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: X11 Applications :: GTK', 'License :: OSI Approved :: GNU General Public License (GPL)', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', # 2.6 might work, but I can't test it myself -- recent # python-gobject versions dropped support for Python 2.6 'Topic :: Office/Business', ], packages=['gtimelog'], package_dir={'gtimelog': 'src/gtimelog'}, package_data={'gtimelog': ['*.ui', '*.png']}, test_suite='gtimelog.tests', zip_safe=False, entry_points=""" [gui_scripts] gtimelog = gtimelog.main:main """, # This is true, but pointless, because PyGObject cannot be installed via # setuptools/distutils # install_requires=['PyGObject'], # or PyGTK ) gtimelog-0.9.1/setup.cfg0000644000175000017500000000007312256037240014202 0ustar mgmg00000000000000[egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 gtimelog-0.9.1/PKG-INFO0000644000175000017500000001020612256037240013455 0ustar mgmg00000000000000Metadata-Version: 1.1 Name: gtimelog Version: 0.9.1 Summary: A Gtk+ time tracking application Home-page: http://mg.pov.lt/gtimelog/ Author: Marius Gedminas Author-email: marius@gedmin.as License: GPL Description: GTimeLog ======== .. image:: https://travis-ci.org/gtimelog/gtimelog.png?branch=master :target: https://travis-ci.org/gtimelog/gtimelog :alt: build status GTimeLog is a simple app for keeping track of time. .. 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, a newer version can usually be found in the PPA: https://launchpad.net/~gtimelog-dev/+archive/ppa 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 (2.6, 2.7 or 3.3) - PyGObject - gobject-introspection type libraries for GTK+, Pango 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: http://mg.pov.lt/gtimelog Mailing list: gtimelog@googlegroups.com (archive at http://groups.google.com/group/gtimelog) IRC: #gtimelog on irc.freenode.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 http://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/CONTRIBUTORS.rst If you want to leave a tip, see https://www.gittip.com/mgedmin/ Changelog --------- 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. Older versions ~~~~~~~~~~~~~~ See the `full changelog`_. .. _full changelog: https://github.com/gtimelog/gtimelog/blob/master/NEWS.rst Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: X11 Applications :: GTK Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.3 Classifier: Topic :: Office/Business gtimelog-0.9.1/scripts/0000755000175000017500000000000012256037240014050 5ustar mgmg00000000000000gtimelog-0.9.1/scripts/today.py0000755000175000017500000001017512245325640015553 0ustar mgmg00000000000000#!/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() gtimelog-0.9.1/scripts/sum.py0000755000175000017500000000147112245325640015236 0ustar mgmg00000000000000#!/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) gtimelog-0.9.1/scripts/README.rst0000644000175000017500000000247712247576321015561 0ustar mgmg00000000000000Old 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. gtimelog-0.9.1/scripts/difftime.py0000755000175000017500000000132012245325640016212 0ustar mgmg00000000000000#!/usr/bin/python import readline 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) gtimelog-0.9.1/scripts/timelog.py0000755000175000017500000000063312245325640016071 0ustar mgmg00000000000000#!/usr/bin/python import datetime import readline # 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() gtimelog-0.9.1/scripts/workdays.py0000755000175000017500000000266412245325640016302 0ustar mgmg00000000000000#!/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() gtimelog-0.9.1/scripts/export-my-calendar.py0000644000175000017500000000123212245325640020135 0ustar mgmg00000000000000#!/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')) gtimelog-0.9.1/gtimelog.rst0000644000175000017500000001160012256036552014725 0ustar mgmg00000000000000======== gtimelog ======== -------------------------------- minimal time logging application -------------------------------- :Author: Marius Gedminas :Date: 2013-12-23 :Copyright: Marius Gedminas :Version: 0.9.1 :Manual section: 1 SYNOPSYS ======== **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. There are two basic views: one shows all the activities in chronological order, with starting and ending times, while another groups all entries with the same into one activity and just shows the total duration. At the end of the day you can send off a daily report by choosing ``Report -> Daily Report``. A mail program (Mutt in a terminal, unless you have changed it in ``~/.gtimelog/gtimelogrc`` or ``~/.config/gtimelog/gtimelogrc``) will be started with all the activities listed in it. 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. --tray Start minimized. --sample-config Write a sample configuration file to 'gtimelogrc.sample'. Single-Instance Options: --replace Replace the already running ``gtimelog`` instance. --quit Tell an already-running ``gtimelog`` instance to quit. --toggle Show/hide the ``gtimelog`` window if already running. --ignore-dbus Do not check if ``gtimelog`` is already running (allows you to have multiple instances running). Debugging Options: --debug Show debug information. --prefer-pygtk Try to use the (obsolete) pygtk library instead of pygi. FILES ===== | **~/.gtimelog/gtimelogrc** | **~/.config/gtimelog/gtimelogrc** Configuration file, see **gtimelogrc**\ (5). | **~/.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/remote-tasks.txt** | **~/.local/share/gtimelog/remote-tasks.txt** Tasks to be shown in the task pane, when ``remote_task_url`` is set. Contains a downloaded copy of whatever is at that URL. SEE ALSO ======== **gtimelogrc**\ (5) gtimelog-0.9.1/docs/0000755000175000017500000000000012256037240013311 5ustar mgmg00000000000000gtimelog-0.9.1/docs/formats.rst0000644000175000017500000000520412250561732015521 0ustar mgmg00000000000000Data Formats ============ These tools were designed for easy interoperability. There are two data formats: one is used for timelog.txt, another is used for daily reports. They are both human and machine readable, easy to edit, easy to parse. 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 (but there are probably bugs lurking with the computation of ``earliest_timestamp``). GTimeLog doesn't re-write the file, it only appends to it. 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". Daily reports ------------- 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. gtimelog-0.9.1/docs/index.rst0000644000175000017500000001401112247575463015165 0ustar mgmg00000000000000GTimeLog 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 Up/Down/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. 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. Right-click anywhere in the task list and you'll get an 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 the config file 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 right-click menu contains an option to fetch an updated version. 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 in the config file). 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. You can use the toolbar 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 -> Daily Report. A mail program (Mutt in a terminal, unless you have changed it in the config file) will be started with all the activities listed in it. My Mutt configuration lets me edit the report before sending it. Correcting mistakes =================== 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 minute-offset ("-10 morning meeting"). Note that the new activity must still be after the last entered event, or things will become confusing! 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 File -> Edit timelog.txt (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 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. GTimeLog watches the modification time and automatically reloads timelog.txt if it notices you changed it. Spreadsheets ============ If you want a more visual idea of how you spend your time you can export the "Work/Slacking stats" to a spreadsheet and graph them. You get 4 values per line: the date, how long after midnight you started work, and how long you spent working and slacking that day, all times in fractional hours. Starting from midnight makes it easy to read the graph scale as the time of day. Try a bargraph with stacked values and both first row and column as labels. Configuration ============= You can change the configuration directory (**~/.config/gtimelog**) by setting the environment variable **$GTIMELOG_HOME** to a non-empty string naming the directory you want to use. For backwards compatibility, **~/.gtimelog**, if it exists, is used instead of the more standard **~/.config/gtimelog**. Syncing ======= GTimeLog has no built-in sync between multiple machines. You can put its files into Dropbox and create a symlink. Future plans ============ * A Preferences dialog * Built-in reporting (i.e. remove rependency on Mutt) * Better history browsing * Internationalization I've no specific time frame for these. gtimelog-0.9.1/docs/gtimelog.png0000644000175000017500000013746612247572636015664 0ustar mgmg00000000000000PNG  IHDR"-EKsBIT|dtEXtSoftwaregnome-screenshot>IDATx|V{ !;62d L{L[6#@$!!{⦅nܸ\"rbX, "ڵke 'gwƒFl7sH~cq%m/9$I,V r^jw3221HnnX,~Vα%Sd1j@3-|41oMfœ~9 ?giGDqV@D,j9x F}e"##IHH --xuLGudggkaD,Ϣ((,kAPWPPTxǒRI$O8t"ܹsZ(tK޾}vSQ]"YYYrbpK@dSev:OR|95ڊҪ/fd.4U ±!2Ks,Ke?N]t1Ssj "X;/Z)9ѪD6\*w)ǘ?+MX9~4Wsorb@\tɤU`QCm)uK̜}.LrrrbX;c% n "_]$ 1k‰~ֲWn7N,]n.6He}H֓ $f-Z1xVvlKfYBVF7W΄?s$[tЖe_YrbVΑ#Gpss3ǎ+3 ^uK bqG\&M=/Tđ HakHƃyI-3UOVa׏\.bIJ.H2xbqXEFv*۶mӺou.u:ND,Ϻ3"iyA% MR"􈮅Êůԁ!ĥKJ9Nx0>\]oq^myG>5wH..J@$ 9bX\+Vg:~v19sزGT.uS)^ ba!%׶r^;wqmN\p _ KtwF,Z3x&~X?K#'*SZeܵס"bX@ĉ&S:u5kְzjwGJWi&u5K0:`],Ϻ3.R+,K z̈)m/RoɌ1}t\:ufdK;buoAIsZ̖ dg++ec^8٩elOK@$t9bX\+6kgj]wEEugRAYEgW^F]P3m!/d_9+++B Tt&_~i/U!JzEtD$CX,׊9yY"-HbbbgbX,~*ڮ\nsK,k*]]\"ܿrbXT9ɇu_ Y~%3ްfR3~bӧxl.hbipZ5֌HfX6z٬d)#ŵfDԙn߾\"qqq)|bX,~jInA(E("u#ŵmk瞞ܹs籸 DbX,u/<~KD,bX,=hF+}P,bX,]k)~pМ?+}P,bX,m_ZbX,ŏe ,bX,ŏ"bX,bX@D,bX, bX,bX,bX,bX,bX,bX, "bX,"bX,bX@D,bX, q]6EUweWɑ?3GЖ)lKNg~Y"+i;z©'oFTwikGew,[XyzX,bI-m^#$*2A$>J|a"~E/$TZು.!r\W #=<7mpp'8iO{$ "bX,~b b x|aRq_~O{C/rR̳BDX4Ϲ(X,b bkV,hҼ3/ Qʹݳ|ʻeaCY׉?{mwYgi|}YҠf|K}I>(0?;LuG| Z9X+cr Ҫ=δݾ3F{G6unxC8Ad+kfԃX,b#bE#k[>,uoˍH/:4n/щgb[\6_K<;&X랕໑. (ȏKm6V -宮kW4wA`O|q?hF峵u"k;ӠE$i4OWq~qAY /ƚN3~O~= {-"ڪD)wU1VrыbX,?cDZ>w@wKp?XH J,skI'l# uq-Q힕φ^4[qؘO/{a& ̂+H)o fQBj;0*'mǣd<xo.|u&ii1=Ӎ6tXS`G "y;T`#U bbX,C]"OR ٨]JleI^6 {l'֗uJ^O~[zcewbл ÎA{ r6N|y,vT<mgl/o1g} xg\趷ʑ] ݈y b^,mD*փtbX,~A _3pvʾkLY8[:bOĻ?îKOnGQ K"Goyl:-xxң sV4黎%-:&% pte>M>fIkj#-"iA$8ɀYpbX,g:fDbIw;ZYH㉼w[?лn0#k컴 /Ό$C_{e_py0E އ=q3D<<1$Zy e3g>~Dbn3 ?^՘ұX*#rO;FI칗%A햶Qyl B㉋ 6rW=Mԃ\bX,1$Epq'XYXbc.W0.$Kv:eS[gJ/'tDLlbX,n?_zbX,D O,==?C9qbX,H=bX,D$zbX, bX,bX,bX,bX,"bX,bX, "bX,DbX,bX@D,bX, bX,bX,bX,bX,"bX,bX, "bX,DbX,bX@D,bX, bX,bX,bX,bX,"ȨH.^:C{t11RbX,Arݍ}vv,8r@X,z; HSn)l۹U*\,:99%A:?ZO؉Cd݄Gm IbӖuru58ed|ڟUWn!R@eSO^HVV&9{RX,uЧՅAw3e!؋duVTVZZ+<*Ϯ= V>j¬߸BXn3qe1kbɲ ׂ G~a,[D #j <.W{l֛ܲ\ϩ9oNQiY4&%Ua('U;hc [%>>}ilO K,gD$V\/OG-xcV#bʵYv<tgɸOiKˢEX|x3jrŐ(c"/LFvsg|Ia'™/|>7 1)[k,{3t~buYb~>k֚gI{Vc5Vx/@ۍ[6u,^>a}hV5U[a֮_H q\G5n={YmAT\_8W KH wB(`rVYu]uBv!i@Ȇͺ~*I8yuVsᲦq>./i5N$p>t٨7jwhos>qXؙ&~"(1Y@PZ^;9ۃS[lĈ#A=-7g=nqs崳m5?0^Ik~ⴇˍcKmkG`*ftY@9e,;rfFwgOR׉܎XI@ܻ:GGGiWh_xN;6QImOP@ vj$-CPpPAmze.aZIMMvE jr |E|܋&*Ju(3^[ q"Gj׫G*rV\X~5AHIkIm]cQ͜!Dm\CHIKȱGFѾ9xX7%{Oi u܉iݚ.s&<ڶϒ}$ދ]c@ByI MF&R;6;Yl"ߖGB3,}(}\r b_5_,\2y-&o ,LHtⓟ1R_v[?t}9繂6=Y/.wCtulƙs:}B ]?]%Ks$% f͙ApppA})v*%Xmd._T2bR%DDd\q^1{|I!0mP@j{#jw#Gil<S^Ձ]|gz*U7ݛ-}9eM3ʉPםp.u$p8O8Ook1$ ]m{7A,hҼ3BњlZѶSKX@|ܕӧ n?rxR ^i698b#,J9i' vpЉϖǝhaQ713h4caI5c?twm'`6tXog{\L"UbpQĠ|޴9 " ෉.VKk{иj<+=k3E%&rdtMFW 6GNZz`5e6t[+BFZ3%Au\3wG?gRF} ^E+>>Vܼ\mv.#hfUDlx.N "*җ=߶a5xĻaӒj@] Mͳgt'?gVwV`۴ZlxߵqY-F?6G,+iqK ہ }5c'vQLPzo犫DVk `Y 6I᜜Q{ ~K,Fp<|6Ŷ+˽tyJݤ5 ,宮5!t;|~uls 6hߜ7T&Õij,<I">['ҶIsF.Y/Cs(w= _/bcS|XI#ILLj m7D-hvÝ)Ӿ#((xB$$k5olKQCXx"FF=F FKYO{vjDŽ * .{j@DwGDEk&-KW,^X/-HXTkZO:g=mNmpo8ͦ\":1vHB dgPb ֒N4v٦î e1K*G b僷N Io9ez!Gцv6wd7&~:&Ɩ{BHR@to"MLretQ}T%/lq2jr #r9~Z5tZvkSq U@DY 2F_O[ܛC~A$-ݕ+- {a *XBYУw1\aG I25\lo%hFR9ebǔ>ؖե-{vE^-[Ѩ~'}Kz $5Ј?| ˔@{9f@^^.~DEG~2D```A})|Rm\:V\| 7o`ܙgђuBuT@y*fUxF{7N\g60c?,B #Ֆͦe죇},u,Ȫ5+<^IJp< Gl{=yC_#8Q|M\w/wdu]pyzJ [YҸ:mvP'"ƺhNG޿(#,e=>p9L{s7Foۥ:f*mG-WX;Ld"Fb~65⿗-i9+0G?6|JLjpPQ2&A\R&`9C*HK>T@$D@D\ ([0 ZZ*]LԮXyyV(r 6MTK/юQ[ՆTR֭-rM"&0讶Ck*seW,z.ba_'\O 9`Ǐj$m/M :5R bj ]gĎ4pI&N MV !j`wٗXr5@/_oDفrګeo%T(C;ĎM,qt<Œv8~'yu7juyWk,q<<1xxcv v_H[VNbb4~ֺC&S`k+bgv?dJH1e-ʉ~ qӓ { ]x&.;VYO\I>۷k~☛w<8k.(i;bxð`j`$eL ^@w+ֶ~4n5c> ӛFbt܊=]Z8aj}XN809箱&sh\<v3IąpGeh{k>>3wq%ӉWblxpt PvVgRǟh[]z%Wo3c44n:h uԧD=#={>\s^v58߱kv")uܶ2/wW t̝?G !*2s݌DJ͞7׮#Qn8޾^lش^<3tT@܉Dw0,3Ld.4wR)?~eS=apQkۊ_}Mf}Zjշd7FVh첒qRGORIFpuR⽟Ա F'֬T3Z}=zG q.+ΐu&}?AKLK}+[K-d?yvV/7u٣,QZNi?&BGQ_0rE~"oFj7nA$$$OOOΟ?ϡCذa=SNV2:B ru=9b֍vDf'z[ELg.:ѵ!k;8(!j?6P?q֭ jP[AiZ'Odڱ'S}z%1VLL6ڵk'u4vޥM]&Q܎ɍvR!CWjZ*|}}ׅjo*kk U/rM͂Xm͝q2\>g jVBBJeTTVllD]OmPtħgX,bX, bX,bX,bX,bX,"bX,bX@D,bX,DbX,bX,bX, bX,bX,bX,bX,"bX,bX@D*C,bX, bX,D셳ܳS,bX\ 1T`Xb.L#D"H$LjХ/ؒK$ׅ QE$D"HT3`Kb,\""H$D""H$D"H$ "H$D"H$ "D"H$ " D"H$DD"B"|f=2r{H]D"H@D$z R;‹n3{V+ >MF֪Fu~~{{>GSRwI~3E~.%RG[{^Z_nķ`:釘߁*m^>g7vޤί7棓; ,,6?.E9o2~*zS b*|vO$ 3?,_A8{y@Ung֢D>:"H@D$t(P70axyqz J2W܅ܰ*]b`ޞƅ8o.xz} )%Qt^dޝGzߺBx~6 -r aB_3qe8`w331*A_F/֡Ӥ8Owsu~i#ŏD 9 =N#%.~UާD4}׸ᅬ+toFܠٶV~(̏Z7RNn#~`=Ku\l8GtFkҮYlZ:iJ>f.Υܲwy:trV[˂}0^Ad*wx+Cd~28Us4gxf̜:y^~t5Y\łY39w)?Kj1-k72,IHH$ Ţ%3oA# I옦4h?h|aO.k = rk4jIUh%&1 "VG8?;;&i0ڋ0f7D+RxEI>})y/8q"#7)w p7Ɨ6:K17f4j\q4ֿ;gTy Azq:tL =]vӽ Jq\~drAzl|).E ) f6uz=Nsd\CI~A6X4u" tΘiO ꎹL]pPm! hR4Y[.DE[όY?rG MלAd4V~Rd|cڪ$it`qg\- ̋ 9‚VT4rsS?KWdn(e:KtF:;^@QN8g+ ]o$ lbwhIY"w}piz̦(:"uZ|W. !rC*N;V>9fYt7Clݶl%gKAĒc0{+>T5h8+=RY|u]prҩ C'txa)5* 2Ӌ@w S* $L3"Uf]֪IU)P}lЍ1 "Uk@a7\/𿮖l:8k_\CJޝWz^=Gda.鷼>:p8PA^8gq"FFio@5^o~9-FD(-۲1CX/mI)(/F,‹3<0m?7ƞf\H(EXq) Vgumg콄(?%u-7iN?W b>kVA@`^ 8 ,;nBbO-fۨLPw37U(Vկ69X:"!1!{b:4yk>Kڸ?HhWH&1r GoFUO^V@$$˹[ٸv+.uT6xeQov͛#ytJ@QYxmμ houY7.fI "!?1{N0DÀHn! SzW/"-uM-hhv1%v>UY~E*dLŅj:"NHU1B 0 D*wʹ=a0)*pb0]hnKD[0Vt#UV /δ[ό#4f6?^۰ + _̼uU@$^cwH;?V0A@e{%/ќ1?Ϸ B VaϜl%-5[eȥ8ltV"3fxr0<3k;2mbtHi p_I(/L4\Z>09gn|L(k3sB2 ).&O50F(H}*o5AĬPUNhjb y$K+H$z, ^rODMiBn=0X=}k* V切ʭ&@$w-s2P@DAD}l_]}l& R4( `Wy=¸"#1p2^V&WJ`!K+SSǦwys/(~1.OUds;z;'ܰw~ uEUί3SZ;?g`ьiZڽ9jv9Ne,5i3W0>8+k0u4eCޚ0]"e3tQn el,7Dz[d9fTxZ ųmC=ꭜ==}a~n׺ >6&nK~qyh?MFfsm(s'fN{h*.ɨEbMޙѡ}}܍.0CIbK r~ət*wRݢm5u,gK;,-lhsY|u]prҩ C':0zc@89`aׂ]QVeHR(N@%ݷ^h|jZu}|5~(m:rg9;˻0ntls&hȝ}izwz_7?^KPʃyx :9P9>VFztJ1|M\N-ahv;6ŪyG#><94s>jiO&h<.&kLoƹVrjzvjA#5ώ]hz"H$ RmK%_(K?~Q c\x/mxufcnh՞UGi+sNI4?%J֡CDUYx2{7\~drAzl|).E c) f6uzkc^ٿ|p߰ |iUA|px}9(IQjA)K% 7fd=$2FjnkM%M%+xl,qfs(HdIW+z(v t?0;ww! ݂grDH/2 9Ț}ޤiE]ΞH/$;֓ZР,<2e1nE$}?lmk/Rt1B=yWΊĤ4칕BsmU8&&mZ3L,j2x%olPH"G9c.WE\D F‡/sε'/ M$+'S3hiњ)*阪SSD""CuͺO g%*!NQ*@Q$4V@d~8HudnIU:pX;x/p9m "Pkbǎ/a|.ȇ2*p,^ۅ}Wm~,&9157c &e7x\pM}=[rS)$8~w]S%|d){b5fX6tAE6u4ԆJxVV7@ dl>aGt:L ev[[H!DLO$y:U*݃EO-49ނkUdÛx WaOPY$XG[o7øVTkvt[ R/ ]M})2g+WA*7n">R N?ޯ-ٞU3$g)/&5olžY2oSM/_Dک0lFs9ZDಷAkʃk㰲Mtqm7UmsZ̑6J{l "ˢ7bJ *k>X0QSW8욏Rz>g5kci6A{{ɮv?&Ge=^$恈+մ: AX1i8йs[,}9L:5]W"SkQ[>lt`@DQ%[UYj †_VEՆJ o1zы|p=R7e|Of$bUIn]o?i|V+s]gG~xyZT۱+dTo{>翘MAHR{Z:dJa0pl,enҭR35 d/]X9Sў7_|Yy!|̉ WzMNL d>~ pnғMz|#R]sd`R@4ɇmZаJr q/4QrM﹚iF^zyMd_=CI)T@8~$gҊ RprJ΅HN!!ԹVު+Kd}ul 1UDj3HQ(k豀 QUDk"few ˯ "hUs0fe w~;E~́ׯU~Ey~B.eV^٧rAM_h[~1.OUr~=؁ݏRh[M3mrgRk|']Xd`^Mm6G+Gkڶ#4lRj/13 ,ʵ̇ 9ޝuc4e n|4Zv8V6.KUl؏4R~fe ՝5 bkmp};zNƍBoܵghSR(u4NjsZD8 U1=[i/5P]FR|umh0q;kފ[ѐiLk&hegMcGxN\̞髂uj4=H@vA:hLD! "z, "zTHMk/<ry/jf<8[3VgKñ:DMM.[>EH@8}Dh9 Iü0IC‘4rgԏ <VT$1"Z OD E=(7i=_HkH1yqwʠPS@.;Yz'Y 'XDc+u* A)0E$ ?]`mmM~|:qRgDc+u* ⾪N"3MDD"H$Dj "F'/=D""H$D""H$D"H$ׅH$D"H$ "D"H$ " D"H$DD"H$D"H@D$D"H$ " 5#L=DD"H$ "=A)L]PL7JG}8f~"AeWuiQ/_*{>G'>^7^+Y݇ ~ciMOǯ7棓; ,,6'Eþ-ezpI4z=mqѿcJeN*+?iy5+ʓ1,eZdà7I+vQ.yS)̓((۱=}C<}w|~ݻ X;3>r%\2DD " \W{<?~Ppx2WZV;%פ+gw!!7,;#1h0oO MlB7tv=>zb]0ߝ7f6bU2񹿌_CI5_ o]yx0y.bªW9e3"NN'3?܅eP17wp ;-nz_mHdH$%h<dy/Gp> POKٟhoc\CRr,nkggGsT$+8 ?yIV/Ø[r*ȟ_+tEEL[^7\$@oN?$)kixKw\Y2|ݖ۳+Cؽ bN +T :ع%~$It埮 X\f'> ܥtȃD* Dԃ}_oZ%X ~ng_Gjƿ`Xg%-X^/6T| ,AdA]۾ (ouk{pO}`aR>t@Q*MK+z0r4IQ7-}ާRϊc&C#:gۆz[9{̝t:}I p "f bVF ayu:dԢWFߞ4Ywү/N8!.-e3TbE-]kYpvXZд4Otq 1БLxKLkHQ: "8`3t hf MGp8Ȍ2td#ky1x3pWdzuT GfKe1nE$}?|ۂ#\ J$;+ZӰV 96wi*լ=t`M0[h6rG q݃,ў:$ `}O[\JȖO6%7D""ѳ"Uf]:+Ѭ)qfX >/R[·oC : "Ukz,{a7\/ 𿮖l8k_t}a[4e_5SbDYc?.VA1ܘokm٘1 D.[Ӵ0O &06_RoWU=Ɏ݌\rN6W6$j}Sa|^rZTD*IJ_]ڑ"^eUDMY  ޯ"XvcMHc^;k:o#dLHrMZ6el^zS_fP5ؘ"&_P7L/Xf^g|Lczfܞ0-&\03< 4%X"HgJ3 R*^6UaBSb*L)}+4^vt)9") TJC&`J MxaQU"w.x>984vI 3[/GOdQtyk_OGΗU^~J?Tlj.sC*4i9>|v"U;fߘh.gQ ȁ_\R)k㰲Mtqm7U@DpW<92FW/M BSZDskDŦeslf}oi1Yܾšb{ҙ"޳hf92Q=W/s=ɧx[hY Tުy.4M濚={31ٙo83p( I=WDޥ,yD""d*O0~ &yί%Q;'|o ~m^׬:"Ekp}pgEOK;%q8z B A@e{%/ќ)x[״ocRN }oYʂ!K :7䰘گ&,ԺalS]_64t*[%ncg1dM}owaEo.VvFSRxы V/e'6X^%)Ui[DOhfW $Op B~0k{܈ɢPSHVB(19e{msG $&&^Hv ұ ڱGs"AcFr>|qd`R5%mT v[\Ph"} RzX,W4M忺;;Oc/6Va &@&lD >[va8|ؓL% DDT w&|^mQǁ~[aI0:uD0{嫼Ta\}ɑvڋn_2i6Wï;qB*}tPYkVvg.XsV5߅D7xM_vn'wr5y>Uj1KgJl׏VJжta`W|}i`amWWe>$1v;Yɲ3h|q&+cmaR|rYneb?Z:Hc!s8Y>23k%]zц9i7*e$})v M41]խImP"3a DjPA$+3Nٿ,lۏq[_ QH@}B>?h5l/ApuD֟^vӚK')_9~ɿ͛2 pBq4}e\2Cӛ>dZyBƬЍuTk! <)`t95=;ڭԱ 6!]Hq.a0{;bռ#C)wIS-6*Ҥ`Z7ִҚ% RݺvՇyDD|O6X[aӾnN\A*S "OؘQWԂ1D"O D1]MA7HVN AfҢ5S<hbۦ5jEjr뗠h~ "CMa~dUDS7#0H$D"Yj|L2-#= ^`dS:|+Y̙m7OeYyJ][70H$D"S36j :ŊIׅΝbaaIchj֬bγfB?90XcouSyDD"H$Dh2ر5ZYT:*J4h:K%_)3XoBZT"yaH$D"gD@UW@!:Z?"U?k"^.J3B4x8 V:`mk4ꇁ{Xcʢ q|@D"H$=g &hegMcv=ӷ*TnvF1 3|DgLVnS ,lp28X~^f=ijlkۆҹ DyDD"H$DO7<&3_i띇YQ>[eGDD"H$D򹷦 v:]>[""H$D"τ`& D"H$ ^,Z29f<<"vAQJ@D$ "D"Ht8\BƤ `о>F~}qrġ$emL:fnу~KWge}6sG3ܥ64?ei%dht%7 "E26NXص@Wve1U$H""H$DG"`(C_% !,ZD5ݗ7!] ]܊ϡ ͓%]pvDIa;: Pxi;B} {6C\&?kq(H@D$D"HH@*! B7Ѷ/{崶&Tew;>Z{]ڑ"^eU4 ^ۅ}FDzkB c^;k:o#dD""H$DG"0t" DU 8r"oO5䗀FКrȺ6+ K:Ng +KFX ]f83*_̑64E2DD"H$DDAx^i/e tT@8b<tߵ ҎY>%-"hݤ[<*g~{k:A2DD"H$DDP6~b\OGB 8XEDmfq5L4";΍, 5d%|7x#1KLj$hLjX6͉e DuJEĞZKeDZR^Af/::>W$ R[Ҥf~rm >rH&A&8ۮ\|HQ'ޝuchEf8wѺřx菵%6.KUgٺ͖hhK#%@`fY& ܶ~YBaOsvK5# R [xV(A@|9 \W)A+30 U'C}eݵaSNMoȬig M>^C`~5e:[C/>_# s)_Sڣ"ս6Dr_ʾ* R{+~ QJ>N/S`%tZ}vAD}Q࿴3V'/zD4$L# \~Vky(@ {!Z"O\t>YANq7k71`rPv kR.sPoC-KP_% Dv9L]5> `-rlnVD 9wM܇)pҜ= d#mzCDD$"0'/.Qj Hp'KZ󬵆<e=Q1_,ԂF]S RH.4 E'*[4峓ڞ+Fꩦԅq3cSe?vs0V0,ut:/4:M5V[2~L^<iԇUQl.ѯ*|t[ 7Vï rA׾7Jp(AX ~nxvx_0%({` % K , )|nm߅o22TRQ.[ufɽZ R5v ,d80~opK{k^ ) ފeM>J룴kVq.a(ޱ)V;2d9u0vZ^DL3ڨ9_vD+TDr߯M1ʱ~ר^ݑ7‘֟XXnYc/>W sϮB7Q孩r}yn%9̿=4 ߵ vشǷW,PѓS_4TAЍx&BЩh͔;9&[D"wݶ J]@nsqv)uz9Sy,hǸH˃ m@ 5^d{rDS=: OɪefjP"YԚ=6S% :5I3ɦy9VG8?;D4缧 :)ˌǪkJ=f7(u`lqdAB |9#7wwޛk}H,Gԯc~|pIݖC<5"bI=ǒəJ+&mZ3Lv&'~ z'}ufjyN5Ff_59 dKuǨ*߼tJ]~'{炝=w_Hv'[GAYxdjύ.WZDҔ,R闕@d5y:HW`oFmhAU r=^]=}if>>yDjXRF+,:TR7W?2ʓ_fܞ0ݮ+HF6er53a5FeQsX %@D-G mRf_V}Γ=+5 GkٔҬ/>Kᡎ8ҵe^g|LS1\Ss.bSM)\Ґ-SQIx_,4UOMQ;fR wzu"_OosI6Ѻx.eG Gn0:\*_"R+ɯpI `;ox<Qt3+kVS "Ǝa-aYhCނAD~"NLC'L\g&j3̓kՒz1/siK~?)J:Pι'k| ?D+gof}}vG-㜝-_9*]3=kH$Ï1L_ToNMWZw3UN}5qC642qlviJ4xeTi8};w[-}կJSEaA&!'&hY:FW_1%c[Xm-C9eMna`L(I#b$@-Jۓr~\l:`u2s_P96J=_8,֯/VD}q %)Mn"?HΤ:'L]gW瞡Zk-")QCrJV _<Ї792`0|6 /׌cd7iLύ>JCYd ;bcf|dO#2X]@D$vf2L}_Ԥri؎W0gs7s=_ah0X4K*$^J:Le~Mh dCGAKˬ'MmـsX:7X>kV5ӯ"Y C̥~1R.ח;K*r8/tj sNbD( ccw{Y^-&e:UfB>.(?BH,z*W⦆ R1=[i4H_7='^gghc~ 20ôZJo,\xC]Xp 7Zbۥ-mhh<;&mFRy̏c{vXmWOvY+D'O* Dr]TDD"b}JN%36DqV졷M;\He<'\\@\V뻀pKO6-XzvoŇʲ& gGjY>LML^<iloi3@rQvvCV֩B@D$D".=w_Hv'[GAYxd*(R! ݂grDH/mokMǸHET,CQ\l:0߿M![h6YQ"m\KFF$gf烎+ WΊĤ4칕0c>xRF|я,Mݺ.DDπ="<dI]Dʂ*$eSF_&/p9-&…ǼvtF>LeP7L/kd^g|Lc\&5kci6kfߵH>(2uaEDCAn٪]Յ{m#8\ L>#j00iM~fJ+o`67J@SQb+̚=i>>Y+~CBnCx2;W (5BT8^K04VѺI7Vzt!HHGYs$g҃LeV31ٙo83Л@~MHM R'\ʪ RQ_ߍƽS&DЋ?O >XI[bKnH_ݑf5mfA>@wޕ7qIs| 4h'kf~\T{ \!hӛ" bE" =Lj$jAn+Smh  ,z="v~B6VH~V&yz/]Je= Oc/6Va &@&ucD >[va8|ؓ:5L1895=;cmC (%vocSwdZ?]?KG%`ZӠI3|:ɚN¸|љ>|̨p1C&+3?C]aT W@%_ݷ ^ ܩR9a]`I}_ot'j gMPz/YIKY+@},4Y*xdA]C۾ (ouk{%K5xEUg+}D=^wn*F^W *H Uʺ~mҎTʔgjf7Sc[j.4EEQ袤nJ9^LoӔN&k*Jɺ.89cԅ!K]ǧƮӚį!0zCFA̙a1:ÎXh}u0ZmkwiU"f)a]混DQD$ ĶKZ8о='mFRamLGKG[ٶ9|PN>ukmWWjp74kP#ZmP"Ȍ|ԍq' ?i˶u뺨9YMA7HVN AfҢ5SMz۴f™Xm3&'~ &KGQ}Kdɨ n:8Ƌ M Eq-.y0kH1 ':Q¹#Yϛ4}Wۭ=$U%(|&1, Z  G5IC f^S1!Z|+J(O/%mGn p7WOn#Mٽ!h>Q .AR3P 륺U󎒶 xZҔ`vMOj83HsV#wݶ J8d=룁@jJnsqvɩ,;: Pxi;JZn:>Rϯs\~3=FzgةxdXYR OɪDMώpn=oH$ L2ƞ~% "g:0kV/Odd DR/0 ŕ'֔ l'L3h!%iNZQ#3$nm_T袣3R1!) $XBjHg X}b?e?J\KiTZe*N-۷PՁ@ 1{Ұ||Jk5xS  ;N)]͕ ɐ׋cY@D=W`hݛe_V} R D]oQlľ/"jWFfKsQF5=.'*p,kиm6Sˍ^ՒyP_9?zUos\~琉iL#3m7,vͪ09;>ppZg4ϥ)X:9~ pnғMgjn0UPm_L&+O@DmUAg+$?_ ~l`ქXiHc0eoHpPP܇%_~YV|yEfSt}OA3?p:n_N_qm[o绀9i5FSsOD*Ͱc0-EdE182=kH@Hq8`L\L>d[ue 47.Y싻g(I9hrqMGr&X;UfmSI'?.X\ɹt 5d%W\%P$qv4[j:Ol J)Uc[XWUD T&??6~@&Rux ۏMgݫ^RgX/D)7WwC[D1"[=)yЖyV(A18c R6z?XC642…lviJ4xz 7۹B ѵlZn:5Bl 9@DgFXi<ĹA49AXcD}drb":1S8X=}`uH|g:;3&hegMcv=W}g⹢?ظ,ŧ J4.#Y~)^7βFQ1q|4d!iΚUȆ؏:BTj5@D-jm6G+Gk*KG6hެY%$Wu,f IV3 "=]~0ƾ`L/6@fJo,@خ,8ncj23H?E |}MT!a?RwfĻnlC,m0Fgˍ]i/Ř #Wې?TS/?UM!u4ޙXa{5 "&f1z|{_:[zKKkKldvMt2}H@D$zVADT[R!8[h4"b}JN%:DΘ=iѳҍ8y-gGH$ R!`9NtVݙʙ{Ȅ򹷦 v"f$chvL)E""H ɋTw1ɒ<ɒb5UD̉y>4D"2<;N^Af.8#!"H@.*ߵ vشǷ'oiE"HHf D""H$D"H@D$D"H@D$D"H$D"H$DD"H$DD"H$D"H$ "D"H$ " D"H$DD"S{n~=/ %HH$53% , c߹~ױ=}DQݱƦU&]Nw|~)f̂ztPԇiQu_em-uT`-Sq8ݝ Z٪9&[ԛZ\7=Dpy}AONCL}UWOGT,n՛W|tr'%Y;Űm|Z$DDG "JUCv4|Wu^(-,"y6swIG[lMyEy/@'Hq4_ x@U Jr85lhW􀈚 0n)w`$SO|^>Jjzg M>^C`s@Sz x0y.bªW H:.X+G76i*ΰ %.+xa%YDwޘو^W 2z.~&Uf|8LE"H@D$z R)Bn4'5 qͱsZ D^X3#˚H~y`ht>t\9bK%@M@LIT_/dF~şq񍤠)ay-ruū^o+MV049;tGAF:懈BD"HAjW=ntuuzMù#K  (&FhS,9Ot bx{B& 5M8}m+w/3z*B. ptM LJBtq-FEQ袬nJ0NjmdmwTovLH',Zv+2t]+e7k:6Uc9oU¸|љ>|Tzt^&ԃ}_oQ%./] ex` <Qɗ1 |ҮYm߅o22=NQ.[uf=*6r4Otq'%QH&K+Dh')/8q"#7wwޛ& X؏9Aȏi-m3u~].\fiteM*g>ӎ7en^<'.lKQb BpٟM}!p{l'$["H$ "=U Rq&}h.K;aC+qUDMY J gzev[[HQB43QOY_DԮ8J;QkN{lG] UO^rZ[vcMHh8ϏyuZ" bϠ=iNIΫx /BZDtͺ^uS%MyP_YoGEW|^@ч҂oC UAc^=}if>>yOD zr9ŵx`yidn1UYv\ÿ{g)hQio5^o~9-FDի4t.D"" "oO5%T>AkA!8,,id:ʒ}VhDLo0 !3+& __:wnVcj*sQDOh} 1׎QR&1ۗYM7<69Q1.O@" kZZuaޭ2 ^ 4D?Wm|6TU(*L)%8ku1">V@;d? Qyx?i)b_|7򙖾hsgז_lѪ)&9;MbRV)"* * "D""ѳ "y h8ZrUZ@@'HĮ?`֌@ 5A,h_DLC:n3b #+ͣu\?/`tcm混FZD-a| ,ߥ/+ݟDbJ횕e8;Z3hřx菵%6.K5>Q|}ivu嚾qW5s flNc٭ڇnGMR 3nTnu(J͖hhK#2frQG75kٯl)?@YQh;KgÐ?_?A06vˏe{TA]l ;g5D$p-!! "B@D$D"\cD"H$DD"H$+!!hDDr]D"H$=+$>xp~H H$D"<=DD"H$WOtDD"H$d%1H H$D"H@D$D" #H|P$e{js/?m =Eg/Dou/8k+ " -4h+? jҽ8;4vˉDCJ6X;m߉}zCմϾ#NI9 ~[cKx>ԫmkn*i`,7eݣi'SuiLpCAu}(RQӏ//dּݙЪ_KUO,]^ ̵Tw/t{ \p]O><ٺ˺0ͫ5leML~I:ŰmD"ѓ"" wiDo]nk͘KU_ҁOPE>wt8AA~6 b-);]\MרU "j0- pQޝ@GYi=}ӧ{sAHXvMPvp76AQ; ""n* (;$W=[) TV ܃T%=j"R)])׾H֏J'͟OJ3};ާW)boI..wzynha*Qiq/ʔ3%ǼE4%ͨPmm*j*[_iY?n"Գߟc<9Ai!T ɣOuSjc>Nk7"r R_JþoaB;E%JscW%(Vm7?N>uE! s"f-:c*}nSE*":z>uD%HϞt nmPnAx@?^@M)}h U8Y}~H`u|=vX}y][Mm}Cԩ{/ eɫ,kc>#4]JuFWmf]/=m_GXyHM/yHw 6x k2˿7ʣc[xo:Ufnd4rǿ*WڙvE8Ez3LwuQ1̬_4yI٬ A3ԪxzkSe{6`@%դӨaz-nQis>M6 PD^ߎQDWWög P%C4asxQ8pegg-/" Ҭ> KglW^I&T顲߻FT[ë!m\jyar>uTό^5&:E6G SӾBUT(3ťںtSO}ZUӃy >!КRfHɻQ`o-ҹ:2mN}עeq-9{qȳ^OFx\s[תTФ)ͬgx闝Sߔ"xio)"bs7תXoMadN.|l}Y}xte4?[lkDccRSnߙ2)$^[["R) 1<"*Sa:ؤWD33CxJ 8pY~OE Α#)*߳es<*"oZPo84$3\>5~ᧈT%!q4Ӷ*VjPtmιG넯{: v=5"J/thn]0EjNyd~̉\l}ewf~^=߄!ÈCz_w!ku Q;I}WWfz wY/P+ <`N/ YRy)QLIX.W=hB컈y$t6ZD(ny2TxE$q"RW("hvPzNЭ6՝z1囋S4zLUTӖ)v|Ith)" /_#R"b}?{lT̚8B/-8H]ǯURuӋH5aUZLM0FÆ T7[-kt Lx˒>}yac>p{M7닁f^9[הFz+f'ZVDn5E$"ΩY@ˊ쫹;Y}hOzw͠M""fwo*j`}h]D쫈|~Hř ~V"j:g&6ZsəW}WDj4g=e=7j!"b ӬI0V|?"<_0%d{_7j~u1}`S U:NS}wv5U].5/y৆mVDKI+E߿E`Grjb࢈EVg*ӊ,m8dL)Zi"5Jl:xA UYR _:UN [d{=[z^=(a ]x|U16MyS; U][zo+/YNƬwyfڼ}zq|-gugӚ'ӡ.p X__b!&EdWD8ޞ3V}BuKH6NUYμ sSP/1MO~.o R;z/9{XݭoP6uuҝwքKEnB)iU5߲(_"b1qE'I!)_Jߗw~"$?*D#>NfJ}_uSiϯmw%fkYÿE,`](d3?1tyWlU%P8}A4|!E-duzv@&~oM\Vo*1TkC|er~ϧSDqAxXܛQ].Ђ~az\ _PUE:u7SBRR)"ภt4u׈8Ż}Y/nI/t_#.f,p\PD: " לEV@EPD("E"PD("E"PD("E"PD("E"@("@0c"@(""A@(""@.@qA@EPD("E"PDqA@ @EPDEPDEภPDE"PD("E"@(" \p\PD("E"PD(""@("@`࢈@@PDE"PD("E" EPDE"PD("E"@(" \"@(""@(""@PDE0pQDEPDE"PD("E"@("@@PDE"PD("E"PD("E"PD(""@("@EPDEภPDE"PD("E"@(" \"@(""B@EPD("E"PD("E"PD("E"@("@`࢈@EPD("E"PD@(""@aE"PD("E=@(""@.@qA@EPD("E"PDqA@ @EPDEPDEภPDE"PD("E"@(" \p\PD("E"PD8H("E"PD@(""E(""@8.(""@("@EPDE@EPD("E"PD("E"PD("E"PD("E"@("@`࢈1ֳZo9l1c"@89PB!Bk:uŌE8.w B!EB!B!B!PD!B!"ђ$ QqB!B%ּi͝y̤"X;Mw&C'B!.dy3IKTai.\abI>LieKgrDW!B!풍S<󧯙B*//0cך)"Ʉ\e+]BB!B%s5gq3iMM:\uYs"ETb TTD!BHDB^EÅ"EkN݂Bn"R]]k]v*"T\\LBȍ@E#"B!"B!ECc"NE$*!_#fUDBnLt-";Tu uQD("PD!tPDڱIP@!9﷈TTT(??ZW HG.[M!çj[;g0Oߣ}B4u[Jmz5|5Ht },_UgܯӠt( EwGfJE$g ܟrAz=O/@}\o{fH>'B!7{>@vK3S~of~zNцtm MOfݥC4aYنez஁ a‹ږR|1K\ѻ5uv%zd}?SD;VpGI;Tg=@ې^ }5ɬՊ+.jlrΖ<{f̝`7m5Eg'[zt ^F4}ym]=v0MVLRwLmL)i6ZJ[XDzhǧYXZp:V?唩(^k&6-NSqa/R``_\w>n0ݬ;/'*k|<^ .!X37Yifレ+U9ىpOug2I?'(x <ҙOyf> |ǵլ"yu tL'W5czh~HSw/PmI pzJک/V.MCtJX%h%nRyNVe13utO㣈\qz3Խ\滈Dy bcx}Rv O~IH'^2/]En3kޡn+K'Zbt!uFU: (dLE:r_2 1ŗ^hq cǴ4.o3+4Ьz}>OoTyK])w79lpǬm(-~V7~~[G%e?BuƊYYY~-g~H>`t}OW4 hRnulG]atF=杚u~&詟"گ' mBq_$}64 =fj5']!hRŤEz'DTD,c*)EGd9[?4zLG+ HO Ѡ7Ϫ;Â{J/OQYox *6z1}P:\eSUڛh$O[T7[C`s D檼@I;`^9lVeSSef-VI}D!B-"gvH>GßЎz%Rd NʕFydҫrRк 5Qou ySdSּ(1hx)SnY_fTݞKϏ ֠u៭Yk\c(s^U=fi^5gXBK}FNUfݿҭe3~2/Ryfޟ?AN56q)PMw@˪ e^{x~zf3Ccw[Ar 0]Cju (IݭVO.K49{pǯ7P6uu\ώ%$P[д4ު4mV>!%O[9MۗmҔ}k9 B!H9V췈XovuR!r>S Wܖf4cJNs)-{-qBmfӈhH t|ZW iĺJT]]hJN-Qnmʪ xߎ6݆r~D͹c!=%~V>= 0.5 else icon_file_dark log.debug('Menu bar color: (%g, %g, %g), averages to %g; picking %s', color.red, color.green, color.blue, value, filename) return filename class SimpleStatusIcon(IconChooser): """Status icon for gtimelog in the notification area.""" def __init__(self, gtimelog_window): self.gtimelog_window = gtimelog_window self.timelog = gtimelog_window.timelog self.trayicon = None if not hasattr(gtk, 'StatusIcon'): # You must be using a very old PyGtk. return self.icon = gtk_status_icon_new(self.icon_name) self.last_tick = False self.tick() self.icon.connect('activate', self.on_activate) self.icon.connect('popup-menu', self.on_popup_menu) if gtk_version == 2: self.gtimelog_window.main_window.connect( 'style-set', self.on_style_set) else: # assume Gtk+ 3 self.gtimelog_window.main_window.connect( 'style-updated', self.on_style_set) gobject.timeout_add_seconds(1, self.tick) self.gtimelog_window.entry_watchers.append(self.entry_added) self.gtimelog_window.tray_icon = self def available(self): """Is the icon supported by this system? SimpleStatusIcon needs PyGtk 2.10 or newer """ return self.icon is not None def on_style_set(self, *args): """The user chose a different theme.""" self.icon.set_from_file(self.icon_name) def on_activate(self, widget): """The user clicked on the icon.""" self.gtimelog_window.toggle_visible() def on_popup_menu(self, widget, button, activate_time): """The user clicked on the icon.""" tray_icon_popup_menu = self.gtimelog_window.tray_icon_popup_menu if toolkit == "gi": tray_icon_popup_menu.popup( None, None, gtk.StatusIcon.position_menu, self.icon, button, activate_time) else: tray_icon_popup_menu.popup( None, None, gtk.status_icon_position_menu, button, activate_time, self.icon) def entry_added(self, entry): """An entry has been added.""" self.tick() def tick(self): """Tick every second.""" self.icon.set_tooltip_text(self.tip()) return True def tip(self): """Compute tooltip text.""" current_task = self.gtimelog_window.task_entry.get_text() if not current_task: current_task = 'nothing' tip = 'GTimeLog: working on {0}'.format(current_task) total_work, total_slacking = self.timelog.window.totals() tip += '\nWork done today: {0}'.format(format_duration(total_work)) time_left = self.gtimelog_window.time_left_at_work(total_work) if time_left is not None: if time_left < datetime.timedelta(0): time_left = datetime.timedelta(0) tip += '\nTime left at work: {0}'.format( format_duration(time_left)) return tip class AppIndicator(IconChooser): """Ubuntu's application indicator for gtimelog.""" # XXX: on Ubuntu 10.04 the app indicator apparently doesn't understand # set_icon('/absolute/path'), and so gtimelog ends up being without an # icon. I don't know if I want to continue supporting Ubuntu 10.04. def __init__(self, gtimelog_window): self.gtimelog_window = gtimelog_window self.timelog = gtimelog_window.timelog self.indicator = None if new_app_indicator is None: return self.indicator = new_app_indicator( 'gtimelog', self.icon_name, APPINDICATOR_CATEGORY) self.indicator.set_status(APPINDICATOR_ACTIVE) self.indicator.set_menu(gtimelog_window.app_indicator_menu) self.gtimelog_window.tray_icon = self if gtk_version == 2: self.gtimelog_window.main_window.connect( 'style-set', self.on_style_set) else: # assume Gtk+ 3 self.gtimelog_window.main_window.connect( 'style-updated', self.on_style_set) def available(self): """Is the icon supported by this system? AppIndicator needs python-appindicator """ return self.indicator is not None def on_style_set(self, *args): """The user chose a different theme.""" self.indicator.set_icon(self.icon_name) class OldTrayIcon(IconChooser): """Old tray icon for gtimelog, shows a ticking clock. Uses the old and deprecated egg.trayicon module. """ def __init__(self, gtimelog_window): self.gtimelog_window = gtimelog_window self.timelog = gtimelog_window.timelog self.trayicon = None try: import egg.trayicon except ImportError: # Nothing to do here, move along or install python-gnome2-extras # which was later renamed to python-eggtrayicon. return self.eventbox = gtk.EventBox() hbox = gtk.HBox() self.icon = gtk.Image() self.icon.set_from_file(self.icon_name) hbox.add(self.icon) self.time_label = gtk.Label() hbox.add(self.time_label) self.eventbox.add(hbox) self.trayicon = egg.trayicon.TrayIcon('GTimeLog') self.trayicon.add(self.eventbox) self.last_tick = False self.tick(force_update=True) self.trayicon.show_all() if gtk_version == 2: self.gtimelog_window.main_window.connect( 'style-set', self.on_style_set) else: # assume Gtk+ 3 self.gtimelog_window.main_window.connect( 'style-updated', self.on_style_set) tray_icon_popup_menu = gtimelog_window.tray_icon_popup_menu self.eventbox.connect_object( 'button-press-event', self.on_press, tray_icon_popup_menu) self.eventbox.connect('button-release-event', self.on_release) gobject.timeout_add_seconds(1, self.tick) self.gtimelog_window.entry_watchers.append(self.entry_added) self.gtimelog_window.tray_icon = self def available(self): """Is the icon supported by this system? OldTrayIcon needs egg.trayicon, which is now deprecated and likely no longer available in modern Linux distributions. """ return self.trayicon is not None def on_style_set(self, *args): """The user chose a different theme.""" self.icon.set_from_file(self.icon_name) def on_press(self, widget, event): """A mouse button was pressed on the tray icon label.""" if event.button != 3: return main_window = self.gtimelog_window.main_window # This should be unnecessary, as we now show/hide menu items # immediatelly after showing/hiding the main window. if main_window.get_property('visible'): self.gtimelog_window.tray_show.hide() self.gtimelog_window.tray_hide.show() else: self.gtimelog_window.tray_show.show() self.gtimelog_window.tray_hide.hide() # I'm assuming toolkit == 'pygtk' here, since there's now way the old # EggTrayIcon can work with PyGI/Gtk+ 3. widget.popup(None, None, None, event.button, event.time) def on_release(self, widget, event): """A mouse button was released on the tray icon label.""" if event.button != 1: return self.gtimelog_window.toggle_visible() def entry_added(self, entry): """An entry has been added.""" self.tick(force_update=True) def tick(self, force_update=False): """Tick every second.""" now = datetime.datetime.now().replace(second=0, microsecond=0) if now != self.last_tick or force_update: # Do not eat CPU too much self.last_tick = now last_time = self.timelog.window.last_time() if last_time is None: self.time_label.set_text(now.strftime('%H:%M')) else: self.time_label.set_text( format_duration_short(now - last_time)) self.trayicon.set_tooltip_text(self.tip()) return True def tip(self): """Compute tooltip text.""" current_task = self.gtimelog_window.task_entry.get_text() if not current_task: current_task = 'nothing' tip = 'GTimeLog: working on {0}'.format(current_task) total_work, total_slacking = self.timelog.window.totals() tip += '\nWork done today: {0}'.format(format_duration(total_work)) time_left = self.gtimelog_window.time_left_at_work(total_work) if time_left is not None: if time_left < datetime.timedelta(0): time_left = datetime.timedelta(0) tip += '\nTime left at work: {0}'.format( format_duration(time_left)) return tip class MainWindow: """Main application window.""" # URL to use for Help -> Online Documentation. help_url = "http://mg.pov.lt/gtimelog" def __init__(self, timelog, settings, tasks): """Create the main window.""" self.timelog = timelog self.settings = settings self.tasks = tasks self.tray_icon = None self.last_tick = None self.footer_mark = None # Try to prevent timer routines mucking with the buffer while we're # mucking with the buffer. Not sure if it is necessary. self.lock = False # I'm representing a tristate with two booleans (for config file # backwards compat), let's normalize nonsensical states. self.chronological = (settings.chronological and not settings.summary_view) self.summary_view = settings.summary_view self.show_tasks = settings.show_tasks self.looking_at_date = None self.entry_watchers = [] self._init_ui() def _init_ui(self): """Initialize the user interface.""" builder = gtk.Builder() builder.add_from_file(ui_file) # Set initial state of menu items *before* we hook up signals chronological_menu_item = builder.get_object('chronological') chronological_menu_item.set_active(self.chronological) summary_menu_item = builder.get_object('summary') summary_menu_item.set_active(self.summary_view) show_task_pane_item = builder.get_object('show_task_pane') show_task_pane_item.set_active(self.show_tasks) # Now hook up signals. builder.connect_signals(self) # Store references to UI elements we're going to need later self.app_indicator_menu = builder.get_object('app_indicator_menu') self.appind_show = builder.get_object('appind_show') self.tray_icon_popup_menu = builder.get_object('tray_icon_popup_menu') self.tray_show = builder.get_object('tray_show') self.tray_hide = builder.get_object('tray_hide') self.tray_show.hide() self.about_dialog = builder.get_object('about_dialog') self.about_dialog_ok_btn = builder.get_object('ok_button') self.about_dialog_ok_btn.connect('clicked', self.close_about_dialog) self.about_text = builder.get_object('about_text') self.about_text.set_markup( self.about_text.get_label() % dict(version=__version__)) self.calendar_dialog = builder.get_object('calendar_dialog') self.calendar = builder.get_object('calendar') self.calendar.connect( 'day_selected_double_click', self.on_calendar_day_selected_double_click) self.two_calendar_dialog = builder.get_object('two_calendar_dialog') self.calendar1 = builder.get_object('calendar1') self.calendar2 = builder.get_object('calendar2') self.main_window = builder.get_object('main_window') self.main_window.connect('delete_event', self.delete_event) self.current_view_label = builder.get_object('current_view_label') self.log_view = builder.get_object('log_view') self.set_up_log_view_columns() self.task_pane = builder.get_object('task_list_pane') if not self.show_tasks: self.task_pane.hide() self.task_pane_info_label = builder.get_object('task_pane_info_label') self.tasks.loading_callback = self.task_list_loading self.tasks.loaded_callback = self.task_list_loaded self.tasks.error_callback = self.task_list_error self.task_list = builder.get_object('task_list') self.task_store = gtk.TreeStore(str, str) self.task_list.set_model(self.task_store) column = gtk.TreeViewColumn('Task', gtk.CellRendererText(), text=0) self.task_list.append_column(column) self.task_list.connect('row_activated', self.task_list_row_activated) self.task_list_popup_menu = builder.get_object('task_list_popup_menu') self.task_list.connect_object( 'button_press_event', self.task_list_button_press, self.task_list_popup_menu) task_list_edit_menu_item = builder.get_object('task_list_edit') if not self.settings.edit_task_list_cmd: task_list_edit_menu_item.set_sensitive(False) self.time_label = builder.get_object('time_label') self.task_entry = builder.get_object('task_entry') self.task_entry.connect('changed', self.task_entry_changed) self.task_entry.connect('key_press_event', self.task_entry_key_press) self.add_button = builder.get_object('add_button') self.add_button.connect('clicked', self.add_entry) buffer = self.log_view.get_buffer() self.log_buffer = buffer buffer.create_tag('today', foreground='blue') buffer.create_tag('duration', foreground='red') buffer.create_tag('time', foreground='green') buffer.create_tag('slacking', foreground='gray') self.set_up_task_list() self.set_up_completion() self.set_up_history() self.populate_log() self.update_show_checkbox() self.tick(True) gobject.timeout_add_seconds(1, self.tick) def set_up_log_view_columns(self): """Set up tab stops in the log view.""" # we can't get a Pango context for unrealized widgets self.log_view.realize() pango_context = self.log_view.get_pango_context() em = pango_context.get_font_description().get_size() tabs = pango_tabarray_new(2, False) tabs.set_tab(0, PANGO_ALIGN_LEFT, 9 * em) tabs.set_tab(1, PANGO_ALIGN_LEFT, 12 * em) self.log_view.set_tabs(tabs) def w(self, text, tag=None): """Write some text at the end of the log buffer.""" buffer = self.log_buffer if tag: buffer.insert_with_tags_by_name(buffer.get_end_iter(), text, tag) else: buffer.insert(buffer.get_end_iter(), text) def populate_log(self): """Populate the log.""" self.lock = True buffer = self.log_buffer buffer.set_text('') if self.footer_mark is not None: buffer.delete_mark(self.footer_mark) self.footer_mark = None if self.looking_at_date is None: today = self.timelog.day window = self.timelog.window else: today = self.looking_at_date window = self.timelog.window_for_day(today) today = "{:%A, %Y-%m-%d} (week {:0>2})".format(today, today.isocalendar()[1]) self.current_view_label.set_text(today) if self.chronological: for item in window.all_entries(): self.write_item(item) elif self.summary_view: entries, totals = window.categorized_work_entries() for category, duration in sorted(totals.items()): self.write_group(category or 'no category', duration) where = buffer.get_end_iter() where.backward_cursor_position() buffer.place_cursor(where) else: work, slack = window.grouped_entries() for start, entry, duration in work + slack: self.write_group(entry, duration) where = buffer.get_end_iter() where.backward_cursor_position() buffer.place_cursor(where) self.add_footer() self.scroll_to_end() self.lock = False def delete_footer(self): buffer = self.log_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.log_buffer self.footer_mark = buffer.create_mark( 'footer', buffer.get_end_iter(), True) window = self.daily_window(self.looking_at_date) total_work, total_slacking = window.totals() weekly_window = self.weekly_window(self.looking_at_date) week_total_work, week_total_slacking = weekly_window.totals() work_days_this_week = weekly_window.count_days() self.w('\n') self.w('Total work done: ') self.w(format_duration(total_work), 'duration') self.w(' (') self.w(format_duration(week_total_work), 'duration') self.w(' this week') if work_days_this_week: per_diem = week_total_work / work_days_this_week self.w(', ') self.w(format_duration(per_diem), 'duration') self.w(' per day') self.w(')\n') self.w('Total slacking: ') self.w(format_duration(total_slacking), 'duration') self.w(' (') self.w(format_duration(week_total_slacking), 'duration') self.w(' this week') if work_days_this_week: per_diem = week_total_slacking / work_days_this_week self.w(', ') self.w(format_duration(per_diem), 'duration') self.w(' per day') self.w(')\n') if self.looking_at_date is None: time_left = self.time_left_at_work(total_work) else: time_left = None if time_left is not None: time_to_leave = datetime.datetime.now() + time_left if time_left < datetime.timedelta(0): time_left = datetime.timedelta(0) self.w('Time left at work: ') self.w(format_duration(time_left), 'duration') self.w(' (till ') self.w(time_to_leave.strftime('%H:%M'), 'time') self.w(')') if self.settings.show_office_hours and self.looking_at_date is None: self.w('\nAt office today: ') hours = datetime.timedelta(hours=self.settings.hours) total = total_slacking + total_work self.w("%s " % format_duration(total), 'duration') self.w('(') if total > hours: self.w(format_duration(total - hours), 'duration') self.w(' overtime') else: self.w(format_duration(hours - total), 'duration') self.w(' left') self.w(')') def time_left_at_work(self, total_work): """Calculate time left to work.""" last_time = self.timelog.window.last_time() if last_time is None: return None now = datetime.datetime.now() current_task = self.task_entry.get_text() current_task_time = now - last_time if '**' in current_task: total_time = total_work else: total_time = total_work + current_task_time return datetime.timedelta(hours=self.settings.hours) - total_time def write_item(self, item): buffer = self.log_buffer start, stop, duration, entry = item self.w(format_duration(duration), 'duration') period = '\t({0}-{1})\t'.format( start.strftime('%H:%M'), stop.strftime('%H:%M')) self.w(period, 'time') tag = ('slacking' if '**' in entry else None) self.w(entry + '\n', tag) where = buffer.get_end_iter() where.backward_cursor_position() buffer.place_cursor(where) 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 scroll_to_end(self): buffer = self.log_view.get_buffer() end_mark = buffer.create_mark('end', buffer.get_end_iter()) self.log_view.scroll_to_mark(end_mark, 0, False, 0, 0) buffer.delete_mark(end_mark) def set_up_task_list(self): """Set up the task list pane.""" self.task_store.clear() for group_name, group_items in self.tasks.groups: 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.task_list.expand_all() def set_up_history(self): """Set up history.""" self.history = self.timelog.history self.filtered_history = [] self.history_pos = 0 self.history_undo = '' if not self.have_completion: return seen = set() self.completion_choices.clear() for entry in self.history: if entry not in seen: seen.add(entry) self.completion_choices.append([entry]) def set_up_completion(self): """Set up autocompletion.""" if not self.settings.enable_gtk_completion: self.have_completion = False return self.have_completion = hasattr(gtk, 'EntryCompletion') if not self.have_completion: return self.completion_choices = gtk.ListStore(str) completion = gtk.EntryCompletion() completion.set_model(self.completion_choices) completion.set_text_column(0) self.task_entry.set_completion(completion) def add_history(self, entry): """Add an entry to history.""" self.history.append(entry) self.history_pos = 0 if not self.have_completion: return if entry not in [row[0] for row in self.completion_choices]: self.completion_choices.append([entry]) def jump_to_date(self, date): """Switch to looking at a given date""" if self.looking_at_date == date: return self.looking_at_date = date self.populate_log() def jump_to_today(self): """Switch to looking at today""" self.jump_to_date(None) def delete_event(self, widget, data=None): """Try to close the window.""" if self.tray_icon: self.on_hide_activate() return True else: gtk.main_quit() return False def close_about_dialog(self, widget): """Ok clicked in the about dialog.""" self.about_dialog.hide() def on_show_activate(self, widget=None): """Tray icon menu -> Show selected""" self.main_window.present() self.tray_show.hide() self.tray_hide.show() self.update_show_checkbox() def on_hide_activate(self, widget=None): """Tray icon menu -> Hide selected""" self.main_window.hide() self.tray_hide.hide() self.tray_show.show() self.update_show_checkbox() def update_show_checkbox(self): self.ignore_on_toggle_visible = True # This next line triggers both 'activate' and 'toggled' signals. self.appind_show.set_active(self.main_window.get_property('visible')) self.ignore_on_toggle_visible = False ignore_on_toggle_visible = False def on_toggle_visible(self, widget=None): """Application indicator menu -> Show GTimeLog""" if not self.ignore_on_toggle_visible: self.toggle_visible() def toggle_visible(self): """Toggle main window visibility.""" if self.main_window.get_property('visible'): self.on_hide_activate() else: self.on_show_activate() def on_today_toolbutton_clicked(self, widget=None): """Toolbar: Back""" self.jump_to_today() def on_back_toolbutton_clicked(self, widget=None): """Toolbar: Back""" day = (self.looking_at_date or self.timelog.day) self.jump_to_date(day - datetime.timedelta(1)) def on_forward_toolbutton_clicked(self, widget=None): """Toolbar: Forward""" day = (self.looking_at_date or self.timelog.day) day += datetime.timedelta(1) if day >= self.timelog.virtual_today(): self.jump_to_today() else: self.jump_to_date(day) def on_quit_activate(self, widget): """File -> Quit selected""" gtk.main_quit() def on_about_activate(self, widget): """Help -> About selected""" self.about_dialog.show() def on_online_help_activate(self, widget): """Help -> Online Documentation selected""" import webbrowser webbrowser.open(self.help_url) def on_chronological_activate(self, widget): """View -> Chronological""" self.chronological = True self.summary_view = False self.populate_log() def on_grouped_activate(self, widget): """View -> Grouped""" self.chronological = False self.summary_view = False self.populate_log() def on_summary_activate(self, widget): """View -> Summary""" self.chronological = False self.summary_view = True self.populate_log() def daily_window(self, day=None): if not day: day = self.timelog.day return self.timelog.window_for_day(day) def on_daily_report_activate(self, widget): """File -> Daily Report""" reports = Reports(self.timelog.window) self.mail(reports.daily_report) def on_yesterdays_report_activate(self, widget): """File -> Daily Report for Yesterday""" day = self.timelog.day - datetime.timedelta(1) reports = Reports(self.timelog.window_for_day(day)) self.mail(reports.daily_report) def on_previous_day_report_activate(self, widget): """File -> Daily Report for a Previous Day""" day = self.choose_date() if day: reports = Reports(self.timelog.window_for_day(day)) self.mail(reports.daily_report) def choose_date(self): """Pop up a calendar dialog. Returns either a datetime.date, or None. """ if self.calendar_dialog.run() == GTK_RESPONSE_OK: y, m1, d = self.calendar.get_date() day = datetime.date(y, m1 + 1, d) else: day = None self.calendar_dialog.hide() return day def choose_date_range(self): """Pop up a calendar dialog for a date range. Returns either a tuple with two datetime.date objects, or (None, None). """ if self.two_calendar_dialog.run() == GTK_RESPONSE_OK: y1, m1, d1 = self.calendar1.get_date() y2, m2, d2 = self.calendar2.get_date() first = datetime.date(y1, m1 + 1, d1) second = datetime.date(y2, m2 + 1, d2) else: first = second = None self.two_calendar_dialog.hide() return (first, second) def on_calendar_day_selected_double_click(self, widget): """Double-click on a calendar day: close the dialog.""" self.calendar_dialog.response(GTK_RESPONSE_OK) def weekly_window(self, day=None): if not day: day = self.timelog.day return self.timelog.window_for_week(day) def on_weekly_report_activate(self, widget): """File -> Weekly Report""" day = self.timelog.day reports = Reports(self.weekly_window(day=day)) if self.settings.report_style == 'plain': report = reports.weekly_report_plain elif self.settings.report_style == 'categorized': report = reports.weekly_report_categorized else: report = reports.weekly_report_plain self.mail(report) def on_last_weeks_report_activate(self, widget): """File -> Weekly Report for Last Week""" day = self.timelog.day - datetime.timedelta(7) reports = Reports(self.weekly_window(day=day)) if self.settings.report_style == 'plain': report = reports.weekly_report_plain elif self.settings.report_style == 'categorized': report = reports.weekly_report_categorized else: report = reports.weekly_report_plain self.mail(report) def on_previous_week_report_activate(self, widget): """File -> Weekly Report for a Previous Week""" day = self.choose_date() if day: reports = Reports(self.weekly_window(day=day)) if self.settings.report_style == 'plain': report = reports.weekly_report_plain elif self.settings.report_style == 'categorized': report = reports.weekly_report_categorized else: report = reports.weekly_report_plain self.mail(report) def monthly_window(self, day=None): if not day: day = self.timelog.day return self.timelog.window_for_month(day) def on_previous_month_report_activate(self, widget): """File -> Monthly Report for a Previous Month""" day = self.choose_date() if day: reports = Reports(self.monthly_window(day=day)) if self.settings.report_style == 'plain': report = reports.monthly_report_plain elif self.settings.report_style == 'categorized': report = reports.monthly_report_categorized else: report = reports.monthly_report_plain self.mail(report) def on_last_month_report_activate(self, widget): """File -> Monthly Report for Last Month""" day = self.timelog.day - datetime.timedelta(self.timelog.day.day) reports = Reports(self.monthly_window(day=day)) if self.settings.report_style == 'plain': report = reports.monthly_report_plain elif self.settings.report_style == 'categorized': report = reports.monthly_report_categorized else: report = reports.monthly_report_plain self.mail(report) def on_monthly_report_activate(self, widget): """File -> Monthly Report""" reports = Reports(self.monthly_window()) if self.settings.report_style == 'plain': report = reports.monthly_report_plain elif self.settings.report_style == 'categorized': report = reports.monthly_report_categorized else: report = reports.monthly_report_plain self.mail(report) def range_window(self, min, max): if not min: min = self.timelog.day if not max: max = self.timelog.day if max < min: max = min return self.timelog.window_for_date_range(min, max) def on_custom_range_report_activate(self, widget): """File -> Report for a Custom Date Range""" min, max = self.choose_date_range() if min and max: reports = Reports(self.range_window(min, max)) self.mail(reports.custom_range_report_categorized) def on_open_complete_spreadsheet_activate(self, widget): """Report -> Complete Report in Spreadsheet""" tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe! with open(tempfn, 'w') as f: self.timelog.whole_history().to_csv_complete(f) self.spawn(self.settings.spreadsheet, tempfn) def on_open_slack_spreadsheet_activate(self, widget): """Report -> Work/_Slacking stats in Spreadsheet""" tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe! with open(tempfn, 'w') as f: self.timelog.whole_history().to_csv_daily(f) self.spawn(self.settings.spreadsheet, tempfn) def on_edit_timelog_activate(self, widget): """File -> Edit timelog.txt""" self.spawn(self.settings.editor, '"%s"' % self.timelog.filename) def mail(self, write_draft): """Send an email.""" draftfn = tempfile.mktemp(suffix='gtimelog') # XXX unsafe! with codecs.open(draftfn, 'w', encoding='UTF-8') as draft: write_draft(draft, self.settings.email, self.settings.name) self.spawn(self.settings.mailer, draftfn) # XXX rm draftfn when done -- but how? def spawn(self, command, arg=None): """Spawn a process in background""" # XXX shell-escape arg, please. if arg is not None: if '%s' in command: command = command % arg else: command += ' ' + arg os.system(command + " &") def on_reread_activate(self, widget): """File -> Reread""" self.timelog.reread() self.set_up_history() self.populate_log() self.tick(True) def on_show_task_pane_toggled(self, event): """View -> Tasks""" if self.task_pane.get_property('visible'): self.task_pane.hide() else: self.task_pane.show() if self.tasks.check_reload(): self.set_up_task_list() def on_task_pane_close_button_activate(self, event, data=None): """The close button next to the task pane title""" self.task_pane.hide() def task_list_row_activated(self, treeview, path, view_column): """A task was selected in the task pane -- put it to the entry.""" model = treeview.get_model() task = model[path][1] self.task_entry.set_text(task) def grab_focus(): self.task_entry.grab_focus() self.task_entry.set_position(-1) # 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. gobject.idle_add(grab_focus) def task_list_button_press(self, menu, event): if event.button == 3: if toolkit == "gi": menu.popup(None, None, None, None, event.button, event.time) else: menu.popup(None, None, None, event.button, event.time) return True else: return False def on_task_list_reload(self, event): self.tasks.reload() self.set_up_task_list() def on_task_list_edit(self, event): self.spawn(self.settings.edit_task_list_cmd) def task_list_loading(self): self.task_list_loading_failed = False self.task_pane_info_label.set_text('Loading...') self.task_pane_info_label.show() # let the ui update become visible while gtk.events_pending(): gtk.main_iteration() def task_list_error(self): self.task_list_loading_failed = True self.task_pane_info_label.set_text('Could not get task list.') self.task_pane_info_label.show() def task_list_loaded(self): if not self.task_list_loading_failed: self.task_pane_info_label.hide() def task_entry_changed(self, widget): """Reset history position when the task entry is changed.""" self.history_pos = 0 def task_entry_key_press(self, widget, event): """Handle key presses in task entry.""" if event.keyval == gdk.keyval_from_name('Escape') and self.tray_icon: self.on_hide_activate() return True 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 # XXX This interferes with the completion box. How do I determine # whether the completion box is visible or not? if self.have_completion: return False if event.keyval == gdk.keyval_from_name('Up'): self._do_history(1) return True if event.keyval == gdk.keyval_from_name('Down'): self._do_history(-1) return True return False def _do_history(self, delta): """Handle movement in history.""" if not self.history: return if self.history_pos == 0: self.history_undo = self.task_entry.get_text() self.filtered_history = uniq([ l for l in self.history if l.startswith(self.history_undo)]) history = self.filtered_history new_pos = max(0, min(self.history_pos + delta, len(history))) if new_pos == 0: self.task_entry.set_text(self.history_undo) self.task_entry.set_position(-1) else: self.task_entry.set_text(history[-new_pos]) self.task_entry.select_region(0, -1) # Do this after task_entry_changed reset history_pos to 0 self.history_pos = new_pos def add_entry(self, widget, data=None): """Add the task entry to the log.""" if self.looking_at_date is not None: self.jump_to_today() entry = self.task_entry.get_text() if not isinstance(entry, unicode): entry = unicode(entry, 'UTF-8') 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.now() now = now.replace(hour=h, minute=m, second=0, microsecond=0) if self.timelog.valid_time(now): entry = entry[date_match.end():] else: now = None if delta_match: seconds = int(delta_match.group()) * 60 now = datetime.datetime.now().replace(second=0, microsecond=0) now += datetime.timedelta(seconds=seconds) if self.timelog.valid_time(now): entry = entry[delta_match.end():] else: now = None if not entry: return self.add_history(entry) previous_day = self.timelog.day self.timelog.append(entry, now) same_day = self.timelog.day == previous_day if self.chronological and same_day: self.delete_footer() self.write_item(self.timelog.window.last_entry()) self.add_footer() self.scroll_to_end() else: self.populate_log() self.task_entry.set_text('') self.task_entry.grab_focus() self.tick(True) for watcher in self.entry_watchers: watcher(entry) def tick(self, force_update=False): """Tick every second.""" if self.timelog.check_reload(): self.populate_log() self.set_up_history() if self.task_pane.get_property('visible'): if self.tasks.check_reload(): self.set_up_task_list() now = datetime.datetime.now().replace(second=0, microsecond=0) if now == self.last_tick and not force_update: # Do not eat CPU unnecessarily: update the time ticker only when # the minute changes. return True self.last_tick = now last_time = self.timelog.window.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)) # Update "time left to work" if not self.lock and self.looking_at_date is None: self.delete_footer() self.add_footer() return True if dbus: INTERFACE = 'lt.pov.mg.gtimelog.Service' OBJECT_PATH = '/lt/pov/mg/gtimelog/Service' SERVICE = 'lt.pov.mg.gtimelog.GTimeLog' class Service(dbus.service.Object): """Our DBus service, used to communicate with the main instance.""" def __init__(self, main_window): session_bus = dbus.SessionBus() connection = dbus.service.BusName(SERVICE, session_bus) dbus.service.Object.__init__(self, connection, OBJECT_PATH) self.main_window = main_window @dbus.service.method(INTERFACE) def ToggleFocus(self): self.main_window.toggle_visible() @dbus.service.method(INTERFACE) def Present(self): self.main_window.on_show_activate() @dbus.service.method(INTERFACE) def Quit(self): gtk.main_quit() def main(): """Run the program.""" parser = optparse.OptionParser(usage='%prog [options]', version=gtimelog.__version__) parser.add_option('--tray', action='store_true', help="start minimized") parser.add_option('--sample-config', action='store_true', help="write a sample configuration file to 'gtimelogrc.sample'") dbus_options = optparse.OptionGroup(parser, "Single-Instance Options") dbus_options.add_option('--replace', action='store_true', help="replace the already running GTimeLog instance") dbus_options.add_option('--quit', action='store_true', help="tell an already-running GTimeLog instance to quit") dbus_options.add_option('--toggle', action='store_true', help="show/hide the GTimeLog window if already running") dbus_options.add_option('--ignore-dbus', action='store_true', help="do not check if GTimeLog is already running" " (allows you to have multiple instances running)") parser.add_option_group(dbus_options) debug_options = optparse.OptionGroup(parser, "Debugging Options") debug_options.add_option('--debug', action='store_true', help="show debug information") debug_options.add_option('--prefer-pygtk', action='store_true', help="try to use the (obsolete) pygtk library instead of pygi") parser.add_option_group(debug_options) opts, args = parser.parse_args() log.addHandler(logging.StreamHandler(sys.stdout)) if opts.debug: log.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) if opts.sample_config: settings = Settings() settings.save("gtimelogrc.sample") print("Sample configuration file written to gtimelogrc.sample") print("Edit it and save as %s" % settings.get_config_file()) return global dbus if opts.debug: print('GTimeLog version: %s' % gtimelog.__version__) print('Toolkit: %s' % toolkit) print('Gtk+ version: %s' % gtk_version) print('D-Bus available: %s' % ('yes' if dbus else 'no')) print('Config directory: %s' % Settings().get_config_dir()) print('Data directory: %s' % Settings().get_data_dir()) if opts.ignore_dbus: dbus = None # Let's check if there is already an instance of GTimeLog running # and if it is make it present itself or when it is already presented # hide it and then quit. if dbus: dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) try: session_bus = dbus.SessionBus() dbus_service = session_bus.get_object(SERVICE, OBJECT_PATH) if opts.replace or opts.quit: print('gtimelog: Telling the already-running instance to quit') dbus_service.Quit() if opts.quit: sys.exit() elif opts.toggle: dbus_service.ToggleFocus() print('gtimelog: Already running, toggling visibility') sys.exit() elif opts.tray: print('gtimelog: Already running, not doing anything') sys.exit() else: dbus_service.Present() print('gtimelog: Already running, presenting main window') sys.exit() except dbus.DBusException as e: if e.get_dbus_name() == 'org.freedesktop.DBus.Error.ServiceUnknown': # gtimelog is not running: that's fine and not an error at all if opts.quit: print('gtimelog is not running') sys.exit() else: sys.exit('gtimelog: %s' % e) else: # not dbus if opts.quit or opts.replace or opts.toggle: sys.exit("gtimelog: dbus not available") settings = Settings() configdir = settings.get_config_dir() datadir = settings.get_data_dir() try: # Create configdir if it doesn't exist. os.makedirs(configdir) except OSError as error: if error.errno != errno.EEXIST: # XXX: not the most friendly way of error reporting for a GUI app raise try: # Create datadir if it doesn't exist. os.makedirs(datadir) except OSError as error: if error.errno != errno.EEXIST: raise settings_file = settings.get_config_file() if not os.path.exists(settings_file): if opts.debug: print('Saving settings to %s' % settings_file) settings.save(settings_file) else: if opts.debug: print('Loading settings from %s' % settings_file) settings.load(settings_file) if opts.debug: print('Assuming date changes at %s' % settings.virtual_midnight) print('Loading time log from %s' % settings.get_timelog_file()) timelog = TimeLog(settings.get_timelog_file(), settings.virtual_midnight) if settings.task_list_url: if opts.debug: print('Loading cached remote tasks from %s' % os.path.join(datadir, 'remote-tasks.txt')) tasks = RemoteTaskList(settings.task_list_url, os.path.join(datadir, 'remote-tasks.txt')) else: if opts.debug: print('Loading tasks from %s' % os.path.join(datadir, 'tasks.txt')) tasks = TaskList(os.path.join(datadir, 'tasks.txt')) main_window = MainWindow(timelog, settings, tasks) start_in_tray = False if settings.show_tray_icon: if settings.prefer_app_indicator: icons = [AppIndicator, SimpleStatusIcon, OldTrayIcon] elif settings.prefer_old_tray_icon: icons = [OldTrayIcon, SimpleStatusIcon, AppIndicator] else: icons = [SimpleStatusIcon, OldTrayIcon, AppIndicator] if opts.debug: print('Tray icon preference: %s' % ', '.join(icon_class.__name__ for icon_class in icons)) for icon_class in icons: tray_icon = icon_class(main_window) if tray_icon.available(): if opts.debug: print('Tray icon: %s' % icon_class.__name__) start_in_tray = (settings.start_in_tray if settings.start_in_tray else opts.tray) break # found one that works else: if opts.debug: print('%s not available' % icon_class.__name__) if not start_in_tray: main_window.on_show_activate() else: if opts.debug: print('Starting minimized') if dbus: service = Service(main_window) # This is needed to make ^C terminate gtimelog when we're using # gobject-introspection. signal.signal(signal.SIGINT, signal.SIG_DFL) try: gtk.main() except KeyboardInterrupt: pass if __name__ == '__main__': main() gtimelog-0.9.1/src/gtimelog/gtimelog-small-bright.png0000644000175000017500000000232712245325640021665 0ustar mgmg00000000000000PNG  IHDRĴl;sRGBbKGD pHYs  tIME*/G*WIDAT8˽YeƟog̙967hu҂ hJ71EB`JXEAJT HVeZA:s߿t,/?'㞔f)~BkCC텮#wQ5m]TNMܲ텎9. [L& ȿ Cɾ8?.jsI-d!XfmJlߜ:'ԴlvJ04l\T6:nnQoN MVsGF7B"BHd>BӰl ]0LkaZG MeQ[p8 8nE´ABH4%r@6ǴNcΠJ$P*qY9;p2vmQ& |]k4% xY N)٫ZwH87mJY-Gk΁$Zn{1`BBJ)U/Rbbi9i$>> 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') """ 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_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 doctest_TimeWindow_reread_no_file(): """Test for TimeWindow.reread >>> from datetime import datetime, time >>> min = datetime(2013, 12, 3) >>> max = datetime(2013, 12, 4) >>> vm = time(2, 0) >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow('/nosuchfile', min, max, vm) 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 ... ''') >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(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, time >>> min = datetime(2013, 12, 4) >>> max = datetime(2013, 12, 5) >>> 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 ... ''') >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm) 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_reread_callbacks(): """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-03 09:00: stuff ** ... 2013-12-04 09:00: start ** ... 2013-12-04 09:14: gtimelog: write some tests ... 2013-12-06 09:00: future ** ... ''') >>> l = [] >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm, callback=l.append) The callback is invoked with all the entries (not just those in the selected time window). We use it to populate history completion. >>> l ['stuff **', 'start **', 'gtimelog: write some tests', 'future **'] """ 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 ... ''') >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm) >>> window.count_days() 3 """ def doctest_TimeWindow_last_entry(): """Test for TimeWindow.last_entry >>> from datetime import datetime, time >>> vm = time(2, 0) >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(StringIO(), None, None, vm) 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, 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, 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, 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_TimeWindow_to_csv_complete(): r"""Tests for TimeWindow.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 *** ... ''') >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm) >>> import sys >>> window.to_csv_complete(sys.stdout) task,time (minutes) etc,60 something,45 something else,105 """ def doctest_TimeWindow_to_csv_daily(): r"""Tests for TimeWindow.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 ** ... ''') >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm) >>> import sys >>> 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_Reports_weekly_report_categorized(): r"""Tests for Reports.weekly_report_categorized >>> import sys >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> window = TimeWindow(StringIO(), min, max, vm) >>> 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('\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 23:46: misc', ... ''])) >>> window = TimeWindow(fh, min, max, vm) >>> 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 No category: Misc 10:14 ---------------------------------------------------------------------- 10:14 Total work done this week: 14:08 Categories by time spent: No category 10:14 Bong 3:31 Bing 0:23 """ def doctest_Reports_monthly_report_categorized(): r"""Tests for Reports.monthly_report_categorized >>> import sys >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> window = TimeWindow(StringIO(), 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) No work done this month. >>> 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 23:46: misc', ... ''])) >>> window = TimeWindow(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 >>> import sys >>> from datetime import datetime, time, timedelta >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> categories = { ... 'Bing': timedelta(2), ... None: timedelta(1)} >>> window = TimeWindow(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 >>> import sys >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2010, 1, 30) >>> max = datetime(2010, 1, 31) >>> window = TimeWindow(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 = TimeWindow(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 >>> import sys >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 1, 31) >>> window = TimeWindow(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('\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 = TimeWindow(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 >>> import sys >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2007, 9, 1) >>> max = datetime(2007, 10, 1) >>> window = TimeWindow(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 = TimeWindow(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 >>> import sys >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports >>> vm = time(2, 0) >>> min = datetime(2010, 1, 25) >>> max = datetime(2010, 2, 1) >>> window = TimeWindow(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 = TimeWindow(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 """ def doctest_TaskList_missing_file(): """Test for TaskList >>> from gtimelog.timelog import TaskList >>> tasklist = TaskList('/nosuchfile') >>> tasklist.check_reload() False >>> tasklist.reload() """ def doctest_TaskList_real_file(): r"""Test for TaskList >>> tempdir = tempfile.mkdtemp(prefix='gtimelog-test-') >>> taskfile = os.path.join(tempdir, 'tasks.txt') >>> with open(taskfile, 'w') as f: ... _ = f.write('\n'.join([ ... '# comments are skipped', ... 'some task', ... 'other task', ... 'project: do it', ... 'project: fix bugs', ... 'misc: paperwork', ... ]) + '\n') >>> one_second_ago = time.time() - 1 >>> os.utime(taskfile, (one_second_ago, one_second_ago)) >>> from gtimelog.timelog import TaskList >>> tasklist = TaskList(taskfile) >>> pprint(tasklist.groups) [('Other', ['some task', 'other task']), ('misc', ['paperwork']), ('project', ['do it', 'fix bugs'])] >>> tasklist.check_reload() False >>> with open(taskfile, 'w') as f: ... _ = f.write('new tasks\n') >>> tasklist.check_reload() True >>> pprint(tasklist.groups) [('Other', ['new tasks'])] >>> shutil.rmtree(tempdir) """ def doctest_Settings_get_config_dir(): """Test for Settings.get_config_dir >>> from gtimelog.settings import Settings >>> settings = Settings() >>> real_isdir = os.path.isdir Case 1: GTIMELOG_HOME is present in the environment >>> os.environ['HOME'] = '/tmp/home' >>> os.environ['GTIMELOG_HOME'] = '~/.gt' >>> settings.get_config_dir() '/tmp/home/.gt' Case 2: ~/.gtimelog exists >>> del os.environ['GTIMELOG_HOME'] >>> os.path.isdir = lambda dir: True >>> settings.get_config_dir() '/tmp/home/.gtimelog' Case 3: ~/.gtimelog does not exist, so we use XDG >>> os.path.isdir = lambda dir: False >>> settings.get_config_dir() '/tmp/home/.config/gtimelog' Case 4: XDG_CONFIG_HOME is present in the environment >>> os.environ['XDG_CONFIG_HOME'] = '~/.conf' >>> settings.get_config_dir() '/tmp/home/.conf/gtimelog' Cleanup >>> os.path.isdir = real_isdir """ def doctest_Settings_get_data_dir(): """Test for Settings.get_data_dir >>> from gtimelog.settings import Settings >>> settings = Settings() >>> real_isdir = os.path.isdir Case 1: GTIMELOG_HOME is present in the environment >>> os.environ['HOME'] = '/tmp/home' >>> os.environ['GTIMELOG_HOME'] = '~/.gt' >>> settings.get_data_dir() '/tmp/home/.gt' Case 2: ~/.gtimelog exists >>> del os.environ['GTIMELOG_HOME'] >>> os.path.isdir = lambda dir: True >>> settings.get_data_dir() '/tmp/home/.gtimelog' Case 3: ~/.gtimelog does not exist, so we use XDG >>> os.path.isdir = lambda dir: False >>> settings.get_data_dir() '/tmp/home/.local/share/gtimelog' Case 4: XDG_CONFIG_HOME is present in the environment >>> os.environ['XDG_DATA_HOME'] = '~/.data' >>> settings.get_data_dir() '/tmp/home/.data/gtimelog' Cleanup >>> os.path.isdir = real_isdir """ def doctest_Settings_get_config_file(): """Test for Settings.get_config_file >>> from gtimelog.settings import Settings >>> settings = Settings() >>> settings.get_config_dir = lambda: '~/.config/gtimelog' >>> settings.get_config_file() '~/.config/gtimelog/gtimelogrc' """ def doctest_Settings_get_timelog_file(): """Test for Settings.get_timelog_file >>> from gtimelog.settings import Settings >>> settings = Settings() >>> settings.get_data_dir = lambda: '~/.local/share/gtimelog' >>> settings.get_timelog_file() '~/.local/share/gtimelog/timelog.txt' """ def doctest_Settings_load(): """Test for Settings.load >>> from gtimelog.settings import Settings >>> settings = Settings() >>> settings.load('/dev/null') """ def doctest_Settings_save(): """Test for Settings.load >>> tempdir = tempfile.mkdtemp(prefix='gtimelog-test-') >>> from gtimelog.settings import Settings >>> settings = Settings() >>> settings.save(os.path.join(tempdir, 'config')) >>> shutil.rmtree(tempdir) """ def additional_tests(): # for setup.py return doctest.DocTestSuite(optionflags=doctest.NORMALIZE_WHITESPACE) def main(): unittest.TextTestRunner().run(additional_tests()) if __name__ == '__main__': main() gtimelog-0.9.1/src/gtimelog/__init__.py0000644000175000017500000000005712256037141017072 0ustar mgmg00000000000000# The gtimelog package. __version__ = '0.9.1' gtimelog-0.9.1/src/gtimelog/gtimelog-large.png0000644000175000017500000040740612245325640020401 0ustar mgmg00000000000000PNG  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`gtimelog-0.9.1/src/gtimelog/gtimelog.ui0000644000175000017500000013755112245604312017137 0ustar mgmg00000000000000 False About TimeLog center dialog True False vertical True False end gtk-ok True True True False True False False 0 False True end 0 True False 16 True False <span weight="bold" size="xx-large">GTimeLog v%(version)s</span> GTimeLog is a time tracking application. <small>© 2004–2013 Marius Gedminas and contributors</small> True center True True 16 0 False True 2 ok_button False True False _Show GTimeLog True True False gtk-quit True False True True False Choose a Date mouse dialog True False vertical True False end gtk-cancel True True True False True False False 0 gtk-ok True True True True False True False False 1 False True end 0 True True True False True 2 cancelbutton1 okbutton1 False Time Log center 800 500 gtimelog.png True False True False True False _File True False _Reload True False True True _Edit timelog.txt True False True True gtk-quit True False True True True False _View True False True False _Chronological True True False _Grouped True True chronological True False _Summary True chronological True False True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK _Tasks True True True False _Report True False _Daily Report True False True True Daily Report for _Yesterday True False True True Daily Report for a _Previous Day... True False True True _Weekly Report True False True True Weekly Report for _Last Week True False True True Weekly Report for a Pre_vious Week... True False True True _Monthly Report True False True True Monthly Report for Last Month True False True True Monthly Report for a Previous Month... True False True True Report for a Custom Date Range... True False True True True False _Complete Report in Spreadsheet True False True True Work/_Slacking stats in Spreadsheet True False True True True False _Help True False gtk-about True False True True True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK _Online Documentation True False False 0 True True 600 True True False True False True False Back True gtk-go-back False True True False True False 6 Tuesday, 2012-01-31 (week 05) True True True False Forward True gtk-go-forward False True True False Today toolbutton2 True gtk-goto-last False True False True 0 True True in True True 2 False word 2 2 True True 1 True False True False 6 True False True False True False True False 0 _Tasks True True task_list True True True False toolbutton2 True gtk-close False True False True 0 True False 6 True False 6 True True in True True False True True 1 False Downloading tasks... False False 2 True True 0 True False False False 1 True True 1 True True 0 True False True True 2 True False 4 4 True False 00:12 task_entry False False 0 True True True True True True 1 _Add True True True True False half True False False False 2 False True 3 False gtk-refresh True False True True gtk-edit True False True True False True False _Hide True True False _Show True gtk-quit True False True True False Choose a Date Range mouse dialog True False vertical True False end gtk-cancel True True True True True False False 0 gtk-ok True True True True True True False False 1 False True end 0 True False 6 True True True False True 0 True True True False True 1 False True 2 cancelbutton2 okbutton2 gtimelog-0.9.1/src/gtimelog/timelog.py0000644000175000017500000010326512247572171017007 0ustar mgmg00000000000000""" Non-GUI bits of gtimelog. """ import codecs import csv import datetime import os import sys import re import urllib from operator import itemgetter PY3 = sys.version_info[0] >= 3 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.""" 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 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: ', 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 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(l): """Return list with consecutive duplicates removed.""" result = l[:1] for item in l[1:]: if item != result[-1]: result.append(item) return result class TimeWindow(object): """A window into a time log. Reads a time log file and remembers 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. 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 also creates a special "arrival" entry of zero duration. Entries that span virtual midnight boundaries are also converted to "arrival" entries at their end point. The earliest_timestamp attribute contains the first (which should be the oldest) timestamp in the file. """ def __init__(self, filename, min_timestamp, max_timestamp, virtual_midnight, callback=None): self.filename = filename self.min_timestamp = min_timestamp self.max_timestamp = max_timestamp self.virtual_midnight = virtual_midnight self.reread(callback) def reread(self, callback=None): """Parse the time log file and update self.items. Also updates self.earliest_timestamp. """ self.items = [] self.earliest_timestamp = None try: # accept any file-like object # this is a hook for unit tests, really if hasattr(self.filename, 'read'): f = self.filename f.seek(0) else: f = codecs.open(self.filename, encoding='UTF-8') except IOError: return line = '' for line in f: if ': ' not in line: continue time, entry = line.split(': ', 1) try: time = parse_datetime(time) except ValueError: continue else: entry = entry.strip() if callback: callback(entry) if self.earliest_timestamp is None: self.earliest_timestamp = time if self.min_timestamp <= time < self.max_timestamp: self.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 self.items.sort(key=itemgetter(0)) f.close() def last_time(self): """Return the time of the last event (or None if there are no events). """ if not self.items: return None return self.items[-1][0] def all_entries(self): """Iterate over all entries. Yields (start, stop, duration, entry) tuples. The first entry 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 yield start, stop, duration, entry def count_days(self): """Count days that have entries.""" count = 0 last = None for start, stop, duration, entry in self.all_entries(): if last is None or different_days(last, start, self.virtual_midnight): last = start count += 1 return count 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 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 return start, stop, duration, entry def grouped_entries(self, skip_first=True): """Return consolidated entries (grouped by entry title). Returns two list: 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, entry in self.all_entries(): if skip_first: 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) work = sorted(work.values()) slack = sorted(slack.values()) 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: if ': ' in entry: cat, clipped_entry = entry.split(': ', 1) entry_list = entries.get(cat, []) entry_list.append((start, clipped_entry, duration)) entries[cat] = entry_list totals[cat] = totals.get(cat, datetime.timedelta(0)) + duration else: entry_list = entries.get(None, []) entry_list.append((start, entry, duration)) entries[None] = entry_list totals[None] = totals.get( None, datetime.timedelta(0)) + duration return entries, totals def totals(self): """Calculate total time of work and slacking entries. 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, entry in self.all_entries(): if '**' in entry: total_slacking += duration else: total_work += duration return total_work, total_slacking def icalendar(self, output): """Create an iCalendar file with activities.""" output.write("BEGIN:VCALENDAR\n") output.write("PRODID:-//mg.pov.lt/NONSGML GTimeLog//EN\n") output.write("VERSION:2.0\n") try: import socket idhost = socket.getfqdn() except: # can it actually ever fail? idhost = 'localhost' dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") for start, stop, duration, entry in self.all_entries(): output.write("BEGIN:VEVENT\n") output.write("UID:%s@%s\n" % (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 = CSVWriter(output) if title_row: writer.writerow(["task", "time (minutes)"]) work, slack = self.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 = CSVWriter(output) if title_row: writer.writerow(["date", "day-start (hours)", "slacking (hours)", "work (hours)"]) # sum timedeltas per date # timelog must be cronological for this to be dependable d0 = datetime.timedelta(0) days = {} # date -> [time_started, slacking, work] dmin = None for start, stop, duration, entry in self.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 = [(day, as_hours(start), as_hours(slacking), as_hours(work)) for day, (start, slacking, work) in days.items()] items.sort() writer.writerows(items) class Reports(object): """Generation of reports.""" def __init__(self, window): self.window = window def _categorizing_report(self, output, email, who, subject, period_name, estimated_column=False): """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 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) if estimated_column: output.write("estimated actual\n") else: 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:] if estimated_column: output.write(u" %-46s %-14s %s\n" % (entry, '-', format_duration_short(duration))) else: output.write(u" %-61s %+5s\n" % (entry, format_duration_short(duration))) output.write('-' * 70 + '\n') output.write(u"%+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))) 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(u"%-62s %s\n" % ( cat, format_duration_long(duration))) output.write('\n') def _plain_report(self, output, email, who, subject, period_name, estimated_column=False): """Format a report that does not categorize entries. Writes a report template in RFC-822 format to output. """ window = self.window 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) if estimated_column: output.write("estimated actual\n") else: 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 if ': ' in entry: cat, task = entry.split(': ', 1) categories[cat] = categories.get( cat, datetime.timedelta(0)) + duration else: categories[None] = categories.get( None, datetime.timedelta(0)) + duration entry = entry[:1].upper() + entry[1:] if estimated_column: output.write(u"%-46s %-14s %s\n" % (entry, '-', format_duration_long(duration))) else: output.write(u"%-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) def weekly_report_categorized(self, output, email, who, estimated_column=False): """Format a weekly report with entries displayed under categories.""" week = self.window.min_timestamp.isocalendar()[1] subject = u'Weekly report for %s (week %02d)' % (who, week) return self._categorizing_report(output, email, who, subject, period_name='week', estimated_column=estimated_column) def monthly_report_categorized(self, output, email, who, estimated_column=False): """Format a monthly report with entries displayed under categories.""" month = self.window.min_timestamp.strftime('%Y/%m') subject = u'Monthly report for %s (%s)' % (who, month) return self._categorizing_report(output, email, who, subject, period_name='month', estimated_column=estimated_column) def weekly_report_plain(self, output, email, who, estimated_column=False): """Format a weekly report .""" week = self.window.min_timestamp.isocalendar()[1] subject = u'Weekly report for %s (week %02d)' % (who, week) return self._plain_report(output, email, who, subject, period_name='week', estimated_column=estimated_column) def monthly_report_plain(self, output, email, who, estimated_column=False): """Format a monthly report .""" month = self.window.min_timestamp.strftime('%Y/%m') subject = u'Monthly report for %s (%s)' % (who, month) return self._plain_report(output, email, who, subject, period_name='month', estimated_column=estimated_column) def custom_range_report_categorized(self, output, email, who, estimated_column=False): """Format a custom range report with entries displayed under categories.""" min = self.window.min_timestamp.strftime('%Y-%m-%d') max = self.window.max_timestamp - datetime.timedelta(1) max = max.strftime('%Y-%m-%d') subject = u'Custom date range report for %s (%s - %s)' % (who, min, max) return self._categorizing_report(output, email, who, subject, period_name='custom range', estimated_column=estimated_column) 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 # Locale is set as a side effect of 'import gtk', so strftime('%a') # would give us translated names weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] weekday = weekday_names[window.min_timestamp.weekday()] week = window.min_timestamp.isocalendar()[1] output.write(u"To: %s\n" % email) output.write(u"Subject: {0:%Y-%m-%d} report for {who}" u" ({weekday}, week {week:0>2})\n".format( window.min_timestamp, who=who, weekday=weekday, week=week)) output.write('\n') items = list(window.all_entries()) if not items: output.write("No work done today.\n") return start, stop, duration, 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(u"%-62s %s\n" % (entry, format_duration_long(duration))) if ': ' in entry: cat, task = entry.split(': ', 1) categories[cat] = categories.get( cat, datetime.timedelta(0)) + duration else: categories[None] = categories.get( None, 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(u"%-62s %s\n" % (entry, format_duration_long(duration))) output.write('\n') output.write("Time spent slacking: %s\n" % format_duration_long(total_slacking)) class TimeLog(object): """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): self.filename = filename self.virtual_midnight = virtual_midnight 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 = self.get_mtime() if mtime != self.last_mtime: self.reread() return True else: return False def get_mtime(self): """Return the mtime of self.filename, if it exists. Returns None if the file doesn't exist. """ try: return os.stat(self.filename).st_mtime except OSError: return None def reread(self): """Reload today's log.""" self.last_mtime = self.get_mtime() self.day = self.virtual_today() min = datetime.datetime.combine(self.day, self.virtual_midnight) max = min + datetime.timedelta(1) self.history = [] self.window = TimeWindow(self.filename, min, max, self.virtual_midnight, callback=self.history.append) self.need_space = not self.window.items self._cache = {(min, max): self.window} def window_for(self, min, max): """Return a TimeWindow for a specified time interval.""" try: return self._cache[min, max] except KeyError: window = TimeWindow(self.filename, min, max, self.virtual_midnight) if len(self._cache) > 1000: self._cache.clear() self._cache[min, max] = window return window 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): 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 whole_history(self): """Return a TimeWindow for the whole history.""" # XXX I don't like this solution. Better make the min/max filtering # arguments optional in TimeWindow.reread return self.window_for(self.window.earliest_timestamp, datetime.datetime.now()) def raw_append(self, line): """Append a line to the time log file.""" f = codecs.open(self.filename, "a", encoding='UTF-8') if self.need_space: self.need_space = False f.write('\n') f.write(line + '\n') f.close() 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) last = self.window.last_time() if last and different_days(now, last, self.virtual_midnight): # next day: reset self.window self.reread() self.window.items.append((now, entry)) line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry) self.raw_append(line) def valid_time(self, time): if time > datetime.datetime.now(): return False last = self.window.last_time() if last and time < last: return False return True 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). """ 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 = self.get_mtime() if mtime != self.last_mtime: self.load() return True else: return False def get_mtime(self): """Return the mtime of self.filename, if it exists. Returns None if the file doesn't exist. """ try: return os.stat(self.filename).st_mtime except OSError: return None def load(self): """Load task list from a file named self.filename.""" groups = {} self.last_mtime = self.get_mtime() try: with open(self.filename) as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue if ':' in line: group, task = [s.strip() for s in line.split(':', 1)] else: group, task = self.other_title, line groups.setdefault(group, []).append(task) except IOError: pass # the file's not there, so what? self.groups = sorted(groups.items()) def reload(self): """Reload the task list.""" self.load() class RemoteTaskList(TaskList): """Task list stored on a remote server. Keeps a cached copy of the list in a local file, so you can use it offline. """ def __init__(self, url, cache_filename): self.url = url TaskList.__init__(self, cache_filename) self.first_time = True def check_reload(self): """Check whether the task list needs to be reloaded. Download the task list if this is the first time, and a cached copy is not found. Returns True if the file was reloaded. """ if self.first_time: self.first_time = False if not os.path.exists(self.filename): self.download() return True return TaskList.check_reload(self) def download(self): """Download the task list from the server.""" if self.loading_callback: self.loading_callback() try: urllib.urlretrieve(self.url, self.filename) except IOError: if self.error_callback: self.error_callback() self.load() if self.loaded_callback: self.loaded_callback() def reload(self): """Reload the task list.""" self.download() class CSVWriter(object): def __init__(self, *args, **kw): self._writer = csv.writer(*args, **kw) if PY3: def writerow(self, row): self._writer.writerow(row) else: def writerow(self, row): self._writer.writerow([s.encode('UTF-8') if isinstance(s, unicode) else s for s in row]) def writerows(self, rows): for row in rows: self.writerow(row) gtimelog-0.9.1/src/gtimelog/gtimelog-small.png0000644000175000017500000000357712245325640020420 0ustar mgmg00000000000000PNG  IHDRĴl;tEXtSoftwareAdobe ImageReadyqe<diTXtXML:com.adobe.xmp RS,IDATxڴmLasԱLVjVm cIflFKl&/l /C$TɆIr:s2/ɔt^syĽv_4Mc"//O8N$rsrblɟ&u:YS`hiii`u:p+JpiY&k'|ryp8$ Eٯ(J~_ `7hK((/+UUBU&a^W@]  X[w {g'J F=H#(x<蟀 hMۅ^\3(v^D]s|]ZD悝 } xscc&]F(e!CΆ9290o4U5Z0( `[а~}}4"~$KRW_W߾ec'\]s[eCRh8/pQ`2ց,n&!Ss\׿ ]p h(T@VC\ˬ50sIENDB`gtimelog-0.9.1/src/gtimelog/gtimelog.png0000644000175000017500000001654712245325640017313 0ustar mgmg00000000000000PNG  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`gtimelog-0.9.1/Makefile0000644000175000017500000000462012256036032014021 0ustar mgmg00000000000000# # Options # PYTHON = python FILE_WITH_VERSION = src/gtimelog/__init__.py FILE_WITH_CHANGELOG = NEWS.rst # # Interesting targets # manpages = gtimelog.1 gtimelogrc.5 .PHONY: all all: $(manpages) .PHONY: run run: ./gtimelog .PHONY: check test check test: ./runtests .PHONY: coverage coverage: coverage run ./runtests coverage report --include 'src/gtimelog/*' .PHONY: clean clean: rm -rf temp tmp build gtimelog.egg-info find -name '*.pyc' -delete .PHONY: dist dist: $(PYTHON) setup.py sdist .PHONY: distcheck distcheck: check dist # Bit of a chicken-and-egg here, but if the tree is unclean, make # distcheck will fail. @test -z "`git status -s 2>&1`" || { echo; echo "Your working tree is not clean" 1>&2; git status; exit 1; } make dist pkg_and_version=`$(PYTHON) setup.py --name`-`$(PYTHON) setup.py --version` && \ rm -rf tmp && \ mkdir tmp && \ git archive --format=tar --prefix=tmp/tree/ HEAD | tar -xf - && \ cd tmp && \ tar xvzf ../dist/$$pkg_and_version.tar.gz && \ diff -ur $$pkg_and_version tree -x PKG-INFO -x setup.cfg -x '*.egg-info' && \ cd $$pkg_and_version && \ make dist check && \ cd .. && \ mkdir one two && \ cd one && \ tar xvzf ../../dist/$$pkg_and_version.tar.gz && \ cd ../two/ && \ tar xvzf ../$$pkg_and_version/dist/$$pkg_and_version.tar.gz && \ cd .. && \ diff -ur one two -x SOURCES.txt && \ cd .. && \ rm -rf tmp && \ echo "sdist seems to be ok" .PHONY: releasechecklist releasechecklist: @$(PYTHON) setup.py --version | grep -qv dev || { \ echo "Please remove the 'dev' suffix from the version number in $(FILE_WITH_VERSION)"; exit 1; } @$(PYTHON) setup.py --long-description | rst2html --exit-status=2 > /dev/null @ver_and_date="`$(PYTHON) setup.py --version` (`date +%Y-%m-%d`)" && \ grep -q "^$$ver_and_date$$" NEWS.rst || { \ echo "$(FILE_WITH_CHANGELOG) has no entry for $$ver_and_date"; exit 1; } make distcheck .PHONY: release release: releasechecklist # I'm chicken so I won't actually do these things yet @echo "Please run" @echo @echo " $(PYTHON) setup.py sdist register upload && git tag `$(PYTHON) setup.py --version`" @echo @echo "Please increment the version number in $(FILE_WITH_VERSION)" @echo "and add a new empty entry at the top of $(FILE_WITH_CHANGELOG), then" @echo @echo ' git commit -a -m "Post-release version bump" && git push && git push --tags' @echo %.1: %.rst rst2man $< > $@ %.5: %.rst rst2man $< > $@ gtimelog-0.9.1/gtimelogrc.example0000644000175000017500000000215012245325640016072 0ustar mgmg00000000000000# Example configuration file for GTimeLog # Place it in ~/.gtimelog/gtimelogrc [gtimelog] # Your name in activity reports name = Anonymous # Email to send activity reports to list-email = activity@example.com # Command to launch a mailer. %s is replaced with a name of a temporary # file containing the activity report as a RFC-2822 message. If there is # no '%s', the draft file name is appended to the command. mailer = x-terminal-emulator -e mutt -H %s # Command to launch an editor. %s is replaced with the name of the time log # file; if there is no '%s', the name of the log file is appended. editor = gvim # User interface: True enables drop-down history completion (if you have PyGtk # 2.4), False disables and lets you access history by pressing Up/Down. gtk-completion = False # Do you want a systray icon? show_tray_icon = yes # Do you prefer the old systray icon (that shows time taken for the current # task next to the icon), or the new one (just the icon)? prefer_old_tray_icon = yes # How many hours' work in a day. hours = 8 # When does one work day end and another begin virtual_midnight = 02:00 gtimelog-0.9.1/NOTES.rst0000644000175000017500000000053412245604312014003 0ustar mgmg00000000000000To use Thunderbird with GTimeLog, put this bit into ~/.gtimelog/gtimelogrc: mailer = S='%s'; thunderbird -compose "to='$(cat $S|head -1|sed -e "s/^To: //")',subject='$(cat $S|head -2|tail -1|sed -e "s/^Subject: //")',body='$(cat $S|tail -n +4)'" It needs to be a single line. Source: http://d9t.de/blog/gtimelog-mit-thunderbird (Daniel Kraft) gtimelog-0.9.1/.gitignore0000644000175000017500000000015712256036623014360 0ustar mgmg00000000000000gtimelog.egg-info dist build temp .coverage *.py[co] tmp/ .tox/ .pc/ gtimelog.1 gtimelogrc.5 gtimelogrc.sample gtimelog-0.9.1/gtimelog.egg-info/0000755000175000017500000000000012256037240015662 5ustar mgmg00000000000000gtimelog-0.9.1/gtimelog.egg-info/dependency_links.txt0000644000175000017500000000000112256037240021730 0ustar mgmg00000000000000 gtimelog-0.9.1/gtimelog.egg-info/entry_points.txt0000644000175000017500000000007112256037240021156 0ustar mgmg00000000000000 [gui_scripts] gtimelog = gtimelog.main:main gtimelog-0.9.1/gtimelog.egg-info/PKG-INFO0000644000175000017500000001020612256037240016756 0ustar mgmg00000000000000Metadata-Version: 1.1 Name: gtimelog Version: 0.9.1 Summary: A Gtk+ time tracking application Home-page: http://mg.pov.lt/gtimelog/ Author: Marius Gedminas Author-email: marius@gedmin.as License: GPL Description: GTimeLog ======== .. image:: https://travis-ci.org/gtimelog/gtimelog.png?branch=master :target: https://travis-ci.org/gtimelog/gtimelog :alt: build status GTimeLog is a simple app for keeping track of time. .. 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, a newer version can usually be found in the PPA: https://launchpad.net/~gtimelog-dev/+archive/ppa 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 (2.6, 2.7 or 3.3) - PyGObject - gobject-introspection type libraries for GTK+, Pango 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: http://mg.pov.lt/gtimelog Mailing list: gtimelog@googlegroups.com (archive at http://groups.google.com/group/gtimelog) IRC: #gtimelog on irc.freenode.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 http://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/CONTRIBUTORS.rst If you want to leave a tip, see https://www.gittip.com/mgedmin/ Changelog --------- 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. Older versions ~~~~~~~~~~~~~~ See the `full changelog`_. .. _full changelog: https://github.com/gtimelog/gtimelog/blob/master/NEWS.rst Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: X11 Applications :: GTK Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.3 Classifier: Topic :: Office/Business gtimelog-0.9.1/gtimelog.egg-info/not-zip-safe0000644000175000017500000000000112250633242020106 0ustar mgmg00000000000000 gtimelog-0.9.1/gtimelog.egg-info/SOURCES.txt0000644000175000017500000000157212256037240017553 0ustar mgmg00000000000000.coveragerc .gitignore .travis.yml CONTRIBUTING.rst CONTRIBUTORS.rst COPYING MANIFEST.in Makefile NEWS.rst NOTES.rst README.rst TODO.rst gtimelog gtimelog.desktop gtimelog.rst gtimelogrc.example gtimelogrc.rst runtests setup.py tox.ini docs/formats.rst docs/gtimelog.png docs/index.rst gtimelog.egg-info/PKG-INFO gtimelog.egg-info/SOURCES.txt gtimelog.egg-info/dependency_links.txt gtimelog.egg-info/entry_points.txt gtimelog.egg-info/not-zip-safe gtimelog.egg-info/top_level.txt 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/__init__.py src/gtimelog/gtimelog-large.png src/gtimelog/gtimelog-small-bright.png src/gtimelog/gtimelog-small.png src/gtimelog/gtimelog.png src/gtimelog/gtimelog.ui src/gtimelog/main.py src/gtimelog/settings.py src/gtimelog/tests.py src/gtimelog/timelog.pygtimelog-0.9.1/gtimelog.egg-info/top_level.txt0000644000175000017500000000001112256037240020404 0ustar mgmg00000000000000gtimelog gtimelog-0.9.1/gtimelog.desktop0000644000175000017500000000030212247610450015556 0ustar mgmg00000000000000[Desktop Entry] Name=GTimeLog Time Tracker Comment=Track and time daily activities Exec=gtimelog Terminal=false Type=Application StartupNotify=true Icon=gtimelog Categories=Application;Utility; gtimelog-0.9.1/CONTRIBUTING.rst0000644000175000017500000000202212245604312015014 0ustar mgmg00000000000000Contributing to GTimeLog ======================== Contributions are welcome, and not just code patches. I'd love to see * user interface design sketches * icons * documentation * translations (this would need some coding to enable translation first) * 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 `_. 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 $ ./gtimelog Tests ----- Run the test suite with :: $ ./runtests The common ``python setup.py test`` idiom is also supported. Donations --------- Marius has a Gittip account at https://www.gittip.com/mgedmin/ gtimelog-0.9.1/.travis.yml0000644000175000017500000000024712247572247014510 0ustar mgmg00000000000000language: python python: - 2.6 - 2.7 - 3.3 install: - travis_retry pip install . script: - python setup.py test -q notifications: email: false gtimelog-0.9.1/NEWS.rst0000644000175000017500000002350212256037211013667 0ustar mgmg00000000000000Changelog --------- 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 http://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