bouquin 0.3.1__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.1 → bouquin-0.4}/PKG-INFO +20 -12
  2. {bouquin-0.3.1 → bouquin-0.4}/README.md +17 -9
  3. bouquin-0.4/bouquin/bug_report_dialog.py +128 -0
  4. {bouquin-0.3.1 → bouquin-0.4}/bouquin/db.py +260 -2
  5. bouquin-0.4/bouquin/key_prompt.py +106 -0
  6. {bouquin-0.3.1 → bouquin-0.4}/bouquin/locales/en.json +91 -1
  7. {bouquin-0.3.1 → bouquin-0.4}/bouquin/main_window.py +224 -18
  8. {bouquin-0.3.1 → bouquin-0.4}/bouquin/markdown_editor.py +68 -8
  9. {bouquin-0.3.1 → bouquin-0.4}/bouquin/markdown_highlighter.py +23 -0
  10. {bouquin-0.3.1 → bouquin-0.4}/bouquin/settings.py +22 -6
  11. {bouquin-0.3.1 → bouquin-0.4}/bouquin/settings_dialog.py +6 -33
  12. {bouquin-0.3.1 → bouquin-0.4}/bouquin/statistics_dialog.py +64 -3
  13. bouquin-0.4/bouquin/time_log.py +1128 -0
  14. {bouquin-0.3.1 → bouquin-0.4}/bouquin/toolbar.py +10 -1
  15. {bouquin-0.3.1 → bouquin-0.4}/pyproject.toml +3 -2
  16. bouquin-0.3.1/bouquin/key_prompt.py +0 -49
  17. {bouquin-0.3.1 → bouquin-0.4}/LICENSE +0 -0
  18. {bouquin-0.3.1 → bouquin-0.4}/bouquin/__init__.py +0 -0
  19. {bouquin-0.3.1 → bouquin-0.4}/bouquin/__main__.py +0 -0
  20. {bouquin-0.3.1 → bouquin-0.4}/bouquin/find_bar.py +0 -0
  21. {bouquin-0.3.1 → bouquin-0.4}/bouquin/flow_layout.py +0 -0
  22. {bouquin-0.3.1 → bouquin-0.4}/bouquin/history_dialog.py +0 -0
  23. {bouquin-0.3.1 → bouquin-0.4}/bouquin/locales/fr.json +0 -0
  24. {bouquin-0.3.1 → bouquin-0.4}/bouquin/locales/it.json +0 -0
  25. {bouquin-0.3.1 → bouquin-0.4}/bouquin/lock_overlay.py +0 -0
  26. {bouquin-0.3.1 → bouquin-0.4}/bouquin/main.py +0 -0
  27. {bouquin-0.3.1 → bouquin-0.4}/bouquin/save_dialog.py +0 -0
  28. {bouquin-0.3.1 → bouquin-0.4}/bouquin/search.py +0 -0
  29. {bouquin-0.3.1 → bouquin-0.4}/bouquin/strings.py +0 -0
  30. {bouquin-0.3.1 → bouquin-0.4}/bouquin/tag_browser.py +1 -1
  31. {bouquin-0.3.1 → bouquin-0.4}/bouquin/tags_widget.py +0 -0
  32. {bouquin-0.3.1 → bouquin-0.4}/bouquin/theme.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.3.1
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
16
15
  Requires-Dist: pyside6 (>=6.8.1,<7.0.0)
16
+ Requires-Dist: requests (>=2.32.5,<3.0.0)
17
17
  Requires-Dist: sqlcipher3-wheels (>=0.5.5.post0,<0.6.0)
18
18
  Project-URL: Repository, https://git.mig5.net/mig5/bouquin
19
19
  Description-Content-Type: text/markdown
@@ -23,7 +23,11 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  ## Introduction
25
25
 
26
- 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.
27
31
 
28
32
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
29
33
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -31,35 +35,39 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
31
35
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
32
36
  to disk unless the user configures it to be in the settings.
33
37
 
34
- There is deliberately no network connectivity or syncing intended.
38
+ There is deliberately no network connectivity or syncing intended, other than the option to send a bug
39
+ report from within the app.
35
40
 
36
41
  ## Screenshots
37
42
 
43
+ ### General view
38
44
  ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png)
45
+
46
+ ### History panes
39
47
  ![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png)
40
48
  ![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png)
41
49
 
42
- ## Features
50
+ ## Some of the features
43
51
 
44
52
  * Data is encrypted at rest
45
53
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
46
- * Every 'page' is linked to the calendar day
47
54
  * All changes are version controlled, with ability to view/diff versions and revert
48
- * Text is Markdown with basic styling
55
+ * Automatic rendering of basic Markdown syntax
49
56
  * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
50
57
  * Images are supported
51
- * Search all pages, or find text on page (Ctrl+F)
52
- * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
58
+ * Search all pages, or find text on current page
59
+ * Add and manage tags
53
60
  * Automatic periodic saving (or explicitly save)
54
- * Transparent integrity checking of the database when it opens
55
61
  * Automatic locking of the app after a period of inactivity (default 15 min)
