bouquin 0.3.2__tar.gz → 0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {bouquin-0.3.2 → bouquin-0.4}/PKG-INFO +18 -12
  2. {bouquin-0.3.2 → bouquin-0.4}/README.md +16 -9
  3. {bouquin-0.3.2 → bouquin-0.4}/bouquin/bug_report_dialog.py +3 -0
  4. {bouquin-0.3.2 → bouquin-0.4}/bouquin/db.py +258 -0
  5. {bouquin-0.3.2 → bouquin-0.4}/bouquin/locales/en.json +83 -4
  6. {bouquin-0.3.2 → bouquin-0.4}/bouquin/main_window.py +201 -24
  7. {bouquin-0.3.2 → bouquin-0.4}/bouquin/markdown_editor.py +69 -15
  8. {bouquin-0.3.2 → bouquin-0.4}/bouquin/markdown_highlighter.py +23 -0
  9. {bouquin-0.3.2 → bouquin-0.4}/bouquin/settings.py +1 -1
  10. {bouquin-0.3.2 → bouquin-0.4}/bouquin/statistics_dialog.py +1 -1
  11. bouquin-0.4/bouquin/time_log.py +1128 -0
  12. {bouquin-0.3.2 → bouquin-0.4}/bouquin/toolbar.py +11 -9
  13. {bouquin-0.3.2 → bouquin-0.4}/pyproject.toml +2 -2
  14. bouquin-0.3.2/bouquin/screenshot.py +0 -164
  15. {bouquin-0.3.2 → bouquin-0.4}/LICENSE +0 -0
  16. {bouquin-0.3.2 → bouquin-0.4}/bouquin/__init__.py +0 -0
  17. {bouquin-0.3.2 → bouquin-0.4}/bouquin/__main__.py +0 -0
  18. {bouquin-0.3.2 → bouquin-0.4}/bouquin/find_bar.py +0 -0
  19. {bouquin-0.3.2 → bouquin-0.4}/bouquin/flow_layout.py +0 -0
  20. {bouquin-0.3.2 → bouquin-0.4}/bouquin/history_dialog.py +0 -0
  21. {bouquin-0.3.2 → bouquin-0.4}/bouquin/key_prompt.py +0 -0
  22. {bouquin-0.3.2 → bouquin-0.4}/bouquin/locales/fr.json +0 -0
  23. {bouquin-0.3.2 → bouquin-0.4}/bouquin/locales/it.json +0 -0
  24. {bouquin-0.3.2 → bouquin-0.4}/bouquin/lock_overlay.py +0 -0
  25. {bouquin-0.3.2 → bouquin-0.4}/bouquin/main.py +0 -0
  26. {bouquin-0.3.2 → bouquin-0.4}/bouquin/save_dialog.py +0 -0
  27. {bouquin-0.3.2 → bouquin-0.4}/bouquin/search.py +0 -0
  28. {bouquin-0.3.2 → bouquin-0.4}/bouquin/settings_dialog.py +0 -0
  29. {bouquin-0.3.2 → bouquin-0.4}/bouquin/strings.py +0 -0
  30. {bouquin-0.3.2 → bouquin-0.4}/bouquin/tag_browser.py +1 -1
  31. {bouquin-0.3.2 → bouquin-0.4}/bouquin/tags_widget.py +0 -0
  32. {bouquin-0.3.2 → bouquin-0.4}/bouquin/theme.py +0 -0
@@ -1,15 +1,14 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.3.2
3
+ Version: 0.4
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
5
  Home-page: https://git.mig5.net/mig5/bouquin
6
6
  License: GPL-3.0-or-later
7
7
  Author: Miguel Jacq
8
8
  Author-email: mig@mig5.net
9
- Requires-Python: >=3.9,<3.14
9
+ Requires-Python: >=3.10,<3.14
10
10
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.9
13
12
  Classifier: Programming Language :: Python :: 3.10
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
@@ -24,7 +23,11 @@ Description-Content-Type: text/markdown
24
23
 
