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.
- {bouquin-0.3.1 → bouquin-0.4}/PKG-INFO +20 -12
- {bouquin-0.3.1 → bouquin-0.4}/README.md +17 -9
- bouquin-0.4/bouquin/bug_report_dialog.py +128 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/db.py +260 -2
- bouquin-0.4/bouquin/key_prompt.py +106 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/locales/en.json +91 -1
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/main_window.py +224 -18
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/markdown_editor.py +68 -8
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/markdown_highlighter.py +23 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/settings.py +22 -6
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/settings_dialog.py +6 -33
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/statistics_dialog.py +64 -3
- bouquin-0.4/bouquin/time_log.py +1128 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/toolbar.py +10 -1
- {bouquin-0.3.1 → bouquin-0.4}/pyproject.toml +3 -2
- bouquin-0.3.1/bouquin/key_prompt.py +0 -49
- {bouquin-0.3.1 → bouquin-0.4}/LICENSE +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/__init__.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/__main__.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/find_bar.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/flow_layout.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/history_dialog.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/locales/fr.json +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/locales/it.json +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/lock_overlay.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/main.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/save_dialog.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/search.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/strings.py +0 -0
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/tag_browser.py +1 -1
- {bouquin-0.3.1 → bouquin-0.4}/bouquin/tags_widget.py +0 -0
- {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
|
+
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
|
+
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
|
|
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
|

|
|
45
|
+
|
|
46
|
+
### History panes
|
|
39
47
|

|
|
40
48
|

|
|
41
49
|
|
|
42
|
-
##
|
|
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
|
-
*
|
|
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
|
|
52
|
-
* Add
|
|
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
|
|
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
|
|
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
|

|
|
25
|
+
|
|
26
|
+
### History panes
|
|
19
27
|

|
|
20
28
|

|
|
21
29
|
|
|
22
|
-
##
|
|
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
|
-
*
|
|
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
|
|
32
|
-
* Add
|
|
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
|
|
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
|