56
62
  * Rekey the database (change the password)
57
63
  * Export the database to json, html, csv, markdown or .sql (for sqlite3)
58
64
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
59
- * Dark and light themes
65
+ * Dark and light theme support
60
66
  * Automatically generate checkboxes when typing 'TODO'
61
67
  * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
62
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
63
71
 
64
72
 
65
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.
@@ -11,35 +15,39 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
11
15
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
12
16
  to disk unless the user configures it to be in the settings.
13
17
 
14
- There is deliberately no network connectivity or syncing intended.
18
+ There is deliberately no network connectivity or syncing intended, other than the option to send a bug
19
+ report from within the app.
15
20
 
16
21
  ## Screenshots
17
22
 
23
+ ### General view
18
24
  ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png)
25
+
26
+ ### History panes
19
27
  ![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png)
20
28
  ![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png)
21
29
 
22
- ## Features
30
+ ## Some of the features
23
31
 
24
32
  * Data is encrypted at rest
25
33
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
26
- * Every 'page' is linked to the calendar day
27
34
  * All changes are version controlled, with ability to view/diff versions and revert
28
- * Text is Markdown with basic styling
35
+ * Automatic rendering of basic Markdown syntax
29
36
  * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
30
37
  * Images are supported
31
- * Search all pages, or find text on page (Ctrl+F)
32
- * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
38
+ * Search all pages, or find text on current page
39
+ * Add and manage tags
33
40
  * Automatic periodic saving (or explicitly save)
34
- * Transparent integrity checking of the database when it opens
35
41
  * Automatic locking of the app after a period of inactivity (default 15 min)
36
42
  * Rekey the database (change the password)
37
43
  * Export the database to json, html, csv, markdown or .sql (for sqlite3)
38
44
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
39
- * Dark and light themes
45
+ * Dark and light theme support
40
46
  * Automatically generate checkboxes when typing 'TODO'
41
47
  * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
42
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
43
51
 
44
52
 