25
24
  ## Introduction
26
25
 
27
- Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
26
+ Bouquin ("Book-ahn") is a notebook and planner application written in Python, PyQt and SQLCipher.
27
+
28
+ It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
29
+ search, reminders and time logging for those of us who need to keep track of not just TODOs, but
30
+ also how long we spent on them.
28
31
 
29
32
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
30
33
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -37,31 +40,34 @@ report from within the app.
37
40
 
38
41
  ## Screenshots
39
42
 
43
+ ### General view
40
44
  ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png)
45
+
46
+ ### History panes
41
47
  ![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png)
42
48
  ![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png)
43
49
 
44
- ## Features
50
+ ## Some of the features
45
51
 
46
52
  * Data is encrypted at rest
47
53
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
48
- * Every 'page' is linked to the calendar day
49
54
  * All changes are version controlled, with ability to view/diff versions and revert
50
- * Text is Markdown with basic styling
55
+ * Automatic rendering of basic Markdown syntax
51
56
  * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
52
- * Images are supported, as is the ability to take a screenshot and have it insert into the page automatically.
53
- * Search all pages, or find text on page (Ctrl+F)
54
- * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
57
+ * Images are supported
58
+ * Search all pages, or find text on current page
59
+ * Add and manage tags
55
60
  * Automatic periodic saving (or explicitly save)
56
- * Transparent integrity checking of the database when it opens
57
61
  * Automatic locking of the app after a period of inactivity (default 15 min)
58
62
  * Rekey the database (change the password)
59
63
  * Export the database to json, html, csv, markdown or .sql (for sqlite3)
60
64
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
61
- * Dark and light themes
65
+ * Dark and light theme support
62
66
  * Automatically generate checkboxes when typing 'TODO'
63
67
  * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
64
68
  * English, French and Italian locales provided
69
+ * Ability to set reminder alarms in the app against the current line of text on today's date
70
+ * Ability to log time per day and run timesheet reports
65
71
 
66
72
 
67
73
  ## How to install
@@ -3,7 +3,11 @@
3
3
 
4
4
  ## Introduction
5
5
 
6
- Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
6
+ Bouquin ("Book-ahn") is a notebook and planner application written in Python, PyQt and SQLCipher.
7
+
8
+ It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
9
+ search, reminders and time logging for those of us who need to keep track of not just TODOs, but
10
+ also how long we spent on them.
7
11
 
8
12
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
9
13
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -16,31 +20,34 @@ report from within the app.
16
20
 
17
21
  ## Screenshots
18
22
 
23
+ ### General view
19
24
  ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png)
25
+
26
+ ### History panes
20
27
  ![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png)
21
28
  ![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png)
22
29
 
23
- ## Features
30
+ ## Some of the features
24
31
 
25
32
  * Data is encrypted at rest
26
33
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
27
- * Every 'page' is linked to the calendar day
28
34
  * All changes are version controlled, with ability to view/diff versions and revert
29
- * Text is Markdown with basic styling
35
+ * Automatic rendering of basic Markdown syntax
30
36
  * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
31
- * Images are supported, as is the ability to take a screenshot and have it insert into the page automatically.
32
- * Search all pages, or find text on page (Ctrl+F)
33
- * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
37
+ * Images are supported
38
+ * Search all pages, or find text on current page
39
+ * Add and manage tags
34
40
  * Automatic periodic saving (or explicitly save)
35
- * Transparent integrity checking of the database when it opens
36
41
  * Automatic locking of the app after a period of inactivity (default 15 min)
37
42
  * Rekey the database (change the password)
38
43
  * Export the database to json, html, csv, markdown or .sql (for sqlite3)
39
44
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
40
- * Dark and light themes
45
+ * Dark and light theme support
41
46
  * Automatically generate checkboxes when typing 'TODO'
42
47
  * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
43
48
  * English, French and Italian locales provided
49
+ * Ability to set reminder alarms in the app against the current line of text on today's date
50
+ * Ability to log time per day and run timesheet reports
44
51
 
