diff --git a/.gitignore b/.gitignore index bd21efa3b..fd910838b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ intltool-merge.in intltool-update.in libtool ltmain.sh +messages missing mkinstalldirs py-compile diff --git a/README.md b/README.md index bbc7caa83..81b341c03 100644 --- a/README.md +++ b/README.md @@ -3,48 +3,58 @@ Hamster is time tracking for individuals. It helps you to keep track of how much time you have spent during the day on activities you choose to track. +This is the main repo. It is standalone (single module). +All other repositories -`hamster-lib/dbus/cli/gtk`- are part of the separate rewrite effort. +More context is given in the history section below. + Some additional information is available in the [wiki](https://github.com/projecthamster/hamster/wiki) and a static copy of the user documentation is online [here](https://geraldjansen.github.io/hamster-doc/). -During the period 2015-2017 there was a major effort to -[rewrite hamster](https://github.com/projecthamster/hamster-gtk) -(repositories: `hamster-lib/dbus/cli/gtk`). -Unfortunately, after considerable initial progress the work has remained in alpha state -for some time now. Hopefully the effort will be renewed in the future. -In the meantime, this sub-project aims to revive development of the "legacy" Hamster -code base, maintaining database compatibility with the widely installed -[v1.04](https://github.com/projecthamster/hamster/releases/tag/hamster-time-tracker-1.04), -but migrating to `Gtk3` and `python3`. This will allow package maintainers to provide -new packages for recent releases of mainstream Linux distributions for which the old -1.04-based versions are no longer provided. +## Installation -With respect to 1.04, some of the GUI ease of use has been lost, especially for handling -tags, and the stats display is minimal now. So if you are happy with your hamster -application and it is still available for your distribution, upgrade is not recommended -yet. +### Backup database -In the meantime recent (v2.2+) releases have good backward data compatibility and are -reasonably usable. The aim is to provide a new stable v3.0 release in the coming -months (i.e. late 2019). +This legacy hamster should be stable, and keep database compatibility with previous versions. +It should be possible to try a new version and smoothly roll back to the previous version if preferred. +Nevertheless, things can always go wrong. It is strongly advised to backup the database before any version change ! +##### Locate the latest db + +```bash +ls --reverse -clt ~/.local/share/hamster*/*.db +``` +Backup the last file in the list. -## Installation -You can use the usually stable `master` or [download stable releases](https://github.com/projecthamster/hamster/releases). +### Kill hamster daemons -If you upgraded from an existing installation make sure to kill the running -daemons: +When trying a different version, make sure to kill the running daemons: ```bash +# either step-by-step, totally safe pkill -f hamster-service pkill -f hamster-windows-service # check (should be empty) pgrep -af hamster + +# or be bold and kill them all at once: +pkill -ef hamster ``` +### Install from packages + +##### OpenSUSE +https://software.opensuse.org/package/hamster-time-tracker + +##### Snap +Easy installation on any distribution supporting snap: +https://snapcraft.io/hamster-snap + +### Install from sources + #### Dependencies @@ -53,7 +63,7 @@ pgrep -af hamster ###### Ubuntu (tested in 19.04 and 18.04) ```bash -sudo apt install gettext intltool gconf2 gir1.2-gconf-2.0 python3-gi-cairo python3-distutils python3-dbus python3-xdg +sudo apt install gettext intltool python3-gi-cairo python3-distutils python3-dbus python3-xdg # and for documentation sudo apt install gnome-doc-utils yelp ``` @@ -70,29 +80,34 @@ sudo zypper install gnome-doc-utils xml2po yelp *RPM-based instructions below should be updated for python3 (issue [#369](https://github.com/projecthamster/hamster/issues/369)).* -`yum install gettext intltool gnome-python2-gconf dbus-python` +`yum install gettext intltool dbus-python` +##### Help reader If the hamster help pages are not accessible ("unable to open `help:hamster-time-tracker`"), then a [Mallard](https://en.wikipedia.org/wiki/Mallard_(documentation))-capable help reader is required, such as [yelp](https://wiki.gnome.org/Apps/Yelp/). +#### Download source + +##### Git clone + +If familiar with github, just clone the repo and `cd` into it. -#### Trying the development version +##### Download -To use the development version (backup `hamster.db` first !): +Otherwise, to get the `master` development branch (intended to be quite stable): +```bash +wget https://github.com/projecthamster/hamster/archive/master.zip +cd hamster ``` -# either -pgrep -af hamster -# and kill them one by one -# or be bold and kill all process with "hamster" in their command line -pkill -ef hamster -src/hamster-service & -src/hamster-windows-service & -src/hamster-cli +or a specific [release](https://github.com/projecthamster/hamster/releases): +```bash +# replace 2.2.2 by the release version +wget https://github.com/projecthamster/hamster/archive/v2.2.2.zip +cd hamster-2.2.2 ``` - -#### Building and installing +#### Build and install ```bash ./waf configure build @@ -113,11 +128,29 @@ sudo ./waf uninstall ``` +#### Development + +During development (As explained above, backup `hamster.db` first !), +if only python files are changed +(*deeper changes such as the migration to gsettings require a new install*) +the changes can be quickly tested by +``` +# either +pgrep -af hamster +# and kill them one by one +# or be bold and kill all processes with "hamster" in their command line +pkill -ef hamster +python3 src/hamster-service.py & +python3 src/hamster-cli.py +``` +Advantage: running uninstalled is detected, and windows are *not* called via +D-Bus, so that all the traces are visible. + #### Migrating from hamster-applet Previously Hamster was installed everywhere under `hamster-applet`. As the applet is long gone, the paths and file names have changed to -`hamster-time-tracker`. To clean up previous installs follow these steps: +`hamster`. To clean up previous installs follow these steps: ```bash git checkout d140d45f105d4ca07d4e33bcec1fae30143959fe @@ -134,3 +167,29 @@ sudo ./waf uninstall 5. That's it! See [How to contribute](https://github.com/projecthamster/hamster/wiki/How-to-contribute) for more information. + + +## History + +During the period 2015-2017 there was a major effort to +[rewrite hamster](https://github.com/projecthamster/hamster-gtk) +(repositories: `hamster-lib/dbus/cli/gtk`). +Unfortunately, after considerable initial progress the work has remained in alpha state +for some time now. Hopefully the effort will be renewed in the future. + +In the meantime, this sub-project aims to pursue development of the "legacy" Hamster +code base, maintaining database compatibility with the widely installed +[v1.04](https://github.com/projecthamster/hamster/releases/tag/hamster-time-tracker-1.04), +but migrating to `Gtk3` and `python3`. +This will allow package maintainers to provide +new packages for recent releases of mainstream Linux distributions for which the old +1.04-based versions are no longer provided. + +With respect to 1.04, some of the GUI ease of use has been lost, especially for handling +tags, and the stats display is minimal now. So if you are happy with your hamster +application and it is still available for your distribution, upgrade is not recommended +yet. + +In the meantime recent (v2.2+) releases have good backward data compatibility and are +reasonably usable. The aim is to provide a new stable v3.0 release in the coming +months (i.e. early 2020). diff --git a/data/edit_activity.ui b/data/edit_activity.ui index ad5e16a5e..3a138878c 100644 --- a/data/edit_activity.ui +++ b/data/edit_activity.ui @@ -348,6 +348,7 @@ True True edit-clear-all-symbolic + Unsorted activity completion diff --git a/data/hamster.desktop.in.in b/data/hamster.desktop.in similarity index 64% rename from data/hamster.desktop.in.in rename to data/hamster.desktop.in index 24590c64f..78fc314af 100644 --- a/data/hamster.desktop.in.in +++ b/data/hamster.desktop.in @@ -2,8 +2,8 @@ Version=1.0 Type=Application Terminal=false -_Name=Hamster Time Tracker -_Comment=Your personal time keeping tool +Name=Hamster +Comment=Your personal time keeping tool Icon=hamster Exec=@BINDIR@/hamster Categories=GNOME;GTK;Utility; diff --git a/data/hamster.metainfo.xml b/data/hamster.metainfo.xml index 5b6a177db..9667aa0f4 100644 --- a/data/hamster.metainfo.xml +++ b/data/hamster.metainfo.xml @@ -3,7 +3,7 @@ hamster CC0-1.0 GPL-3.0+ and CC-BY-SA-3.0 - Hamster Time Tracker + Hamster Your personal time keeping tool @@ -39,6 +39,7 @@ https://github.com/projecthamster/hamster/blob/master/data/screenshots/overview2.png + ​hamster https://github.com/projecthamster/hamster/wiki hamster diff --git a/data/hamster.schemas.in b/data/hamster.schemas.in deleted file mode 100644 index a89e33510..000000000 --- a/data/hamster.schemas.in +++ /dev/null @@ -1,143 +0,0 @@ - - - - /schemas/apps/hamster/enable_timeout - /apps/hamster/enable_timeout - hamster - bool - true - - Stop tracking on idle - - Stop tracking current activity when computer becomes idle - - - - - /schemas/apps/hamster/stop_on_shutdown - /apps/hamster/stop_on_shutdown - hamster - bool - false - - Stop tracking on shutdown - - Stop tracking current activity on shutdown - - - - - /schemas/apps/hamster/notify_interval - /apps/hamster/notify_interval - hamster - int - 27 - - Remind of current task every x minutes - - Remind of current task every specified amount of minutes. - Set to 0 or greater than 120 to disable reminder. - - - - - /schemas/apps/hamster/notify_on_idle - /apps/hamster/notify_on_idle - hamster - bool - false - - Also remind when no activity is set - - Also remind every notify_interval minutes if no activity - has been started. - - - - - /schemas/apps/hamster/day_start_minutes - /apps/hamster/day_start_minutes - hamster - int - 330 - - At what time does the day start (defaults to 5:30AM) - - Activities will be counted as to belong to yesterday if - the current time is less than the specified day start; and - today, if it is over the time. - Activities that span two days, will tip over to the side - where the largest part of the activity is. - - - - - /schemas/apps/hamster/workspace_tracking - /apps/hamster/workspace_tracking - hamster - list - string - [] - - Should workspace switch trigger activity switch - - List of enabled tracking methods. "name" will enable - switching activities by name defined in workspace_mapping. - "memory" will enable switching to the last activity when - returning to a previous workspace. - - - - - /schemas/apps/hamster/workspace_mapping - /apps/hamster/workspace_mapping - hamster - list - string - [] - - Switch activity on workspace change - - If switching by name is enabled, this list sets the activity - names that should be switched to, workspaces represented by - the index of item. - - - - - - - /schemas/desktop/gnome/keybindings/hamster/activate_hamster_window - /desktop/gnome/keybindings/hamster/activate_hamster_window - hamster - string - - - Show / hide Time Tracker Window - Keyboard shortcut for showing / hiding the Time Tracker window. - - - - /schemas/desktop/gnome/keybindings/hamster/action - /desktop/gnome/keybindings/hamster/action - hamster - string - hamster toggle - - Toggle hamster application window action - Command for toggling visibility of the hamster application window. - - - - /schemas/desktop/gnome/keybindings/hamster/name - /desktop/gnome/keybindings/hamster/name - hamster - string - Toggle hamster application window - - Toggle hamster application window - Toggle visibility of the hamster application window. - - - - diff --git a/data/org.gnome.hamster.gschema.xml b/data/org.gnome.hamster.gschema.xml new file mode 100644 index 000000000..dce4c1611 --- /dev/null +++ b/data/org.gnome.hamster.gschema.xml @@ -0,0 +1,22 @@ + + + + "" + The folder the last report was saved to + + The folder the last report was saved to + + + + + 330 + At what time does the day start (defaults to 5:30AM) + + The hamster day of an activity is the civil date of start time, + provided start time is after day-start. + On the contrary, if start time is earlier than day-start, + then the activity belongs to the previous hamster day. + + + + diff --git a/data/preferences.ui b/data/preferences.ui index 7c3188667..34529093c 100644 --- a/data/preferences.ui +++ b/data/preferences.ui @@ -1,13 +1,7 @@ - + - - - 1 - 121 - 5 - 1 - + False @@ -21,9 +15,10 @@ hamster - + True False + vertical 8 @@ -38,249 +33,49 @@ 4 4 - + True False - 20 + start + start + vertical + 8 - + True False - 4 - 4 - 4 - + True False - 4 - - - Stop tracking on shutdown - True - True - False - True - 0.5 - True - - - - False - True - 0 - - - - - Stop tracking when computer becomes idle - True - True - False - True - 0.5 - True - - - - True - True - 1 - - - - - True - False - True - 0 - none - - - True - False - 12 - - - True - False - - - True - True - adjustment1 - True - False - 0 - bottom - - - - - True - True - 0 - - - - - True - False - 8 - 4 - - - Also remind when no activity is set - True - True - False - True - 0.5 - True - - - - - - True - True - 1 - - - - - - - - - True - False - Remind of current activity every: - True - - - - - True - True - 8 - 2 - - - - - True - False - - - True - False - New day starts at - - - False - True - 4 - 0 - - - - - True - False - - - - - - False - True - 1 - - - - - - - - True - True - 3 - - + New day starts at + + False + True + 4 + 0 + - - - False - True - 0 - - - - - True - False - 0 - none - + True False - 8 - 16 - 4 - - True - False - - - True - False - Use following todo list if available: - - - False - True - 4 - 0 - - - - - True - False - - - - - - True - True - 1 - - - + - - - - True - False - 4 - Integration - - - - + + False + True + 1 + False True - 1 + 2 @@ -306,9 +101,10 @@ 4 4 - + True False + vertical 15 True @@ -317,25 +113,26 @@ False 6 - + True True - + 150 True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + vertical 4 True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 0 _Categories True category_list + 0 False @@ -344,7 +141,7 @@ - + True True GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK @@ -378,7 +175,7 @@ - + True False 4 @@ -474,20 +271,21 @@ - + True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + vertical 4 True False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 0 _Activities True activity_list + 0 False @@ -537,7 +335,7 @@ - + True False 4 @@ -717,10 +515,10 @@ - + True False - end + end gtk-close @@ -732,7 +530,7 @@ False - False + True 0 @@ -740,10 +538,14 @@ False True + end 1 + + + diff --git a/data/wscript_build b/data/wscript_build index 56a85bf8d..13b5827d3 100644 --- a/data/wscript_build +++ b/data/wscript_build @@ -13,28 +13,11 @@ bld.install_files('${DATADIR}/icons/hicolor/32x32/apps', 'art/32x32/hamster.pn bld.install_files('${DATADIR}/icons/hicolor/48x48/apps', 'art/scalable/hamster.png') bld.install_files('${DATADIR}/icons/hicolor/scalable/apps','art/scalable/hamster.svg') -bld.install_files('${DATADIR}/appdata', 'hamster.metainfo.xml') +bld.install_files('${DATADIR}/metainfo', 'hamster.metainfo.xml') for filename in ["hamster.desktop"]: bld(features = "subst", - source= "%s.in.in" % filename, - target= "%s.in" % filename, + source= "%s.in" % filename, + target= "%s" % filename, dict = bld.env ) - - -bld.add_group() - -# process .in files with intl_tool -bld(features = 'intltool_in', - source = 'hamster.schemas.in', - target = 'hamster.schemas', - install_path = bld.env.schemas_destination, - podir = '../po', - flags = ['-s', '-u']) - -bld(features = 'intltool_in', - source = 'hamster.desktop.in', - install_path = '${DATADIR}/applications', - podir = '../po', - flags = ['-d', '-q', '-u']) diff --git a/help/C/input.page b/help/C/input.page index 37d057de8..8c4254bba 100644 --- a/help/C/input.page +++ b/help/C/input.page @@ -12,16 +12,17 @@ type in the activity name in the entry, and hit the Enter key. To specify more detail on the fly, use this syntax: - `time_info activity name @category, some description #tag #other tag with spaces`. + `time_info activity name @category,, some description #tag #other tag with spaces`.

Specify specific times as `13:10-13:45`, and "started 5 minutes ago" as `-5`.

Next comes the activity name

Place the category after the activity name, and start it with an at sign `@`, e.g. `@garden`

-

If you want to add a description and/or tags, add a comma `,`.

-

The description is just freeform text immediately after the comma, and runs until the end of the string or until the first tag.

+

If you want to add a description, add a double comma `,,`.

+

The description is just freeform text immediately after the double comma, and runs until the end of the string or until the beginning of tags.

Place tags at the end, and start each tag with a hash mark `#`.

+

A double comma `,,` can also be placed to indicate the beginning of tags. Otherwise any `#` in the activity, category or description would be interpreted as a starting a tag.

@@ -32,14 +33,14 @@

Forgot to note the important act of watering flowers over lunch.

- tomatoes@garden, digging holes + tomatoes@garden,, digging holes

Need more tomatoes in the garden. Digging holes is purely informational, so added it as a description.

- -7 existentialism, thinking about the vastness of the universe + -7 existentialism,, thinking about the vastness of the universe

Corrected information by informing application that I've been doing something else for the last seven minutes. @@ -57,7 +58,10 @@

Times can be entered with a colon (hh:mm), with a dot (hh.mm) or just the 4 digits (hhmm).

- + + +

Date can be specified in ISO format (YYYY-MM-DD), e.g. `2019-12-24 19:00`. Otherwise, the time belongs to the current hamster day or, in the gui, to the day selected in the timeline.

+
diff --git a/po/.gitignore b/po/.gitignore index 6bc2e0a4c..69b351f38 100644 --- a/po/.gitignore +++ b/po/.gitignore @@ -2,5 +2,6 @@ POTFILES stamp-it Makefile.in.in -hamster-time-tracker.pot +hamster.pot *.gmo +*.mo diff --git a/po/POTFILES.in b/po/POTFILES.in index 00a91f702..d2a97e337 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,15 +1,16 @@ # List of source files containing translatable strings. # Please keep this file sorted alphabetically. -[type: gettext/glade]data/date_range.ui -[type: gettext/glade]data/edit_activity.ui -data/hamster.desktop.in.in -data/hamster.schemas.in -[type: gettext/glade]data/preferences.ui -[type: gettext/python]src/hamster-cli +data/date_range.ui +data/edit_activity.ui +data/preferences.ui +data/hamster.desktop.in +data/org.gnome.hamster.gschema.xml +src/hamster-cli.py src/hamster/about.py src/hamster/edit_activity.py src/hamster/lib/stuff.py src/hamster/overview.py src/hamster/preferences.py src/hamster/reports.py +src/hamster/widgets/activityentry.py src/hamster/widgets/reportchooserdialog.py diff --git a/po/fr.po b/po/fr.po index 778703bf7..ac4b97943 100644 --- a/po/fr.po +++ b/po/fr.po @@ -12,466 +12,269 @@ # Bruno Brouard , 2011-12. # Pierre Henry 2012 # -#: ../src/hamster-cli:342 msgid "" msgstr "" "Project-Id-Version: hamster HEAD\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2012-12-02 19:20+0100\n" -"PO-Revision-Date: 2012-02-23 14:39+0100\n" -"Last-Translator: Pierre Henry \n" +"POT-Creation-Date: 2019-12-25 23:59+0100\n" +"PO-Revision-Date: 2019-12-26 00:14+0100\n" +"Last-Translator: ederag \n" "Language-Team: GNOME French Team \n" -"Language: \n" +"Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n>1;\n" +"X-Generator: Poedit 2.2.1\n" -#: ../data/edit_activity.ui.h:1 ../data/today.ui.h:15 -msgid "Add Earlier Activity" -msgstr "Ajout d'une activité antérieure" - -#: ../data/edit_activity.ui.h:2 ../data/range_pick.ui.h:5 -msgid "to" -msgstr "à" - -#: ../data/edit_activity.ui.h:3 -msgid "in progress" -msgstr "en cours" - -#: ../data/edit_activity.ui.h:4 -msgid "Description:" -msgstr "Description :" - -#: ../data/edit_activity.ui.h:5 -msgid "Time:" -msgstr "Heure :" - -#: ../data/edit_activity.ui.h:6 -msgid "Activity:" -msgstr "Activité :" - -#: ../data/edit_activity.ui.h:7 -msgid "Tags:" -msgstr "Étiquettes :" - -#: ../data/hamster.schemas.in.h:1 -msgid "Stop tracking on idle" -msgstr "Arrêter le suivi en cas d'inactivité" - -#: ../data/hamster.schemas.in.h:2 -msgid "Stop tracking current activity when computer becomes idle" -msgstr "" -"Arrêter le suivi de l'activité en cours quand l'ordinateur devient inactif" - -#: ../data/hamster.schemas.in.h:3 ../data/preferences.ui.h:2 -msgid "Stop tracking on shutdown" -msgstr "Arrêter le suivi à l'extinction de l'ordinateur" - -#: ../data/hamster.schemas.in.h:4 -msgid "Stop tracking current activity on shutdown" -msgstr "Arrêter le suivi de l'activité en cours à l'extinction de l'ordinateur" - -#: ../data/hamster.schemas.in.h:5 -msgid "Remind of current task every x minutes" -msgstr "Rappeler l'activité en cours toutes les n minutes" - -#: ../data/hamster.schemas.in.h:6 -msgid "" -"Remind of current task every specified amount of minutes. Set to 0 or " -"greater than 120 to disable reminder." -msgstr "" -"Rappeler l'activité en cours chaque nombre de minutes spécifié. Définir à 0 " -"ou à plus de 120 pour désactiver le rappel." - -#: ../data/hamster.schemas.in.h:7 ../data/preferences.ui.h:4 -msgid "Also remind when no activity is set" -msgstr "Rappeler également lorsqu'aucune activité n'est définie" - -#: ../data/hamster.schemas.in.h:8 -msgid "" -"Also remind every notify_interval minutes if no activity has been started." -msgstr "" -"Rappeler également toutes les « notify_interval » minutes si aucune activité " -"n'a été commencée." - -#: ../data/hamster.schemas.in.h:9 -msgid "At what time does the day start (defaults to 5:30AM)" -msgstr "Heure de début de journée (5 h 30 par défaut)" - -#: ../data/hamster.schemas.in.h:10 -msgid "" -"Activities will be counted as to belong to yesterday if the current time is " -"less than the specified day start; and today, if it is over the time. " -"Activities that span two days, will tip over to the side where the largest " -"part of the activity is." -msgstr "" -"Les activités sont comptabilisées dans la journée d'hier si l'heure actuelle " -"précède l'heure qui est spécifiée pour le début de la journée ; et dans " -"celle d'aujourd'hui, si c'est après. Les activités ayant cours sur 2 " -"journées sont comptabilisées dans celle où la part la plus grande est " -"effectuée." - -#: ../data/hamster.schemas.in.h:11 -msgid "Should workspace switch trigger activity switch" -msgstr "" -"Indique si le changement d'espace de travail doit déclencher un changement " -"d'activité" - -#: ../data/hamster.schemas.in.h:12 -msgid "" -"List of enabled tracking methods. \"name\" will enable switching activities " -"by name defined in workspace_mapping. \"memory\" will enable switching to " -"the last activity when returning to a previous workspace." -msgstr "" -"Liste des méthodes de suivi actives. « name » active le changement " -"d'activités par nom défini dans workspace_mapping. « memory » active le " -"changement vers la dernière activité en cas de retour dans un espace de " -"travail précédent." - -#: ../data/hamster.schemas.in.h:13 -msgid "Switch activity on workspace change" -msgstr "Changer d'activité lorsqu'on change d'espace de travail" - -#: ../data/hamster.schemas.in.h:14 -msgid "" -"If switching by name is enabled, this list sets the activity names that " -"should be switched to, workspaces represented by the index of item." -msgstr "" -"Si le changement par nom est actif, cette liste donne les noms d'activité " -"auxquelles on pourra passer, les espaces de travail sont représentés par " -"l'indice de l'élément." - -#: ../data/hamster.schemas.in.h:15 -msgid "Show / hide Time Tracker Window" -msgstr "Afficher ou masquer la fenêtre du gestionnaire de temps" +#: data/date_range.ui:50 +msgid "Today:" +msgstr "Aujourd'hui :" -#: ../data/hamster.schemas.in.h:16 -msgid "Keyboard shortcut for showing / hiding the Time Tracker window." -msgstr "" -"Raccourci clavier pour afficher ou masquer la fenêtre du gestionnaire de " -"temps." - -#: ../data/hamster.schemas.in.h:17 -msgid "Toggle hamster application window action" -msgstr "Action pour afficher ou masquer la fenêtre de l'application de Hamster" - -#: ../data/hamster.schemas.in.h:18 -msgid "Command for toggling visibility of the hamster application window." -msgstr "" -"Commande pour afficher ou masquer la fenêtre de l'application de Hamster." - -#: ../data/hamster.schemas.in.h:19 -msgid "Toggle hamster application window" -msgstr "Afficher ou masquer la fenêtre de l'application de Hamster" - -#: ../data/hamster.schemas.in.h:20 -msgid "Toggle visibility of the hamster application window." -msgstr "Afficher ou masquer la fenêtre de l'application de Hamster." - -#: ../data/hamster.desktop.in.in.h:1 -#: ../data/hamster-windows-service.desktop.in.in.h:1 ../data/today.ui.h:1 -#: ../src/hamster-cli:133 ../src/hamster/about.py:39 -#: ../src/hamster/about.py:40 ../src/hamster/today.py:63 -msgid "Time Tracker" -msgstr "Gestionnaire de temps" - -#: ../data/hamster.desktop.in.in.h:2 -#: ../data/hamster-windows-service.desktop.in.in.h:2 -msgid "Project Hamster - track your time" -msgstr "Projet Hamster - Gérez votre emploi du temps" - -#: ../data/hamster-time-tracker-overview.desktop.in.in.h:1 -msgid "Time Tracking Overview" -msgstr "Résumé de la gestion du temps" - -#: ../data/hamster-time-tracker-overview.desktop.in.in.h:2 -msgid "The overview window of hamster time tracker" -msgstr "La fenêtre de résumé du gestionnaire de temps hamster" - -#: ../data/overview_totals.ui.h:1 -msgid "Show Statistics" -msgstr "Afficher les statistiques" - -#: ../data/overview_totals.ui.h:2 -msgid "Categories" -msgstr "Catégories" - -#: ../data/overview_totals.ui.h:3 ../data/overview.ui.h:9 -msgid "Activities" -msgstr "Activités" - -#: ../data/overview_totals.ui.h:4 ../src/hamster-cli:278 -#: ../src/hamster/reports.py:319 ../src/hamster/today.py:150 -msgid "Tags" -msgstr "Étiquettes" +#: data/date_range.ui:99 +msgid "Week:" +msgstr "Semaine :" -#: ../data/overview_totals.ui.h:5 -msgid "No data for this interval" -msgstr "Aucune donnée pour cette période" +#: data/date_range.ui:148 +msgid "Month:" +msgstr "Mois :" -#: ../data/overview.ui.h:1 -msgid "Save report..." -msgstr "Enregistrer le rapport..." +#: data/date_range.ui:196 +msgid "Range:" +msgstr "Période :" -#: ../data/overview.ui.h:2 -msgid "Day" -msgstr "Jour" +#: data/date_range.ui:242 +msgid "to" +msgstr "à" -#: ../data/overview.ui.h:3 -msgid "Week" -msgstr "Semaine" +#: data/date_range.ui:288 +msgid "Apply" +msgstr "Appliquer" -#: ../data/overview.ui.h:4 -msgid "Month" -msgstr "Mois" +#: data/edit_activity.ui:15 +msgid "Add Earlier Activity" +msgstr "Ajout d'une activité antérieure" -#: ../data/overview.ui.h:5 -msgid "Overview — Hamster" -msgstr "Résumé — Hamster" +#. column title in the TSV export format +#: data/edit_activity.ui:120 src/hamster/reports.py:143 +msgid "description" +msgstr "description" -#: ../data/overview.ui.h:6 -msgid "_Overview" -msgstr "_Résumé" +#: data/edit_activity.ui:177 +msgid "start" +msgstr "début" -#: ../data/overview.ui.h:7 ../src/hamster-cli:276 -#: ../src/hamster/preferences.py:212 ../src/hamster/reports.py:317 -#: ../src/hamster/today.py:144 -msgid "Activity" -msgstr "Activité" +#: data/edit_activity.ui:223 +msgid "start date" +msgstr "date de début" -#: ../data/overview.ui.h:8 -msgid "_View" -msgstr "_Affichage" +#: data/edit_activity.ui:250 +msgid "end" +msgstr "fin" -#: ../data/overview.ui.h:10 ../src/hamster/reports.py:308 -msgid "Totals" -msgstr "Totaux" +#: data/edit_activity.ui:296 +msgid "end date" +msgstr "date de fin" -#: ../data/overview.ui.h:11 -msgid "Remove" -msgstr "Supprimer" +#. column title in the TSV export format +#: data/edit_activity.ui:334 src/hamster/reports.py:141 +msgid "category" +msgstr "catégorie" -#: ../data/overview.ui.h:12 -msgid "Add new" -msgstr "Ajouter une nouvelle" +#. column title in the TSV export format +#: data/edit_activity.ui:376 src/hamster/reports.py:133 +msgid "activity" +msgstr "activité" -#: ../data/overview.ui.h:13 -msgid "Edit" -msgstr "Modifier" +#. column title in the TSV export format +#: data/edit_activity.ui:424 src/hamster/reports.py:145 +#: src/hamster/reports.py:294 +msgid "tags" +msgstr "étiquettes" -#: ../data/preferences.ui.h:1 +#: data/preferences.ui:10 msgid "Time Tracker Preferences" msgstr "Préférences du gestionnaire de temps" -#: ../data/preferences.ui.h:3 -msgid "Stop tracking when computer becomes idle" -msgstr "Arrêter le suivi quand l'ordinateur devient inactif" - -#: ../data/preferences.ui.h:5 -msgid "Remind of current activity every:" -msgstr "Rappeler l'activité en cours toutes les :" - -#: ../data/preferences.ui.h:6 +#: data/preferences.ui:51 msgid "New day starts at" msgstr "La journée commence à" -#: ../data/preferences.ui.h:7 -msgid "Use following todo list if available:" -msgstr "Utiliser la liste des tâches suivante si elle est disponible :" - -#: ../data/preferences.ui.h:8 -msgid "Integration" -msgstr "Intégration" - -#: ../data/preferences.ui.h:9 +#: data/preferences.ui:89 msgid "Tracking" msgstr "Suivi" -#: ../data/preferences.ui.h:10 +#: data/preferences.ui:132 msgid "_Categories" msgstr "_Catégories" -#: ../data/preferences.ui.h:11 +#: data/preferences.ui:165 msgid "Category list" msgstr "Liste des catégories" -#: ../data/preferences.ui.h:12 +#: data/preferences.ui:197 msgid "Add category" msgstr "Ajouter une catégorie" -#: ../data/preferences.ui.h:13 +#: data/preferences.ui:222 msgid "Remove category" msgstr "Supprimer la catégorie" -#: ../data/preferences.ui.h:14 +#: data/preferences.ui:247 msgid "Edit category" msgstr "Modifier la catégorie" -#: ../data/preferences.ui.h:15 +#: data/preferences.ui:285 msgid "_Activities" msgstr "_Activités" -#: ../data/preferences.ui.h:16 +#: data/preferences.ui:323 msgid "Activity list" msgstr "Liste des activités" -#: ../data/preferences.ui.h:17 +#: data/preferences.ui:357 src/hamster/edit_activity.py:94 msgid "Add activity" msgstr "Ajouter une activité" -#: ../data/preferences.ui.h:18 +#: data/preferences.ui:382 msgid "Remove activity" msgstr "Supprimer l'activité" -#: ../data/preferences.ui.h:19 +#: data/preferences.ui:407 msgid "Edit activity" msgstr "Modifier l'activité" -#: ../data/preferences.ui.h:20 +#: data/preferences.ui:479 msgid "Tags that should appear in autocomplete" msgstr "Étiquettes qui doivent apparaître dans l'auto-complétion" -#: ../data/preferences.ui.h:21 +#: data/preferences.ui:503 msgid "Categories and Tags" msgstr "Catégories et étiquettes" -#: ../data/preferences.ui.h:22 -msgid "Resume the last activity when returning to a workspace" +#: data/hamster.desktop.in:6 +msgid "Hamster" msgstr "" -"Reprendre la dernière activité lorsqu'on revient sur un espace de travail" - -#: ../data/preferences.ui.h:23 -msgid "Start new activity when switching workspaces:" -msgstr "Commencer une nouvelle activité lorsqu'on change d'espace de travail :" - -#: ../data/preferences.ui.h:24 -msgid "Workspaces" -msgstr "Espaces de travail" - -#: ../data/range_pick.ui.h:1 -msgid "Day:" -msgstr "Jour :" - -#: ../data/range_pick.ui.h:2 -msgid "Week:" -msgstr "Semaine :" - -#: ../data/range_pick.ui.h:3 -msgid "Month:" -msgstr "Mois :" - -#: ../data/range_pick.ui.h:4 -msgid "Range:" -msgstr "Période :" - -#: ../data/range_pick.ui.h:6 -msgid "Apply" -msgstr "Appliquer" - -#: ../data/today.ui.h:2 -msgid "_Tracking" -msgstr "_Suivi" - -#: ../data/today.ui.h:3 -msgid "Add earlier activity" -msgstr "Ajouter une activité antérieure" - -#: ../data/today.ui.h:4 -msgid "Overview" -msgstr "Résumé" -#: ../data/today.ui.h:5 -msgid "Statistics" -msgstr "Statistiques" +#: data/hamster.desktop.in:7 +msgid "Your personal time keeping tool" +msgstr "Votre gestionnaire de temps personnel" -#: ../data/today.ui.h:6 -msgid "_Edit" -msgstr "_Modifier" - -#: ../data/today.ui.h:7 -msgid "_Help" -msgstr "Aid_e" - -#: ../data/today.ui.h:8 -msgid "Contents" -msgstr "Sommaire" - -#: ../data/today.ui.h:9 -msgid "Sto_p tracking" -msgstr "_Arrêter le suivi" - -#: ../data/today.ui.h:10 -msgid "S_witch" -msgstr "C_hanger" - -#: ../data/today.ui.h:11 -msgid "Start _Tracking" -msgstr "_Commencer le suivi" - -#: ../data/today.ui.h:12 -msgid "Start new activity" -msgstr "Commencer une nouvelle activité" +#: data/hamster.desktop.in:8 +msgid "hamster" +msgstr "" -#: ../data/today.ui.h:13 -msgid "Today" -msgstr "Aujourd'hui" +#: data/org.gnome.hamster.gschema.xml:5 data/org.gnome.hamster.gschema.xml:6 +msgid "The folder the last report was saved to" +msgstr "Le dernier répertoire utilisé pour enregistrer un rapport" -#: ../data/today.ui.h:14 -msgid "totals" -msgstr "totaux" +#: data/org.gnome.hamster.gschema.xml:13 +msgid "At what time does the day start (defaults to 5:30AM)" +msgstr "Heure de début de journée (5 h 30 par défaut)" -#: ../data/today.ui.h:16 -msgid "Show Overview" -msgstr "Afficher le résumé" +#: data/org.gnome.hamster.gschema.xml:14 +msgid "" +"The hamster day of an activity is the civil date of start time, provided " +"start time is after day-start. On the contrary, if start time is earlier " +"than day-start, then the activity belongs to the previous hamster day." +msgstr "" -#: ../src/hamster-cli:254 ../src/hamster/today.py:289 +#: src/hamster-cli.py:214 msgid "No activity" msgstr "Aucune activité" -#: ../src/hamster-cli:277 ../src/hamster/preferences.py:155 -#: ../src/hamster/reports.py:318 +#: src/hamster-cli.py:236 src/hamster/reports.py:299 +msgid "Activity" +msgstr "Activité" + +#: src/hamster-cli.py:237 src/hamster/preferences.py:115 +#: src/hamster/reports.py:300 msgid "Category" msgstr "Catégorie" -#: ../src/hamster-cli:279 ../src/hamster/reports.py:323 +#: src/hamster-cli.py:238 src/hamster/reports.py:301 +msgid "Tags" +msgstr "Étiquettes" + +#: src/hamster-cli.py:239 src/hamster/reports.py:305 msgid "Description" msgstr "Description" -#: ../src/hamster-cli:280 ../src/hamster/reports.py:320 +#: src/hamster-cli.py:240 src/hamster/reports.py:302 msgid "Start" msgstr "Début" -#: ../src/hamster-cli:281 ../src/hamster/reports.py:321 +#: src/hamster-cli.py:241 src/hamster/reports.py:303 msgid "End" msgstr "Fin" -#: ../src/hamster-cli:282 ../src/hamster/reports.py:322 +#: src/hamster-cli.py:242 src/hamster/reports.py:304 msgid "Duration" msgstr "Durée" -#: ../src/hamster-cli:308 -#, fuzzy -msgid "Uncategorized" -msgstr "catégories" +#: src/hamster-cli.py:268 src/hamster/preferences.py:53 +#: src/hamster/reports.py:77 src/hamster/reports.py:109 +#: src/hamster/reports.py:240 src/hamster/widgets/activityentry.py:615 +msgid "Unsorted" +msgstr "Divers" + +#: src/hamster-cli.py:305 +msgid "" +"\n" +"Actions:\n" +" * start / track [start-time] [end-time]: Track an activity\n" +" * stop: Stop tracking current activity.\n" +" * list [start-date [end-date]]: List activities\n" +" * search [terms] [start-date [end-date]]: List activities matching a " +"search\n" +" term\n" +" * export [html|tsv|ical|xml] [start-date [end-date]]: Export activities " +"with\n" +" the specified format\n" +" * current: Print current activity\n" +" * activities: List all the activities names, one per line.\n" +" * categories: List all the categories names, one per line.\n" +"\n" +" * overview / prefs / add / about: launch specific window\n" +"\n" +"Time formats:\n" +" * 'YYYY-MM-DD hh:mm': If start-date is missing, it will default to " +"today.\n" +" If end-date is missing, it will default to start-date.\n" +" * '-minutes': Relative time in minutes from the current date and time.\n" +"Note:\n" +" * For list/search/export a \"hamster day\" starts at the time set in " +"the\n" +" preferences (default 05:00) and ends one minute earlier the next day.\n" +" Activities are reported for each \"hamster day\" in the interval.\n" +"\n" +"Example usage:\n" +" hamster start bananas -20\n" +" start activity 'bananas' with start time 20 minutes ago\n" +"\n" +" hamster search pancakes 2012-08-01 2012-08-30\n" +" look for an activity matching terms 'pancakes` between 1st and 30st\n" +" August 2012. Will check against activity, category, description and " +"tags\n" +msgstr "" -#: ../src/hamster/about.py:42 +#: src/hamster/about.py:33 msgid "Project Hamster — track your time" msgstr "Projet Hamster — Gérez votre emploi du temps" -#: ../src/hamster/about.py:43 +#: src/hamster/about.py:34 msgid "Copyright © 2007–2010 Toms Bauģis and others" msgstr "Copyright © 2007–2010 Toms Bauģis et autres" -#: ../src/hamster/about.py:45 +#: src/hamster/about.py:36 msgid "Project Hamster Website" msgstr "Site Web du projet Hamster" -#: ../src/hamster/about.py:46 +#: src/hamster/about.py:37 msgid "About Time Tracker" msgstr "À propos du gestionnaire de temps" -#: ../src/hamster/about.py:56 +#: src/hamster/about.py:47 msgid "translator-credits" msgstr "" "Pierre-Luc Beaudoin \n" @@ -479,186 +282,87 @@ msgstr "" "Claude Paroz \n" "Jonathan Ernst \n" "Laurent Coudeur \n" -"Jean-Philippe Fleury " - -#: ../src/hamster/db.py:288 ../src/hamster/db.py:298 ../src/hamster/db.py:354 -#: ../src/hamster/db.py:658 ../src/hamster/db.py:845 -#: ../src/hamster/edit_activity.py:59 ../src/hamster/preferences.py:58 -#: ../src/hamster/reports.py:88 ../src/hamster/reports.py:127 -#: ../src/hamster/reports.py:256 ../src/hamster/today.py:275 -msgid "Unsorted" -msgstr "Divers" +"Jean-Philippe Fleury \n" +"Ederag " -#. defaults -#: ../src/hamster/db.py:937 -msgid "Work" -msgstr "Travail" - -#: ../src/hamster/db.py:938 -msgid "Reading news" -msgstr "Lecture de nouvelles" - -#: ../src/hamster/db.py:939 -msgid "Checking stocks" -msgstr "Vérification des indices boursiers" - -#: ../src/hamster/db.py:940 -msgid "Super secret project X" -msgstr "Projet X super secret" - -#: ../src/hamster/db.py:941 -msgid "World domination" -msgstr "Domination mondiale" - -#: ../src/hamster/db.py:943 -msgid "Day-to-day" -msgstr "Quotidien" - -#: ../src/hamster/db.py:944 -msgid "Lunch" -msgstr "Repas" +#: src/hamster/edit_activity.py:92 +msgid "Update activity" +msgstr "Mettre à jour l'activité" -#: ../src/hamster/db.py:945 -msgid "Watering flowers" -msgstr "Arrosage des plantes" +#: src/hamster/overview.py:76 +msgid "Menu" +msgstr "" -#: ../src/hamster/db.py:946 -msgid "Doing handstands" -msgstr "Faire le poirier" +#: src/hamster/overview.py:82 +#, fuzzy +msgid "Filter activities" +msgstr "activités" -#: ../src/hamster/edit_activity.py:75 -msgid "Update activity" -msgstr "Mettre à jour l'activité" +#: src/hamster/overview.py:88 +#, fuzzy +msgid "Stop tracking (Ctrl-SPACE)" +msgstr "_Arrêter le suivi" -#. duration in round hours -#: ../src/hamster/lib/stuff.py:57 -#, python-format -msgid "%dh" -msgstr "%d h" +#: src/hamster/overview.py:94 +#, fuzzy +msgid "Add activity (Ctrl-+)" +msgstr "Ajouter une activité" -#. duration less than hour -#: ../src/hamster/lib/stuff.py:60 -#, python-format -msgid "%dmin" -msgstr "%d min" +#: src/hamster/overview.py:100 +#, fuzzy +msgid "Export..." +msgstr "Enregistrer le rapport..." -#. x hours, y minutes -#: ../src/hamster/lib/stuff.py:63 -#, python-format -msgid "%dh %dmin" -msgstr "%d h %d min" +#: src/hamster/overview.py:102 +#, fuzzy +msgid "Tracking Settings" +msgstr "Suivi" -#. label of date range if looking on single day -#. date format for overview label when only single day is visible -#. Using python datetime formatting syntax. See: -#. http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/lib/stuff.py:80 -msgid "%B %d, %Y" -msgstr "%d %B %Y" - -#. label of date range if start and end years don't match -#. letter after prefixes (start_, end_) is the one of -#. standard python date formatting ones- you can use all of them -#. see http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/lib/stuff.py:86 -#, python-format -msgid "%(start_B)s %(start_d)s, %(start_Y)s – %(end_B)s %(end_d)s, %(end_Y)s" -msgstr "%(start_d)s %(start_B)s %(start_Y)s – %(end_d)s %(end_B)s %(end_Y)s" - -#. label of date range if start and end month do not match -#. letter after prefixes (start_, end_) is the one of -#. standard python date formatting ones- you can use all of them -#. see http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/lib/stuff.py:92 -#, python-format -msgid "%(start_B)s %(start_d)s – %(end_B)s %(end_d)s, %(end_Y)s" -msgstr "%(start_d)s %(start_B)s – %(end_d)s %(end_B)s %(end_Y)s" - -#. label of date range for interval in same month -#. letter after prefixes (start_, end_) is the one of -#. standard python date formatting ones- you can use all of them -#. see http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/lib/stuff.py:98 -#, python-format -msgid "%(start_B)s %(start_d)s – %(end_d)s, %(end_Y)s" -msgstr "%(start_d)s – %(end_d)s %(start_B)s %(end_Y)s" +#: src/hamster/overview.py:104 +#, fuzzy +msgid "Help" +msgstr "Aid_e" -#: ../src/hamster/overview_activities.py:88 -msgctxt "overview list" -msgid "%A, %b %d" -msgstr "%A %d %B" +#: src/hamster/overview.py:286 +msgid "Click to see stats" +msgstr "" -#: ../src/hamster/overview_totals.py:161 -#, python-format -msgid "%s hours tracked total" -msgstr "%s heures suivies au total" +#: src/hamster/overview.py:590 +msgid "Failed to open {}" +msgstr "" -#. Translators: 'None' refers here to the Todo list choice in Hamster preferences (Tracking tab) -#: ../src/hamster/preferences.py:113 -msgid "None" -msgstr "Aucune" +#: src/hamster/overview.py:591 +msgid "Error: \"{}\" - is a help browser installed on this computer?" +msgstr "" -#: ../src/hamster/preferences.py:130 ../src/hamster/preferences.py:208 +#: src/hamster/preferences.py:91 msgid "Name" msgstr "Nom" -#: ../src/hamster/preferences.py:664 +#: src/hamster/preferences.py:476 msgid "New category" msgstr "Nouvelle catégorie" -#: ../src/hamster/preferences.py:677 +#: src/hamster/preferences.py:490 msgid "New activity" msgstr "Nouvelle activité" -#. notify interval slider value label -#: ../src/hamster/preferences.py:738 -#, python-format -msgid "%(interval_minutes)d minute" -msgid_plural "%(interval_minutes)d minutes" -msgstr[0] "%(interval_minutes)d minute" -msgstr[1] "%(interval_minutes)d minutes" - -#. notify interval slider value label -#: ../src/hamster/preferences.py:743 -msgid "Never" -msgstr "Jamais" - -#. column title in the TSV export format -#: ../src/hamster/reports.py:148 -msgid "activity" -msgstr "activité" - #. column title in the TSV export format -#: ../src/hamster/reports.py:150 +#: src/hamster/reports.py:135 msgid "start time" msgstr "heure de début" #. column title in the TSV export format -#: ../src/hamster/reports.py:152 +#: src/hamster/reports.py:137 msgid "end time" msgstr "heure de fin" #. column title in the TSV export format -#: ../src/hamster/reports.py:154 +#: src/hamster/reports.py:139 msgid "duration minutes" msgstr "durée en minutes" -#. column title in the TSV export format -#: ../src/hamster/reports.py:156 -msgid "category" -msgstr "catégorie" - -#. column title in the TSV export format -#: ../src/hamster/reports.py:158 -msgid "description" -msgstr "description" - -#. column title in the TSV export format -#: ../src/hamster/reports.py:160 ../src/hamster/reports.py:312 -msgid "tags" -msgstr "étiquettes" - -#: ../src/hamster/reports.py:207 +#: src/hamster/reports.py:191 #, python-format msgid "" "Activity report for %(start_B)s %(start_d)s, %(start_Y)s – %(end_B)s " @@ -667,7 +371,7 @@ msgstr "" "Rapport d'activité du %(start_d)s %(start_B)s %(start_Y)s au %(end_d)s " "%(end_B)s %(end_Y)s" -#: ../src/hamster/reports.py:209 +#: src/hamster/reports.py:193 #, python-format msgid "" "Activity report for %(start_B)s %(start_d)s – %(end_B)s %(end_d)s, %(end_Y)s" @@ -675,12 +379,12 @@ msgstr "" "Rapport d'activité du %(start_d)s %(start_B)s au %(end_d)s %(end_B)s " "%(end_Y)s" -#: ../src/hamster/reports.py:211 +#: src/hamster/reports.py:195 #, python-format msgid "Activity report for %(start_B)s %(start_d)s, %(start_Y)s" msgstr "Rapport d'activité du %(start_d)s %(start_B)s %(start_Y)s" -#: ../src/hamster/reports.py:213 +#: src/hamster/reports.py:197 #, python-format msgid "Activity report for %(start_B)s %(start_d)s – %(end_d)s, %(end_Y)s" msgstr "Rapport d'activité du %(start_d)s au %(end_d)s %(start_B)s %(end_Y)s" @@ -688,203 +392,454 @@ msgstr "Rapport d'activité du %(start_d)s au %(end_d)s %(start_B)s %(end_Y)s" #. date column format for each row in HTML report #. Using python datetime formatting syntax. See: #. http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/reports.py:265 ../src/hamster/reports.py:297 +#: src/hamster/reports.py:249 src/hamster/reports.py:281 msgctxt "html report" msgid "%b %d, %Y" msgstr "%d %b %Y" -#. grand_total = _("%s hours") % ("%.1f" % (total_duration.seconds / 60.0 / 60 + total_duration.days * 24)), -#: ../src/hamster/reports.py:306 +#: src/hamster/reports.py:288 msgid "Totals by Day" msgstr "Totaux par jour" -#: ../src/hamster/reports.py:307 +#: src/hamster/reports.py:289 msgid "Activity Log" msgstr "Journal d'activité" -#: ../src/hamster/reports.py:310 +#: src/hamster/reports.py:290 +msgid "Totals" +msgstr "Totaux" + +#: src/hamster/reports.py:292 msgid "activities" msgstr "activités" -#: ../src/hamster/reports.py:311 +#: src/hamster/reports.py:293 msgid "categories" msgstr "catégories" -#: ../src/hamster/reports.py:314 +#: src/hamster/reports.py:296 msgid "Distinguish:" msgstr "Distinguer :" -#: ../src/hamster/reports.py:316 +#: src/hamster/reports.py:298 msgid "Date" msgstr "Date" -#: ../src/hamster/reports.py:326 +#: src/hamster/reports.py:308 msgid "Show template" msgstr "Afficher le modèle" -#: ../src/hamster/reports.py:327 +#: src/hamster/reports.py:309 #, python-format msgid "You can override it by storing your version in %(home_folder)s" msgstr "" "Vous pouvez le remplacer en enregistrant votre version dans %(home_folder)s" -#: ../src/hamster/stats.py:147 -msgctxt "years" -msgid "All" -msgstr "Toutes" +#: src/hamster/widgets/reportchooserdialog.py:37 +msgid "Save Report — Time Tracker" +msgstr "Enregistrer le rapport — Gestionnaire de temps" -#: ../src/hamster/stats.py:177 -msgid "" -"There is no data to generate statistics yet.\n" -"A week of usage would be nice!" -msgstr "" -"Il n'y a pas encore de données pour générer des statistiques.\n" -"Une semaine d'utilisation serait préférable !" +#: src/hamster/widgets/reportchooserdialog.py:55 +msgid "HTML Report" +msgstr "Rapport HTML" -#: ../src/hamster/stats.py:180 -msgid "Collecting data — check back after a week has passed!" -msgstr "La collecte des données est en cours — revenez dans une semaine !" +#: src/hamster/widgets/reportchooserdialog.py:63 +msgid "Tab-Separated Values (TSV)" +msgstr "Valeurs séparées par des tabulations (TSV)" -#. date format for the first record if the year has not been selected -#. Using python datetime formatting syntax. See: -#. http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/stats.py:331 -msgctxt "first record" -msgid "%b %d, %Y" -msgstr "%d %b %Y" +#: src/hamster/widgets/reportchooserdialog.py:71 +msgid "XML" +msgstr "XML" -#. date of first record when year has been selected -#. Using python datetime formatting syntax. See: -#. http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/stats.py:336 -msgctxt "first record" -msgid "%b %d" -msgstr "%d %b" +#: src/hamster/widgets/reportchooserdialog.py:78 +msgid "iCal" +msgstr "iCal" -#: ../src/hamster/stats.py:338 -#, python-format -msgid "First activity was recorded on %s." -msgstr "La première activité a été enregistrée le %s." +#. title in the report file name +#: src/hamster/widgets/reportchooserdialog.py:95 +msgid "Time track" +msgstr "Gestion du temps" -#: ../src/hamster/stats.py:347 ../src/hamster/stats.py:351 -#, python-format -msgid "%(num)s year" -msgid_plural "%(num)s years" -msgstr[0] "%(num)s année" -msgstr[1] "%(num)s années" +#~ msgid "in progress" +#~ msgstr "en cours" + +#~ msgid "Description:" +#~ msgstr "Description :" + +#~ msgid "Time:" +#~ msgstr "Heure :" + +#~ msgid "Activity:" +#~ msgstr "Activité :" + +#~ msgid "Tags:" +#~ msgstr "Étiquettes :" + +#~ msgid "Stop tracking on idle" +#~ msgstr "Arrêter le suivi en cas d'inactivité" + +#~ msgid "Stop tracking current activity when computer becomes idle" +#~ msgstr "" +#~ "Arrêter le suivi de l'activité en cours quand l'ordinateur devient inactif" + +#~ msgid "Stop tracking on shutdown" +#~ msgstr "Arrêter le suivi à l'extinction de l'ordinateur" + +#~ msgid "Stop tracking current activity on shutdown" +#~ msgstr "" +#~ "Arrêter le suivi de l'activité en cours à l'extinction de l'ordinateur" -#. FIXME: difficult string to properly pluralize -#: ../src/hamster/stats.py:356 -#, python-format -msgid "" -"Time tracked so far is %(human_days)s human days (%(human_years)s) or " -"%(working_days)s working days (%(working_years)s)." -msgstr "" -"Le temps suivi à ce jour est de %(human_days)s jours (%(human_years)s) ou " -"%(working_days)s jours ouvrables (%(working_years)s)." +#~ msgid "Remind of current task every x minutes" +#~ msgstr "Rappeler l'activité en cours toutes les n minutes" -#. How the date of the longest activity should be displayed in statistics -#. Using python datetime formatting syntax. See: -#. http://docs.python.org/library/time.html#time.strftime -#: ../src/hamster/stats.py:374 -msgctxt "date of the longest activity" -msgid "%b %d, %Y" -msgstr "%d %b %Y" +#~ msgid "" +#~ "Remind of current task every specified amount of minutes. Set to 0 or " +#~ "greater than 120 to disable reminder." +#~ msgstr "" +#~ "Rappeler l'activité en cours chaque nombre de minutes spécifié. Définir à " +#~ "0 ou à plus de 120 pour désactiver le rappel." -#: ../src/hamster/stats.py:379 -#, python-format -msgid "Longest continuous work happened on %(date)s and was %(hours)s hour." -msgid_plural "" -"Longest continuous work happened on %(date)s and was %(hours)s hours." -msgstr[0] "" -"Le temps de travail continu le plus long a été enregistré le %(date)s et " -"était de %(hours)s heure." -msgstr[1] "" -"Le temps de travail continu le plus long a été enregistré le %(date)s et " -"était de %(hours)s heures." - -#. total records (in selected scope) -#: ../src/hamster/stats.py:387 -#, python-format -msgid "There is %s record." -msgid_plural "There are %s records." -msgstr[0] "Il y a %s enregistrement." -msgstr[1] "Il y a %s enregistrements." +#~ msgid "Also remind when no activity is set" +#~ msgstr "Rappeler également lorsqu'aucune activité n'est définie" -#: ../src/hamster/stats.py:407 -msgid "Hamster would like to observe you some more!" -msgstr "Hamster souhaite vous observer plus longtemps !" +#~ msgid "" +#~ "Also remind every notify_interval minutes if no activity has been started." +#~ msgstr "" +#~ "Rappeler également toutes les « notify_interval » minutes si aucune " +#~ "activité n'a été commencée." -#: ../src/hamster/stats.py:409 -#, python-format -msgid "" -"With %s percent of all activities starting before 9am, you seem to be an " -"early bird." -msgstr "" -"Avec %s %% d'activités commencées avant 9 h du matin, vous devez être " -"matinal." +#~ msgid "" +#~ "Activities will be counted as to belong to yesterday if the current time " +#~ "is less than the specified day start; and today, if it is over the time. " +#~ "Activities that span two days, will tip over to the side where the " +#~ "largest part of the activity is." +#~ msgstr "" +#~ "Les activités sont comptabilisées dans la journée d'hier si l'heure " +#~ "actuelle précède l'heure qui est spécifiée pour le début de la journée ; " +#~ "et dans celle d'aujourd'hui, si c'est après. Les activités ayant cours " +#~ "sur 2 journées sont comptabilisées dans celle où la part la plus grande " +#~ "est effectuée." -#: ../src/hamster/stats.py:412 -#, python-format -msgid "" -"With %s percent of all activities starting after 11pm, you seem to be a " -"night owl." -msgstr "" -"Avec %s %% d'activités commencées après 11 h du soir, vous semblez être un " -"oiseau de nuit." +#~ msgid "Should workspace switch trigger activity switch" +#~ msgstr "" +#~ "Indique si le changement d'espace de travail doit déclencher un " +#~ "changement d'activité" -#: ../src/hamster/stats.py:415 -#, python-format -msgid "" -"With %s percent of all activities being shorter than 15 minutes, you seem to " -"be a busy bee." -msgstr "" -"Avec %s %% d'activités ne dépassant pas 15 minutes, vous semblez être très " -"occupé." +#~ msgid "" +#~ "List of enabled tracking methods. \"name\" will enable switching " +#~ "activities by name defined in workspace_mapping. \"memory\" will enable " +#~ "switching to the last activity when returning to a previous workspace." +#~ msgstr "" +#~ "Liste des méthodes de suivi actives. « name » active le changement " +#~ "d'activités par nom défini dans workspace_mapping. « memory » active le " +#~ "changement vers la dernière activité en cas de retour dans un espace de " +#~ "travail précédent." -#: ../src/hamster/today.py:243 -msgid "No records today" -msgstr "Aucun enregistrement aujourd'hui" +#~ msgid "Switch activity on workspace change" +#~ msgstr "Changer d'activité lorsqu'on change d'espace de travail" -#: ../src/hamster/today.py:250 -#, python-format -msgid "%(category)s: %(duration)s" -msgstr "%(category)s : %(duration)s" +#~ msgid "" +#~ "If switching by name is enabled, this list sets the activity names that " +#~ "should be switched to, workspaces represented by the index of item." +#~ msgstr "" +#~ "Si le changement par nom est actif, cette liste donne les noms d'activité " +#~ "auxquelles on pourra passer, les espaces de travail sont représentés par " +#~ "l'indice de l'élément." -#. duration in main drop-down per category in hours -#: ../src/hamster/today.py:253 -#, python-format -msgid "%sh" -msgstr "%s h" +#~ msgid "Show / hide Time Tracker Window" +#~ msgstr "Afficher ou masquer la fenêtre du gestionnaire de temps" -#: ../src/hamster/today.py:280 -msgid "Just started" -msgstr "Vient de commencer" +#~ msgid "Keyboard shortcut for showing / hiding the Time Tracker window." +#~ msgstr "" +#~ "Raccourci clavier pour afficher ou masquer la fenêtre du gestionnaire de " +#~ "temps." -#: ../src/hamster/widgets/reportchooserdialog.py:39 -msgid "Save Report — Time Tracker" -msgstr "Enregistrer le rapport — Gestionnaire de temps" +#~ msgid "Toggle hamster application window action" +#~ msgstr "" +#~ "Action pour afficher ou masquer la fenêtre de l'application de Hamster" -#: ../src/hamster/widgets/reportchooserdialog.py:57 -msgid "HTML Report" -msgstr "Rapport HTML" +#~ msgid "Command for toggling visibility of the hamster application window." +#~ msgstr "" +#~ "Commande pour afficher ou masquer la fenêtre de l'application de Hamster." -#: ../src/hamster/widgets/reportchooserdialog.py:65 -msgid "Tab-Separated Values (TSV)" -msgstr "Valeurs séparées par des tabulations (TSV)" +#~ msgid "Toggle hamster application window" +#~ msgstr "Afficher ou masquer la fenêtre de l'application de Hamster" -#: ../src/hamster/widgets/reportchooserdialog.py:73 -msgid "XML" -msgstr "XML" +#~ msgid "Toggle visibility of the hamster application window." +#~ msgstr "Afficher ou masquer la fenêtre de l'application de Hamster." -#: ../src/hamster/widgets/reportchooserdialog.py:80 -msgid "iCal" -msgstr "iCal" +#~ msgid "Time Tracker" +#~ msgstr "Gestionnaire de temps" -#. title in the report file name -#: ../src/hamster/widgets/reportchooserdialog.py:97 -msgid "Time track" -msgstr "Gestion du temps" +#~ msgid "Project Hamster - track your time" +#~ msgstr "Projet Hamster - Gérez votre emploi du temps" + +#~ msgid "Time Tracking Overview" +#~ msgstr "Résumé de la gestion du temps" + +#~ msgid "The overview window of hamster time tracker" +#~ msgstr "La fenêtre de résumé du gestionnaire de temps hamster" + +#~ msgid "Show Statistics" +#~ msgstr "Afficher les statistiques" + +#~ msgid "Categories" +#~ msgstr "Catégories" + +#~ msgid "Activities" +#~ msgstr "Activités" + +#~ msgid "No data for this interval" +#~ msgstr "Aucune donnée pour cette période" + +#~ msgid "Day" +#~ msgstr "Jour" + +#~ msgid "Week" +#~ msgstr "Semaine" + +#~ msgid "Month" +#~ msgstr "Mois" + +#~ msgid "Overview — Hamster" +#~ msgstr "Résumé — Hamster" + +#~ msgid "_Overview" +#~ msgstr "_Résumé" + +#~ msgid "_View" +#~ msgstr "_Affichage" + +#~ msgid "Remove" +#~ msgstr "Supprimer" + +#~ msgid "Add new" +#~ msgstr "Ajouter une nouvelle" + +#~ msgid "Edit" +#~ msgstr "Modifier" + +#~ msgid "Stop tracking when computer becomes idle" +#~ msgstr "Arrêter le suivi quand l'ordinateur devient inactif" + +#~ msgid "Remind of current activity every:" +#~ msgstr "Rappeler l'activité en cours toutes les :" + +#~ msgid "Use following todo list if available:" +#~ msgstr "Utiliser la liste des tâches suivante si elle est disponible :" + +#~ msgid "Integration" +#~ msgstr "Intégration" + +#~ msgid "Resume the last activity when returning to a workspace" +#~ msgstr "" +#~ "Reprendre la dernière activité lorsqu'on revient sur un espace de travail" + +#~ msgid "Start new activity when switching workspaces:" +#~ msgstr "" +#~ "Commencer une nouvelle activité lorsqu'on change d'espace de travail :" + +#~ msgid "Workspaces" +#~ msgstr "Espaces de travail" + +#~ msgid "Day:" +#~ msgstr "Jour :" + +#~ msgid "_Tracking" +#~ msgstr "_Suivi" + +#~ msgid "Add earlier activity" +#~ msgstr "Ajouter une activité antérieure" + +#~ msgid "Overview" +#~ msgstr "Résumé" + +#~ msgid "Statistics" +#~ msgstr "Statistiques" + +#~ msgid "_Edit" +#~ msgstr "_Modifier" + +#~ msgid "Contents" +#~ msgstr "Sommaire" + +#~ msgid "S_witch" +#~ msgstr "C_hanger" + +#~ msgid "Start _Tracking" +#~ msgstr "_Commencer le suivi" + +#~ msgid "Start new activity" +#~ msgstr "Commencer une nouvelle activité" + +#~ msgid "totals" +#~ msgstr "totaux" + +#~ msgid "Show Overview" +#~ msgstr "Afficher le résumé" + +#, fuzzy +#~ msgid "Uncategorized" +#~ msgstr "catégories" + +#~ msgid "Work" +#~ msgstr "Travail" + +#~ msgid "Reading news" +#~ msgstr "Lecture de nouvelles" + +#~ msgid "Checking stocks" +#~ msgstr "Vérification des indices boursiers" + +#~ msgid "Super secret project X" +#~ msgstr "Projet X super secret" + +#~ msgid "World domination" +#~ msgstr "Domination mondiale" + +#~ msgid "Day-to-day" +#~ msgstr "Quotidien" + +#~ msgid "Lunch" +#~ msgstr "Repas" + +#~ msgid "Watering flowers" +#~ msgstr "Arrosage des plantes" + +#~ msgid "Doing handstands" +#~ msgstr "Faire le poirier" + +#~ msgid "%dh" +#~ msgstr "%d h" + +#~ msgid "%dmin" +#~ msgstr "%d min" + +#~ msgid "%dh %dmin" +#~ msgstr "%d h %d min" + +#~ msgid "%B %d, %Y" +#~ msgstr "%d %B %Y" + +#~ msgid "" +#~ "%(start_B)s %(start_d)s, %(start_Y)s – %(end_B)s %(end_d)s, %(end_Y)s" +#~ msgstr "%(start_d)s %(start_B)s %(start_Y)s – %(end_d)s %(end_B)s %(end_Y)s" + +#~ msgid "%(start_B)s %(start_d)s – %(end_B)s %(end_d)s, %(end_Y)s" +#~ msgstr "%(start_d)s %(start_B)s – %(end_d)s %(end_B)s %(end_Y)s" + +#~ msgid "%(start_B)s %(start_d)s – %(end_d)s, %(end_Y)s" +#~ msgstr "%(start_d)s – %(end_d)s %(start_B)s %(end_Y)s" + +#~ msgctxt "overview list" +#~ msgid "%A, %b %d" +#~ msgstr "%A %d %B" + +#~ msgid "%s hours tracked total" +#~ msgstr "%s heures suivies au total" + +#~ msgid "None" +#~ msgstr "Aucune" + +#~ msgid "%(interval_minutes)d minute" +#~ msgid_plural "%(interval_minutes)d minutes" +#~ msgstr[0] "%(interval_minutes)d minute" +#~ msgstr[1] "%(interval_minutes)d minutes" + +#~ msgid "Never" +#~ msgstr "Jamais" + +#~ msgctxt "years" +#~ msgid "All" +#~ msgstr "Toutes" + +#~ msgid "" +#~ "There is no data to generate statistics yet.\n" +#~ "A week of usage would be nice!" +#~ msgstr "" +#~ "Il n'y a pas encore de données pour générer des statistiques.\n" +#~ "Une semaine d'utilisation serait préférable !" + +#~ msgid "Collecting data — check back after a week has passed!" +#~ msgstr "La collecte des données est en cours — revenez dans une semaine !" + +#~ msgctxt "first record" +#~ msgid "%b %d, %Y" +#~ msgstr "%d %b %Y" + +#~ msgctxt "first record" +#~ msgid "%b %d" +#~ msgstr "%d %b" + +#~ msgid "First activity was recorded on %s." +#~ msgstr "La première activité a été enregistrée le %s." + +#~ msgid "%(num)s year" +#~ msgid_plural "%(num)s years" +#~ msgstr[0] "%(num)s année" +#~ msgstr[1] "%(num)s années" + +#~ msgid "" +#~ "Time tracked so far is %(human_days)s human days (%(human_years)s) or " +#~ "%(working_days)s working days (%(working_years)s)." +#~ msgstr "" +#~ "Le temps suivi à ce jour est de %(human_days)s jours (%(human_years)s) ou " +#~ "%(working_days)s jours ouvrables (%(working_years)s)." + +#~ msgctxt "date of the longest activity" +#~ msgid "%b %d, %Y" +#~ msgstr "%d %b %Y" + +#~ msgid "Longest continuous work happened on %(date)s and was %(hours)s hour." +#~ msgid_plural "" +#~ "Longest continuous work happened on %(date)s and was %(hours)s hours." +#~ msgstr[0] "" +#~ "Le temps de travail continu le plus long a été enregistré le %(date)s et " +#~ "était de %(hours)s heure." +#~ msgstr[1] "" +#~ "Le temps de travail continu le plus long a été enregistré le %(date)s et " +#~ "était de %(hours)s heures." + +#~ msgid "There is %s record." +#~ msgid_plural "There are %s records." +#~ msgstr[0] "Il y a %s enregistrement." +#~ msgstr[1] "Il y a %s enregistrements." + +#~ msgid "Hamster would like to observe you some more!" +#~ msgstr "Hamster souhaite vous observer plus longtemps !" + +#~ msgid "" +#~ "With %s percent of all activities starting before 9am, you seem to be an " +#~ "early bird." +#~ msgstr "" +#~ "Avec %s %% d'activités commencées avant 9 h du matin, vous devez être " +#~ "matinal." + +#~ msgid "" +#~ "With %s percent of all activities starting after 11pm, you seem to be a " +#~ "night owl." +#~ msgstr "" +#~ "Avec %s %% d'activités commencées après 11 h du soir, vous semblez être " +#~ "un oiseau de nuit." + +#~ msgid "" +#~ "With %s percent of all activities being shorter than 15 minutes, you seem " +#~ "to be a busy bee." +#~ msgstr "" +#~ "Avec %s %% d'activités ne dépassant pas 15 minutes, vous semblez être " +#~ "très occupé." + +#~ msgid "No records today" +#~ msgstr "Aucun enregistrement aujourd'hui" + +#~ msgid "%(category)s: %(duration)s" +#~ msgstr "%(category)s : %(duration)s" + +#~ msgid "%sh" +#~ msgstr "%s h" + +#~ msgid "Just started" +#~ msgstr "Vient de commencer" #~ msgid "Show activities window" #~ msgstr "Afficher la fenêtre des activités" diff --git a/po/wscript b/po/wscript old mode 100755 new mode 100644 diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 diff --git a/src/README.md b/src/README.md deleted file mode 100644 index e10def301..000000000 --- a/src/README.md +++ /dev/null @@ -1,7 +0,0 @@ - -When working on hamster, use hamster-cli to launch all the windows - it will -detected that you are running uninstalled and will not call the windows via -D-Bus so that you can see all the traces. - -For data retreaval bits, ```killall hamster-service; ./hamster-service``` should -do the trick. diff --git a/src/hamster-cli b/src/hamster-cli.py old mode 100755 new mode 100644 similarity index 72% rename from src/hamster-cli rename to src/hamster-cli.py index 6158a0f89..632ee785b --- a/src/hamster-cli +++ b/src/hamster-cli.py @@ -25,11 +25,19 @@ import sys, os import argparse import re -import datetime as dt + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk as gtk +from gi.repository import Gio as gio from hamster import client, reports from hamster import logger as hamster_logger -from hamster.lib import default_logger, Fact, stuff +from hamster.overview import Overview +from hamster.preferences import PreferencesEditor +from hamster.lib import default_logger, stuff +from hamster.lib import datetime as dt +from hamster.lib.fact import Fact logger = default_logger(__file__) @@ -62,10 +70,10 @@ def fact_dict(fact_data, with_date): if fact_data.end_time: fact['end'] = fact_data.end_time.strftime(fmt) else: - end_date = stuff.hamster_now() + end_date = dt.datetime.now() fact['end'] = '' - fact['duration'] = stuff.format_duration(fact_data.delta) + fact['duration'] = fact_data.delta.format() fact['activity'] = fact_data.activity fact['category'] = fact_data.category @@ -79,56 +87,81 @@ def fact_dict(fact_data, with_date): return fact -_DATETIME_PATTERN = ('^((?P-\d.+)?|(' - '(?P\d{4}-\d{2}-\d{2})?' - '(?P ?\d{2}:\d{2})?' - '(?P ?-)?' - '(?P ?\d{4}-\d{2}-\d{2})?' - '(?P ?\d{2}:\d{2})?)?)' - '(?P\D.+)?$') -_DATETIME_REGEX = re.compile(_DATETIME_PATTERN) -def parse_datetime_range(arg): - '''Parse a date and time.''' - match = _DATETIME_REGEX.match(arg) - if not match: - return None, None - - fragments = match.groupdict() - rest = (fragments['rest'] or '').strip() - - # bail out early on relative minutes - if fragments['relative']: - delta_minutes = int(fragments['relative']) - return stuff.hamster_now() - dt.timedelta(minutes=delta_minutes), rest - - start_time, end_time = None, None - - def to_time(timestr): - timestr = (timestr or "").strip() - try: - return dt.datetime.strptime(timestr, "%Y-%m-%d").date() - except ValueError: - try: - return dt.datetime.strptime(timestr, "%H:%M").time() - except ValueError: - pass - return None - - now = stuff.hamster_now().time() - - if fragments['date1'] or fragments['time1']: - start_time = dt.datetime.combine(to_time(fragments['date1']) or dt.date.today(), - to_time(fragments['time1']) or now) - if fragments['date2'] or fragments['time2']: - end_time = dt.datetime.combine(to_time(fragments['date2']) or start_time or dt.date.today(), - to_time(fragments['time2']) or now) - - return start_time, end_time - - +class Hamster(gtk.Application): + def __init__(self): + # inactivity_timeout: How long (ms) the service should stay alive + # after all windows have been closed. + gtk.Application.__init__(self, + application_id="org.gnome.Hamster.WindowServer", + #inactivity_timeout=10000, + register_session=True) + + self.overview_controller = None # overview window controller + self.prefs_controller = None # settings window controller + + self.connect("startup", self.on_startup) + self.connect("activate", self.on_activate) + + # we need them before the startup phase + # so register/activate_action work before the app is ran. + # cf. https://gitlab.gnome.org/GNOME/glib/blob/master/gio/tests/gapplication-example-actions.c + self.add_actions() + + def add_actions(self): + action = gio.SimpleAction.new("overview", None) + action.connect("activate", self.on_activate_overview) + self.add_action(action) + + action = gio.SimpleAction.new("prefs", None) + action.connect("activate", self.on_activate_prefs) + self.add_action(action) + + action = gio.SimpleAction.new("quit", None) + action.connect("activate", self.on_activate_quit) + self.add_action(action) + + def on_activate(self, data=None): + logger.debug("activate") + if not self.get_windows(): + self.activate_action("overview") + + def on_activate_overview(self, action=None, data=None): + self._open_window(action.get_name(), data) + + def on_activate_prefs(self, action=None, data=None): + self._open_window(action.get_name(), data) + + def on_activate_quit(self, data=None): + self.on_activate_quit() + + def on_startup(self, data=None): + logger.debug("startup") + + def _open_window(self, name, data=None): + logger.debug("opening '{}'".format(name)) + + if name == "overview": + if not self.overview_controller: + self.overview_controller = Overview() + logger.debug("new Overview") + controller = self.overview_controller + elif name == "prefs": + if not self.prefs_controller: + self.prefs_controller = PreferencesEditor() + logger.debug("new PreferencesEditor") + controller = self.prefs_controller + + window = controller.window + if window not in self.get_windows(): + self.add_window(window) + logger.debug("window added") + controller.present() + logger.debug("window presented") + + +class HamsterCli(object): + """Command line interface.""" -class HamsterClient(object): - '''The main application.''' def __init__(self): self.storage = client.Storage() @@ -192,14 +225,11 @@ def start(self, *args): print("Error: please specify activity") return - activity = args[0] - start_time, end_time = parse_datetime_range(" ".join(args[1:])) - start_time = start_time or stuff.hamster_now() - - self.storage.add_fact(Fact(activity, - start_time = start_time, - end_time = end_time)) - + fact = Fact.parse(" ".join(args), range_pos="tail") + if fact.start_time is None: + fact.start_time = dt.datetime.now() + self.storage.check_fact(fact, default_day=dt.hday.today()) + self.storage.add_fact(fact) def stop(self, *args): '''Stop tracking the current activity.''' @@ -211,7 +241,7 @@ def export(self, *args): export_format, start_time, end_time = "html", None, None if args: export_format = args[0] - start_time, end_time = parse_datetime_range(" ".join(args[1:])) + (start_time, end_time), __ = dt.Range.parse(" ".join(args[1:])) start_time = start_time or dt.datetime.combine(dt.date.today(), dt.time()) end_time = end_time or start_time.replace(hour=23, minute=59, second=59) @@ -248,7 +278,7 @@ def categories(self, *args): def list(self, *times): """list facts within a date range""" - start_time, end_time = parse_datetime_range(" ".join(times or [])) + (start_time, end_time), __ = dt.Range.parse(" ".join(times or [])) start_time = start_time or dt.datetime.combine(dt.date.today(), dt.time()) end_time = end_time or start_time.replace(hour=23, minute=59, second=59) @@ -260,7 +290,7 @@ def current(self, *args): facts = self.storage.get_todays_facts() if facts and not facts[-1].end_time: print("{} {}".format(str(facts[-1]).strip(), - stuff.format_duration(facts[-1].delta, human=False))) + facts[-1].delta.format(fmt="HH:MM"))) else: print((_("No activity"))) @@ -272,7 +302,7 @@ def search(self, *args): if args: search = args[0] - start_time, end_time = parse_datetime_range(" ".join(args[1:])) + (start_time, end_time), __ = dt.Range.parse(" ".join(args[1:])) start_time = start_time or dt.datetime.combine(dt.date.today(), dt.time()) end_time = end_time or start_time.replace(hour=23, minute=59, second=59) @@ -336,12 +366,12 @@ def _list(self, start_time, end_time, search=""): cats = [] total_duration = dt.timedelta() for cat, duration in sorted(by_cat.items(), key=lambda x: x[1], reverse=True): - cats.append("{}: {}".format(cat, stuff.format_duration(duration))) + cats.append("{}: {}".format(cat, duration.format())) total_duration += duration for line in word_wrap(", ".join(cats), 80): print(line) - print("Total: ", stuff.format_duration(total_duration)) + print("Total: ", total_duration.format()) print() @@ -390,7 +420,9 @@ def version(self): look for an activity matching terms 'pancakes` between 1st and 30st August 2012. Will check against activity, category, description and tags """) - hamster_client = HamsterClient() + hamster_client = HamsterCli() + app = Hamster() + logger.debug("app instanciated") import signal signal.signal(signal.SIGINT, signal.SIG_DFL) # gtk3 screws up ctrl+c @@ -408,15 +440,25 @@ def version(self): parser.add_argument("action", nargs="?", default="overview") parser.add_argument('action_args', nargs=argparse.REMAINDER, default=[]) - args = parser.parse_args() + args, unknown_args = parser.parse_known_args() # logger for current script logger.setLevel(args.log_level) # hamster_logger for the rest hamster_logger.setLevel(args.log_level) - command = args.action - if hasattr(hamster_client, command): - getattr(hamster_client, command)(*args.action_args) + action = args.action + + if action in ("about", "overview", "prefs"): + app.register() + app.activate_action(action) + logger.debug("run") + status = app.run([sys.argv[0]] + unknown_args) + logger.debug("app exited") + sys.exit(status) + elif action == "add": + pass + elif hasattr(hamster_client, action): + getattr(hamster_client, action)(*args.action_args) else: sys.exit(usage % {'prog': sys.argv[0]}) diff --git a/src/hamster-service b/src/hamster-service.py old mode 100755 new mode 100644 similarity index 66% rename from src/hamster-service rename to src/hamster-service.py index 2fcbe4e46..f94c26153 --- a/src/hamster-service +++ b/src/hamster-service.py @@ -1,25 +1,30 @@ #!/usr/bin/env python3 # nicked off gwibber -import logging -from gi.repository import GLib as glib +import dbus +import dbus.service -import datetime as dt +from gi.repository import GLib as glib from gi.repository import Gio as gio from hamster import logger as hamster_logger from hamster.lib import i18n -i18n.setup_i18n() +i18n.setup_i18n() # noqa: E402 from hamster.storage import db -from hamster.lib import default_logger, desktop, Fact, stuff +from hamster.lib import datetime as dt +from hamster.lib import default_logger from hamster.lib.dbus import ( DBusMainLoop, - dbus, fact_signature, + from_dbus_date, from_dbus_fact, - to_dbus_fact + from_dbus_fact_json, + from_dbus_range, + to_dbus_fact, + to_dbus_fact_json ) +from hamster.lib.fact import Fact, FactError logger = default_logger(__file__) @@ -51,10 +56,6 @@ def __init__(self, loop): None) self.__monitor.connect("changed", self._on_us_change) - # central place were we plug in all the notifications and such - self.integrations = desktop.DesktopIntegrations(self) - - def run_fixtures(self): """we start with an empty database and then populate with default values. This way defaults can be localized!""" @@ -134,17 +135,88 @@ def Toggle(self): # facts @dbus.service.method("org.gnome.Hamster", in_signature='siib', out_signature='i') def AddFact(self, fact_str, start_time, end_time, temporary=False): + """Add fact specified by a string. + + Args: + fact_str (str): string to be parsed. + start_time (int): Start datetime timestamp. + For backward compatibility with the + gnome shell extension, + 0 is special and means dt.datetime.now(). + Otherwise, overrides the parsed value. + -1 means None. + end_time (int): Start datetime timestamp. + If different from 0, overrides the parsed value. + -1 means None. + Returns: + fact id (int), 0 means failure. + + Note: see datetime.utcfromtimestamp documentation + for the precise meaning of timestamps. + """ fact = Fact.parse(fact_str) - fact.start_time = dt.datetime.utcfromtimestamp(start_time) if start_time else None - fact.end_time = dt.datetime.utcfromtimestamp(end_time) if end_time else None - - return self.add_fact(fact) or 0 + if start_time == -1: + fact.start_time = None + elif start_time == 0: + fact.start_time = dt.datetime.now() + else: + fact.start_time = dt.datetime.utcfromtimestamp(start_time) + + if end_time == -1: + fact.end_time = None + elif end_time != 0: + fact.end_time = dt.datetime.utcfromtimestamp(end_time) + + return self.add_fact(fact) + + + @dbus.service.method("org.gnome.Hamster", in_signature='s', out_signature='i') + def AddFactJSON(self, dbus_fact): + """Add fact given in JSON format. + + This is the preferred method if the fact fields are known separately, + as activity, category, description and tags are passed "verbatim". + Only datetimes are interpreted + (2020-01-20: JSON does not know datetimes). + + Args: + dbus_fact (str): fact in JSON format (cf. from_dbus_fact_json). + + Returns: + fact id (int), 0 means failure. + """ + fact = from_dbus_fact_json(dbus_fact) + return self.add_fact(fact) + + + @dbus.service.method("org.gnome.Hamster", + in_signature="si", + out_signature='bs') + def CheckFact(self, dbus_fact, dbus_default_day): + """Check fact validity. + + Useful to determine in advance whether the fact + can be included in the database. + + Args: + dbus_fact (str): fact in JSON format (cf. AddFactJSON) + + Returns: + success (boolean): True upon success. + message (str): what's wrong. + """ - @dbus.service.method("org.gnome.Hamster", in_signature=fact_signature, out_signature='i') - def AddFactVerbatim(self, dbus_fact): - fact = from_dbus_fact(dbus_fact) - return self.add_fact(fact) or 0 + fact = from_dbus_fact_json(dbus_fact) + dd = from_dbus_date(dbus_default_day) + try: + self.check_fact(fact, default_day=dd) + success = True + message = "" + except FactError as error: + success = False + message = str(error) + return success, message @dbus.service.method("org.gnome.Hamster", @@ -156,6 +228,18 @@ def GetFact(self, fact_id): return to_dbus_fact(fact) + @dbus.service.method("org.gnome.Hamster", + in_signature='i', + out_signature="s") + def GetFactJSON(self, fact_id): + """Get fact by id. + + Return fact in JSON format (cf. to_dbus_fact_json) + """ + fact = self.get_fact(fact_id) + return to_dbus_fact_json(fact) + + @dbus.service.method("org.gnome.Hamster", in_signature='isiib', out_signature='i') def UpdateFact(self, fact_id, fact, start_time, end_time, temporary = False): start_time = start_time or None @@ -165,15 +249,23 @@ def UpdateFact(self, fact_id, fact, start_time, end_time, temporary = False): end_time = end_time or None if end_time: end_time = dt.datetime.utcfromtimestamp(end_time) - return self.update_fact(fact_id, fact, start_time, end_time, temporary) or 0 - - + return self.update_fact(fact_id, fact, start_time, end_time, temporary) + + @dbus.service.method("org.gnome.Hamster", - in_signature='i{}'.format(fact_signature), + in_signature='is', out_signature='i') - def UpdateFactVerbatim(self, fact_id, dbus_fact): - fact = from_dbus_fact(dbus_fact) - return self.update_fact(fact_id, fact) or 0 + def UpdateFactJSON(self, fact_id, dbus_fact): + """Update fact. + + Args: + fact_id (int): fact id in the database. + dbus_fact (str): new fact content, in JSON format. + Returns: + int: new id (0 means failure) + """ + fact = from_dbus_fact_json(dbus_fact) + return self.update_fact(fact_id, fact) @dbus.service.method("org.gnome.Hamster", in_signature='i') @@ -201,6 +293,8 @@ def GetFacts(self, start_date, end_date, search_terms): i end_date: Seconds since epoch (timestamp). Use 0 for today s search_terms: Bleh. If starts with "not ", the search terms will be reversed Returns an array of D-Bus fact structures. + + Legacy. To be superceded by GetFactsJSON at some point. """ #TODO: Assert start > end ? start = dt.date.today() @@ -214,13 +308,47 @@ def GetFacts(self, start_date, end_date, search_terms): return [to_dbus_fact(fact) for fact in self.get_facts(start, end, search_terms)] + @dbus.service.method("org.gnome.Hamster", + in_signature='ss', + out_signature='as') + def GetFactsJSON(self, dbus_range, search_terms): + """Gets facts between the day of start and the day of end. + + Args: + dbus_range (str): same format as on the command line. + (cf. dt.Range.parse) + search_terms (str): If starts with "not ", + the search terms will be reversed + Return: + array of D-Bus facts in JSON format. + (cf. to_dbus_fact_json) + + This will be the preferred way to get facts. + """ + range = from_dbus_range(dbus_range) + return [to_dbus_fact_json(fact) + for fact in self.get_facts(range, search_terms=search_terms)] + + @dbus.service.method("org.gnome.Hamster", out_signature='a{}'.format(fact_signature)) def GetTodaysFacts(self): - """Gets facts of today, respecting hamster midnight. See GetFacts for - return info""" + """Gets facts of today, + respecting hamster midnight. See GetFacts for return info. + + Legacy, to be superceded by GetTodaysFactsJSON at some point. + """ return [to_dbus_fact(fact) for fact in self.get_todays_facts()] + @dbus.service.method("org.gnome.Hamster", out_signature='as') + def GetTodaysFactsJSON(self): + """Gets facts of the current hamster day. + + Return an array of facts in JSON format. + """ + return [to_dbus_fact_json(fact) for fact in self.get_todays_facts()] + + # categories @dbus.service.method("org.gnome.Hamster", in_signature='s', out_signature = 'i') def AddCategory(self, name): @@ -245,7 +373,7 @@ def GetCategories(self): # activities @dbus.service.method("org.gnome.Hamster", in_signature='si', out_signature = 'i') - def AddActivity(self, name, category_id = -1): + def AddActivity(self, name, category_id): return self.add_activity(name, category_id) @dbus.service.method("org.gnome.Hamster", in_signature='isi') @@ -257,7 +385,7 @@ def RemoveActivity(self, id): return self.remove_activity(id) @dbus.service.method("org.gnome.Hamster", in_signature='i', out_signature='a(isis)') - def GetCategoryActivities(self, category_id = -1): + def GetCategoryActivities(self, category_id): return [(row['id'], row['name'], row['category_id'], diff --git a/src/hamster-windows-service b/src/hamster-windows-service.py old mode 100755 new mode 100644 similarity index 100% rename from src/hamster-windows-service rename to src/hamster-windows-service.py diff --git a/src/hamster/__init__.py b/src/hamster/__init__.py index 50309f4b3..9f6790180 100644 --- a/src/hamster/__init__.py +++ b/src/hamster/__init__.py @@ -1,4 +1,14 @@ +import gi +gi.require_version('Gtk', '3.0') # noqa: E402 +gi.require_version('PangoCairo', '1.0') # noqa: E402 +# for some reason performance is improved by importing Gtk early +from gi.repository import Gtk as gtk + from hamster.lib import default_logger logger = default_logger(__name__) + +# cleanup namespace +del default_logger +del gtk # performance is retained diff --git a/src/hamster/about.py b/src/hamster/about.py index 8069318f9..c6b3507ef 100644 --- a/src/hamster/about.py +++ b/src/hamster/about.py @@ -28,8 +28,7 @@ def __init__(self, parent = None): about = gtk.AboutDialog() self.window = about infos = { - "program-name" : _("Hamster Time Tracker"), - "name" : _("Time Tracker"), #this should be deprecated in gtk 2.10 + "program-name" : "Hamster", "version" : runtime.version, "comments" : _("Project Hamster — track your time"), "copyright" : _("Copyright © 2007–2010 Toms Bauģis and others"), diff --git a/src/hamster/client.py b/src/hamster/client.py index e4d16837e..ab3f4b9d7 100644 --- a/src/hamster/client.py +++ b/src/hamster/client.py @@ -19,10 +19,23 @@ # along with Project Hamster. If not, see . +import dbus +import logging +logger = logging.getLogger(__name__) # noqa: E402 + from calendar import timegm from gi.repository import GObject as gobject -from hamster.lib import Fact, hamster_now -from hamster.lib.dbus import DBusMainLoop, dbus, from_dbus_fact, to_dbus_fact + +from hamster.lib.dbus import ( + DBusMainLoop, + from_dbus_fact_json, + to_dbus_date, + to_dbus_fact, + to_dbus_fact_json, + to_dbus_range, + ) +from hamster.lib.fact import Fact, FactError +from hamster.lib import datetime as dt class Storage(gobject.GObject): @@ -94,21 +107,18 @@ def get_todays_facts(self): """returns facts of the current date, respecting hamster midnight hamster midnight is stored in gconf, and presented in minutes """ - return [from_dbus_fact(fact) for fact in self.conn.GetTodaysFacts()] + return [from_dbus_fact_json(fact) for fact in self.conn.GetTodaysFactsJSON()] - def get_facts(self, date, end_date=None, search_terms=""): + def get_facts(self, start, end=None, search_terms=""): """Returns facts for the time span matching the optional filter criteria. In search terms comma (",") translates to boolean OR and space (" ") to boolean AND. Filter is applied to tags, categories, activity names and description """ - date = timegm(date.timetuple()) - end_date = end_date or 0 - if end_date: - end_date = timegm(end_date.timetuple()) - return [from_dbus_fact(fact) for fact in self.conn.GetFacts(date, - end_date, - search_terms)] + range = dt.Range.from_start_end(start, end) + dbus_range = to_dbus_range(range) + return [from_dbus_fact_json(fact) + for fact in self.conn.GetFactsJSON(dbus_range, search_terms)] def get_activities(self, search = ""): """returns list of activities name matching search criteria. @@ -142,7 +152,26 @@ def update_autocomplete_tags(self, tags): def get_fact(self, id): """returns fact by it's ID""" - return from_dbus_fact(self.conn.GetFact(id)) + return from_dbus_fact_json(self.conn.GetFactJSON(id)) + + def check_fact(self, fact, default_day=None): + """Check Fact validity for inclusion in the storage. + + default_day (date): Default hamster day, + used to simplify some hint messages + (remove unnecessary dates). + None is safe (always show dates). + """ + if not fact.start_time: + # Do not even try to pass fact through D-Bus as + # conversions would fail in this case. + raise FactError("Missing start time") + dbus_fact = to_dbus_fact_json(fact) + dbus_day = to_dbus_date(default_day) + success, message = self.conn.CheckFact(dbus_fact, dbus_day) + if not success: + raise FactError(message) + return success, message def add_fact(self, fact, temporary_activity = False): """Add fact (Fact).""" @@ -150,17 +179,17 @@ def add_fact(self, fact, temporary_activity = False): if not fact.start_time: logger.info("Adding fact without any start_time is deprecated") - fact.start_time = hamster_now() + fact.start_time = dt.datetime.now() - dbus_fact = to_dbus_fact(fact) - new_id = self.conn.AddFactVerbatim(dbus_fact) + dbus_fact = to_dbus_fact_json(fact) + new_id = self.conn.AddFactJSON(dbus_fact) return new_id def stop_tracking(self, end_time = None): """Stop tracking current activity. end_time can be passed in if the activity should have other end time than the current moment""" - end_time = timegm((end_time or hamster_now()).timetuple()) + end_time = timegm((end_time or dt.datetime.now()).timetuple()) return self.conn.StopTracking(end_time) def remove_fact(self, fact_id): @@ -173,8 +202,8 @@ def update_fact(self, fact_id, fact, temporary_activity = False): fact_id after update should not be used anymore. Instead use the ID from the fact dict that is returned by this function""" - dbus_fact = to_dbus_fact(fact) - new_id = self.conn.UpdateFactVerbatim(fact_id, dbus_fact) + dbus_fact = to_dbus_fact_json(fact) + new_id = self.conn.UpdateFactJSON(fact_id, dbus_fact) return new_id diff --git a/src/hamster/edit_activity.py b/src/hamster/edit_activity.py index 99684e201..b28a11ea3 100644 --- a/src/hamster/edit_activity.py +++ b/src/hamster/edit_activity.py @@ -20,18 +20,18 @@ from gi.repository import GObject as gobject from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk -from textwrap import dedent + import time -import datetime as dt """ TODO: hook into notifications and refresh our days if some evil neighbour edit fact window has dared to edit facts """ from hamster import widgets +from hamster.lib import datetime as dt from hamster.lib.configuration import runtime, conf, load_ui_file -from hamster.lib.stuff import ( - hamsterday_time_to_datetime, hamster_today, hamster_now, escape_pango) -from hamster.lib import Fact +from hamster.lib.fact import Fact, FactError +from hamster.lib.stuff import escape_pango + class CustomFactController(gobject.GObject): @@ -42,6 +42,8 @@ class CustomFactController(gobject.GObject): def __init__(self, parent=None, fact_id=None, base_fact=None): gobject.GObject.__init__(self) + self._date = None # for the date property + self._gui = load_ui_file("edit_activity.ui") self.window = self.get_widget('custom_fact_window') self.window.set_size_request(600, 200) @@ -53,8 +55,7 @@ def __init__(self, parent=None, fact_id=None, base_fact=None): self.activity_entry = widgets.ActivityEntry(widget=self.get_widget('activity'), category_widget=self.category_entry) - self.cmdline = widgets.CmdLineEntry() - self.get_widget("command line box").add(self.cmdline) + self.cmdline = widgets.CmdLineEntry(parent=self.get_widget("command line box")) self.cmdline.connect("focus_in_event", self.on_cmdline_focus_in_event) self.cmdline.connect("focus_out_event", self.on_cmdline_focus_out_event) @@ -67,14 +68,12 @@ def __init__(self, parent=None, fact_id=None, base_fact=None): self.end_date = widgets.Calendar(widget=self.get_widget("end date"), expander=self.get_widget("end date expander")) - self.end_time = widgets.TimeInput() - self.get_widget("end time box").add(self.end_time) + self.end_time = widgets.TimeInput(parent=self.get_widget("end time box")) self.start_date = widgets.Calendar(widget=self.get_widget("start date"), expander=self.get_widget("start date expander")) - self.start_time = widgets.TimeInput() - self.get_widget("start time box").add(self.start_time) + self.start_time = widgets.TimeInput(parent=self.get_widget("start time box")) self.tags_entry = widgets.TagsEntry() self.get_widget("tags box").add(self.tags_entry) @@ -87,20 +86,20 @@ def __init__(self, parent=None, fact_id=None, base_fact=None): if fact_id: # editing self.fact = runtime.storage.get_fact(fact_id) - self.date = self.fact.date self.window.set_title(_("Update activity")) else: self.window.set_title(_("Add activity")) - self.date = hamster_today() self.get_widget("delete_button").set_sensitive(False) if base_fact: # start a clone now. - self.fact = base_fact.copy(start_time=hamster_now(), + self.fact = base_fact.copy(start_time=dt.datetime.now(), end_time=None) else: - self.fact = Fact(start_time=hamster_now()) + self.fact = Fact(start_time=dt.datetime.now()) original_fact = self.fact + # TODO: should use hday, not date. + self.date = self.fact.date self.update_fields() self.update_cmdline(select=True) @@ -127,6 +126,23 @@ def __init__(self, parent=None, fact_id=None, base_fact=None): self.validate_fields() self.window.show_all() + @property + def date(self): + """Default hamster day.""" + return self._date + + @date.setter + def date(self, value): + delta = value - self._date if self._date else None + self._date = value + self.cmdline.default_day = value + if self.fact and delta: + if self.fact.start_time: + self.fact.start_time += delta + if self.fact.end_time: + self.fact.end_time += delta + # self.update_fields() here would enter an infinite loop + def on_prev_day_clicked(self, button): self.increment_date(-1) @@ -144,10 +160,6 @@ def get_widget(self, name): def increment_date(self, days): delta = dt.timedelta(days=days) self.date += delta - if self.fact.start_time: - self.fact.start_time += delta - if self.fact.end_time: - self.fact.end_time += delta self.update_fields() def show(self): @@ -172,12 +184,12 @@ def on_category_changed(self, widget): def on_cmdline_changed(self, widget): if self.master_is_cmdline: - fact = Fact.parse(self.cmdline.get_text(), date=self.date) + fact = Fact.parse(self.cmdline.get_text(), default_day=self.date) previous_cmdline_fact = self.cmdline_fact # copy the entered fact before any modification self.cmdline_fact = fact.copy() if fact.start_time is None: - fact.start_time = hamster_now() + fact.start_time = dt.datetime.now() if fact.description == previous_cmdline_fact.description: # no change to description here, keep the main one fact.description = self.fact.description @@ -235,7 +247,7 @@ def on_start_date_changed(self, widget): # preserve fact duration self.fact.end_time += delta self.end_date.date = self.fact.end_time - self.date = self.fact.date or hamster_today() + self.date = self.fact.date or dt.hday.today() self.validate_fields() self.update_cmdline() @@ -255,8 +267,7 @@ def on_start_time_changed(self, widget): new_time) else: # date not specified; result must fall in current hamster_day - new_start_time = hamsterday_time_to_datetime(hamster_today(), - new_time) + new_start_time = dt.datetime.from_day_time(dt.hday.today(), new_time) else: new_start_time = None self.fact.start_time = new_start_time @@ -273,12 +284,13 @@ def on_tags_changed(self, widget): def update_cmdline(self, select=None): """Update the cmdline entry content.""" self.cmdline_fact = self.fact.copy(description=None) - label = self.cmdline_fact.serialized(prepend_date=False) + label = self.cmdline_fact.serialized(default_day=self.date) with self.cmdline.handler_block(self.cmdline.checker): self.cmdline.set_text(label) if select: - time_str = self.cmdline_fact.serialized_time(prepend_date=False) - self.cmdline.select_region(0, len(time_str)) + # select the range string exactly (without separator) + __, rest = dt.Range.parse(label, position="head", separator="") + self.cmdline.select_region(0, len(label) - len(rest)) def update_fields(self): """Update gui fields content.""" @@ -318,7 +330,7 @@ def validate_fields(self): """ fact = self.fact - now = hamster_now() + now = dt.datetime.now() self.get_widget("button-next-day").set_sensitive(self.date < now.date()) if self.date == now.date(): @@ -329,34 +341,13 @@ def validate_fields(self): self.draw_preview(fact.start_time or default_dt, fact.end_time or default_dt) - if fact.start_time is None: - self.update_status(status="wrong", markup="Missing start time") - return None - - if not fact.activity: - self.update_status(status="wrong", markup="Missing activity") + try: + runtime.storage.check_fact(fact, default_day=self.date) + except FactError as error: + self.update_status(status="wrong", markup=str(error)) return None - if (fact.delta < dt.timedelta(0)) and fact.end_time: - fact.end_time += dt.timedelta(days=1) - markup = dedent("""\ - Working late ? - Duration would be negative. - This happens when the activity crosses the - hamster day start time ({:%H:%M} from tracking settings). - - Changing the end time date to the next day. - Pressing the button would save - an actvity going from - {} - to - {} - (in civil local time) - """.format(conf.day_start, fact.start_time, fact.end_time)) - self.update_status(status="warning", markup=markup) - return fact - - roundtrip_fact = Fact.parse(fact.serialized()) + roundtrip_fact = Fact.parse(fact.serialized(), default_day=self.date) if roundtrip_fact != fact: self.update_status(status="wrong", markup="Fact could not be parsed back") return None diff --git a/src/hamster/external.py b/src/hamster/external.py deleted file mode 100644 index 2426652b8..000000000 --- a/src/hamster/external.py +++ /dev/null @@ -1,146 +0,0 @@ -# - coding: utf-8 - - -# Copyright (C) 2007 Patryk Zawadzki -# Copyright (C) 2008, 2010 Toms Bauģis - -# This file is part of Project Hamster. - -# Project Hamster is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Project Hamster 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 Project Hamster. If not, see . - -import logging -logger = logging.getLogger(__name__) # noqa: E402 - -from hamster.lib.configuration import conf -from gi.repository import GObject as gobject -import dbus, dbus.mainloop.glib - -try: - import evolution - from evolution import ecal -except: - evolution = None - -try: - import taskw - from taskw import TaskWarrior -except: - taskw = None - -class ActivitiesSource(gobject.GObject): - def __init__(self): - gobject.GObject.__init__(self) - self.source = conf.get("activities_source") - self.__gtg_connection = None - - if self.source == "evo" and not evolution: - self.source = "" # on failure pretend that there is no evolution - elif self.source == "gtg": - gobject.GObject.__init__(self) - dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - elif self.source == "task" and not taskw: - self.source = "" - - - def get_activities(self, query = None): - if not self.source: - return [] - - if self.source == "evo": - return [activity for activity in get_eds_tasks() - if query is None or activity['name'].startswith(query)] - - elif self.source == "gtg": - conn = self.__get_gtg_connection() - if not conn: - return [] - - activities = [] - - tasks = [] - try: - tasks = conn.GetTasks() - except dbus.exceptions.DBusException: #TODO too lame to figure out how to connect to the disconnect signal - self.__gtg_connection = None - return self.get_activities(query) # reconnect - - - for task in tasks: - if query is None or task['title'].lower().startswith(query): - name = task['title'] - if len(task['tags']): - name = "%s, %s" % (name, " ".join([tag.replace("@", "#") for tag in task['tags']])) - - activities.append({"name": name, - "category": ""}) - - return activities - - elif self.source == "task": - conn = TaskWarrior () - if not conn: - return [] - - activities = [] - tasks = [] - - task_filter = {'status':'pending'} - tasks = conn.filter_tasks(task_filter) - - for task in tasks: - name = task['description'].replace(",","") # replace comma - category = "" - if 'tags' in task: - name = "%s, %s " % (name, " ".join(task['tags'])) - - if 'project' in task: - category = task['project'] - - activities.append({"name":name,"category":category}) - - return activities - - def __get_gtg_connection(self): - bus = dbus.SessionBus() - if self.__gtg_connection and bus.name_has_owner("org.gnome.GTG"): - return self.__gtg_connection - - if bus.name_has_owner("org.gnome.GTG"): - self.__gtg_connection = dbus.Interface(bus.get_object('org.gnome.GTG', '/org/gnome/GTG'), - dbus_interface='org.gnome.GTG') - return self.__gtg_connection - else: - return None - - - -def get_eds_tasks(): - try: - sources = ecal.list_task_sources() - tasks = [] - if not sources: - # BUG - http://bugzilla.gnome.org/show_bug.cgi?id=546825 - sources = [('default', 'default')] - - for source in sources: - category = source[0] - - data = ecal.open_calendar_source(source[1], ecal.CAL_SOURCE_TYPE_TODO) - if data: - for task in data.get_all_objects(): - if task.get_status() in [ecal.ICAL_STATUS_NONE, ecal.ICAL_STATUS_INPROCESS]: - tasks.append({'name': task.get_summary(), 'category' : category}) - return tasks - except Exception as e: - logger.warn(e) - return [] diff --git a/src/hamster/idle.py b/src/hamster/idle.py deleted file mode 100644 index 5d70e019d..000000000 --- a/src/hamster/idle.py +++ /dev/null @@ -1,146 +0,0 @@ -# - coding: utf-8 - - -# Copyright (C) 2008 Patryk Zawadzki - -# This file is part of Project Hamster. - -# Project Hamster is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Project Hamster 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 Project Hamster. If not, see . - -import logging -logger = logging.getLogger(__name__) # noqa: E402 - -import dbus -import datetime as dt -import gi - -from dbus.lowlevel import Message - -gi.require_version('GConf', '2.0') -from gi.repository import GConf as gconf -from gi.repository import GObject as gobject - -class DbusIdleListener(gobject.GObject): - """ - Listen for idleness coming from org.gnome.ScreenSaver - - Monitors org.gnome.ScreenSaver for idleness. There are two types, - implicit (due to inactivity) and explicit (lock screen), that need to be - handled differently. An implicit idle state should subtract the - time-to-become-idle (as specified in the gconf) from the last activity, - but an explicit idle state should not. - - The signals are inspected for the "ActiveChanged" and "Lock" - members coming from the org.gnome.ScreenSaver interface and the - and is_screen_locked members are updated appropriately. - """ - __gsignals__ = { - "idle-changed": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)) - } - def __init__(self): - gobject.GObject.__init__(self) - - self.screensaver_uri = "org.gnome.ScreenSaver" - self.screen_locked = False - self.idle_from = None - self.timeout_minutes = 0 # minutes after session is considered idle - self.idle_was_there = False # a workaround variable for pre 2.26 - - try: - self.bus = dbus.SessionBus() - except: - return 0 - # Listen for chatter on the screensaver interface. - # We cannot just add additional match strings to narrow down - # what we hear because match strings are ORed together. - # E.g., if we were to make the match string - # "interface='org.gnome.ScreenSaver', type='method_call'", - # we would not get only screensaver's method calls, rather - # we would get anything on the screensaver interface, as well - # as any method calls on *any* interface. Therefore the - # bus_inspector needs to do some additional filtering. - self.bus.add_match_string_non_blocking("interface='%s'" % - self.screensaver_uri) - self.bus.add_message_filter(self.bus_inspector) - - - def bus_inspector(self, bus, message): - """ - Inspect the bus for screensaver messages of interest - """ - - # We only care about stuff on this interface. We did filter - # for it above, but even so we still hear from ourselves - # (hamster messages). - if message.get_interface() != self.screensaver_uri: - return True - - member = message.get_member() - - if member in ("SessionIdleChanged", "ActiveChanged"): - logger.debug("%s -> %s" % (member, message.get_args_list())) - - idle_state = message.get_args_list()[0] - if idle_state: - self.idle_from = dt.datetime.now() - - # from gnome screensaver 2.24 to 2.28 they have switched - # configuration keys and signal types. - # luckily we can determine key by signal type - if member == "SessionIdleChanged": - delay_key = "/apps/gnome-screensaver/idle_delay" - else: - delay_key = "/desktop/gnome/session/idle_delay" - - client = gconf.Client.get_default() - self.timeout_minutes = client.get_int(delay_key) - - else: - self.screen_locked = False - self.idle_from = None - - if member == "ActiveChanged": - # ActiveChanged comes before SessionIdleChanged signal - # as a workaround for pre 2.26, we will wait a second - maybe - # SessionIdleChanged signal kicks in - def dispatch_active_changed(idle_state): - if not self.idle_was_there: - self.emit('idle-changed', idle_state) - self.idle_was_there = False - - gobject.timeout_add_seconds(1, dispatch_active_changed, idle_state) - - else: - # dispatch idle status change to interested parties - self.idle_was_there = True - self.emit('idle-changed', idle_state) - - elif member == "Lock": - # in case of lock, lock signal will be sent first, followed by - # ActiveChanged and SessionIdle signals - logger.debug("Screen Lock Requested") - self.screen_locked = True - - return - - - def getIdleFrom(self): - if not self.idle_from: - return dt.datetime.now() - - if self.screen_locked: - return self.idle_from - else: - # Only subtract idle time from the running task when - # idleness is due to time out, not a screen lock. - return self.idle_from - dt.timedelta(minutes = self.timeout_minutes) diff --git a/src/hamster/lib/__init__.py b/src/hamster/lib/__init__.py index cbe8172b5..1f930cc87 100644 --- a/src/hamster/lib/__init__.py +++ b/src/hamster/lib/__init__.py @@ -1,379 +1,8 @@ import logging logger = logging.getLogger(__name__) # noqa: E402 -import calendar -import datetime as dt -import re -from copy import deepcopy - -from hamster.lib.stuff import ( - datetime_to_hamsterday, - hamsterday_time_to_datetime, - hamster_now, -) - - -DATE_FMT = "%Y-%m-%d" -TIME_FMT = "%H:%M" - - -# match #tag followed by any space or # that will be ignored -# tag must not contain #, comma, or any space character -tag_re = re.compile(r""" - [\s,]* # any spaces or commas (or nothing) - \# # hash character - ([^#\s]+) # the tag (anything but # or spaces) - [\s#,]* # any spaces, #, or commas (or nothing) - $ # end of text -""", flags=re.VERBOSE) - - -# match time, such as "01:32", "13.56" or "0116" -time_re = re.compile(r""" - ^ # start of string - (?P[0-1]?[0-9] | [2][0-3]) # hour (2 digits, between 00 and 23) - [:,\.]? # separator can be colon, - # dot, comma, or nothing - (?P[0-5][0-9]) # minute (2 digits, between 00 and 59) - $ # end of string -""", flags=re.VERBOSE) - - -def extract_time(match): - """extract time from a time_re match.""" - hour = int(match.group('hour')) - minute = int(match.group('minute')) - return dt.time(hour, minute) - - -def figure_time(str_time): - if not str_time or not str_time.strip(): - return None - - # strip everything non-numeric and consider hours to be first number - # and minutes - second number - numbers = re.split("\D", str_time) - numbers = [x for x in numbers if x!=""] - - hours, minutes = None, None - - if len(numbers) == 1 and len(numbers[0]) == 4: - hours, minutes = int(numbers[0][:2]), int(numbers[0][2:]) - else: - if len(numbers) >= 1: - hours = int(numbers[0]) - if len(numbers) >= 2: - minutes = int(numbers[1]) - - if (hours is None or minutes is None) or hours > 24 or minutes > 60: - return None #no can do - - return hamster_now() - - -class Fact(object): - def __init__(self, activity="", category=None, description=None, tags=None, - start_time=None, end_time=None, id=None, activity_id=None): - """Homogeneous chunk of activity. - - The category, description and tags must be passed explicitly. - - To provide the whole fact information as a single string, - please use Fact.parse(string). - - id (int): id in the database. - Should be used with extreme caution, knowing exactly why. - (only for very specific direct database read/write) - """ - - self.activity = activity - self.category = category - self.description = description - self.tags = tags or [] - self.start_time = start_time - self.end_time = end_time - self.id = id - self.activity_id = activity_id - - # TODO: might need some cleanup - def as_dict(self): - date = self.date - return { - 'id': int(self.id) if self.id else "", - 'activity': self.activity, - 'category': self.category, - 'description': self.description, - 'tags': [tag.strip() for tag in self.tags], - 'date': calendar.timegm(date.timetuple()) if date else "", - 'start_time': self.start_time if isinstance(self.start_time, str) else calendar.timegm(self.start_time.timetuple()), - 'end_time': self.end_time if isinstance(self.end_time, str) else calendar.timegm(self.end_time.timetuple()) if self.end_time else "", - 'delta': self.delta.total_seconds() # ugly, but needed for report.py - } - - @property - def activity(self): - """Activity name.""" - return self._activity - - @activity.setter - def activity(self, value): - self._activity = value.strip() if value else "" - - @property - def category(self): - return self._category - - @category.setter - def category(self, value): - self._category = value.replace(",", "").strip() if value else "" - - def copy(self, **kwds): - """Return an independent copy, with overrides as keyword arguments. - - By default, only copy user-visible attributes. - To also copy the id, use fact.copy(id=fact.id) - """ - fact = deepcopy(self) - fact._set(**kwds) - return fact - - @property - def date(self): - """hamster day, determined from start_time. - - Note: Setting date is a one-shot modification of - the start_time and end_time (if defined), - to match the given value. - Any subsequent modification of start_time - can result in different self.date. - """ - return datetime_to_hamsterday(self.start_time) - - @date.setter - def date(self, value): - if self.start_time: - previous_start_time = self.start_time - self.start_time = hamsterday_time_to_datetime(value, self.start_time.time()) - if self.end_time: - # start_time date prevails. - # Shift end_time to preserve the fact duration. - self.end_time += self.start_time - previous_start_time - elif self.end_time: - self.end_time = hamsterday_time_to_datetime(value, self.end_time.time()) - - @property - def delta(self): - """Duration (datetime.timedelta).""" - end_time = self.end_time if self.end_time else hamster_now() - return end_time - self.start_time - - @property - def description(self): - return self._description - - @description.setter - def description(self, value): - self._description = value.strip() if value else "" - - @classmethod - def parse(cls, string, date=None): - fact = Fact() - fact.date = date - phase = "start_time" if date else "date" - for key, val in parse_fact(string, phase, {}, date).items(): - setattr(fact, key, val) - return fact - - def serialized_name(self): - res = self.activity - - if self.category: - res += "@%s" % self.category - - if self.description: - res += ", %s" % self.description - - if self.tags: - res += " %s" % " ".join("#%s" % tag for tag in self.tags) - return res - - def serialized_time(self, prepend_date=True): - time = "" - if self.start_time: - if prepend_date: - time += self.date.strftime(DATE_FMT) + " " - time += self.start_time.strftime(TIME_FMT) - if self.end_time: - time = "%s-%s" % (time, self.end_time.strftime(TIME_FMT)) - return time - - def serialized(self, prepend_date=True): - """Return a string fully representing the fact.""" - name = self.serialized_name() - datetime = self.serialized_time(prepend_date) - # no need for space if name or datetime is missing - space = " " if name and datetime else "" - return "{}{}{}".format(datetime, space, name) - - def _set(self, **kwds): - """Modify attributes. - - Private, used only in copy. It is more readable to be explicit, e.g.: - fact.start_time = ... - fact.end_time = ... - """ - for attr, value in kwds.items(): - if not hasattr(self, attr): - raise AttributeError(f"'{attr}' not found") - else: - setattr(self, attr, value) - - def __eq__(self, other): - return (isinstance(other, Fact) - and self.activity == other.activity - and self.category == other.category - and self.description == other.description - and self.end_time == other.end_time - and self.start_time == other.start_time - and self.tags == other.tags - ) - - def __repr__(self): - return self.serialized(prepend_date=True) - - -def parse_fact(text, phase=None, res=None, date=None): - """tries to extract fact fields from the string - the optional arguments in the syntax makes us actually try parsing - values and fallback to next phase - start -> [end] -> activity[@category] -> tags - - Returns dict for the fact and achieved phase - - TODO - While we are now bit cooler and going recursively, this code - still looks rather awfully spaghetterian. What is the real solution? - - Tentative syntax: - [date] start_time[-end_time] activity[@category][, description]{[,] { })#tag} - According to the legacy tests, # were allowed in the description - """ - now = hamster_now() - - # determine what we can look for - phases = [ - "date", # hamster day - "start_time", - "end_time", - "tags", - "activity", - "category", - ] - - phase = phase or phases[0] - phases = phases[phases.index(phase):] - if res is None: - res = {} - - text = text.strip() - if not text: - return res - - fragment = re.split("[\s|#]", text, 1)[0].strip() - - # remove a fragment assumed to be at the beginning of text - remove_fragment = lambda text, fragment: text[len(fragment):] - - if "date" in phases: - # if there is any date given, it must be at the front - try: - date = dt.datetime.strptime(fragment, DATE_FMT).date() - remaining_text = remove_fragment(text, fragment) - except ValueError: - date = datetime_to_hamsterday(now) - remaining_text = text - return parse_fact(remaining_text, "start_time", res, date) - - if "start_time" in phases or "end_time" in phases: - - # -delta ? - delta_re = re.compile("^-[0-9]{1,3}$") - if delta_re.match(fragment): - # TODO untested - # delta_re was probably thought to be used - # alone or together with a start_time - # but using "now" prevents the latter - res[phase] = now + dt.timedelta(minutes=int(fragment)) - remaining_text = remove_fragment(text, fragment) - return parse_fact(remaining_text, phases[phases.index(phase)+1], res, date) - - # only starting time ? - m = re.search(time_re, fragment) - if m: - time = extract_time(m) - res[phase] = hamsterday_time_to_datetime(date, time) - remaining_text = remove_fragment(text, fragment) - return parse_fact(remaining_text, phases[phases.index(phase)+1], res, date) - - # start-end ? - start, __, end = fragment.partition("-") - m_start = re.search(time_re, start) - m_end = re.search(time_re, end) - if m_start and m_end: - start_time = extract_time(m_start) - end_time = extract_time(m_end) - res["start_time"] = hamsterday_time_to_datetime(date, start_time) - res["end_time"] = hamsterday_time_to_datetime(date, end_time) - remaining_text = remove_fragment(text, fragment) - return parse_fact(remaining_text, "tags", res, date) - - if "tags" in phases: - # Need to start from the end, because - # the description can hold some '#' characters - tags = [] - remaining_text = text - while True: - m = re.search(tag_re, remaining_text) - if not m: - break - tag = m.group(1) - tags.append(tag) - # strip the matched string (including #) - remaining_text = remaining_text[:m.start()] - # put tags back in input order - res["tags"] = list(reversed(tags)) - return parse_fact(remaining_text, "activity", res, date) - - if "activity" in phases: - activity = re.split("[@|#|,]", text, 1)[0] - if looks_like_time(activity): - # want meaningful activities - return res - - res["activity"] = activity - remaining_text = remove_fragment(text, activity) - return parse_fact(remaining_text, "category", res, date) - - if "category" in phases: - category, _, description = text.partition(",") - res["category"] = category.lstrip("@").strip() or None - res["description"] = description.strip() or None - return res - - return {} - - -_time_fragment_re = [ - re.compile("^-$"), - re.compile("^([0-1]?[0-9]?|[2]?[0-3]?)$"), - re.compile("^([0-1]?[0-9]|[2][0-3]):?([0-5]?[0-9]?)$"), - re.compile("^([0-1]?[0-9]|[2][0-3]):([0-5][0-9])-?([0-1]?[0-9]?|[2]?[0-3]?)$"), - re.compile("^([0-1]?[0-9]|[2][0-3]):([0-5][0-9])-([0-1]?[0-9]|[2][0-3]):?([0-5]?[0-9]?)$"), -] -def looks_like_time(fragment): - if not fragment: - return False - return any((r.match(fragment) for r in _time_fragment_re)) +from hamster.lib.fact import Fact # for backward compatibility with v2 def default_logger(name): diff --git a/src/hamster/lib/charting.py b/src/hamster/lib/charting.py index e88adb313..c4843aa5c 100644 --- a/src/hamster/lib/charting.py +++ b/src/hamster/lib/charting.py @@ -20,9 +20,10 @@ from gi.repository import GObject as gobject from gi.repository import Gtk as gtk from gi.repository import Pango as pango -import datetime as dt + import time from hamster.lib import graphics, stuff +from hamster.lib import datetime as dt import locale class Bar(graphics.Sprite): diff --git a/src/hamster/lib/configuration.py b/src/hamster/lib/configuration.py index 749d66e49..ae9825329 100644 --- a/src/hamster/lib/configuration.py +++ b/src/hamster/lib/configuration.py @@ -18,7 +18,6 @@ # along with Project Hamster. If not, see . """ -gconf part of this code copied from Gimmie (c) Alex Gravely via Conduit (c) John Stowers, 2006 License: GPLv2 """ @@ -28,17 +27,21 @@ import os from hamster.client import Storage from xdg.BaseDirectory import xdg_data_home -import datetime as dt +from gi.repository import Gdk as gdk +from gi.repository import Gio as gio +from gi.repository import GLib as glib from gi.repository import GObject as gobject from gi.repository import Gtk as gtk -import gi -gi.require_version('GConf', '2.0') -from gi.repository import GConf as gconf +from hamster.lib import datetime as dt class Controller(gobject.GObject): + """Window creator and handler. + + If parent is given, this is a dialog for parent, otherwise a toplevel. + """ __gsignals__ = { "on-close": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, ()), } @@ -55,34 +58,48 @@ def __init__(self, parent=None, ui_file=""): self._gui = None self.window = gtk.Window() + if parent: + # Essential for positioning on wayland. + # This should also select the correct window type if unset yet. + # https://specifications.freedesktop.org/wm-spec/wm-spec-1.3.html + self.window.set_transient_for(parent.get_toplevel()) + # this changes nothing, the dialog appears centered on the screen: + # self.window.set_type_hint(gdk.WindowTypeHint.DIALOG) + self.window.connect("delete-event", self.window_delete_event) if self._gui: self._gui.connect_signals(self) - def get_widget(self, name): """ skip one variable (huh) """ return self._gui.get_object(name) - def window_delete_event(self, widget, event): self.close_window() def close_window(self): - if not self.parent: - gtk.main_quit() - else: - """ - for obj, handler in self.external_listeners: - obj.disconnect(handler) - """ - self.window.destroy() - self.window = None - self.emit("on-close") + # Do not try to just hide; + # dialogs are populated upon instanciation anyway + self.window.destroy() + self.window = None + self.emit("on-close") + + def present(self): + """Show window and bring it to the foreground.""" + # workaround https://gitlab.gnome.org/GNOME/gtk/issues/624 + # fixed in gtk-3.24.1 (2018-09-19) + # self.overview_controller.window.present() + self.window.present_with_time(glib.get_monotonic_time() / 1000) def show(self): + """Show window. + It might be obscurd by others though. + See also: presents + """ self.window.show() + def __bool__(self): + return True if self.window else False def load_ui_file(name): """loads interface from the glade file; sorts out the path business""" @@ -193,154 +210,58 @@ def get_prefs_class(): dialogs = Dialogs() -class GConfStore(gobject.GObject, Singleton): +class GSettingsStore(gobject.GObject, Singleton): """ - Settings implementation which stores settings in GConf + Settings implementation which stores settings in GSettings Snatched from the conduit project (http://live.gnome.org/Conduit) """ - GCONF_DIR = "/apps/hamster/" - VALID_KEY_TYPES = (bool, str, int, list, tuple) - DEFAULTS = { - 'enable_timeout' : False, # Should hamster stop tracking on idle - 'stop_on_shutdown' : False, # Should hamster stop tracking on shutdown - 'notify_on_idle' : False, # Remind also if no activity is set - 'notify_interval' : 27, # Remind of current activity every X minutes - 'day_start_minutes' : 5 * 60 + 30, # At what time does the day start (5:30AM) - 'overview_window_box' : [], # X, Y, W, H - 'overview_window_maximized' : False, # Is overview window maximized - 'standalone_window_box' : [], # X, Y, W, H - 'standalone_window_maximized' : False, # Is overview window maximized - 'activities_source' : "", # Source of TODO items ("", "evo", "gtg") - 'last_report_folder' : "~", # Path to directory where the last report was saved - } __gsignals__ = { - "conf-changed": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)) + "changed": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)) } + def __init__(self): gobject.GObject.__init__(self) - self._client = gconf.Client.get_default() - self._client.add_dir(self.GCONF_DIR[:-1], gconf.ClientPreloadType.PRELOAD_RECURSIVE) - self._notifications = [] + self._settings = gio.Settings(schema_id='org.gnome.Hamster') - def _fix_key(self, key): + def _key_changed(self, client, key, data=None): """ - Appends the GCONF_PREFIX to the key if needed - - @param key: The key to check - @type key: C{string} - @returns: The fixed key - @rtype: C{string} + Callback when a GSettings key changes """ - if not key.startswith(self.GCONF_DIR): - return self.GCONF_DIR + key - else: - return key - - def _key_changed(self, client, cnxn_id, entry, data=None): - """ - Callback when a gconf key changes - """ - key = self._fix_key(entry.key)[len(self.GCONF_DIR):] - value = self._get_value(entry.value, self.DEFAULTS[key]) - - self.emit('conf-changed', key, value) - - - def _get_value(self, value, default): - """calls appropriate gconf function by the default value""" - vtype = type(default) - - if vtype is bool: - return value.get_bool() - elif vtype is str: - return value.get_string() - elif vtype is int: - return value.get_int() - elif vtype in (list, tuple): - l = [] - for i in value.get_list(): - l.append(i.get_string()) - return l - - return None + value = self._settings.get_value(key) + self.emit('changed', key, value) def get(self, key, default=None): """ Returns the value of the key or the default value if the key is - not yet in gconf + not yet in GSettings """ - - #function arguments override defaults - if default is None: - default = self.DEFAULTS.get(key, None) - vtype = type(default) - - #we now have a valid key and type - if default is None: - logger.warn("Unknown key: %s, must specify default value" % key) - return None - - if vtype not in self.VALID_KEY_TYPES: - logger.warn("Invalid key type: %s" % vtype) - return None - - #for gconf refer to the full key path - key = self._fix_key(key) - - if key not in self._notifications: - self._client.notify_add(key, self._key_changed, None) - self._notifications.append(key) - - value = self._client.get(key) + value = self._settings.get_value(key) if value is None: - self.set(key, default) - return default - - value = self._get_value(value, default) - if value is not None: - return value + logger.warn("Unknown GSettings key: %s" % key) - logger.warn("Unknown gconf key: %s" % key) - return None + return value.unpack() def set(self, key, value): """ - Sets the key value in gconf and connects adds a signal + Sets the key value in GSettings and connects adds a signal which is fired if the key changes """ logger.debug("Settings %s -> %s" % (key, value)) - if key in self.DEFAULTS: - vtype = type(self.DEFAULTS[key]) - else: - vtype = type(value) - - if vtype not in self.VALID_KEY_TYPES: - logger.warn("Invalid key type: %s" % vtype) - return False - - #for gconf refer to the full key path - key = self._fix_key(key) - - if vtype is bool: - self._client.set_bool(key, value) - elif vtype is str: - self._client.set_string(key, value) - elif vtype is int: - self._client.set_int(key, value) - elif vtype in (list, tuple): - #Save every value as a string - strvalues = [str(i) for i in value] - #self._client.set_list(key, gconf.VALUE_STRING, strvalues) - + default = self._settings.get_default_value(key) + assert default is not None + self._settings.set_value(key, glib.Variant(default.get_type().dup_string(), value)) return True + def bind(self, key, obj, prop): + self._settings.bind(key, obj, prop, gio.SettingsBindFlags.DEFAULT) + @property def day_start(self): """Start of the hamster day.""" - day_start_minutes = self.get("day_start_minutes") + day_start_minutes = self.get("day-start-minutes") hours, minutes = divmod(day_start_minutes, 60) return dt.time(hours, minutes) -conf = GConfStore() +conf = GSettingsStore() diff --git a/src/hamster/lib/datetime.py b/src/hamster/lib/datetime.py new file mode 100644 index 000000000..2592a9d43 --- /dev/null +++ b/src/hamster/lib/datetime.py @@ -0,0 +1,694 @@ +# This file is part of Hamster +# Copyright (c) The Hamster time tracker developers +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Hamster datetime. + +Python datetime replacement, tuned for hamster use. +""" + + +import logging +logger = logging.getLogger(__name__) # noqa: E402 + +import datetime as pdt # standard datetime +import re + +from collections import namedtuple +from textwrap import dedent +from functools import lru_cache + + +class datetime: # predeclaration for return type annotations + pass + + +class time: # predeclaration for return type annotations + pass + + +class date(pdt.date): + """Hamster date. + + Should replace the python datetime.date in any customer code. + + Same as python date, with the addition of parse methods. + """ + + FMT = "%Y-%m-%d" # ISO format + + def __new__(cls, year, month, day): + return pdt.date.__new__(cls, year, month, day) + + def __add__(self, other): + # python date.__add__ was not type stable prior to 3.8 + return self.from_pdt(self.to_pdt() + other) + + __radd__ = __add__ + + def __sub__(self, other): + # python date.__sub__ was not type stable prior to 3.8 + if isinstance(other, pdt.timedelta): + return self.from_pdt(self.to_pdt() - other) + elif isinstance(other, pdt.date): + return timedelta.from_pdt(self.to_pdt() - other) + else: + raise NotImplementedError("subtract {}".format(type(other))) + + @classmethod + def parse(cls, s): + """Return date from string.""" + m = cls.re.search(s) + return cls(year=int(m.group('year')), + month=int(m.group('month')), + day=int(m.group('day')) + ) + + @classmethod + def pattern(cls, detailed=True): + if detailed: + return dedent(r""" + (?P\d{4}) # 4 digits + - # dash + (?P\d{2}) # 2 digits + - # dash + (?P\d{2}) # 2 digits + """) + else: + return r"""\d{4}-\d{2}-\d{2}""" + + @classmethod + def from_pdt(cls, d): + """Convert python date to hamster date.""" + return cls(d.year, d.month, d.day) + + def to_pdt(self): + """Convert to python date.""" + return pdt.date(self.year, self.month, self.day) + +# For datetime that will need to be outside the class. +# Same here for consistency +date.re = re.compile(date.pattern(), flags=re.VERBOSE) + + +class hday(date): + """Hamster day. + + Same as date, but taking into account day-start. + A day starts at conf.day_start and ends when the next day starts. + """ + + def __new__(cls, year, month, day): + return date.__new__(cls, year, month, day) + + @property + def end(self) -> datetime: + """Day end.""" + return datetime.from_day_time(self + timedelta(days=1), self.start_time()) + + @property + def start(self) -> datetime: + """Day start.""" + return datetime.from_day_time(self, self.start_time()) + + @classmethod + def start_time(cls) -> time: + """Day start time.""" + # work around cyclic imports + from hamster.lib.configuration import conf + return conf.day_start + + @classmethod + def today(cls): + """Return the current hamster day.""" + return datetime.now().hday() + + +class time(pdt.time): + """Hamster time. + + Should replace the python datetime.time in any customer code. + Specificities: + - rounded to minutes + - conversion to and from string facilities + """ + + FMT = "%H:%M" # display format, e.g. 13:30 + + def __new__(cls, + hour=0, minute=0, + second=0, microsecond=0, + tzinfo=None, fold=0): + # round down to zero seconds and microseconds + return pdt.time.__new__(cls, + hour=hour, minute=minute, + second=0, microsecond=0, + tzinfo=None, fold=fold) + + @classmethod + def _extract_time(cls, match, h="hour", m="minute"): + """Extract time from a time.re match. + + Custom group names allow to use the same method + for two times in the same regexp (e.g. for range parsing) + + h (str): name of the group containing the hour + m (str): name of the group containing the minute + + seealso: time.parse + """ + h_str = match.group(h) + m_str = match.group(m) + if h_str and m_str: + hour = int(h_str) + minute = int(m_str) + return cls(hour, minute) + else: + return None + + @classmethod + def parse(cls, s): + """Parse time from string.""" + m = cls.re.search(s) + return cls._extract_time(m) if m else None + + # For datetime that must be a method. + # Same here for consistency. + @classmethod + def pattern(cls): + """Return a time pattern with all group names.""" + + # remove the indentation for easier debugging. + return dedent(r""" + (?P # hour + [0-9](?=[,\.:]) # positive lookahead: + # allow a single digit only if + # followed by a colon, dot or comma + | [0-1][0-9] # 00 to 19 + | [2][0-3] # 20 to 23 + ) + [,\.:]? # Separator can be colon, + # dot, comma, or nothing. + (?P[0-5][0-9]) # minute (2 digits, between 00 and 59) + (?!\d?-\d{2}-\d{2}) # Negative lookahead: + # avoid matching date by inadvertance. + # For instance 2019-12-05 + # might be caught as 2:01. + # Requiring space or - would not work: + # 2019-2025 is the 20:19-20:25 range. + """) + + +# For datetime that will need to be outside the class. +# Same here for consistency +time.re = re.compile(time.pattern(), flags=re.VERBOSE) + + +class datetime(pdt.datetime): + """Hamster datetime. + + Should replace the python datetime.datetime in any customer code. + Specificities: + - rounded to minutes + - conversion to and from string facilities + """ + + # display format, e.g. 2020-01-20 20:40 + FMT = "{} {}".format(date.FMT, time.FMT) + + def __new__(cls, year, month, day, + hour=0, minute=0, + second=0, microsecond=0, + tzinfo=None, *, fold=0): + # round down to zero seconds and microseconds + return pdt.datetime.__new__(cls, year, month, day, + hour=hour, minute=minute, + second=0, microsecond=0, + tzinfo=None, fold=fold) + + def __add__(self, other): + # python datetime.__add__ was not type stable prior to 3.8 + return datetime.from_pdt(self.to_pdt() + other) + + # similar to https://stackoverflow.com/q/51966126/3565696 + # __getnewargs_ex__ did not work, brute force required + def __deepcopy__(self, memo): + return datetime(self.year, self.month, self.day, + self.hour, self.minute, + self.second, self.microsecond, + self.tzinfo, fold=self.fold) + + __radd__ = __add__ + + def __sub__(self, other): + # python datetime.__sub__ was not type stable prior to 3.8 + if isinstance(other, timedelta): + return datetime.from_pdt(self.to_pdt() - other) + elif isinstance(other, datetime): + return timedelta.from_pdt(self.to_pdt() - other) + else: + raise NotImplementedError("subtract {}".format(type(other))) + + def __str__(self): + if self.tzinfo: + raise NotImplementedError("Stay tuned...") + else: + return self.strftime(self.FMT) + + @classmethod + def _extract_datetime(cls, match, d="date", h="hour", m="minute", r="relative", + default_day=None): + """extract datetime from a datetime.pattern match. + + Custom group names allow to use the same method + for two datetimes in the same regexp (e.g. for range parsing) + + h (str): name of the group containing the hour + m (str): name of the group containing the minute + r (str): name of the group containing the relative time + default_day (dt.date): the datetime will belong to this hamster day if + date is missing. + """ + _time = time._extract_time(match, h, m) + if _time: + date_str = match.group(d) + if date_str: + _date = date.parse(date_str) + return datetime.combine(_date, _time) + else: + return datetime.from_day_time(default_day, _time) + else: + relative_str = match.group(r) + if relative_str and relative_str != "--": + return timedelta(minutes=int(relative_str)) + else: + return None + + # not a property, to match other extractions such as .date() or .time() + def hday(self) -> hday: + """Return the day belonged to. + + The hamster day start is taken into account. + """ + + # work around cyclic imports + from hamster.lib.configuration import conf + + _day = hday(self.year, self.month, self.day) + if self.time() < conf.day_start: + # early morning, between midnight and day_start + # => the hamster day is the previous civil day + _day -= timedelta(days=1) + + # return only the date + return _day + + @classmethod + def from_day_time(cls, d: hday, t: time): + """Return a datetime with time t belonging to day. + + The hamster day start is taken into account. + """ + + # work around cyclic imports + from hamster.lib.configuration import conf + + if t < conf.day_start: + # early morning, between midnight and day_start + # => the hamster day is the previous civil day + civil_date = d + timedelta(days=1) + else: + civil_date = d + return cls.combine(civil_date, t) + + # Note: fromisoformat appears in python3.7 + + @classmethod + def from_pdt(cls, t): + """Convert python datetime to hamster datetime.""" + return cls(t.year, t.month, t.day, + t.hour, t.minute, + t.second, t.microsecond, + t.tzinfo, fold=t.fold) + + @classmethod + def now(cls): + """Current datetime.""" + return cls.from_pdt(pdt.datetime.now()) + + @classmethod + def parse(cls, s, default_day=None): + """Parse a datetime from text. + + default_day (dt.date): + If start is given without any date (e.g. just hh:mm), + put the corresponding datetime in default_day. + Defaults to today. + """ + + # datetime.re is added below, after the class definition + # it will be found at runtime + m = datetime.re.search(s) + return cls._extract_datetime(m, default_day=default_day) if m else None + + @classmethod + @lru_cache() + def pattern(cls, n=None): + """Return a datetime pattern with all group names. + + If n is given, all groups are suffixed with str(n). + """ + + # remove the indentation => easier debugging. + base_pattern = dedent(r""" + (?P + # note: need to double the brackets + # for .format + (? + -- # double dash: None + | # or + [-+] # minus or plus: relative to ref + \d{{1,3}} # 1, 2 or 3 digits + ) + | # or + (?P{})? # maybe date + \s? # maybe one space + {} # time + ) + """).format(date.pattern(), time.pattern()) + if n is None: + return base_pattern + else: + to_replace = ("whole", "relative", + "year", "month", "day", "date", "tens", "hour", "minute") + specifics = ["{}{}".format(s, n) for s in to_replace] + res = base_pattern + for src, dest in zip(to_replace, specifics): + res = res.replace(src, dest) + return res + + def to_pdt(self): + """Convert to python datetime.""" + return pdt.datetime(self.year, self.month, self.day, + self.hour, self.minute, + self.second, self.microsecond, + self.tzinfo, fold=self.fold) + + +# outside class; need the class to be defined first +datetime.re = re.compile(datetime.pattern(), flags=re.VERBOSE) + + +class Range(): + """Time span between two datetimes.""" + + # slight memory optimization; no further attributes besides start or end. + __slots__ = ('start', 'end') + + def __init__(self, start=None, end=None): + self.start = start + self.end = end + + def __bool__(self): + return not (self.start is None and self.end is None) + + def __eq__(self, other): + if isinstance(other, Range): + return self.start == other.start and self.end == other.end + else: + return False + + # allow start, end = range + def __iter__(self): + return (self.start, self.end).__iter__() + + def format(self, default_day=None, explicit_none=True): + """Return a string representing the time range. + + Start date is shown only if start does not belong to default_day. + End date is shown only if end does not belong to + the same hamster day as start. + """ + + none_str = "--" if explicit_none else "" + + if self.start: + if self.start.hday() != default_day: + start_str = self.start.strftime(datetime.FMT) + else: + start_str = self.start.strftime(time.FMT) + else: + start_str = none_str + + if self.end: + if self.end.hday() != self.start.hday(): + end_str = self.end.strftime(datetime.FMT) + else: + end_str = self.end.strftime(time.FMT) + else: + end_str = none_str + + if end_str: + return "{} - {}".format(start_str, end_str) + else: + return start_str + + @classmethod + def parse(cls, text, + position="exact", separator="\s+", default_day=None, ref="now"): + """Parse a start-end range from text. + + position (str): "exact" to match exactly the full text + "head" to search only at the beginning of text, and + "tail" to search only at the end. + + separator (str): regexp pattern (e.g. '\s+') meant to separate the datetime + from the rest. Discarded for "exact" position. + + default_day (date): If start is given without any date (e.g. just hh:mm), + put the corresponding datetime in default_day. + Defaults to today. + Note: the default end day is always the start day, so + "2019-11-27 23:50 - 00:20" lasts 30 minutes. + + ref (datetime): reference for relative times + (e.g. -15: quarter hour before ref). + For testing purposes only + (note: this will be removed later on, + and replaced with datetime.now mocking in pytest). + For users, it should be "now". + Return: + (range, rest) + range (Range): Range(None, None) if no match is found. + rest (str): remainder of the text. + """ + + if ref == "now": + ref = datetime.now() + + if default_day is None: + default_day = hday.today() + + assert position in ("exact", "head", "tail"), "position unknown: '{}'".format(position) + if position == "exact": + p = "^{}$".format(cls.pattern()) + elif position == "head": + # ( )?: require either only the range (no rest), + # or separator between range and rest, + # to avoid matching 10.00@cat + # .*? so rest is as little as possible + p = "^{}( {}(?P.*?) )?$".format(cls.pattern(), separator) + elif position == "tail": + p = "^( (?P.*?){} )? {}$".format(separator, cls.pattern()) + # No need to compile, recent patterns are cached by re. + # DOTALL, so rest may contain newlines + # (important for multiline descriptions) + m = re.search(p, text, flags=re.VERBOSE | re.DOTALL) + + if not m: + return Range(None, None), text + elif position == "exact": + rest = "" + else: + rest = m.group("rest") or "" + + if m.group('firstday'): + # only day given for start + firstday = hday.parse(m.group('firstday')) + start = firstday.start + else: + firstday = None + start = datetime._extract_datetime(m, d="date1", h="hour1", + m="minute1", r="relative1", + default_day=default_day) + if isinstance(start, pdt.timedelta): + # relative to ref, actually + assert ref, "relative start needs ref" + start = ref + start + + if m.group('lastday'): + lastday = hday.parse(m.group('lastday')) + end = lastday.end + elif firstday: + end = firstday.end + elif m.group('duration'): + duration = int(m.group('duration')) + end = start + timedelta(minutes=duration) + else: + end_default_day = start.hday() if start else default_day + end = datetime._extract_datetime(m, d="date2", h="hour2", + m="minute2", r="relative2", + default_day=end_default_day) + if isinstance(end, pdt.timedelta): + # relative to ref, actually + assert ref, "relative end needs ref" + end = ref + end + + return Range(start, end), rest + + @classmethod + @lru_cache() + def pattern(cls): + return dedent(r""" + ( # start + {} # datetime: relative1 or (date1, hour1, and minute1) + | # or + (?P{}) # date without time + ) + ( + + (?P # (only needed if end time is given) + \s? # maybe one space + - # dash + \s? # maybe one space + | # or + \s # one space exactly + ) + ( # end + {} # datetime: relative2 or (date2, hour2, and minute2) + | # or + (?P{}) # date without time + | + (?P + (? - -# This file is part of Project Hamster. - -# Project Hamster is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Project Hamster 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 Project Hamster. If not, see . - -import logging -logger = logging.getLogger(__name__) # noqa: E402 - -import datetime as dt -from calendar import timegm -from gi.repository import GObject as gobject - - -from hamster import idle -from hamster.lib.configuration import conf -import dbus - - -class DesktopIntegrations(object): - def __init__(self, storage): - self.storage = storage # can't use client as then we get in a dbus loop - self._last_notification = None - - dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - self.bus = dbus.SessionBus() - - self.conf_enable_timeout = conf.get("enable_timeout") - self.conf_notify_on_idle = conf.get("notify_on_idle") - self.conf_notify_interval = conf.get("notify_interval") - conf.connect('conf-changed', self.on_conf_changed) - - self.idle_listener = idle.DbusIdleListener() - self.idle_listener.connect('idle-changed', self.on_idle_changed) - - gobject.timeout_add_seconds(60, self.check_hamster) - - - def check_hamster(self): - """refresh hamster every x secs - load today, check last activity etc.""" - try: - # can't use the client because then we end up in a dbus loop - # as this is initiated in storage - todays_facts = self.storage._Storage__get_todays_facts() - self.check_user(todays_facts) - except Exception as e: - logger.error("Error while refreshing: %s" % e) - finally: # we want to go on no matter what, so in case of any error we find out about it sooner - return True - - - def check_user(self, todays_facts): - """check if we need to notify user perhaps""" - interval = self.conf_notify_interval - if interval <= 0 or interval >= 121: - return - - now = dt.datetime.now() - message = None - - last_activity = todays_facts[-1] if todays_facts else None - - # update duration of current task - if last_activity and not last_activity.end_time: - delta = now - last_activity.start_time - duration = delta.seconds / 60 - - if duration and duration % interval == 0: - message = _("Working on %s") % last_activity.activity - self.notify_user(message) - - elif self.conf_notify_on_idle: - #if we have no last activity, let's just calculate duration from 00:00 - if (now.minute + now.hour * 60) % interval == 0: - self.notify_user(_("No activity")) - - - def notify_user(self, summary="", details=""): - if not hasattr(self, "_notification_conn"): - self._notification_conn = dbus.Interface(self.bus.get_object('org.freedesktop.Notifications', - '/org/freedesktop/Notifications', - follow_name_owner_changes=True), - dbus_interface='org.freedesktop.Notifications') - conn = self._notification_conn - - notification = conn.Notify("Project Hamster", - self._last_notification or 0, - "hamster", - summary, - details, - [], - {"urgency": dbus.Byte(0), "transient" : True}, - -1) - self._last_notification = notification - - - def on_idle_changed(self, event, state): - # state values: 0 = active, 1 = idle - if state == 1 and self.conf_enable_timeout: - idle_from = self.idle_listener.getIdleFrom() - idle_from = timegm(idle_from.timetuple()) - self.storage.StopTracking(idle_from) - - - def on_conf_changed(self, event, key, value): - if hasattr(self, "conf_%s" % key): - setattr(self, "conf_%s" % key, value) diff --git a/src/hamster/lib/fact.py b/src/hamster/lib/fact.py new file mode 100644 index 000000000..aa31d38f5 --- /dev/null +++ b/src/hamster/lib/fact.py @@ -0,0 +1,250 @@ +# This file is part of Hamster +# Copyright (c) The Hamster time tracker developers +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Fact definition.""" + + +import logging +logger = logging.getLogger(__name__) # noqa: E402 + +import calendar + +from copy import deepcopy + +from hamster.lib import datetime as dt +from hamster.lib.parsing import parse_fact + + +class FactError(Exception): + """Generic Fact error.""" + + +class Fact(object): + def __init__(self, activity="", category=None, description=None, tags=None, + range=None, start=None, end=None, start_time=None, end_time=None, + id=None, activity_id=None): + """Homogeneous chunk of activity. + + The category, description and tags must be passed explicitly. + + To provide the whole fact information as a single string, + please use Fact.parse(string). + + range (dt.Range): time spanned by the fact. For convenience, + the `start` and `end` arguments can be given instead. + start (dt.datetime); Start of the fact range. + Mutually exclusive with `range`. + end (dt.datetime); End of the fact range. + Mutually exclusive with `range`. + start_time (dt.datetime): Deprecated. Same as start. + end_time (dt.datetime): Deprecated. Same as end. + + id (int): id in the database. + Should be used with extreme caution, knowing exactly why. + (only for very specific direct database read/write) + """ + + self.activity = activity + self.category = category + self.description = description + self.tags = tags or [] + if range: + assert not start, "range already given" + assert not end, "range already given" + assert not start_time, "range already given" + assert not end_time, "range already given" + self.range = range + else: + if start_time: + assert not start, "use only start, not start_time" + start = start_time + if end_time: + assert not end, "use only end, not end_time" + end = end_time + self.range = dt.Range(start, end) + self.id = id + self.activity_id = activity_id + + # TODO: might need some cleanup + def as_dict(self): + date = self.date + return { + 'id': int(self.id) if self.id else "", + 'activity': self.activity, + 'category': self.category, + 'description': self.description, + 'tags': [tag.strip() for tag in self.tags], + 'date': calendar.timegm(date.timetuple()) if date else "", + 'start_time': self.range.start if isinstance(self.range.start, str) else calendar.timegm(self.range.start.timetuple()), + 'end_time': self.range.end if isinstance(self.range.end, str) else calendar.timegm(self.range.end.timetuple()) if self.range.end else "", + 'delta': self.delta.total_seconds() # ugly, but needed for report.py + } + + @property + def activity(self): + """Activity name.""" + return self._activity + + @activity.setter + def activity(self, value): + self._activity = value.strip() if value else "" + + @property + def category(self): + return self._category + + @category.setter + def category(self, value): + self._category = value.strip() if value else "" + + def copy(self, **kwds): + """Return an independent copy, with overrides as keyword arguments. + + By default, only copy user-visible attributes. + To also copy the id, use fact.copy(id=fact.id) + """ + fact = deepcopy(self) + fact._set(**kwds) + return fact + + @property + def date(self): + """hamster day, determined from start_time. + + Note: Setting date is a one-shot modification of + the start_time and end_time (if defined), + to match the given value. + Any subsequent modification of start_time + can result in different self.date. + """ + return self.range.start.hday() if self.range.start else None + + @date.setter + def date(self, value): + if self.range.start: + previous_start_time = self.range.start + self.range.start = dt.datetime.from_day_time(value, self.range.start.time()) + if self.range.end: + # start_time date prevails. + # Shift end_time to preserve the fact duration. + self.range.end += self.range.start - previous_start_time + elif self.range.end: + self.range.end = dt.datetime.from_day_time(value, self.range.end.time()) + + @property + def delta(self): + """Duration (datetime.timedelta).""" + end_time = self.range.end if self.range.end else dt.datetime.now() + return end_time - self.range.start + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value.strip() if value else "" + + @property + def end_time(self): + """Fact range end. + + Deprecated, use self.range.end instead. + """ + return self.range.end + + @end_time.setter + def end_time(self, value): + self.range.end = value + + @property + def start_time(self): + """Fact range start. + + Deprecated, use self.range.start instead. + """ + return self.range.start + + @start_time.setter + def start_time(self, value): + self.range.start = value + + @classmethod + def parse(cls, string, range_pos="head", default_day=None, ref="now"): + fact = Fact() + for key, val in parse_fact(string, range_pos=range_pos, + default_day=default_day, ref=ref).items(): + setattr(fact, key, val) + return fact + + def serialized_name(self): + res = self.activity + + if self.category: + res += "@%s" % self.category + + if self.description: + res += ',, ' + res += self.description + + if ('#' in self.activity + or '#' in self.category + or '#' in self.description + ): + # need a tag barrier + res += ",, " + + if self.tags: + # double comma is a left barrier for tags, + # which is useful only if previous fields contain a hash + res += " %s" % " ".join("#%s" % tag for tag in self.tags) + return res + + def serialized(self, range_pos="head", default_day=None): + """Return a string fully representing the fact.""" + name = self.serialized_name() + if range_pos == "head": + # Is activity starting range-like ? + subfact = Fact.parse(self.activity, range_pos=range_pos, + default_day=default_day) + need_explicit = bool(subfact.range) + else: + # TODO: should check last tag. + need_explicit = False + datetime = self.range.format(default_day=default_day, + explicit_none=need_explicit) + # no need for space if name or datetime is missing + space = " " if name and datetime else "" + assert range_pos in ("head", "tail") + if range_pos == "head": + return "{}{}{}".format(datetime, space, name) + else: + return "{}{}{}".format(name, space, datetime) + + def _set(self, **kwds): + """Modify attributes. + + Private, used only in copy. It is more readable to be explicit, e.g.: + fact.range.start = ... + fact.range.end = ... + """ + for attr, value in kwds.items(): + if not hasattr(self, attr): + raise AttributeError(f"'{attr}' not found") + else: + setattr(self, attr, value) + + def __eq__(self, other): + return (isinstance(other, Fact) + and self.activity == other.activity + and self.category == other.category + and self.description == other.description + and self.range.end == other.range.end + and self.range.start == other.range.start + and self.tags == other.tags + ) + + def __repr__(self): + return self.serialized(default_day=None) diff --git a/src/hamster/lib/graphics.py b/src/hamster/lib/graphics.py index b47429e1e..5c532e556 100644 --- a/src/hamster/lib/graphics.py +++ b/src/hamster/lib/graphics.py @@ -7,8 +7,7 @@ from collections import defaultdict import math -import datetime as dt - +import datetime as dt # need the original python granularity here from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk @@ -29,6 +28,8 @@ import colorsys from collections import deque + + # lemme know if you know a better way how to get default font _test_label = gtk.Label("Hello") _font_desc = _test_label.get_style().font_desc.to_string() diff --git a/src/hamster/lib/layout.py b/src/hamster/lib/layout.py index 4c4df3c39..2ba81f4dc 100644 --- a/src/hamster/lib/layout.py +++ b/src/hamster/lib/layout.py @@ -4,7 +4,6 @@ # Copyright (c) 2014 Toms Baugis # Dual licensed under the MIT or GPL Version 2 licenses. -import datetime as dt import math from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk @@ -12,6 +11,7 @@ from gi.repository import Pango as pango from collections import defaultdict +from hamster.lib import datetime as dt from hamster.lib import graphics diff --git a/src/hamster/lib/parsing.py b/src/hamster/lib/parsing.py new file mode 100644 index 000000000..527c574c9 --- /dev/null +++ b/src/hamster/lib/parsing.py @@ -0,0 +1,98 @@ +import logging +logger = logging.getLogger(__name__) # noqa: E402 + +import re + +from hamster.lib import datetime as dt + + +# separator between times and activity +ACTIVITY_SEPARATOR = "\s+" + + +# match #tag followed by any space or # that will be ignored +# tag must not contain '#' or ',' +tag_re = re.compile(r""" + \# # hash character + (?P + [^#,]+ # (anything but hash or comma) + ) + \s* # maybe spaces + # forbid double comma (tag can not be before the tags barrier): + ,? # single comma (or none) + \s* # maybe space + $ # end of text +""", flags=re.VERBOSE) + +tags_separator = re.compile(r""" + (,{0,2}) # 0, 1 or 2 commas + \s* # maybe spaces + $ # end of text +""", flags=re.VERBOSE) + + +def parse_fact(text, range_pos="head", default_day=None, ref="now"): + """Extract fact fields from the string. + + Returns found fields as a dict. + + Tentative syntax (not accurate): + start [- end_time] activity[@category][,, description][,,]{ #tag} + According to the legacy tests, # were allowed in the description + """ + + res = {} + + text = text.strip() + if not text: + return res + + # datetimes + # force at least a space to avoid matching 10.00@cat + (start, end), remaining_text = dt.Range.parse(text, position=range_pos, + separator=ACTIVITY_SEPARATOR, + default_day=default_day) + res["start_time"] = start + res["end_time"] = end + + # tags + # Need to start from the end, because + # the description can hold some '#' characters + tags = [] + while True: + # look for tags separators + # especially the tags barrier + m = re.search(tags_separator, remaining_text) + remaining_text = remaining_text[:m.start()] + if m.group(1) == ",,": + # tags barrier found + break + + # look for tag + m = re.search(tag_re, remaining_text) + if m: + tag = m.group('tag').strip() + # strip the matched string (including #) + remaining_text = remaining_text[:m.start()] + tags.append(tag) + else: + # no tag + break + + # put tags back in input order + res["tags"] = list(reversed(tags)) + + # description + # first look for double comma (description hard left boundary) + head, sep, description = remaining_text.partition(",,") + res["description"] = description.strip() + remaining_text = head.strip() + + # activity + split = remaining_text.rsplit('@', maxsplit=1) + activity = split[0] + category = split[1] if len(split) > 1 else "" + res["activity"] = activity + res["category"] = category + + return res diff --git a/src/hamster/lib/pytweener.py b/src/hamster/lib/pytweener.py index 393f899cb..3998a0c25 100644 --- a/src/hamster/lib/pytweener.py +++ b/src/hamster/lib/pytweener.py @@ -9,10 +9,11 @@ # All kinds of slashing and dashing by Toms Baugis 2010, 2014 import math import collections -import datetime as dt import time import re +from hamster.lib import datetime as dt + class Tweener(object): def __init__(self, default_duration = None, tween = None): """Tweener diff --git a/src/hamster/lib/stuff.py b/src/hamster/lib/stuff.py index 632d4b89e..9de892ec6 100644 --- a/src/hamster/lib/stuff.py +++ b/src/hamster/lib/stuff.py @@ -24,86 +24,55 @@ import logging logger = logging.getLogger(__name__) # noqa: E402 -import gi -import datetime as dt - -gi.require_version('Gtk', '3.0') from gi.repository import Gtk as gtk from gi.repository import Pango as pango from itertools import groupby -import datetime as dt import calendar import time import re import locale import os +from hamster.lib import datetime as dt -def datetime_to_hamsterday(civil_date_time): - """Return the hamster day corresponding to a given civil datetime. - The hamster day start is taken into account. - """ +# for pre-v3.0 backward compatibility +hamster_now = dt.datetime.now +hamster_today = dt.hday.today +hamsterday_time_to_datetime = dt.datetime.from_day_time - if civil_date_time is None: - return None - # work around cyclic imports - from hamster.lib.configuration import conf +def datetime_to_hamsterday(civil_date_time): + """Return the hamster day corresponding to a given civil datetime. - if civil_date_time.time() < conf.day_start: - # early morning, between midnight and day_start - # => the hamster day is the previous civil day - hamster_date_time = civil_date_time - dt.timedelta(days=1) - else: - hamster_date_time = civil_date_time - # return only the date - return hamster_date_time.date() + Deprecated: use civil_date_time.hday() instead + The hamster day start is taken into account. + """ -def hamster_now(): - # current datetime truncated to the minute - return hamster_round(dt.datetime.now()) + return civil_date_time.hday() def hamster_round(time): - """Round time or datetime.""" + """Round time or datetime. + + Deprecated: hamster.lib/datetime.time or datetime are already rounded. + """ if time is None: return None else: return time.replace(second=0, microsecond=0) -def hamster_today(): - """Return the current hamster day.""" - return datetime_to_hamsterday(hamster_now()) - - -def hamsterday_time_to_datetime(hamsterday, time): - """Return the civil datetime corresponding to a given hamster day and time. - - The hamster day start is taken into account. - """ - - # work around cyclic imports - from hamster.lib.configuration import conf - - if time < conf.day_start: - # early morning, between midnight and day_start - # => the hamster day is the previous civil day - civil_date = hamsterday + dt.timedelta(days=1) - else: - civil_date = hamsterday - return dt.datetime.combine(civil_date, time) - - def format_duration(minutes, human = True): """formats duration in a human readable format. - accepts either minutes or timedelta""" + accepts either minutes or timedelta - if isinstance(minutes, dt.timedelta): - minutes = duration_minutes(minutes) + Deprecated: use timedelta.format() instead. + """ + + minutes = duration_minutes(minutes) if not minutes: if human: @@ -187,16 +156,17 @@ def month(view_date): def duration_minutes(duration): """returns minutes from duration, otherwise we keep bashing in same math""" - if isinstance(duration, list): + if isinstance(duration, dt.timedelta): + return duration.total_seconds() / 60 + elif isinstance(duration, (int, float)): + return duration + elif isinstance(duration, list): res = dt.timedelta() for entry in duration: res += entry - return duration_minutes(res) - elif isinstance(duration, dt.timedelta): - return duration.total_seconds() / 60 else: - return duration + raise NotImplementedError("received {}".format(type(duration))) def zero_hour(date): diff --git a/src/hamster/overview.py b/src/hamster/overview.py old mode 100755 new mode 100644 index ef0db61f9..3b8f6fee2 --- a/src/hamster/overview.py +++ b/src/hamster/overview.py @@ -19,29 +19,28 @@ import sys import bisect -import datetime as dt import itertools import webbrowser from collections import defaultdict from math import ceil +from gi.repository import GLib as glib from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk from gi.repository import GObject as gobject - -import gi -gi.require_version('PangoCairo', '1.0') from gi.repository import PangoCairo as pangocairo from gi.repository import Pango as pango import cairo import hamster.client +from hamster.lib import datetime as dt from hamster.lib import graphics from hamster.lib import layout from hamster import reports from hamster.lib import stuff from hamster import widgets +from hamster.preferences import PreferencesEditor from hamster.lib.configuration import dialogs from hamster.lib.configuration import Controller @@ -67,7 +66,7 @@ def __init__(self): gtk.StyleContext.add_class(box.get_style_context(), "linked") self.pack_start(box) - self.range_pick = RangePick(stuff.hamster_today()) + self.range_pick = RangePick(dt.hday.today()) self.pack_start(self.range_pick) self.system_button = gtk.MenuButton() @@ -247,7 +246,7 @@ def _draw(self, context, opacity, matrix): bar_start_x = label_width + margin for i, (label, value) in enumerate(self.values): g.set_color(self.label_color) - duration_str = stuff.format_duration(value, human=False) + duration_str = value.format(fmt="HH:MM") markup_label = stuff.escape_pango(str(label)) markup_duration = stuff.escape_pango(duration_str) self.layout.set_markup("{}, {}".format(markup_label, markup_duration)) @@ -416,6 +415,8 @@ class Overview(Controller): def __init__(self, parent = None): Controller.__init__(self, parent) + self.prefs_dialog = None # preferences dialog controller + self.window.set_position(gtk.WindowPosition.CENTER) self.window.set_default_icon_name("hamster") self.window.set_default_size(700, 500) @@ -460,7 +461,7 @@ def __init__(self, parent = None): main.pack_start(self.totals, False, True, 1) # FIXME: should store and recall date_range from hamster.lib.configuration.conf - hamster_day = stuff.datetime_to_hamsterday(dt.datetime.today()) + hamster_day = dt.hday.today() self.header_bar.range_pick.set_range(hamster_day) self.header_bar.range_pick.connect("range-selected", self.on_range_selected) self.header_bar.add_activity_button.connect("clicked", self.on_add_activity_clicked) @@ -583,7 +584,7 @@ def on_help_clicked(self, menu): uri = "help:hamster" try: gtk.show_uri(None, uri, gdk.CURRENT_TIME) - except gi.repository.GLib.Error: + except glib.Error: msg = sys.exc_info()[1].args[0] dialog = gtk.MessageDialog(self.window, 0, gtk.MessageType.ERROR, gtk.ButtonsType.CLOSE, @@ -594,8 +595,10 @@ def on_help_clicked(self, menu): dialog.destroy() def on_prefs_clicked(self, menu): - dialogs.prefs.show(self) - + if self.prefs_dialog: + self.prefs_dialog.present() + else: + self.prefs_dialog = PreferencesEditor(parent=self.window) def on_export_clicked(self, menu): if self.report_chooser: diff --git a/src/hamster/preferences.py b/src/hamster/preferences.py old mode 100755 new mode 100644 index 38ade3803..81f18f2f2 --- a/src/hamster/preferences.py +++ b/src/hamster/preferences.py @@ -21,11 +21,10 @@ from gi.repository import Gdk as gdk from gi.repository import GObject as gobject -import datetime as dt - -from gettext import ngettext - -from hamster.lib.configuration import Controller +from hamster import widgets +from hamster.lib import datetime as dt +from hamster.lib import stuff +from hamster.lib.configuration import Controller, runtime, conf def get_prev(selection, model): @@ -38,6 +37,7 @@ def get_prev(selection, model): else: return None + class CategoryStore(gtk.ListStore): def __init__(self): #id, name, color_code, order @@ -71,35 +71,14 @@ def load(self, category_id): activity['category_id']]) -formats = ["fixed", "symbolic", "minutes"] -appearances = ["text", "icon", "both"] - -from hamster.lib.configuration import runtime, conf -from hamster import widgets -from hamster.lib import stuff - - - class PreferencesEditor(Controller): TARGETS = [ ('MY_TREE_MODEL_ROW', gtk.TargetFlags.SAME_WIDGET, 0), ('MY_TREE_MODEL_ROW', gtk.TargetFlags.SAME_APP, 0), ] - def __init__(self, parent = None): Controller.__init__(self, parent, ui_file="preferences.ui") - # Translators: 'None' refers here to the Todo list choice in Hamster preferences (Tracking tab) - self.activities_sources = [("", _("None")), - ("evo", "Evolution"), - ("gtg", "Getting Things Gnome"), - ("task", "Taskwarrior")] - self.todo_combo = gtk.ComboBoxText() - for code, label in self.activities_sources: - self.todo_combo.append_text(label) - self.todo_combo.connect("changed", self.on_todo_combo_changed) - self.get_widget("todo_pick").add(self.todo_combo) - # create and fill activity tree self.activity_tree = self.get_widget('activity_list') @@ -127,7 +106,6 @@ def __init__(self, parent = None): (self.selection, self.selection.connect('changed', self.activity_changed, self.activity_store)) ]) - # create and fill category tree self.category_tree = self.get_widget('category_list') self.get_widget("categories_label").set_mnemonic_widget(self.category_tree) @@ -158,7 +136,6 @@ def __init__(self, parent = None): self.day_start = widgets.TimeInput(dt.time(5,30)) self.get_widget("day_start_placeholder").add(self.day_start) - self.load_config() # Allow enable drag and drop of rows including row move @@ -187,36 +164,16 @@ def __init__(self, parent = None): self.show() - def show(self): self.get_widget("notebook1").set_current_page(0) self.window.show_all() - - def on_todo_combo_changed(self, combo): - conf.set("activities_source", self.activities_sources[combo.get_active()][0]) - - def load_config(self, *args): - self.get_widget("shutdown_track").set_active(conf.get("stop_on_shutdown")) - self.get_widget("idle_track").set_active(conf.get("enable_timeout")) - self.get_widget("notify_interval").set_value(conf.get("notify_interval")) - - self.get_widget("notify_on_idle").set_active(conf.get("notify_on_idle")) - self.get_widget("notify_on_idle").set_sensitive(conf.get("notify_interval") <=120) - self.day_start.time = conf.day_start self.tags = [tag["name"] for tag in runtime.storage.get_tags(only_autocomplete=True)] self.get_widget("autocomplete_tags").set_text(", ".join(self.tags)) - - current_source = conf.get("activities_source") - for i, (code, label) in enumerate(self.activities_sources): - if code == current_source: - self.todo_combo.set_active(i) - - def on_autocomplete_tags_view_focus_out_event(self, view, event): buf = self.get_widget("autocomplete_tags") updated_tags = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), 0) @@ -227,7 +184,6 @@ def on_autocomplete_tags_view_focus_out_event(self, view, event): runtime.storage.update_autocomplete_tags(updated_tags) - def drag_data_get_data(self, treeview, context, selection, target_id, etime): treeselection = treeview.get_selection() @@ -251,8 +207,6 @@ def select_category(self, id): self.category_tree.set_cursor((i, )) i += 1 - - def on_category_list_drag_motion(self, treeview, drag_context, x, y, eventtime): self.prev_selected_category = None try: @@ -271,8 +225,6 @@ def on_category_list_drag_motion(self, treeview, drag_context, x, y, eventtime): else: treeview.enable_model_drag_dest([drop_no], gdk.DragAction.MOVE) - - def on_category_drop(self, treeview, context, x, y, selection, info, etime): model = self.category_tree.get_model() @@ -313,7 +265,6 @@ def category_edited_cb(self, cell, path, new_text, model): model[path][1] = new_text - def activity_name_edited_cb(self, cell, path, new_text, model): id = model[path][0] category_id = model[path][2] @@ -340,7 +291,6 @@ def activity_name_edited_cb(self, cell, path, new_text, model): model[path][1] = new_text return True - def category_changed_cb(self, selection, model): """ enables and disables action buttons depending on selected item """ (model, iter) = selection.get_selected() @@ -365,7 +315,6 @@ def _get_selected_category(self): return model[iter][0] if iter else None - def activity_changed(self, selection, model): """ enables and disables action buttons depending on selected item """ (model, iter) = selection.get_selected() @@ -375,7 +324,6 @@ def activity_changed(self, selection, model): self.get_widget('activity_edit').set_sensitive(iter != None) self.get_widget('activity_remove').set_sensitive(iter != None) - def _del_selected_row(self, tree): selection = tree.get_selection() (model, iter) = selection.get_selected() @@ -407,7 +355,6 @@ def unsorted_painter(self, column, cell, model, iter, data): def on_activity_list_button_pressed(self, tree, event): self.activityCell.set_property("editable", False) - def on_activity_list_button_released(self, tree, event): if event.button == 1 and tree.get_path_at_pos(int(event.x), int(event.y)): # Get treeview path. @@ -437,7 +384,6 @@ def on_category_list_button_released(self, tree, event): self.prev_selected_category = path - def on_activity_remove_clicked(self, button): self.remove_current_activity() @@ -449,8 +395,6 @@ def on_activity_edit_clicked(self, button): path = model.get_path(iter) self.activity_tree.set_cursor_on_cell(path, self.activityColumn, self.activityCell, True) - - """keyboard events""" def on_activity_list_key_pressed(self, tree, event_key): key = event_key.keyval @@ -470,7 +414,6 @@ def remove_current_activity(self): runtime.storage.remove_activity(model[iter][0]) self._del_selected_row(self.activity_tree) - def on_category_remove_clicked(self, button): self.remove_current_category() @@ -482,7 +425,6 @@ def on_category_edit_clicked(self, button): path = model.get_path(iter) self.category_tree.set_cursor_on_cell(path, self.categoryColumn, self.categoryCell, True) - def on_category_list_key_pressed(self, tree, event_key): key = event_key.keyval @@ -540,7 +482,6 @@ def on_category_add_clicked(self, button): focus_cell = None, start_editing = True) - def on_activity_add_clicked(self, button): """ appends row, jumps to it and allows user to input name """ category_id = self._get_selected_category() @@ -559,48 +500,16 @@ def on_activity_remove_clicked(self, button): removable_id = self._del_selected_row(self.activity_tree) runtime.storage.remove_activity(removable_id) - - def on_shutdown_track_toggled(self, checkbox): - conf.set("stop_on_shutdown", checkbox.get_active()) - - def on_idle_track_toggled(self, checkbox): - conf.set("enable_timeout", checkbox.get_active()) - - def on_notify_on_idle_toggled(self, checkbox): - conf.set("notify_on_idle", checkbox.get_active()) - - def on_notify_interval_format_value(self, slider, value): - if value <=120: - # notify interval slider value label - label = ngettext("%(interval_minutes)d minute", - "%(interval_minutes)d minutes", - value) % {'interval_minutes': value} - else: - # notify interval slider value label - label = _("Never") - - return label - - def on_notify_interval_value_changed(self, scale): - value = int(scale.get_value()) - conf.set("notify_interval", value) - self.get_widget("notify_on_idle").set_sensitive(value <= 120) - def on_day_start_changed(self, widget): day_start = self.day_start.time if day_start is None: return day_start = day_start.hour * 60 + day_start.minute - - conf.set("day_start_minutes", day_start) - - - + conf.set("day-start-minutes", day_start) def on_close_button_clicked(self, button): self.close_window() - def close_window(self): if self.parent: for obj, handler in self.external_listeners: diff --git a/src/hamster/reports.py b/src/hamster/reports.py index 2ee1fe84d..69a4a258d 100644 --- a/src/hamster/reports.py +++ b/src/hamster/reports.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with Project Hamster. If not, see . import os, sys -import datetime as dt from xml.dom.minidom import Document import csv import copy @@ -30,6 +29,7 @@ from string import Template from textwrap import dedent +from hamster.lib import datetime as dt from hamster.lib.configuration import runtime from hamster.lib import stuff from hamster.lib.i18n import C_ @@ -255,7 +255,7 @@ def _write_fact(self, fact): start_iso = fact.start_time.isoformat(), end = end_time_str, end_iso = end_time_iso_str, - duration = stuff.format_duration(fact.delta) or "", + duration = fact.delta.format(), duration_minutes = "%d" % (stuff.duration_minutes(fact.delta)), duration_decimal = "%.2f" % (stuff.duration_minutes(fact.delta) / 60.0), description = fact.description or "" diff --git a/src/hamster/storage/db.py b/src/hamster/storage/db.py index 6b646979c..e36dd06c2 100644 --- a/src/hamster/storage/db.py +++ b/src/hamster/storage/db.py @@ -25,7 +25,6 @@ logger = logging.getLogger(__name__) # noqa: E402 import os, time -import datetime as dt import itertools import sqlite3 as sqlite from shutil import copy as copyfile @@ -35,13 +34,15 @@ print("Could not import gio - requires pygobject. File monitoring will be disabled") gio = None -from hamster.lib import Fact +from hamster.lib import datetime as dt from hamster.lib.configuration import conf -from hamster.lib.stuff import hamster_today, hamster_now +from hamster.lib.fact import Fact from hamster.storage import storage -UNSORTED_ID = -1 +# note: "zero id means failure" is quite standard, +# and that kind of convention will be mandatory for the dbus interface +# (None cannot pass through an integer signature). class Storage(storage.Storage): con = None # Connection will be created on demand @@ -58,6 +59,9 @@ def __init__(self, unsorted_localized="Unsorted", database_dir=None): database_dir (path): Directory holding the database file, or None to use the default location. + + Note: Zero id means failure. + Unsorted category id is hard-coded as -1 """ storage.Storage.__init__(self) @@ -115,11 +119,17 @@ def __init_db_file(self, database_dir): # check if we have a database at all if not os.path.exists(db_path): # handle pre-existing hamster-applet database - old_db_path = os.path.join(xdg_data_home, 'hamster-applet', 'hamster.db') - if os.path.exists(old_db_path): - logger.warning("Linking {} with {}".format(old_db_path, db_path)) - os.link(old_db_path, db_path) - else: + # try most recent directories first + # change from hamster-applet to hamster-time-tracker: + # 9f345e5e (2019-09-19) + old_dirs = ['hamster-time-tracker', 'hamster-applet'] + for old_dir in old_dirs: + old_db_path = os.path.join(xdg_data_home, old_dir, 'hamster.db') + if os.path.exists(old_db_path): + logger.warning("Linking {} with {}".format(old_db_path, db_path)) + os.link(old_db_path, db_path) + break + if not os.path.exists(db_path): # make a copy of the empty template hamster.db try: from hamster import defs @@ -169,7 +179,7 @@ def __get_tag_ids(self, tags): changes = False # check if any of tags needs resurrection - set_complete = [str(tag["id"]) for tag in db_tags if tag["autocomplete"] == "false"] + set_complete = [str(tag["id"]) for tag in db_tags if tag["autocomplete"] in (0, "false")] if set_complete: changes = True self.execute("update tags set autocomplete='true' where id in (%s)" % ", ".join(set_complete)) @@ -206,7 +216,8 @@ def __update_autocomplete_tags(self, tags): gone = self.fetchall(query, tags) to_delete = [str(tag["id"]) for tag in gone if tag["occurences"] == 0] - to_uncomplete = [str(tag["id"]) for tag in gone if tag["occurences"] > 0 and tag["autocomplete"] == "true"] + to_uncomplete = [str(tag["id"]) for tag in gone + if tag["occurences"] > 0 and tag["autocomplete"] in (1, "true")] if to_delete: self.execute("delete from tags where id in (%s)" % ", ".join(to_delete)) @@ -234,6 +245,18 @@ def __update_activity(self, id, name, category_id): def __change_category(self, id, category_id): + """Change the category of an activity. + + If an activity already exists with the same name + in the target category, + make all relevant facts use this target activity. + + Args: + id (int): id of the source activity + category_id (int): id of the target category + Returns: + boolean: whether the database was changed. + """ # first check if we don't have an activity with same name before us activity = self.fetchone("select name from activities where id = ?", (id, )) existing_activity = self.__get_activity_by_name(activity['name'], category_id) @@ -297,7 +320,10 @@ def __update_category(self, id, name): def __get_activity_by_name(self, name, category_id = None, resurrect = True): - """get most recent, preferably not deleted activity by it's name""" + """Get most recent, preferably not deleted activity by it's name. + If category_id is None or 0, show all activities matching name. + Otherwise, filter on the specified category. + """ if category_id: query = """ @@ -342,9 +368,13 @@ def __get_activity_by_name(self, name, category_id = None, resurrect = True): return None def __get_category_id(self, name): - """returns category by it's name""" + """Return category id from its name. + + 0 means none found. + """ if not name: - return UNSORTED_ID + # Unsorted + return -1 query = """ SELECT id from categories @@ -358,7 +388,7 @@ def __get_category_id(self, name): if res: return res['id'] - return None + return 0 def _dbfact_to_libfact(self, db_fact): """Convert a db fact (coming from __group_facts) to Fact.""" @@ -419,7 +449,7 @@ def __group_tags(self, facts): def __touch_fact(self, fact, end_time = None): - end_time = end_time or hamster_now() + end_time = end_time or dt.datetime.now() # tasks under one minute do not count if end_time - fact.start_time < dt.timedelta(minutes = 1): self.__remove_fact(fact.id) @@ -493,7 +523,7 @@ def __solve_overlaps(self, start_time, end_time): for fact in conflicts: # fact is a sqlite.Row, indexable by column name - fact_end_time = fact["end_time"] or hamster_now() + fact_end_time = fact["end_time"] or dt.datetime.now() # won't eliminate as it is better to have overlapping entries than loosing data if start_time < fact["start_time"] and end_time > fact_end_time: @@ -516,6 +546,7 @@ def __solve_overlaps(self, start_time, end_time): description=fact["description"], start_time=end_time, end_time=fact_end_time) + storage.Storage.check_fact(new_fact) new_fact_id = self.__add_fact(new_fact) # copy tags @@ -553,8 +584,6 @@ def __add_fact(self, fact, temporary=False): """ logger.info("adding fact {}".format(fact)) - self.validate_fact(fact) # sanity check - start_time = fact.start_time end_time = fact.end_time @@ -562,11 +591,9 @@ def __add_fact(self, fact, temporary=False): tags = [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.get_tag_ids(fact.tags)] # now check if maybe there is also a category - category_id = None - if fact.category: - category_id = self.__get_category_id(fact.category) - if not category_id: - category_id = self.__add_category(fact.category) + category_id = self.__get_category_id(fact.category) + if not category_id: + category_id = self.__add_category(fact.category) # try to find activity, resurrect if not temporary activity_id = self.__get_activity_by_name(fact.activity, @@ -579,7 +606,7 @@ def __add_fact(self, fact, temporary=False): activity_id = activity_id['id'] # if we are working on +/- current day - check the last_activity - if (dt.timedelta(days=-1) <= hamster_now() - start_time <= dt.timedelta(days=1)): + if (dt.timedelta(days=-1) <= dt.datetime.now() - start_time <= dt.timedelta(days=1)): # pull in previous facts facts = self.__get_todays_facts() @@ -656,18 +683,15 @@ def __add_fact(self, fact, temporary=False): return fact_id def __last_insert_rowid(self): - return self.fetchone("SELECT last_insert_rowid();")[0] + return self.fetchone("SELECT last_insert_rowid();")[0] or 0 def __get_todays_facts(self): - return self.__get_facts(hamster_today()) + return self.__get_facts(dt.Range.today()) - def __get_facts(self, date, end_date = None, search_terms = ""): - split_time = conf.day_start - datetime_from = dt.datetime.combine(date, split_time) - - end_date = end_date or date - datetime_to = dt.datetime.combine(end_date, split_time) + dt.timedelta(days=1, seconds=-1) + def __get_facts(self, range, search_terms=""): + datetime_from = range.start + datetime_to = range.end logger.info("searching for facts from {} to {}".format(datetime_from, datetime_to)) @@ -977,3 +1001,25 @@ def run_fixtures(self): print("updated database from version %d to %d" % (version, current_version)) self.end_transaction() + + +# datetime/sql conversions + +DATETIME_LOCAL_FMT = "%Y-%m-%d %H:%M:%S" + + +def adapt_datetime(t): + """Convert datetime t to the suitable sql representation.""" + return t.isoformat(" ") + + +def convert_datetime(s): + """Convert the sql timestamp to datetime. + + s is in bytes + """ + return dt.datetime.strptime(s.decode('utf-8'), DATETIME_LOCAL_FMT) + + +sqlite.register_adapter(dt.datetime, adapt_datetime) +sqlite.register_converter("timestamp", convert_datetime) diff --git a/src/hamster/storage/storage.py b/src/hamster/storage/storage.py index 4b9d56dd7..bfad0c225 100644 --- a/src/hamster/storage/storage.py +++ b/src/hamster/storage/storage.py @@ -21,9 +21,12 @@ import logging logger = logging.getLogger(__name__) # noqa: E402 -import datetime as dt -from hamster.lib import Fact -from hamster.lib.stuff import hamster_now +from hamster.lib import datetime as dt + +from textwrap import dedent + +from hamster.lib.fact import Fact, FactError + class Storage(object): def run_fixtures(self): @@ -39,8 +42,47 @@ def dispatch_overwrite(self): self.facts_changed() self.activities_changed() - # facts + @classmethod + def check_fact(cls, fact, default_day=None): + """Check Fact validity for inclusion in the storage. + + Raise FactError(message) on failure. + """ + if fact.start_time is None: + raise FactError("Missing start time") + + if fact.end_time and (fact.delta < dt.timedelta(0)): + fixed_fact = Fact(start_time=fact.start_time, + end_time=fact.end_time + dt.timedelta(days=1)) + suggested_range_str = fixed_fact.range.format(default_day=default_day) + # work around cyclic imports + from hamster.lib.configuration import conf + raise FactError(dedent( + """\ + Duration would be negative. + Working late ? + This happens when the activity crosses the + hamster day start time ({:%H:%M} from tracking settings). + + Suggestion: move the end to the next day; the range would become: + {} + (in civil local time) + """.format(conf.day_start, suggested_range_str) + )) + + if not fact.activity: + raise FactError("Missing activity") + + if ',' in fact.category: + raise FactError(dedent( + """\ + Forbidden comma in category: '{}' + Note: The description separator changed + from single comma to double comma ',,' (cf. PR #482). + """.format(fact.category) + )) + def add_fact(self, fact, start_time=None, end_time=None, temporary=False): """Add fact. @@ -59,6 +101,8 @@ def add_fact(self, fact, start_time=None, end_time=None, temporary=False): fact.start_time = start_time fact.end_time = end_time + # better fail before opening the transaction + self.check_fact(fact) self.start_transaction() result = self.__add_fact(fact, temporary) self.end_transaction() @@ -72,6 +116,8 @@ def get_fact(self, fact_id): return self.__get_fact(fact_id) def update_fact(self, fact_id, fact, start_time=None, end_time=None, temporary=False): + # better fail before opening the transaction + self.check_fact(fact) self.start_transaction() self.__remove_fact(fact_id) # to be removed once update facts use Fact directly. @@ -86,11 +132,6 @@ def update_fact(self, fact_id, fact, start_time=None, end_time=None, temporary=F self.facts_changed() return result - def validate_fact(self, fact): - """Check fact validity for inclusion into storage.""" - assert fact.activity, "missing activity" - assert fact.start_time, "missing start_time" - def stop_tracking(self, end_time): """Stops tracking the current activity""" facts = self.__get_todays_facts() @@ -109,8 +150,9 @@ def remove_fact(self, fact_id): self.end_transaction() - def get_facts(self, start_date, end_date, search_terms): - return self.__get_facts(start_date, end_date, search_terms) + def get_facts(self, start, end=None, search_terms=""): + range = dt.Range.from_start_end(start, end) + return self.__get_facts(range, search_terms) def get_todays_facts(self): diff --git a/src/hamster/widgets/__init__.py b/src/hamster/widgets/__init__.py index 6ec4d49b4..47108e149 100644 --- a/src/hamster/widgets/__init__.py +++ b/src/hamster/widgets/__init__.py @@ -17,11 +17,11 @@ # You should have received a copy of the GNU General Public License # along with Project Hamster. If not, see . -import datetime as dt from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk from gi.repository import Pango as pango +from hamster.lib import datetime as dt # import our children from hamster.widgets.activityentry import ( ActivityEntry, diff --git a/src/hamster/widgets/activityentry.py b/src/hamster/widgets/activityentry.py index 87555ee56..b72f43d63 100644 --- a/src/hamster/widgets/activityentry.py +++ b/src/hamster/widgets/activityentry.py @@ -22,24 +22,22 @@ import bisect import cairo -import datetime as dt import re from gi.repository import Gdk as gdk from gi.repository import Gtk as gtk from gi.repository import GObject as gobject -import gi -gi.require_version('PangoCairo', '1.0') from gi.repository import PangoCairo as pangocairo from gi.repository import Pango as pango from collections import defaultdict from copy import deepcopy from hamster import client -from hamster.lib import Fact, looks_like_time +from hamster.lib import datetime as dt from hamster.lib import stuff from hamster.lib import graphics from hamster.lib.configuration import runtime +from hamster.lib.fact import Fact def extract_search(text): @@ -202,12 +200,19 @@ def on_enter_frame(self, scene, context): class CmdLineEntry(gtk.Entry): def __init__(self, updating=True, **kwargs): - gtk.Entry.__init__(self) + gtk.Entry.__init__(self, **kwargs) + + # default day for times without date + self.default_day = None # to be set by the caller, if editing an existing fact self.original_fact = None self.popup = gtk.Window(type = gtk.WindowType.POPUP) + self.popup.set_type_hint(gdk.WindowTypeHint.COMBO) # why not + self.popup.set_attached_to(self) # attributes + self.popup.set_transient_for(self.get_ancestor(gtk.Window)) # position + box = gtk.Frame() box.set_shadow_type(gtk.ShadowType.IN) self.popup.add(box) @@ -286,7 +291,7 @@ def load_suggestions(self): self.todays_facts = self.storage.get_todays_facts() # list of facts of last month - now = stuff.hamster_now() + now = dt.datetime.now() last_month = self.storage.get_facts(now - dt.timedelta(days=30), now) # naive recency and frequency rank @@ -352,7 +357,7 @@ def update_suggestions(self, text=""): res = [] fact = Fact.parse(text) - now = stuff.hamster_now() + now = dt.datetime.now() # figure out what we are looking for # time -> activity[@category] -> tags -> description @@ -405,7 +410,7 @@ def update_suggestions(self, text=""): description = "stop now" variant_fact = self.original_fact.copy() variant_fact.end_time = now - elif self.original_fact == self.todays_facts[-1]: + elif self.todays_facts and self.original_fact == self.todays_facts[-1]: # that one is too dangerous, except for the last entry description = "keep up" # Do not use Fact(..., end_time=None): it would be a no-op @@ -414,7 +419,7 @@ def update_suggestions(self, text=""): if variant_fact: variant_fact.description = None - variant = variant_fact.serialized(prepend_date=False) + variant = variant_fact.serialized(default_day=self.default_day) variants.append((description, variant)) else: @@ -425,7 +430,7 @@ def update_suggestions(self, text=""): prev_fact = self.todays_facts[-1] if self.todays_facts else None if prev_fact and prev_fact.end_time: - since = stuff.format_duration(now - prev_fact.end_time) + since = (now - prev_fact.end_time).format() description = "from previous activity, %s ago" % since variant = prev_fact.end_time.strftime("%H:%M ") variants.append((description, variant)) diff --git a/src/hamster/widgets/dates.py b/src/hamster/widgets/dates.py index 5be7494e3..059a6c5fc 100644 --- a/src/hamster/widgets/dates.py +++ b/src/hamster/widgets/dates.py @@ -20,11 +20,11 @@ from gi.repository import GObject as gobject from gi.repository import Gtk as gtk from gi.repository import Pango as pango -import datetime as dt + import calendar import re - +from hamster.lib import datetime as dt from hamster.lib import stuff from hamster.lib.configuration import load_ui_file diff --git a/src/hamster/widgets/dayline.py b/src/hamster/widgets/dayline.py index 069065885..d08bb1066 100644 --- a/src/hamster/widgets/dayline.py +++ b/src/hamster/widgets/dayline.py @@ -18,13 +18,13 @@ # along with Project Hamster. If not, see . import time -import datetime as dt from gi.repository import Gtk as gtk from gi.repository import GObject as gobject from gi.repository import PangoCairo as pangocairo -from hamster.lib import stuff, graphics, pytweener +from hamster.lib import datetime as dt +from hamster.lib import graphics, pytweener from hamster.lib.configuration import conf @@ -92,7 +92,7 @@ def __init__(self, start_time = None): self.day_start = conf.day_start - start_time = start_time or stuff.hamster_now() + start_time = start_time or dt.datetime.now() self.view_time = start_time or dt.datetime.combine(start_time.date(), self.day_start) @@ -238,8 +238,8 @@ def on_enter_frame(self, scene, context): pangocairo.show_layout(context, layout) #current time - if self.view_time < stuff.hamster_now() < self.view_time + dt.timedelta(hours = self.scope_hours): - minutes = round((stuff.hamster_now() - self.view_time).seconds / 60 / minute_pixel) + if self.view_time < dt.datetime.now() < self.view_time + dt.timedelta(hours = self.scope_hours): + minutes = round((dt.datetime.now() - self.view_time).seconds / 60 / minute_pixel) g.rectangle(minutes, 0, self.width, self.height) g.fill(colors['normal_bg'], 0.7) diff --git a/src/hamster/widgets/facttree.py b/src/hamster/widgets/facttree.py index 6febb1380..e8aa51e35 100644 --- a/src/hamster/widgets/facttree.py +++ b/src/hamster/widgets/facttree.py @@ -19,7 +19,6 @@ import bisect import cairo -import datetime as dt from collections import defaultdict from gi.repository import GObject as gobject @@ -28,6 +27,7 @@ from gi.repository import PangoCairo as pangocairo from gi.repository import Pango as pango +from hamster.lib import datetime as dt from hamster.lib import graphics from hamster.lib import stuff @@ -233,7 +233,7 @@ def show(self, g, colors, fact=None, is_selected=False): g.restore_context() - self.duration_label.show(g, stuff.format_duration(self.fact.delta), x=self.width - 105) + self.duration_label.show(g, self.fact.delta.format(), x=self.width - 105) g.restore_context() @@ -477,7 +477,7 @@ def set_facts(self, facts): start = self.facts[0].date end = self.facts[-1].date else: - start = end = stuff.hamster_today() + start = end = dt.hday.today() by_date = defaultdict(list) for fact in self.facts: diff --git a/src/hamster/widgets/reportchooserdialog.py b/src/hamster/widgets/reportchooserdialog.py index 5ff2822f3..0db0e981b 100644 --- a/src/hamster/widgets/reportchooserdialog.py +++ b/src/hamster/widgets/reportchooserdialog.py @@ -43,7 +43,7 @@ def __init__(self): gtk.ResponseType.OK)) # try to set path to last known folder or fall back to home - report_folder = os.path.expanduser(conf.get("last_report_folder")) + report_folder = os.path.expanduser(conf.get("last-report-folder")) if os.path.exists(report_folder): self.dialog.set_current_folder(report_folder) else: @@ -131,7 +131,7 @@ def on_save_button_clicked(self): categories = [] - conf.set("last_report_folder", os.path.dirname(path)) + conf.set("last-report-folder", os.path.dirname(path)) # format, path, start_date, end_date self.emit("report-chosen", format, path) diff --git a/src/hamster/widgets/timeinput.py b/src/hamster/widgets/timeinput.py index 8b871e5bf..a9dbe0ede 100644 --- a/src/hamster/widgets/timeinput.py +++ b/src/hamster/widgets/timeinput.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with Project Hamster. If not, see . -import datetime as dt import calendar import re @@ -25,7 +24,8 @@ from gi.repository import Gtk as gtk from gi.repository import GObject as gobject -from hamster.lib.stuff import format_duration, hamster_round +from hamster.lib import datetime as dt +from hamster.lib.stuff import hamster_round class TimeInput(gtk.Entry): __gsignals__ = { @@ -33,8 +33,8 @@ class TimeInput(gtk.Entry): } - def __init__(self, time = None, start_time = None): - gtk.Entry.__init__(self) + def __init__(self, time=None, start_time=None, **kwargs): + gtk.Entry.__init__(self, **kwargs) self.news = False self.set_width_chars(7) #7 is like 11:24pm @@ -43,6 +43,10 @@ def __init__(self, time = None, start_time = None): self.popup = gtk.Window(type = gtk.WindowType.POPUP) + self.popup.set_type_hint(gdk.WindowTypeHint.COMBO) # why not + self.popup.set_attached_to(self) # attributes + self.popup.set_transient_for(self.get_ancestor(gtk.Window)) # position + time_box = gtk.ScrolledWindow() time_box.set_policy(gtk.PolicyType.NEVER, gtk.PolicyType.ALWAYS) time_box.set_shadow_type(gtk.ShadowType.IN) @@ -221,7 +225,7 @@ def show_popup(self): while i_time < end_time: row_text = self._format_time(i_time) if self.start_time is not None: - delta_text = format_duration(i_time - i_time_0) + delta_text = (i_time - i_time_0).format() row_text += " (%s)" % delta_text diff --git a/tests/stuff_test.py b/tests/stuff_test.py index 6bd0de113..7d72656af 100644 --- a/tests/stuff_test.py +++ b/tests/stuff_test.py @@ -2,33 +2,40 @@ # a convoluted line to add hamster module to absolute path sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), "../src"))) +import datetime as pdt import unittest -from hamster.lib import Fact -from hamster.lib.stuff import hamster_now +import re +from hamster.lib import datetime as dt +from hamster.lib.dbus import ( + to_dbus_fact, + to_dbus_fact_json, + to_dbus_range, + from_dbus_fact, + from_dbus_fact_json, + from_dbus_range, + ) +from hamster.lib.fact import Fact -class TestActivityInputParsing(unittest.TestCase): - def test_datetime_to_hamsterday(self): - import datetime as dt - from hamster.lib import datetime_to_hamsterday - date_time = dt.datetime(2018, 8, 13, 23, 10) # 2018-08-13 23:10 - expected = dt.date(2018, 8, 13) - self.assertEqual(datetime_to_hamsterday(date_time), expected) - date_time = dt.datetime(2018, 8, 14, 0, 10) # 2018-08-14 0:10 - expected = dt.date(2018, 8, 13) - self.assertEqual(datetime_to_hamsterday(date_time), expected) +class TestFact(unittest.TestCase): + + def test_range(self): + t1 = dt.datetime(2020, 1, 15, 13, 30) + t2 = dt.datetime(2020, 1, 15, 15, 30) + range = dt.Range(t1, t2) + fact = Fact(range=range) + self.assertEqual(fact.range.start, t1) + self.assertEqual(fact.range.end, t2) + fact = Fact(start=t1, end=t2) + self.assertEqual(fact.range.start, t1) + self.assertEqual(fact.range.end, t2) + # backward compatibility (before v3.0) + fact = Fact(start_time=t1, end_time=t2) + self.assertEqual(fact.range.start, t1) + self.assertEqual(fact.range.end, t2) - def test_hamsterday_time_to_datetime(self): - import datetime as dt - from hamster.lib import hamsterday_time_to_datetime - hamsterday = dt.date(2018, 8, 13) - time = dt.time(23, 10) - expected = dt.datetime(2018, 8, 13, 23, 10) # 2018-08-13 23:10 - self.assertEqual(hamsterday_time_to_datetime(hamsterday, time), expected) - hamsterday = dt.date(2018, 8, 13) - time = dt.time(0, 10) - expected = dt.datetime(2018, 8, 14, 0, 10) # 2018-08-14 00:10 - self.assertEqual(hamsterday_time_to_datetime(hamsterday, time), expected) + +class TestFactParsing(unittest.TestCase): def test_plain_name(self): # plain activity name @@ -39,6 +46,12 @@ def test_plain_name(self): assert not activity.category assert not activity.description + def test_only_range(self): + fact = Fact.parse("-20") + assert not fact.activity + fact = Fact.parse("-20 -10") + assert not fact.activity + def test_with_start_time(self): # with time activity = Fact.parse("12:35 with start time") @@ -72,7 +85,7 @@ def test_category(self): def test_description(self): # plain activity name - activity = Fact.parse("case, with added descriptiön") + activity = Fact.parse("case,, with added descriptiön") self.assertEqual(activity.activity, "case") self.assertEqual(activity.description, "with added descriptiön") assert not activity.category @@ -82,9 +95,9 @@ def test_description(self): def test_tags(self): # plain activity name - activity = Fact.parse("case, with added #de description #and, #some #tägs") - self.assertEqual(activity.activity, "case") - self.assertEqual(activity.description, "with added #de description") + activity = Fact.parse("#case,, description with #hash,, #and, #some #tägs") + self.assertEqual(activity.activity, "#case") + self.assertEqual(activity.description, "description with #hash") self.assertEqual(set(activity.tags), set(["and", "some", "tägs"])) assert not activity.category assert activity.start_time is None @@ -92,7 +105,7 @@ def test_tags(self): def test_full(self): # plain activity name - activity = Fact.parse("1225-1325 case@cat, description #ta non-tag #tag #bäg") + activity = Fact.parse("1225-1325 case@cat,, description #ta non-tag,, #tag #bäg") self.assertEqual(activity.start_time.strftime("%H:%M"), "12:25") self.assertEqual(activity.end_time.strftime("%H:%M"), "13:25") self.assertEqual(activity.activity, "case") @@ -101,7 +114,7 @@ def test_full(self): self.assertEqual(set(activity.tags), set(["bäg", "tag"])) def test_copy(self): - fact1 = Fact.parse("12:25-13:25 case@cat, description #tag #bäg") + fact1 = Fact.parse("12:25-13:25 case@cat,, description #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1.start_time, fact2.start_time) self.assertEqual(fact1.end_time, fact2.end_time) @@ -119,7 +132,7 @@ def test_copy(self): self.assertEqual(fact3.tags, ["changed"]) def test_comparison(self): - fact1 = Fact.parse("12:25-13:25 case@cat, description #tag #bäg") + fact1 = Fact.parse("12:25-13:25 case@cat,, description #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1, fact2) fact2 = fact1.copy() @@ -131,12 +144,11 @@ def test_comparison(self): fact2 = fact1.copy() fact2.description = "abcd" self.assertNotEqual(fact1, fact2) - import datetime as dt fact2 = fact1.copy() - fact2.start_time = hamster_now() + fact2.range.start = fact1.range.start + dt.timedelta(minutes=1) self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() - fact2.end_time = hamster_now() + fact2.range.end = fact1.range.end + dt.timedelta(minutes=1) self.assertNotEqual(fact1, fact2) # wrong order fact2 = fact1.copy() @@ -149,12 +161,12 @@ def test_comparison(self): def test_decimal_in_activity(self): # cf. issue #270 - fact = Fact.parse("12:25-13:25 10.0@ABC, Two Words #tag #bäg") + fact = Fact.parse("12:25-13:25 10.0@ABC,, Two Words #tag #bäg") self.assertEqual(fact.activity, "10.0") self.assertEqual(fact.category, "ABC") self.assertEqual(fact.description, "Two Words") # should not pick up a time here - fact = Fact.parse("10.00@ABC, Two Words #tag #bäg") + fact = Fact.parse("10.00@ABC,, Two Words #tag #bäg") self.assertEqual(fact.activity, "10.00") self.assertEqual(fact.category, "ABC") self.assertEqual(fact.description, "Two Words") @@ -173,5 +185,272 @@ def test_spaces(self): fact3 = Fact() self.assertEqual(fact3.serialized(), "") + def test_commas(self): + fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma") + self.assertEqual(fact.activity, "activity, with comma") + self.assertEqual(fact.category, "category") + self.assertEqual(fact.description, "description, with comma") + self.assertEqual(fact.tags, []) + fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma, #tag1, #tag2") + self.assertEqual(fact.activity, "activity, with comma") + self.assertEqual(fact.category, "category") + self.assertEqual(fact.description, "description, with comma") + self.assertEqual(fact.tags, ["tag1", "tag2"]) + fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma and #hash,, #tag1, #tag2") + self.assertEqual(fact.activity, "activity, with comma") + self.assertEqual(fact.category, "category") + self.assertEqual(fact.description, "description, with comma and #hash") + self.assertEqual(fact.tags, ["tag1", "tag2"]) + + # ugly. Really need pytest + def test_roundtrips(self): + for start_time in ( + None, + dt.time(12, 33), + ): + for end_time in ( + None, + dt.time(13, 34), + ): + for activity in ( + "activity", + "#123 with two #hash", + "activity, with comma", + "17.00 tea", + ): + for category in ( + "", + "category", + ): + for description in ( + "", + "description", + "with #hash", + "with, comma", + "with @at", + "multiline\ndescription", + ): + for tags in ( + [], + ["single"], + ["with space"], + ["two", "tags"], + ["with @at"], + ): + start = dt.datetime.from_day_time(dt.hday.today(), + start_time + ) if start_time else None + end = dt.datetime.from_day_time(dt.hday.today(), + end_time + ) if end_time else None + if end and not start: + # end without start is not parseable + continue + fact = Fact(start_time=start, + end_time=end, + activity=activity, + category=category, + description=description, + tags=tags) + for range_pos in ("head", "tail"): + fact_str = fact.serialized(range_pos=range_pos) + parsed = Fact.parse(fact_str, range_pos=range_pos) + self.assertEqual(fact, parsed) + self.assertEqual(parsed.range.start, fact.range.start) + self.assertEqual(parsed.range.end, fact.range.end) + self.assertEqual(parsed.activity, fact.activity) + self.assertEqual(parsed.category, fact.category) + self.assertEqual(parsed.description, fact.description) + self.assertEqual(parsed.tags, fact.tags) + + +class TestDatetime(unittest.TestCase): + def test_datetime_from_day_time(self): + day = dt.date(2018, 8, 13) + time = dt.time(23, 10) + expected = dt.datetime(2018, 8, 13, 23, 10) # 2018-08-13 23:10 + self.assertEqual(dt.datetime.from_day_time(day, time), expected) + day = dt.date(2018, 8, 13) + time = dt.time(0, 10) + expected = dt.datetime(2018, 8, 14, 0, 10) # 2018-08-14 00:10 + self.assertEqual(dt.datetime.from_day_time(day, time), expected) + + def test_format_timedelta(self): + delta = dt.timedelta(minutes=10) + self.assertEqual(delta.format("human"), "10min") + delta = dt.timedelta(hours=5, minutes=0) + self.assertEqual(delta.format("human"), "5h") + delta = dt.timedelta(hours=5, minutes=10) + self.assertEqual(delta.format("human"), "5h 10min") + delta = dt.timedelta(hours=5, minutes=10) + self.assertEqual(delta.format("HH:MM"), "05:10") + + def test_datetime_hday(self): + date_time = dt.datetime(2018, 8, 13, 23, 10) # 2018-08-13 23:10 + expected = dt.date(2018, 8, 13) + self.assertEqual(date_time.hday(), expected) + date_time = dt.datetime(2018, 8, 14, 0, 10) # 2018-08-14 0:10 + expected = dt.date(2018, 8, 13) + self.assertEqual(date_time.hday(), expected) + today = dt.hday.today() + self.assertEqual(type(today), dt.hday) + + def test_parse_date(self): + date = dt.date.parse("2020-01-05") + self.assertEqual(date, pdt.date(2020, 1, 5)) + + def test_parse_time(self): + self.assertEqual(dt.time.parse("9:01"), pdt.time(9, 1)) + self.assertEqual(dt.time.parse("9.01"), pdt.time(9, 1)) + self.assertEqual(dt.time.parse("12:01"), pdt.time(12, 1)) + self.assertEqual(dt.time.parse("12.01"), pdt.time(12, 1)) + self.assertEqual(dt.time.parse("1201"), pdt.time(12, 1)) + + def test_parse_datetime(self): + self.assertEqual(dt.datetime.parse("2020-01-05 9:01"), pdt.datetime(2020, 1, 5, 9, 1)) + + def test_datetime_patterns(self): + p = dt.datetime.pattern(1) + s = "12:03" + m = re.fullmatch(p, s, re.VERBOSE) + time = dt.datetime._extract_datetime(m, d="date1", h="hour1", m="minute1", r="relative1", + default_day=dt.hday.today()) + self.assertEqual(time.strftime("%H:%M"), "12:03") + s = "2019-12-01 12:36" + m = re.fullmatch(p, s, re.VERBOSE) + time = dt.datetime._extract_datetime(m, d="date1", h="hour1", m="minute1", r="relative1") + self.assertEqual(time.strftime("%Y-%m-%d %H:%M"), "2019-12-01 12:36") + s = "-25" + m = re.fullmatch(p, s, re.VERBOSE) + relative = dt.datetime._extract_datetime(m, d="date1", h="hour1", m="minute1", r="relative1", + default_day=dt.hday.today()) + self.assertEqual(relative, dt.timedelta(minutes=-25)) + s = "2019-12-05" + m = re.search(p, s, re.VERBOSE) + self.assertEqual(m, None) + + def test_parse_datetime_range(self): + # only match clean + s = "10.00@cat" + (start, end), rest = dt.Range.parse(s, position="head") + self.assertEqual(start, None) + self.assertEqual(end, None) + s = "12:02" + (start, end), rest = dt.Range.parse(s) + self.assertEqual(start.strftime("%H:%M"), "12:02") + self.assertEqual(end, None) + s = "12:03 13:04" + (start, end), rest = dt.Range.parse(s) + self.assertEqual(start.strftime("%H:%M"), "12:03") + self.assertEqual(end.strftime("%H:%M"), "13:04") + s = "12:35 activity" + (start, end), rest = dt.Range.parse(s, position="head") + self.assertEqual(start.strftime("%H:%M"), "12:35") + self.assertEqual(end, None) + s = "2019-12-01 12:33 activity" + (start, end), rest = dt.Range.parse(s, position="head") + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-12-01 12:33") + self.assertEqual(end, None) + + ref = dt.datetime(2019, 11, 29, 13, 55) # 2019-11-29 13:55 + + s = "-25 activity" + (start, end), rest = dt.Range.parse(s, position="head", ref=ref) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-11-29 13:30") + self.assertEqual(end, None) + s = "+25 activity" + (start, end), rest = dt.Range.parse(s, position="head", ref=ref) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-11-29 14:20") + self.assertEqual(end, None) + s = "-55 -25 activity" + (start, end), rest = dt.Range.parse(s, position="head", ref=ref) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-11-29 13:00") + self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2019-11-29 13:30") + s = "+25 +55 activity" + (start, end), rest = dt.Range.parse(s, position="head", ref=ref) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-11-29 14:20") + self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2019-11-29 14:50") + s = "-55 -120 activity" + (start, end), rest = dt.Range.parse(s, position="head", ref=ref) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-11-29 13:00") + self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2019-11-29 11:55") + s = "-50 20 activity" + (start, end), rest = dt.Range.parse(s, position="head", ref=ref) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2019-11-29 13:05") + self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2019-11-29 13:25") + + s = "2019-12-05" # single hamster day + (start, end), rest = dt.Range.parse(s, ref=ref) + just_before = start - dt.timedelta(seconds=1) + just_after = end + dt.timedelta(seconds=1) + self.assertEqual(just_before.hday(), pdt.date(2019, 12, 4)) + self.assertEqual(just_after.hday(), pdt.date(2019, 12, 6)) + s = "2019-12-05 2019-12-07" # hamster days range + (start, end), rest = dt.Range.parse(s, ref=ref) + just_before = start - dt.timedelta(seconds=1) + just_after = end + dt.timedelta(seconds=1) + self.assertEqual(just_before.hday(), dt.date(2019, 12, 4)) + self.assertEqual(just_after.hday(), dt.date(2019, 12, 8)) + + s = "14:30 - --" + (start, end), rest = dt.Range.parse(s, ref=ref) + self.assertEqual(start.strftime("%H:%M"), "14:30") + self.assertEqual(end, None) + + def test_rounding(self): + dt1 = dt.datetime(2019, 12, 31, hour=13, minute=14, second=10, microsecond=11) + self.assertEqual(dt1.second, 0) + self.assertEqual(dt1.microsecond, 0) + self.assertEqual(str(dt1), "2019-12-31 13:14") + + def test_type_stability(self): + dt1 = dt.datetime(2020, 1, 10, hour=13, minute=30) + dt2 = dt.datetime(2020, 1, 10, hour=13, minute=40) + delta = dt2 - dt1 + self.assertEqual(type(delta), dt.timedelta) + _sum = dt1 + delta + self.assertEqual(_sum, dt.datetime(2020, 1, 10, hour=13, minute=40)) + self.assertEqual(type(_sum), dt.datetime) + _sub = dt1 - delta + self.assertEqual(_sub, dt.datetime(2020, 1, 10, hour=13, minute=20)) + self.assertEqual(type(_sub), dt.datetime) + + opposite = - delta + self.assertEqual(opposite, dt.timedelta(minutes=-10)) + self.assertEqual(type(opposite), dt.timedelta) + _sum = delta + delta + self.assertEqual(_sum, dt.timedelta(minutes=20)) + self.assertEqual(type(_sum), dt.timedelta) + _sub = delta - delta + self.assertEqual(_sub, dt.timedelta()) + self.assertEqual(type(_sub), dt.timedelta) + + +class TestDBus(unittest.TestCase): + def test_round_trip(self): + fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma #and #tags") + dbus_fact = to_dbus_fact_json(fact) + return_fact = from_dbus_fact_json(dbus_fact) + self.assertEqual(return_fact, fact) + + dbus_fact = to_dbus_fact(fact) + return_fact = from_dbus_fact(dbus_fact) + self.assertEqual(return_fact, fact) + + fact = Fact.parse("11:00 activity") + dbus_fact = to_dbus_fact_json(fact) + return_fact = from_dbus_fact_json(dbus_fact) + self.assertEqual(return_fact, fact) + + dbus_fact = to_dbus_fact(fact) + return_fact = from_dbus_fact(dbus_fact) + self.assertEqual(return_fact, fact) + + range, __ = dt.Range.parse("2020-01-19 11:00 - 2020-01-19 12:00") + dbus_range = to_dbus_range(range) + return_range = from_dbus_range(dbus_range) + self.assertEqual(return_range, range) + + if __name__ == '__main__': unittest.main() diff --git a/wscript b/wscript index 22f2a3c40..b7ea6579a 100644 --- a/wscript +++ b/wscript @@ -13,7 +13,7 @@ from waflib import Logs, Utils def configure(conf): conf.load('gnu_dirs') # for DATADIR - + conf.load('glib2') # for GSettings support conf.load('python') conf.check_python_version(minver=(3,4,0)) @@ -26,16 +26,10 @@ def configure(conf): conf.env.GETTEXT_PACKAGE = "hamster" conf.env.PACKAGE = "hamster" - # gconf_dir is defined in options - conf.env.schemas_destination = '{}/schemas'.format(conf.options.gconf_dir) - conf.recurse("help") def options(opt): - opt.add_option('--gconf-dir', action='store', default='/etc/gconf', dest='gconf_dir', - help='gconf base directory [default: /etc/gconf]') - # the waf default value is /usr/local, which causes issues (e.g. #309) # opt.parser.set_defaults(prefix='/usr') did not update the help string, # hence need to replace the whole option @@ -46,13 +40,9 @@ def options(opt): def build(bld): - bld.install_files('${LIBDIR}/hamster', - """src/hamster-service - src/hamster-windows-service - """, - chmod=Utils.O755) - - bld.install_as('${BINDIR}/hamster', "src/hamster-cli", chmod=Utils.O755) + bld.install_as('${LIBDIR}/hamster/hamster-service', "src/hamster-service.py", chmod=Utils.O755) + bld.install_as('${LIBDIR}/hamster/hamster-windows-service', "src/hamster-windows-service.py", chmod=Utils.O755) + bld.install_as('${BINDIR}/hamster', "src/hamster-cli.py", chmod=Utils.O755) bld.install_files('${PREFIX}/share/bash-completion/completion', @@ -60,7 +50,7 @@ def build(bld): bld(features='py', - source=bld.path.ant_glob('src/**/*.py'), + source=bld.path.ant_glob('src/hamster/**/*.py'), install_from='src') # set correct flags in defs.py @@ -84,27 +74,8 @@ def build(bld): bld.recurse("po data help") - - def manage_gconf_schemas(ctx, action): - """Install or uninstall hamster gconf schemas. - - Requires the stored hamster.schemas - (usually in /etc/gconf/schemas/) to be present. - - Hence install should be a post-fun, - and uninstall a pre-fun. - """ - - assert action in ("install", "uninstall") - if ctx.cmd == action: - schemas_file = "{}/hamster.schemas".format(ctx.env.schemas_destination) - cmd = 'GCONF_CONFIG_SOURCE=$(gconftool-2 --get-default-source) gconftool-2 --makefile-{}-rule {} 1> /dev/null'.format(action, schemas_file) - err = ctx.exec_command(cmd) - if err: - Logs.warn('The following command failed:\n{}'.format(cmd)) - else: - Logs.pprint('YELLOW', 'Successfully {}ed gconf schemas'.format(action)) - + bld(features='glib2', + settings_schema_files = ['data/org.gnome.hamster.gschema.xml']) def update_icon_cache(ctx): """Update the gtk icon cache.""" @@ -119,6 +90,4 @@ def build(bld): Logs.pprint('YELLOW', 'Successfully updated GTK icon cache') - bld.add_post_fun(lambda bld: manage_gconf_schemas(bld, "install")) bld.add_post_fun(update_icon_cache) - bld.add_pre_fun(lambda bld: manage_gconf_schemas(bld, "uninstall"))