45
53
  ## How to install
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+
5
+ import requests
6
+
7
+ from PySide6.QtWidgets import (
8
+ QDialog,
9
+ QVBoxLayout,
10
+ QLabel,
11
+ QTextEdit,
12
+ QDialogButtonBox,
13
+ QMessageBox,
14
+ )
15
+
16
+ from . import strings
17
+
18
+
19
+ BUG_REPORT_HOST = "https://nr.mig5.net"
20
+ ROUTE = "forms/bouquin/bugs"
21
+
22
+
23
+ class BugReportDialog(QDialog):
24
+ """
25
+ Dialog to collect a bug report
26
+ """
27
+
28
+ MAX_CHARS = 5000
29
+
30
+ def __init__(self, parent=None):
31
+ super().__init__(parent)
32
+ self.setWindowTitle(strings._("report_a_bug"))
33
+
34
+ layout = QVBoxLayout(self)
35
+
36
+ header = QLabel(strings._("bug_report_explanation"))
37
+ header.setWordWrap(True)
38
+ layout.addWidget(header)
39
+
40
+ self.text_edit = QTextEdit()
41
+ self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
42
+ layout.addWidget(self.text_edit)
43
+
44
+ self.text_edit.textChanged.connect(self._enforce_max_length)
45
+
46
+ # Buttons: Cancel / Send
47
+ button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
48
+ button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
49
+ button_box.accepted.connect(self._send)
50
+ button_box.rejected.connect(self.reject)
51
+ layout.addWidget(button_box)
52
+
53
+ self.setMinimumWidth(560)
54
+
55
+ self.text_edit.setFocus()
56
+
57
+ # ------------Helpers ------------ #
58
+
59
+ def _enforce_max_length(self):
60
+ text = self.text_edit.toPlainText()
61
+ if len(text) <= self.MAX_CHARS:
62
+ return
63
+
64
+ # Remember cursor position
65
+ cursor = self.text_edit.textCursor()
66
+ pos = cursor.position()
67
+
68
+ # Trim and restore without re-entering this slot
69
+ self.text_edit.blockSignals(True)
70
+ self.text_edit.setPlainText(text[: self.MAX_CHARS])
71
+ self.text_edit.blockSignals(False)
72
+
73
+ # Clamp cursor position to end of text
74
+ if pos > self.MAX_CHARS:
75
+ pos = self.MAX_CHARS
76
+
77
+ cursor.setPosition(pos)
78
+ self.text_edit.setTextCursor(cursor)
79
+
80
+ def _send(self):
81
+ text = self.text_edit.toPlainText().strip()
82
+ if not text:
83
+ QMessageBox.warning(
84
+ self,
85
+ strings._("report_a_bug"),
86
+ strings._("bug_report_empty"),
87
+ )
88
+ return
89
+
90
+ # Get current app version
91
+ try:
92
+ version = importlib.metadata.version("bouquin")
93
+ except importlib.metadata.PackageNotFoundError:
94
+ version = "unknown"
95
+
96
+ payload: dict[str, str] = {
97
+ "message": text,
98
+ "version": version,
99
+ }
100
+
101
+ # POST as JSON
102
+ try:
103
+ resp = requests.post(
104
+ f"{BUG_REPORT_HOST}/{ROUTE}",
105
+ json=payload,
106
+ timeout=10,
107
+ )
108
+ except Exception as e:
109
+ QMessageBox.critical(
110
+ self,
111
+ strings._("report_a_bug"),
112
+ strings._("bug_report_send_failed") + f"\n{e}",
113
+ )
114
+ return
115
+
116
+ if resp.status_code == 201:
117
+ QMessageBox.information(
118
+ self,
119
+ strings._("report_a_bug"),
120
+ strings._("bug_report_sent_ok"),
121
+ )
122
+ self.accept()
123
+ else:
124
+ QMessageBox.critical(
125
+ self,
126
+ strings._("report_a_bug"),
127
+ strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
128
+ )
@@ -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()
@@ -433,7 +477,7 @@ class DBManager:
433
477
  """
434
478
  if not name:
435
479
  return "#CCCCCC"
436
- h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16)
480
+ h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) # nosec
437
481
  return _TAG_COLORS[h % len(_TAG_COLORS)]
438
482
 
439
483
  # -------- Tags: per-page -------------------------------------------
@@ -514,7 +558,7 @@ class DBManager:
514
558
  SELECT id, name
515
559
  FROM tags
516
560
  WHERE name IN ({placeholders});
517
- """,
561
+ """, # nosec
518
562
  tuple(final_tag_names),
519
563
  ).fetchall()
520
564
  ids_by_name = {r["name"]: r["id"] for r in rows}
@@ -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()
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from PySide6.QtWidgets import (
6
+ QDialog,
7
+ QVBoxLayout,
8
+ QHBoxLayout,
9
+ QLabel,
10
+ QLineEdit,
11
+ QPushButton,
12
+ QDialogButtonBox,
13
+ QFileDialog,
14
+ )
15
+
16
+ from . import strings
17
+
18
+
19
+ class KeyPrompt(QDialog):
20
+ def __init__(
21
+ self,
22
+ parent=None,
23
+ title: str = strings._("key_prompt_enter_key"),
24
+ message: str = strings._("key_prompt_enter_key"),
25
+ initial_db_path: str | Path | None = None,
26
+ show_db_change: bool = False,
27
+ ):
28
+ """
29
+ Prompt the user for the key required to decrypt the database.
30
+
31
+ Used when opening the app, unlocking the idle locked screen,
32
+ or when rekeying.
33
+
34
+ If show_db_change is true, also show a QFileDialog allowing to
35
+ select a database file, else the default from settings is used.
36
+ """
37
+ super().__init__(parent)
38
+ self.setWindowTitle(title)
39
+
40
+ self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None
41
+
42
+ v = QVBoxLayout(self)
43
+
44
+ v.addWidget(QLabel(message))
45
+
46
+ # DB chooser
47
+ self.path_edit: QLineEdit | None = None
48
+ if show_db_change:
49
+ path_row = QHBoxLayout()
50
+ self.path_edit = QLineEdit()
51
+ if self._db_path is not None:
52
+ self.path_edit.setText(str(self._db_path))
53
+
54
+ browse_btn = QPushButton(strings._("select_notebook"))
55
+
56
+ def _browse():
57
+ start_dir = str(self._db_path or "")
58
+ fname, _ = QFileDialog.getOpenFileName(
59
+ self,
60
+ strings._("select_notebook"),
61
+ start_dir,
62
+ "SQLCipher DB (*.db);;All files (*)",
63
+ )
64
+ if fname:
65
+ self._db_path = Path(fname)
66
+ if self.path_edit is not None:
67
+ self.path_edit.setText(fname)
68
+
69
+ browse_btn.clicked.connect(_browse)
70
+
71
+ path_row.addWidget(self.path_edit, 1)
72
+ path_row.addWidget(browse_btn)
73
+ v.addLayout(path_row)
74
+
75
+ # Key entry
76
+ self.key_entry = QLineEdit()
77
+ self.key_entry.setEchoMode(QLineEdit.Password)
78
+ v.addWidget(self.key_entry)
79
+
80
+ toggle = QPushButton(strings._("show"))
81
+ toggle.setCheckable(True)
82
+ toggle.toggled.connect(
83
+ lambda c: self.key_entry.setEchoMode(
84
+ QLineEdit.Normal if c else QLineEdit.Password
85
+ )
86
+ )
87
+ v.addWidget(toggle)
88
+
89
+ bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
90
+ bb.accepted.connect(self.accept)
91
+ bb.rejected.connect(self.reject)
92
+ v.addWidget(bb)
93
+
94
+ self.key_entry.setFocus()
95
+ self.resize(500, self.sizeHint().height())
96
+
97
+ def key(self) -> str:
98
+ return self.key_entry.text()
99
+
100
+ def db_path(self) -> Path | None:
101
+ """Return the chosen DB path (or None if unchanged/not shown)."""
102
+ if self.path_edit is not None:
103
+ text = self.path_edit.text().strip()
104
+ if text:
105
+ return Path(text)
106
+ return self._db_path