45
52
 
46
53
  ## How to install
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib.metadata
4
+
4
5
  import requests
5
6
 
6
7
  from PySide6.QtWidgets import (
@@ -49,6 +50,8 @@ class BugReportDialog(QDialog):
49
50
  button_box.rejected.connect(self.reject)
50
51
  layout.addWidget(button_box)
51
52
 
53
+ self.setMinimumWidth(560)
54
+
52
55
  self.text_edit.setFocus()
53
56
 
54
57
  # ------------Helpers ------------ #
@@ -17,6 +17,18 @@ from . import strings
17
17
 
18
18
  Entry = Tuple[str, str]
19
19
  TagRow = Tuple[int, str, str]
20
+ ProjectRow = Tuple[int, str] # (id, name)
21
+ ActivityRow = Tuple[int, str] # (id, name)
22
+ TimeLogRow = Tuple[
23
+ int, # id
24
+ str, # page_date (yyyy-MM-dd)
25
+ int,
26
+ str, # project_id, project_name
27
+ int,
28
+ str, # activity_id, activity_name
29
+ int, # minutes
30
+ str | None, # note
31
+ ]
20
32
 
21
33
  _TAG_COLORS = [
22
34
  "#FFB3BA", # soft red
@@ -148,6 +160,38 @@ class DBManager:
148
160
  );
149
161
 
150
162
  CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id);
163
+
164
+ CREATE TABLE IF NOT EXISTS projects (
165
+ id INTEGER PRIMARY KEY,
166
+ name TEXT NOT NULL UNIQUE
167
+ );
168
+
169
+ CREATE TABLE IF NOT EXISTS activities (
170
+ id INTEGER PRIMARY KEY,
171
+ name TEXT NOT NULL UNIQUE
172
+ );
173
+
174
+ CREATE TABLE IF NOT EXISTS time_log (
175
+ id INTEGER PRIMARY KEY,
176
+ page_date TEXT NOT NULL, -- FK to pages.date (yyyy-MM-dd)
177
+ project_id INTEGER NOT NULL, -- FK to projects.id
178
+ activity_id INTEGER NOT NULL, -- FK to activities.id
179
+ minutes INTEGER NOT NULL, -- duration in minutes
180
+ note TEXT,
181
+ created_at TEXT NOT NULL DEFAULT (
182
+ strftime('%Y-%m-%dT%H:%M:%fZ','now')
183
+ ),
184
+ FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE,
185
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE RESTRICT,
186
+ FOREIGN KEY(activity_id) REFERENCES activities(id) ON DELETE RESTRICT
187
+ );
188
+
189
+ CREATE INDEX IF NOT EXISTS ix_time_log_date
190
+ ON time_log(page_date);
191
+ CREATE INDEX IF NOT EXISTS ix_time_log_project
192
+ ON time_log(project_id);
193
+ CREATE INDEX IF NOT EXISTS ix_time_log_activity
194
+ ON time_log(activity_id);
151
195
  """
152
196
  )
153
197
  self.conn.commit()
@@ -746,6 +790,220 @@ class DBManager:
746
790
  revisions_by_date,
747
791
  )
748
792
 
793
+ # -------- Time logging: projects & activities ---------------------
794
+
795
+ def list_projects(self) -> list[ProjectRow]:
796
+ cur = self.conn.cursor()
797
+ rows = cur.execute(
798
+ "SELECT id, name FROM projects ORDER BY LOWER(name);"
799
+ ).fetchall()
800
+ return [(r["id"], r["name"]) for r in rows]
801
+
802
+ def add_project(self, name: str) -> int:
803
+ name = name.strip()
804
+ if not name:
805
+ raise ValueError("empty project name")
806
+ with self.conn:
807
+ cur = self.conn.cursor()
808
+ cur.execute(
809
+ "INSERT OR IGNORE INTO projects(name) VALUES (?);",
810
+ (name,),
811
+ )
812
+ row = cur.execute(
813
+ "SELECT id, name FROM projects WHERE name = ?;",
814
+ (name,),
815
+ ).fetchone()
816
+ return row["id"]
817
+
818
+ def rename_project(self, project_id: int, new_name: str) -> None:
819
+ new_name = new_name.strip()
820
+ if not new_name:
821
+ return
822
+ with self.conn:
823
+ self.conn.execute(
824
+ "UPDATE projects SET name = ? WHERE id = ?;",
825
+ (new_name, project_id),
826
+ )
827
+
828
+ def delete_project(self, project_id: int) -> None:
829
+ with self.conn:
830
+ self.conn.execute(
831
+ "DELETE FROM projects WHERE id = ?;",
832
+ (project_id,),
833
+ )
834
+
835
+ def list_activities(self) -> list[ActivityRow]:
836
+ cur = self.conn.cursor()
837
+ rows = cur.execute(
838
+ "SELECT id, name FROM activities ORDER BY LOWER(name);"
839
+ ).fetchall()
840
+ return [(r["id"], r["name"]) for r in rows]
841
+
842
+ def add_activity(self, name: str) -> int:
843
+ name = name.strip()
844
+ if not name:
845
+ raise ValueError("empty activity name")
846
+ with self.conn:
847
+ cur = self.conn.cursor()
848
+ cur.execute(
849
+ "INSERT OR IGNORE INTO activities(name) VALUES (?);",
850
+ (name,),
851
+ )
852
+ row = cur.execute(
853
+ "SELECT id, name FROM activities WHERE name = ?;",
854
+ (name,),
855
+ ).fetchone()
856
+ return row["id"]
857
+
858
+ def rename_activity(self, activity_id: int, new_name: str) -> None:
859
+ new_name = new_name.strip()
860
+ if not new_name:
861
+ return
862
+ with self.conn:
863
+ self.conn.execute(
864
+ "UPDATE activities SET name = ? WHERE id = ?;",
865
+ (new_name, activity_id),
866
+ )
867
+
868
+ def delete_activity(self, activity_id: int) -> None:
869
+ with self.conn:
870
+ self.conn.execute(
871
+ "DELETE FROM activities WHERE id = ?;",
872
+ (activity_id,),
873
+ )
874
+
875
+ # -------- Time logging: entries -----------------------------------
876
+
877
+ def add_time_log(
878
+ self,
879
+ date_iso: str,
880
+ project_id: int,
881
+ activity_id: int,
882
+ minutes: int,
883
+ note: str | None = None,
884
+ ) -> int:
885
+ with self.conn:
886
+ cur = self.conn.cursor()
887
+ # Ensure a page row exists even if there is no text content yet
888
+ cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
889
+ cur.execute(
890
+ """
891
+ INSERT INTO time_log(page_date, project_id, activity_id, minutes, note)
892
+ VALUES (?, ?, ?, ?, ?);
893
+ """,
894
+ (date_iso, project_id, activity_id, minutes, note),
895
+ )
896
+ return cur.lastrowid
897
+
898
+ def update_time_log(
899
+ self,
900
+ entry_id: int,
901
+ project_id: int,
902
+ activity_id: int,
903
+ minutes: int,
904
+ note: str | None = None,
905
+ ) -> None:
906
+ with self.conn:
907
+ self.conn.execute(
908
+ """
909
+ UPDATE time_log
910
+ SET project_id = ?, activity_id = ?, minutes = ?, note = ?
911
+ WHERE id = ?;
912
+ """,
913
+ (project_id, activity_id, minutes, note, entry_id),
914
+ )
915
+
916
+ def delete_time_log(self, entry_id: int) -> None:
917
+ with self.conn:
918
+ self.conn.execute(
919
+ "DELETE FROM time_log WHERE id = ?;",
920
+ (entry_id,),
921
+ )
922
+
923
+ def time_log_for_date(self, date_iso: str) -> list[TimeLogRow]:
924
+ cur = self.conn.cursor()
925
+ rows = cur.execute(
926
+ """
927
+ SELECT
928
+ t.id,
929
+ t.page_date,
930
+ t.project_id,
931
+ p.name AS project_name,
932
+ t.activity_id,
933
+ a.name AS activity_name,
934
+ t.minutes,
935
+ t.note
936
+ FROM time_log t
937
+ JOIN projects p ON p.id = t.project_id
938
+ JOIN activities a ON a.id = t.activity_id
939
+ WHERE t.page_date = ?
940
+ ORDER BY LOWER(p.name), LOWER(a.name), t.id;
941
+ """,
942
+ (date_iso,),
943
+ ).fetchall()
944
+
945
+ result: list[TimeLogRow] = []
946
+ for r in rows:
947
+ result.append(
948
+ (
949
+ r["id"],
950
+ r["page_date"],
951
+ r["project_id"],
952
+ r["project_name"],
953
+ r["activity_id"],
954
+ r["activity_name"],
955
+ r["minutes"],
956
+ r["note"],
957
+ )
958
+ )
959
+ return result
960
+
961
+ def time_report(
962
+ self,
963
+ project_id: int,
964
+ start_date_iso: str,
965
+ end_date_iso: str,
966
+ granularity: str = "day", # 'day' | 'week' | 'month'
967
+ ) -> list[tuple[str, str, int]]:
968
+ """
969
+ Return (time_period, activity_name, total_minutes) tuples between start and end
970
+ for a project, grouped by period and activity.
971
+ time_period is:
972
+ - 'YYYY-MM-DD' for day
973
+ - 'YYYY-WW' for week
974
+ - 'YYYY-MM' for month
975
+ """
976
+ if granularity == "day":
977
+ bucket_expr = "page_date"
978
+ elif granularity == "week":
979
+ # ISO-like year-week; SQLite weeks start at 00
980
+ bucket_expr = "strftime('%Y-%W', page_date)"
981
+ else: # month
982
+ bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
983
+
984
+ cur = self.conn.cursor()
985
+ rows = cur.execute(
986
+ f"""
987
+ SELECT
988
+ {bucket_expr} AS bucket,
989
+ a.name AS activity_name,
990
+ t.note AS note,
991
+ SUM(t.minutes) AS total_minutes
992
+ FROM time_log t
993
+ JOIN activities a ON a.id = t.activity_id
994
+ WHERE t.project_id = ?
995
+ AND t.page_date BETWEEN ? AND ?
996
+ GROUP BY bucket, activity_name
997
+ ORDER BY bucket, LOWER(activity_name);
998
+ """, # nosec
999
+ (project_id, start_date_iso, end_date_iso),
1000
+ ).fetchall()
1001
+
1002
+ return [
1003
+ (r["bucket"], r["activity_name"], r["note"], r["total_minutes"])
1004
+ for r in rows
1005
+ ]
1006
+
749
1007
  def close(self) -> None:
750
1008
  if self.conn is not None:
751
1009
  self.conn.close()
@@ -155,8 +155,87 @@
155
155
  "bug_report_send_failed": "Could not send bug report.",
156
156
  "bug_report_sent_ok": "Bug report sent. Thank you!",
157
157
  "send": "Send",
158
- "screenshot": "Take screenshot",
159
- "screenshot_could_not_save": "Could not save screenshot",
160
- "screenshot_get_ready": "You will have five seconds to position your screen before a preview is taken.\n\nYou will be able to click and drag to select a specific region of the preview.",
161
- "screenshot_click_and_drag": "Click and drag to select a region or press enter to accept the whole region"
158
+ "set_reminder": "Set reminder prompt",
159
+ "set_reminder_prompt": "Enter a time",
160
+ "reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
161
+ "invalid_time_title": "Invalid time",
162
+ "invalid_time_message": "Please enter a time in the format HH:MM",
163
+ "dismiss": "Dismiss",
164
+ "toolbar_alarm": "Set reminder alarm",
165
+ "activities": "Activities",
166
+ "activity": "Activity",
167
+ "note": "Note",
168
+ "activity_delete_error_message": "A problem occurred deleting the activity",
169
+ "activity_delete_error_title": "Problem deleting activity",
170
+ "activity_rename_error_message": "A problem occurred renaming the activity",
171
+ "activity_rename_error_title": "Problem renaming activity",
172
+ "activity_required_message": "An activity name is required",
173
+ "activity_required_title": "Activity name required",
174
+ "add_activity": "Add activity",
175
+ "add_project": "Add project",
176
+ "add_time_entry": "Add time entry",
177
+ "time_period": "Time period",
178
+ "by_day": "by day",
179
+ "by_month": "by month",
180
+ "by_week": "by week",
181
+ "date_range": "Date range",
182
+ "delete_activity": "Delete activity",
183
+ "delete_activity_confirm": "Are you sure you want to delete this activity?",
184
+ "delete_activity_title": "Delete activity - are you sure?",
185
+ "delete_project": "Delete project",
186
+ "delete_project_confirm": "Are you sure you want to delete this project?",
187
+ "delete_project_title": "Delete project - are you sure?",
188
+ "delete_time_entry": "Delete time entry",
189
+ "group_by": "Group by",
190
+ "hours": "Hours",
191
+ "invalid_activity_message": "The activity is invalid",
192
+ "invalid_activity_title": "Invalid activity",
193
+ "invalid_project_message": "The project is invalid",
194
+ "invalid_project_title": "Invalid project",
195
+ "label_key": "Label",
196
+ "manage_activities": "Manage activities",
197
+ "manage_projects": "Manage projects",
198
+ "manage_projects_activities": "Manage project activities",
199
+ "open_time_log": "Open time log",
200
+ "project": "Project",
201
+ "project_delete_error_message": "A problem occurred deleting the project",
202
+ "project_delete_error_title": "Problem deleting project",
203
+ "project_rename_error_message": "A problem occurred renaming the project",
204
+ "project_rename_error_title": "Problem renaming project",
205
+ "project_required_message": "A project is required",
206
+ "project_required_title": "Project required",
207
+ "projects": "Projects",
208
+ "rename_activity": "Rename activity",
209
+ "rename_project": "Rename project",
210
+ "run_report": "Run report",
211
+ "add_project_label": "Add a project",
212
+ "add_activity_label": "Add an activity",
213
+ "select_activity_message": "Select an activity",
214
+ "select_activity_title": "Select activity",
215
+ "select_project_message": "Select a project",
216
+ "select_project_title": "Select project",
217
+ "time_log": "Time log",
218
+ "time_log_collapsed_hint": "Time log",
219
+ "time_log_date_label": "Time log date: {date}",
220
+ "time_log_for": "Time log for {date}",
221
+ "time_log_no_date": "Time log",
222
+ "time_log_no_entries": "No time entries yet",
223
+ "time_log_report": "Time log report",
224
+ "time_log_report_title": "Time log for {project}",
225
+ "time_log_report_meta": "From {start} to {end}, grouped {granularity}",
226
+ "time_log_total_hours": "Total time spent",
227
+ "time_log_with_total": "Time log ({hours:.2f}h)",
228
+ "time_log_total_hours": "Total for day: {hours:.2f}h",
229
+ "title_key": "title",
230
+ "update_time_entry": "Update time entry",
231
+ "time_report_total": "Total: {hours:.2f} hours",
232
+ "no_report_title": "No report",
233
+ "no_report_message": "Please run a report before exporting.",
234
+ "total": "Total",
235
+ "export_csv": "Export CSV",
236
+ "export_csv_error_title": "Export failed",
237
+ "export_csv_error_message": "Could not write CSV file:\n{error}",
238
+ "export_pdf": "Export PDF",
239
+ "export_pdf_error_title": "PDF export failed",
240
+ "export_pdf_error_message": "Could not write PDF file:\n{error}"
162
241
  }