Compare commits

..

107 commits
0.5.2 ... main

Author SHA1 Message Date
5bb61273da
Fix version change
All checks were successful
CI / test (push) Successful in 14m5s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 25s
2026-01-30 17:03:38 +11:00
7f2c88f52b
Fix carrying over data to next day from over-capturing data belonging to next header section
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
Other dependency updates
2026-01-30 16:49:45 +11:00
9f399c589d
Update urllib3 dependency to resolve CVE-2026-21441
All checks were successful
CI / test (push) Successful in 13m36s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2026-01-09 12:07:46 +11:00
dd1ae74b19
remove 'fc' from release root
All checks were successful
CI / test (push) Successful in 13m51s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2026-01-03 12:50:36 +11:00
5f89c4286e
Fix releasing for Fedora
All checks were successful
CI / test (push) Successful in 13m32s
Lint / test (push) Successful in 35s
Trivy / test (push) Successful in 23s
2026-01-03 12:11:49 +11:00
7bb2746a0f
Prep for supporting other fedora versions later
All checks were successful
CI / test (push) Successful in 13m41s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2026-01-01 17:09:37 +11:00
b192264dbf
server migration
All checks were successful
CI / test (push) Successful in 8m5s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-31 17:12:21 +11:00
f8aab05cb7
Bump to 0.8.2
All checks were successful
CI / test (push) Successful in 8m4s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-31 16:09:29 +11:00
04f67a786f
Add ability to delete an invoice via Manage Invoices dialog 2025-12-31 16:09:16 +11:00
827565838f
Bump to 0.8.1
All checks were successful
CI / test (push) Successful in 8m23s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 18s
2025-12-26 18:02:04 +11:00
dce124e083
prep for 0.8.1 2025-12-26 18:01:56 +11:00
7e47cef602
Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
All checks were successful
CI / test (push) Successful in 8m21s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 18s
2025-12-26 17:06:45 +11:00
9c7cb7ba2b
Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
Some checks failed
CI / test (push) Failing after 8m4s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-26 09:03:20 +11:00
2eba0df85a
copy the rpm after signing it, you idiot
All checks were successful
CI / test (push) Successful in 8m54s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 18s
2025-12-24 18:55:55 +11:00
48e18e0408
Sign rpms before createrepo_c
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-24 18:27:24 +11:00
26c136900e
Add chown and rpmsign step to rpm build
All checks were successful
CI / test (push) Successful in 8m39s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 18s
2025-12-24 17:58:20 +11:00
9b457278f9
Add rpm
All checks were successful
CI / test (push) Successful in 8m7s
Lint / test (push) Successful in 35s
Trivy / test (push) Successful in 18s
2025-12-24 15:26:41 +11:00
4fda9833ed
Bump to 0.8.0
All checks were successful
CI / test (push) Successful in 8m57s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 18s
2025-12-23 17:54:06 +11:00
c853be5eff
More tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-23 17:53:26 +11:00
5f18b6daec
prep changelog for debian package 2025-12-23 17:33:19 +11:00
ab1ed55830
README.md clarifications 2025-12-23 17:29:12 +11:00
4ae9797588
Remove unneeded tests-debian-packaging.sh, we have it in CI now 2025-12-23 17:26:36 +11:00
807d11ca75
Add ability to collapse/expand sections of text
All checks were successful
CI / test (push) Successful in 8m44s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-23 17:18:02 +11:00
757517dcc4
Don't offer to download latest AppImage unless we are running as an AppImage already 2025-12-23 16:01:23 +11:00
df6ea8d139
Add 'Last Month' date range for timesheet reports 2025-12-23 16:00:57 +11:00
426142c0c3
Add missing strings (for English and French) 2025-12-23 15:21:00 +11:00
4ff4d24b42
Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
All checks were successful
CI / test (push) Successful in 8m42s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 18s
2025-12-23 14:04:43 +11:00
0a64dc525d
Allow setting a code block on a line that already has text (it will start a newline for the codeblock) 2025-12-23 13:42:33 +11:00
b925d2e89e
Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. 2025-12-23 13:19:26 +11:00
d0c6c94e9d
Fix trivy exit code
All checks were successful
CI / test (push) Successful in 8m10s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 17s
2025-12-22 17:27:42 +11:00
2d1c4f5b21
Add .desktop file for Debian
All checks were successful
CI / test (push) Successful in 8m20s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 19s
2025-12-22 17:17:04 +11:00
6d199dbca2
Fix dependency on my sqlcipher4 package
All checks were successful
CI / test (push) Successful in 8m13s
Lint / test (push) Successful in 35s
Trivy / test (push) Successful in 17s
2025-12-21 15:43:17 +11:00
ffc52bdf08
Depend on my own bouquin-sqlcipher4 package (upgraded to latest SQLCipher 4.12.0). Package a deb for Debian Trixie
Some checks failed
CI / test (push) Failing after 1m9s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 18s
2025-12-21 15:26:39 +11:00
886b809bd3
Add pre-commit, fix trailing whitespace
All checks were successful
CI / test (push) Successful in 8m57s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 23s
2025-12-18 13:48:42 +11:00
e6010969cb
Don't block on pyproject modification if the version has already been bumped
All checks were successful
CI / test (push) Successful in 8m44s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 19s
2025-12-16 15:28:24 +11:00
492633df9f
Update urllib3
All checks were successful
CI / test (push) Successful in 8m52s
Lint / test (push) Successful in 39s
Trivy / test (push) Successful in 20s
2025-12-16 15:17:22 +11:00
dcb62d34af
Allow carrying unchecked TODOs to weekends. Add 'group by activity' in time log reports
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-16 15:15:38 +11:00
13b1ad7373
Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
All checks were successful
CI / test (push) Successful in 8m56s
Lint / test (push) Successful in 40s
Trivy / test (push) Successful in 19s
2025-12-13 10:48:10 +11:00
7abd99fe24
Bump to 0.7.2 2025-12-13 10:45:10 +11:00
2112de39b8
Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) 2025-12-13 10:39:49 +11:00
206670454f
Improvements to StatisticsDialog
All checks were successful
CI / test (push) Successful in 9m33s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 21s
It now shows statistics about logged time, reminders, etc.
Sections are grouped for better readability.

Improvements to Manage Reminders dialog to show date of alarm
2025-12-12 18:41:05 +11:00
3106d408ab
Reminders improvements
* Fix Reminders to fire right on the minute after adding them during runtime
 * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings
2025-12-12 16:38:45 +11:00
d809244cf8
Invoicing should not be enabled by default
All checks were successful
CI / test (push) Successful in 8m52s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 21s
2025-12-12 14:36:27 +11:00
28446340f8
Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-12 14:32:23 +11:00
c1c95ca0ca
Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
All checks were successful
CI / test (push) Successful in 9m34s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 23s
2025-12-12 14:10:43 +11:00
7a75d33bb0
0.7.0
All checks were successful
CI / test (push) Successful in 8m43s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 21s
2025-12-11 16:17:22 +11:00
57614cefa1
Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
All checks were successful
CI / test (push) Successful in 8m58s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 21s
2025-12-11 15:45:03 +11:00
fb873edcb5
isort followed by black
All checks were successful
CI / test (push) Successful in 9m47s
Lint / test (push) Successful in 40s
Trivy / test (push) Successful in 22s
2025-12-11 14:03:08 +11:00
0862ce7fd6
Say just 'once' (not 'once (today)') in reminders, now that we can set the specific date
All checks were successful
CI / test (push) Successful in 9m23s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 20s
2025-12-10 18:27:15 +11:00
61b3e5b45a
Code comments 2025-12-09 12:48:59 +11:00
81878c63d9
Invoicing
All checks were successful
CI / test (push) Successful in 7m5s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 25s
2025-12-08 20:34:11 +11:00
e5c7ccb1da
Another
All checks were successful
CI / test (push) Successful in 6m25s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 25s
2025-12-06 11:17:16 +11:00
9b2260f6a7
use freeze_qt_time ?
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-06 11:12:06 +11:00
aeb3d863e2
shorter alarm in future to reduce risk of cross-midnight UTC issue
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-06 11:10:42 +11:00
f5c52eaf3b
Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
Some checks failed
CI / test (push) Failing after 6m31s
Lint / test (push) Successful in 35s
Trivy / test (push) Successful in 23s
2025-12-06 10:47:06 +11:00
778d988ebd
Time Log Report fixes
All checks were successful
CI / test (push) Successful in 6m33s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 21s
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
 * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
2025-12-05 18:33:50 +11:00
2464147a59
0.6.3
All checks were successful
CI / test (push) Successful in 5m46s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 22s
2025-12-04 17:09:56 +11:00
0795de8572
Add missing locale strings 2025-12-04 16:37:25 +11:00
0ec3ff273d
Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
All checks were successful
CI / test (push) Successful in 5m56s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 21s
2025-12-04 16:31:21 +11:00
304650dd54
Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
Some checks failed
CI / test (push) Successful in 5m54s
Lint / test (push) Failing after 31s
Trivy / test (push) Successful in 23s
2025-12-04 15:53:42 +11:00
9dc0a620be
Timesheet report tweaks
All checks were successful
CI / test (push) Successful in 5m55s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports.
 * Default date range to start from this month.
 * Allow 'All Projects' for timesheet reports.
2025-12-04 13:40:04 +11:00
1e12cae78e
Whitespace
All checks were successful
CI / test (push) Successful in 6m6s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 24s
2025-12-03 18:03:48 +11:00
498765c782
Add notify on failure (webhook) 2025-12-03 17:58:30 +11:00
28c0dd761f
Adjustment to make pyflakes happy re: timer
All checks were successful
CI / test (push) Successful in 5m48s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 24s
2025-12-03 17:44:25 +11:00
9ded9b4a10
0.6.2
Some checks failed
CI / test (push) Successful in 6m5s
Lint / test (push) Failing after 31s
Trivy / test (push) Successful in 24s
2025-12-03 17:27:36 +11:00
3d0f4a7787
Indent tabs by 4 spaces in code block editor dialog 2025-12-03 17:27:15 +11:00
b06f213522
Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way 2025-12-03 17:19:30 +11:00
8823a304cf
Comment adjutments 2025-12-03 15:14:27 +11:00
f8909d7fcb
Add 'Created at' to time log table. Show total hours for the day in the time log table (not just in the widget in sidebar)
All checks were successful
CI / test (push) Successful in 6m19s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 25s
2025-12-03 14:59:57 +11:00
779049e467
Ensure that adding a document whilst on an older date page, uses that date as its upload date
All checks were successful
CI / test (push) Successful in 5m43s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 23s
2025-12-02 17:27:30 +11:00
bae91f56e6
Screenshot update
All checks were successful
CI / test (push) Successful in 5m44s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 22s
2025-12-02 16:22:02 +11:00
3900920b7e
Screenshot update again
All checks were successful
CI / test (push) Successful in 5m55s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 22s
2025-12-02 16:02:59 +11:00
3d088fd8d9
0.6.1
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-02 16:00:29 +11:00
55aa8be2c2
Update screenshot 2025-12-02 16:00:03 +11:00
bffa615c13
More code coverage / remove duplicate tests
All checks were successful
CI / test (push) Successful in 5m56s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 22s
2025-12-02 14:58:57 +11:00
605444b149
Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time. 2025-12-02 14:58:45 +11:00
0b76f0b490
Consolidate some code related to opening documents using the Documents feature. More code coverage
All checks were successful
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 22s
CI / test (push) Successful in 6m4s
2025-12-02 11:01:27 +11:00
25f0c28582
Fix reminders tests from segfaulting
All checks were successful
CI / test (push) Successful in 5m43s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 24s
2025-12-02 09:45:54 +11:00
422411f12e
Add documents feature
Some checks failed
CI / test (push) Failing after 3m53s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s
2025-12-01 15:51:47 +11:00
23b6ce62a3
Fix tests
Some checks failed
CI / test (push) Failing after 5m4s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 22s
2025-12-01 10:34:58 +11:00
535a380616
Ensure time log reports have an extension
Some checks failed
CI / test (push) Failing after 5m0s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 22s
2025-12-01 10:27:44 +11:00
4d3593e960
0.5.5 2025-12-01 10:22:48 +11:00
7d58acfc7d
remove unneeded import since calendar theming now fully moved to theme.py
Some checks failed
CI / test (push) Failing after 5m9s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 22s
2025-12-01 10:20:20 +11:00
22b4cf4da7
Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager) 2025-12-01 10:19:41 +11:00
078f56a39b
Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text.
All checks were successful
CI / test (push) Successful in 4m58s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2025-12-01 09:32:32 +11:00
f2bf337049
Also load the smaller time log dialog when Pomodoro timer stops
All checks were successful
CI / test (push) Successful in 5m1s
Lint / test (push) Successful in 1m1s
Trivy / test (push) Successful in 22s
2025-12-01 09:18:09 +11:00
a27b1d702a
Add a simplified time log button in the sidebar widget for quick adding
All checks were successful
CI / test (push) Successful in 5m10s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 21s
2025-12-01 09:12:18 +11:00
3aed9badc2
Add filedust to release.sh flow 2025-12-01 08:55:43 +11:00
3b3087cc37
More markdown tests
All checks were successful
CI / test (push) Successful in 5m12s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2025-11-30 16:30:46 +11:00
95b7d828b5
Some more tests
All checks were successful
CI / test (push) Successful in 5m6s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 24s
2025-11-30 15:20:11 +11:00
32aa1780cf
0.5.4
All checks were successful
CI / test (push) Successful in 5m1s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2025-11-30 13:10:05 +11:00
7ed45c919c
Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it)
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-30 13:09:18 +11:00
f20ac56624
0.5.3
All checks were successful
CI / test (push) Successful in 5m15s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
2025-11-29 16:45:27 +11:00
cbbd19ceda
checkbox ticked in toolbar
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-29 16:43:54 +11:00
a4e44643a6
Fix removing code block metadata on delete
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-29 16:41:02 +11:00
9ab70c76f8
Don't show 'day' label in Reminders unless day choice is selected
All checks were successful
CI / test (push) Successful in 5m19s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2025-11-29 16:16:40 +11:00
9db4cda8cc
Fixes for code block background painting, and add line numbers to Code Editor dialog
All checks were successful
CI / test (push) Successful in 5m31s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2025-11-29 16:03:35 +11:00
dc1046632c
Allow deleting code blocks
Some checks failed
CI / test (push) Failing after 5m30s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 24s
2025-11-29 10:41:18 +11:00
57f11abb99
Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
Some checks failed
CI / test (push) Failing after 5m47s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2025-11-29 10:10:51 +11:00
7a207df0f3
Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops. Some code block defenses for extra newline 2025-11-28 17:08:02 +11:00
f6fa0aa997
Fix weekend date colours being incorrect on theme change while app is running 2025-11-28 15:27:49 +11:00
1a56fa80ca
Slightly fade the text of a checkbox line if the checkbox is checked. 2025-11-28 15:05:10 +11:00
e160827708
Prevent double-click of checkbox leading to selecting/highlighting it 2025-11-28 14:30:21 +11:00
4029d7604e
Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it) 2025-11-28 14:22:18 +11:00
a56d6512d3
CHANGELOG tweaks 2025-11-28 14:15:39 +11:00
f3ddd2a83c
Use DejaVuSans font instead of Noto (for text). Prevent triple-select selecting the checkbox/bullet 2025-11-28 14:14:40 +11:00
a3c74a218f
Adjust history icon (again) 2025-11-28 13:56:18 +11:00
85 changed files with 14821 additions and 1801 deletions

View file

@ -0,0 +1,80 @@
name: CI
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl gnupg2 ca-certificates
mkdir -p /usr/share/keyrings
curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor -o /usr/share/keyrings/mig5.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net trixie main" | tee /etc/apt/sources.list.d/mig5.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
devscripts \
debhelper \
dh-python \
python3-all-dev \
python3-setuptools \
python3-wheel \
libssl-dev \
rsync \
pybuild-plugin-pyproject \
python3-poetry-core \
python3-sqlcipher4 \
python3-pyside6.qtwidgets \
python3-pyside6.qtcore \
python3-pyside6.qtgui \
python3-pyside6.qtsvg \
python3-pyside6.qtprintsupport \
python3-requests \
python3-markdown \
libxcb-cursor0 \
fonts-noto-core
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Build deb
run: |
mkdir /out
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
./ /out/
cd /out/
export DEBEMAIL="mig@mig5.net"
export DEBFULLNAME="Miguel Jacq"
dch --distribution "trixie" --local "~trixie" "CI build for trixie"
dpkg-buildpackage -us -uc -b
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

View file

@ -35,3 +35,16 @@ jobs:
run: |
./tests.sh
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

View file

@ -25,3 +25,17 @@ jobs:
pyflakes3 tests/*
vulture
bandit -s B110 -r bouquin/
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

View file

@ -23,4 +23,18 @@ jobs:
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry --skip-version-check --exit-code 1 .
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ __pycache__
dist
.coverage
*.db
*.pdf
*.csv
*.html

26
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,26 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ["--select=F"]
types: [python]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
files: ^bouquin/
args: ["-s", "B110"]

View file

@ -1,3 +1,119 @@
# 0.8.3
* Update urllib3 dependency to resolve CVE-2026-21441
* Fix carrying over data to next day from over-capturing data belonging to next header section
* Other dependency updates
# 0.8.2
* Add ability to delete an invoice via 'Manage Invoices' dialog
# 0.8.1
* Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
* Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
# 0.8.0
* Add .desktop file for Debian
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter.
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
* Add ability to collapse/expand sections of text.
* Add 'Last Month' date range for timesheet reports
* Add missing strings (for English and French)
* Don't offer to download latest AppImage unless we are running as an AppImage already
# 0.7.5
* Fix import of sqlcipher4
# 0.7.4
* Depend on my own bouquin-sqlcipher4 package (upgraded to latest SQLCipher 4.12.0)
* Package a deb for Debian Trixie
# 0.7.3
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
# 0.7.2
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
# 0.7.1
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
* Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
* Invoicing should not be enabled by default
* Fix Reminders to fire right on the minute after adding them during runtime
* It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
* Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
# 0.7.0
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
* Add 'Last week' to Time Report dialog range option
* Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
# 0.6.4
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
* Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
* Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
# 0.6.3
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
* Allow 'All Projects' for timesheet reports.
* Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
* Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
# 0.6.2
* Ensure that adding a document whilst on an older date page, uses that date as its upload date
* Add 'Created at' to time log table.
* Show total hours for the day in the time log table (not just in the widget in sidebar)
* Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
* Indent tabs by 4 spaces in code block editor dialog
# 0.6.1
* Consolidate some code related to opening documents using the Documents feature.
* Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time.
* More code coverage
# 0.6.0
* Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature.
* Close time log dialog if opened via the + button from sidebar widget
* Only show tags in Statistics widget if tags are enabled
* Fix rounding up/down in Pomodoro timer to the closest 15 min interval
# 0.5.5
* Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option)
* Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text.
* Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager)
* Ensure time log reports have an extension
# 0.5.4
* Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it)
# 0.5.3
* Prevent triple-click select from selecting the list item (e.g checkbox, bullet)
* Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues.
* Change History icon (again)
* Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it)
* Prevent double-click of checkbox leading to selecting/highlighting it
* Slightly fade the text of a checkbox line if the checkbox is checked.
* Fix weekend date colours being incorrect on theme change while app is running
* Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops
* Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
# 0.5.2
* Update icon again to remove background

86
Dockerfile.debbuild Normal file
View file

@ -0,0 +1,86 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=debian:bookworm
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
# Build deps
RUN set -eux; \
. /etc/os-release; \
apt-get update; \
apt-get install -y --no-install-recommends curl gnupg2 ca-certificates; \
mkdir -p /usr/share/keyrings; \
curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor -o /usr/share/keyrings/mig5.gpg; \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net trixie main" | tee /etc/apt/sources.list.d/mig5.list; \
apt-get update; \
apt-get install -y --no-install-recommends \
build-essential \
devscripts \
debhelper \
dh-python \
python3-all-dev \
python3-setuptools \
python3-wheel \
libssl-dev \
rsync \
pybuild-plugin-pyproject \
python3-poetry-core \
python3-sqlcipher4 \
python3-pyside6.qtwidgets \
python3-pyside6.qtcore \
python3-pyside6.qtgui \
python3-pyside6.qtsvg \
python3-pyside6.qtprintsupport \
python3-requests \
python3-markdown \
libxcb-cursor0 \
fonts-noto-core \
; \
rm -rf /var/lib/apt/lists/*
# Build runner script
RUN set -eux; \
cat > /usr/local/bin/build-deb <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
WORK="${WORKROOT}/src"
OUT="${OUT:-/out}"
mkdir -p "$WORK" "$OUT"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
"${SRC}/" "${WORK}/"
cd "${WORK}"
if [ -n "${SUITE:-}" ]; then
export DEBEMAIL="mig@mig5.net"
export DEBFULLNAME="Miguel Jacq"
dch --distribution "$SUITE" --local "~${SUITE}" "CI build for $SUITE"
fi
dpkg-buildpackage -us -uc -b
shopt -s nullglob
cp -v "${WORKROOT}"/*.deb \
"${WORKROOT}"/*.changes \
"${WORKROOT}"/*.buildinfo \
"${WORKROOT}"/*.dsc \
"${WORKROOT}"/*.tar.* \
"${OUT}/" || true
echo "Artifacts copied to ${OUT}"
EOF
RUN chmod +x /usr/local/bin/build-deb
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/build-deb"]

110
Dockerfile.rpmbuild Normal file
View file

@ -0,0 +1,110 @@
# syntax=docker/dockerfile:1
FROM fedora:42
# rpmbuild in a container does not auto-install BuildRequires. Since we're
# building directly in Docker (not mock), we pre-install the common deps that
# Fedora's pyproject macros will require for Bouquin.
#
# NOTE: bouquin also needs python3dist(sqlcipher4) at build time (because
# %pyproject_buildrequires includes runtime deps). That one is NOT in Fedora;
# we install it from /deps.
RUN set -eux; \
dnf -y update; \
dnf -y install \
rpm-build rpmdevtools \
redhat-rpm-config \
gcc \
make \
findutils \
tar \
gzip \
rsync \
python3 \
python3-devel \
python3-pip \
python3-setuptools \
python3-wheel \
pyproject-rpm-macros \
python3-rpm-macros \
python3-poetry-core \
desktop-file-utils \
python3-requests \
python3-markdown \
python3-pyside6 \
xcb-util-cursor ; \
dnf -y clean all
RUN set -eux; cat > /usr/local/bin/build-rpm <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}"
DEPS_DIR="${DEPS_DIR:-/deps}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
echo "Version ID is ${VERSION_ID}"
# Install bouquin-sqlcipher4 from local rpm
# Filter out .src.rpm and debug* subpackages if present.
if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then
mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)' | grep "${VERSION_ID}")
if [ "${#rpms[@]}" -gt 0 ]; then
echo "Installing dependency RPMs from ${DEPS_DIR}:"
printf ' - %s\n' "${rpms[@]}"
dnf -y install "${rpms[@]}"
dnf -y clean all
else
echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2
fi
else
echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(sqlcipher4)," >&2
echo " mount your bouquin-sqlcipher4 RPM directory as -v <dir>:/deps" >&2
fi
mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src"
rm -rf "${WORK}"
mkdir -p "${WORK}"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
"${SRC}/" "${WORK}/"
cd "${WORK}"
# Determine version from pyproject.toml unless provided
if [ -n "${VERSION:-}" ]; then
ver="${VERSION}"
else
ver="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)".*/\1/')"
fi
TOPDIR="${WORKROOT}/rpmbuild"
mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tarball="${TOPDIR}/SOURCES/bouquin-${ver}.tar.gz"
tar -czf "${tarball}" --transform "s#^#bouquin/#" .
cp -v "rpm/bouquin.spec" "${TOPDIR}/SPECS/bouquin.spec"
rpmbuild -ba "${TOPDIR}/SPECS/bouquin.spec" \
--define "_topdir ${TOPDIR}" \
--define "upstream_version ${ver}"
shopt -s nullglob
cp -v "${TOPDIR}"/RPMS/*/*.rpm "${OUT}/" || true
cp -v "${TOPDIR}"/SRPMS/*.src.rpm "${OUT}/" || true
echo "Artifacts copied to ${OUT}"
EOF
RUN chmod +x /usr/local/bin/build-rpm
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/build-rpm"]

View file

@ -12,8 +12,14 @@ It is designed to treat each day as its own 'page', complete with Markdown rende
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
also how long we spent on them.
It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
For those who rely on that time logging for work, there is also an Invoicing feature that can
generate invoices of that time spent.
There is also support for embedding documents in a file manager.
It uses SQLCipher as a drop-in replacement for SQLite3.
This means that the underlying database for the notebook is encrypted at rest.
To increase security, the SQLCipher key is requested when the app is opened, and is not written
to disk unless the user configures it to be in the settings.
@ -51,16 +57,18 @@ report from within the app, or optionally to check for new versions to upgrade t
</div>
## Some of the features
## Features
* Data is encrypted at rest
* Encryption key is prompted for and never stored, unless user chooses to via Settings
* All changes are version controlled, with ability to view/diff versions, revert or delete revisions
* Automatic rendering of basic Markdown syntax
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
* Automatic rendering of basic Markdown syntax
* Basic code block editing/highlighting
* Ability to collapse/expand sections of text
* Ability to increase/decrease font size
* Images are supported
* Search all pages, or find text on current page
* Add and manage tags
* Automatic periodic saving (or explicitly save)
* Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password)
@ -68,19 +76,49 @@ report from within the app, or optionally to check for new versions to upgrade t
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
* Dark and light theme support
* Automatically generate checkboxes when typing 'TODO'
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday.
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next day.
* English, French and Italian locales provided
* Ability to set reminder alarms (which will be flashed as the reminder)
* Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
* Ability to set reminder alarms (which will be flashed as the reminder or can be sent as webhooks/email notifications)
* Ability to log time per day for different projects/activities, pomodoro-style log timer, timesheet reports and invoicing of time spent
* Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
* Add and manage tags on pages and documents
## How to install
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
Unless you are using the Debian option below:
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
* Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
* If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
### Debian 13 ('Trixie')
```bash
sudo mkdir -p /usr/share/keyrings
curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mig5.list
sudo apt update
sudo apt install bouquin
```
### Fedora 42
```bash
sudo rpm --import https://mig5.net/static/mig5.asc
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
[mig5]
name=mig5 Repository
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://mig5.net/static/mig5.asc
EOF
sudo dnf upgrade --refresh
sudo dnf install bouquin
```
### From PyPi/pip
@ -97,13 +135,4 @@ If downloading from my Forgejo's Releases page, you may wish to verify the GPG s
* Run `poetry install` to install dependencies
* Run `poetry run bouquin` to start the application.
### From the releases page
* Download the whl and run it
## How to run the tests
* Clone the repo
* Ensure you have poetry installed
* Run `poetry install --with test`
* Run `./tests.sh`
Alternatively, you can download the source code and wheels from Releases as well.

View file

@ -3,19 +3,17 @@ from __future__ import annotations
import importlib.metadata
import requests
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QTextEdit,
QDialogButtonBox,
QLabel,
QMessageBox,
QTextEdit,
QVBoxLayout,
)
from . import strings
BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs"

View file

@ -0,0 +1,258 @@
from __future__ import annotations
import re
from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QLabel,
QPlainTextEdit,
QVBoxLayout,
QWidget,
)
from . import strings
class _LineNumberArea(QWidget):
def __init__(self, editor: "CodeEditorWithLineNumbers"):
super().__init__(editor)
self._editor = editor
def sizeHint(self) -> QSize: # type: ignore[override]
return QSize(self._editor.line_number_area_width(), 0)
def paintEvent(self, event): # type: ignore[override]
self._editor.line_number_area_paint_event(event)
class CodeEditorWithLineNumbers(QPlainTextEdit):
"""QPlainTextEdit with a non-selectable line-number gutter on the left."""
def __init__(self, parent=None):
super().__init__(parent)
# Allow Tab to insert indentation (not move focus between widgets)
self.setTabChangesFocus(False)
# Track whether we just auto-inserted indentation on Enter
self._last_enter_was_empty_indent = False
self._line_number_area = _LineNumberArea(self)
self.blockCountChanged.connect(self._update_line_number_area_width)
self.updateRequest.connect(self._update_line_number_area)
self.cursorPositionChanged.connect(self._line_number_area.update)
self._update_line_number_area_width()
self._update_tab_stop_width()
# ---- layout / sizing -------------------------------------------------
def setFont(self, font: QFont) -> None: # type: ignore[override]
"""Ensure tab width stays at 4 spaces when the font changes."""
super().setFont(font)
self._update_tab_stop_width()
def _update_tab_stop_width(self) -> None:
"""Set tab width to 4 spaces."""
metrics = QFontMetrics(self.font())
# Tab width = width of 4 space characters
self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
def line_number_area_width(self) -> int:
# Enough digits for large-ish code blocks.
digits = max(2, len(str(max(1, self.blockCount()))))
fm = QFontMetrics(self._line_number_font())
return fm.horizontalAdvance("9" * digits) + 8
def _line_number_font(self) -> QFont:
"""Font to use for line numbers (slightly smaller than main text)."""
font = self.font()
if font.pointSize() > 0:
font.setPointSize(font.pointSize() - 1)
else:
# fallback for pixel-sized fonts
font.setPointSizeF(font.pointSizeF() * 0.9)
return font
def _update_line_number_area_width(self) -> None:
margin = self.line_number_area_width()
self.setViewportMargins(margin, 0, 0, 0)
def resizeEvent(self, event): # type: ignore[override]
super().resizeEvent(event)
cr = self.contentsRect()
self._line_number_area.setGeometry(
QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
)
def _update_line_number_area(self, rect, dy) -> None:
if dy:
self._line_number_area.scroll(0, dy)
else:
self._line_number_area.update(
0, rect.y(), self._line_number_area.width(), rect.height()
)
if rect.contains(self.viewport().rect()):
self._update_line_number_area_width()
# ---- painting --------------------------------------------------------
def line_number_area_paint_event(self, event) -> None:
painter = QPainter(self._line_number_area)
painter.fillRect(event.rect(), self.palette().base())
# Use a slightly smaller font for numbers
painter.setFont(self._line_number_font())
# Faded colour: same blend used for completed-task text in
# MarkdownHighlighter (text colour towards background).
pal = self.palette()
text_fg = pal.color(QPalette.Text)
text_bg = pal.color(QPalette.Base)
t = 0.55 # same factor as completed_task_format
faded = QColor(
int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
)
painter.setPen(faded)
block = self.firstVisibleBlock()
block_number = block.blockNumber()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
fm = self.fontMetrics()
line_height = fm.height()
right_margin = self._line_number_area.width() - 4
while block.isValid() and top <= event.rect().bottom():
if block.isVisible() and bottom >= event.rect().top():
number = str(block_number + 1)
painter.setPen(self.palette().text().color())
painter.drawText(
0,
int(top),
right_margin,
line_height,
Qt.AlignRight | Qt.AlignVCenter,
number,
)
block = block.next()
top = bottom
bottom = top + self.blockBoundingRect(block).height()
block_number += 1
def keyPressEvent(self, event): # type: ignore[override]
"""Auto-retain indentation on newlines (Tab/space) like the markdown editor.
Rules:
- If the current line is indented, Enter inserts a newline + the same indent.
- If the current line contains only indentation, a *second* Enter clears the indent
and starts an unindented line (similar to exiting bullets/checkboxes).
"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
cursor = self.textCursor()
block_text = cursor.block().text()
indent = re.match(r"[ \t]*", block_text).group(0) # type: ignore[union-attr]
if indent:
rest = block_text[len(indent) :]
indent_only = rest.strip() == ""
if indent_only and self._last_enter_was_empty_indent:
# Second Enter on an indentation-only line: remove that line and
# start a fresh, unindented line.
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
cursor.removeSelectedText()
cursor.insertText("\n")
self.setTextCursor(cursor)
self._last_enter_was_empty_indent = False
return
# First Enter: keep indentation
super().keyPressEvent(event)
self.textCursor().insertText(indent)
self._last_enter_was_empty_indent = True
return
# No indent -> normal Enter
self._last_enter_was_empty_indent = False
super().keyPressEvent(event)
return
# Any other key resets the empty-indent-enter flag
self._last_enter_was_empty_indent = False
super().keyPressEvent(event)
class CodeBlockEditorDialog(QDialog):
def __init__(
self, code: str, language: str | None, parent=None, allow_delete: bool = False
):
super().__init__(parent)
self.setWindowTitle(strings._("edit_code_block"))
self.setMinimumSize(650, 650)
self._code_edit = CodeEditorWithLineNumbers(self)
self._code_edit.setPlainText(code)
# Track whether the user clicked "Delete"
self._delete_requested = False
# Language selector (optional)
self._lang_combo = QComboBox(self)
languages = [
"",
"bash",
"css",
"html",
"javascript",
"php",
"python",
]
self._lang_combo.addItems(languages)
if language and language in languages:
self._lang_combo.setCurrentText(language)
# Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
if allow_delete:
delete_btn = buttons.addButton(
strings._("delete_code_block"),
QDialogButtonBox.ButtonRole.DestructiveRole,
)
delete_btn.clicked.connect(self._on_delete_clicked)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(strings._("locale") + ":", self))
layout.addWidget(self._lang_combo)
layout.addWidget(self._code_edit)
layout.addWidget(buttons)
def _on_delete_clicked(self) -> None:
"""Mark this dialog as 'delete requested' and close as Accepted."""
self._delete_requested = True
self.accept()
def was_deleted(self) -> bool:
"""Return True if the user chose to delete the code block."""
return self._delete_requested
def code(self) -> str:
return self._code_edit.toPlainText()
def language(self) -> str | None:
text = self._lang_combo.currentText().strip()
return text or None

View file

@ -1,9 +1,9 @@
from __future__ import annotations
import re
from typing import Optional, Dict
from typing import Dict, Optional
from PySide6.QtGui import QColor, QTextCharFormat, QFont
from PySide6.QtGui import QColor, QFont, QTextCharFormat
class CodeHighlighter:
@ -41,6 +41,7 @@ class CodeHighlighter:
"not",
"or",
"pass",
"pprint",
"print",
"raise",
"return",
@ -180,6 +181,7 @@ class CodeHighlighter:
"unset",
"use",
"var",
"var_dump",
"while",
"xor",
"yield",
@ -348,7 +350,7 @@ class CodeBlockMetadata:
return ""
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
return "<!-- code-langs: " + ",".join(items) + " -->"
return "<!-- code-langs: " + ",".join(items) + " -->\n"
def deserialize(self, text: str):
"""Deserialize metadata from text."""
@ -365,3 +367,7 @@ class CodeBlockMetadata:
self._block_languages[int(block_num)] = lang
except ValueError:
pass
def clear_language(self, block_number: int):
"""Remove any stored language for a given block, if present."""
self._block_languages.pop(block_number, None)

File diff suppressed because it is too large Load diff

64
bouquin/document_utils.py Normal file
View file

@ -0,0 +1,64 @@
"""
Utility functions for document operations.
This module provides shared functionality for document handling across
different widgets (TodaysDocumentsWidget, DocumentsDialog, SearchResultsDialog,
and TagBrowserDialog).
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox, QWidget
from . import strings
if TYPE_CHECKING:
from .db import DBManager
def open_document_from_db(
db: DBManager, doc_id: int, file_name: str, parent_widget: Optional[QWidget] = None
) -> bool:
"""
Open a document by fetching it from the database and opening with system default app.
"""
# Fetch document data from database
try:
data = db.document_data(doc_id)
except Exception as e:
# Show error dialog if parent widget is provided
if parent_widget:
QMessageBox.warning(
parent_widget,
strings._("project_documents_title"),
strings._("documents_open_failed").format(error=str(e)),
)
return False
# Extract file extension
suffix = Path(file_name).suffix or ""
# Create temporary file with same extension
tmp = tempfile.NamedTemporaryFile(
prefix="bouquin_doc_",
suffix=suffix,
delete=False,
)
# Write data to temp file
try:
tmp.write(data)
tmp.flush()
finally:
tmp.close()
# Open with system default application
success = QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
return success

601
bouquin/documents.py Normal file
View file

@ -0,0 +1,601 @@
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QFileDialog,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QStyle,
QTableWidget,
QTableWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager, DocumentRow
from .settings import load_db_config
from .time_log import TimeCodeManagerDialog
class TodaysDocumentsWidget(QFrame):
"""
Collapsible sidebar widget showing today's documents.
"""
def __init__(
self, db: DBManager, date_iso: str, parent: QWidget | None = None
) -> None:
super().__init__(parent)
self._db = db
self._current_date = date_iso
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header (toggle + open-documents button)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("todays_documents"))
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.open_btn = QToolButton()
self.open_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.open_btn.setToolTip(strings._("project_documents_title"))
self.open_btn.setAutoRaise(True)
self.open_btn.clicked.connect(self._open_documents_dialog)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.open_btn)
# Body: list of today's documents
self.body = QWidget()
body_layout = QVBoxLayout(self.body)
body_layout.setContentsMargins(0, 4, 0, 0)
body_layout.setSpacing(2)
self.list = QListWidget()
self.list.setSelectionMode(QAbstractItemView.SingleSelection)
self.list.setMaximumHeight(160)
self.list.itemDoubleClicked.connect(self._open_selected_document)
body_layout.addWidget(self.list)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# Initial fill
self.reload()
# ----- public API ---------------------------------------------------
def reload(self) -> None:
"""Refresh the list of today's documents."""
self.list.clear()
rows = self._db.todays_documents(self._current_date)
if not rows:
item = QListWidgetItem(strings._("todays_documents_none"))
item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
self.list.addItem(item)
return
for doc_id, file_name, project_name in rows:
label = file_name
extra_parts = []
if project_name:
extra_parts.append(project_name)
if extra_parts:
label = f"{file_name} - " + " · ".join(extra_parts)
item = QListWidgetItem(label)
item.setData(
Qt.ItemDataRole.UserRole,
{"doc_id": doc_id, "file_name": file_name},
)
self.list.addItem(item)
# ----- internals ----------------------------------------------------
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
self.reload()
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
self.reload()
def _open_selected_document(self, item: QListWidgetItem) -> None:
data = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(data, dict):
return
doc_id = data.get("doc_id")
file_name = data.get("file_name") or ""
if doc_id is None or not file_name:
return
self._open_document(int(doc_id), file_name)
def _open_document(self, doc_id: int, file_name: str) -> None:
"""Open a document from the list."""
from .document_utils import open_document_from_db
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
def _open_documents_dialog(self) -> None:
"""Open the full DocumentsDialog."""
dlg = DocumentsDialog(self._db, self, current_date=self._current_date)
dlg.exec()
# Refresh after any changes
self.reload()
class DocumentsDialog(QDialog):
"""
Per-project document manager.
- Choose a project
- See list of attached documents
- Add (from file), open (via temp file), delete
- Inline-edit description
- Inline-edit tags (comma-separated), using the global tags table
"""
FILE_COL = 0
TAGS_COL = 1
DESC_COL = 2
ADDED_COL = 3
SIZE_COL = 4
def __init__(
self,
db: DBManager,
parent: QWidget | None = None,
initial_project_id: Optional[int] = None,
current_date: Optional[str] = None,
) -> None:
super().__init__(parent)
self._db = db
self.cfg = load_db_config()
self._reloading_docs = False
self._search_text: str = ""
self._current_date = current_date # Store the current date for document uploads
self.setWindowTitle(strings._("project_documents_title"))
self.resize(900, 450)
root = QVBoxLayout(self)
# --- Project selector -------------------------------------------------
form = QFormLayout()
proj_row = QHBoxLayout()
self.project_combo = QComboBox()
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
self.manage_projects_btn.clicked.connect(self._manage_projects)
proj_row.addWidget(self.project_combo, 1)
proj_row.addWidget(self.manage_projects_btn)
form.addRow(strings._("project"), proj_row)
# --- Search box (all projects) ----------------------------------------
self.search_edit = QLineEdit()
self.search_edit.setClearButtonEnabled(True)
self.search_edit.setPlaceholderText(strings._("documents_search_placeholder"))
self.search_edit.textChanged.connect(self._on_search_text_changed)
form.addRow(strings._("documents_search_label"), self.search_edit)
root.addLayout(form)
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
# --- Table of documents ----------------------------------------------
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("documents_col_file"), # FILE_COL
strings._("documents_col_tags"), # TAGS_COL
strings._("documents_col_description"), # DESC_COL
strings._("documents_col_added"), # ADDED_COL
strings._("documents_col_size"), # SIZE_COL
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.FILE_COL, QHeaderView.Stretch)
header.setSectionResizeMode(self.TAGS_COL, QHeaderView.ResizeToContents)
header.setSectionResizeMode(self.DESC_COL, QHeaderView.Stretch)
header.setSectionResizeMode(self.ADDED_COL, QHeaderView.ResizeToContents)
header.setSectionResizeMode(self.SIZE_COL, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
# Editable: tags + description
self.table.setEditTriggers(
QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked
)
self.table.itemChanged.connect(self._on_item_changed)
self.table.itemDoubleClicked.connect(self._on_open_clicked)
root.addWidget(self.table, 1)
# --- Buttons ---------------------------------------------------------
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.add_btn = QPushButton(strings._("documents_add"))
self.add_btn.clicked.connect(self._on_add_clicked)
btn_row.addWidget(self.add_btn)
self.open_btn = QPushButton(strings._("documents_open"))
self.open_btn.clicked.connect(self._on_open_clicked)
btn_row.addWidget(self.open_btn)
self.delete_btn = QPushButton(strings._("documents_delete"))
self.delete_btn.clicked.connect(self._on_delete_clicked)
btn_row.addWidget(self.delete_btn)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
root.addLayout(btn_row)
# Separator at bottom (purely cosmetic)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
root.addWidget(line)
# Init data
self._reload_projects()
self._select_initial_project(initial_project_id)
self._reload_documents()
# --- Helpers -------------------------------------------------------------
def _reload_projects(self) -> None:
self.project_combo.blockSignals(True)
try:
self.project_combo.clear()
for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id)
finally:
self.project_combo.blockSignals(False)
def _select_initial_project(self, project_id: Optional[int]) -> None:
if project_id is None:
if self.project_combo.count() > 0:
self.project_combo.setCurrentIndex(0)
return
idx = self.project_combo.findData(project_id)
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
elif self.project_combo.count() > 0:
self.project_combo.setCurrentIndex(0)
def _current_project(self) -> Optional[int]:
idx = self.project_combo.currentIndex()
if idx < 0:
return None
proj_id = self.project_combo.itemData(idx)
return int(proj_id) if proj_id is not None else None
def _manage_projects(self) -> None:
dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self)
dlg.exec()
self._reload_projects()
self._reload_documents()
def _on_search_text_changed(self, text: str) -> None:
"""Update the in-memory search text and reload the table."""
self._search_text = text
self._reload_documents()
def _reload_documents(self) -> None:
search = (self._search_text or "").strip()
self._reloading_docs = True
try:
self.table.setRowCount(0)
if search:
# Global search across all projects
rows: list[DocumentRow] = self._db.search_documents(search)
else:
proj_id = self._current_project()
if proj_id is None:
return
rows = self._db.documents_for_project(proj_id)
self.table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
(
doc_id,
_project_id,
project_name,
file_name,
description,
size_bytes,
uploaded_at,
) = r
# Col 0: File
file_item = QTableWidgetItem(file_name)
file_item.setData(Qt.ItemDataRole.UserRole, doc_id)
file_item.setFlags(file_item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row_idx, self.FILE_COL, file_item)
# Col 1: Tags (comma-separated)
tags = self._db.get_tags_for_document(doc_id)
tag_names = [name for (_tid, name, _color) in tags]
tags_text = ", ".join(tag_names)
tags_item = QTableWidgetItem(tags_text)
# If there is at least one tag, colour the cell using the first tag's colour
if tags:
first_color = tags[0][2]
if first_color:
col = QColor(first_color)
tags_item.setBackground(col)
# Choose a readable text color
if col.lightness() < 128:
tags_item.setForeground(QColor("#ffffff"))
else:
tags_item.setForeground(QColor("#000000"))
self.table.setItem(row_idx, self.TAGS_COL, tags_item)
if not self.cfg.tags:
self.table.hideColumn(self.TAGS_COL)
# Col 2: Description (editable)
desc_item = QTableWidgetItem(description or "")
self.table.setItem(row_idx, self.DESC_COL, desc_item)
# Col 3: Added at (editable)
added_label = uploaded_at
added_item = QTableWidgetItem(added_label)
self.table.setItem(row_idx, self.ADDED_COL, added_item)
# Col 4: Size (not editable)
size_item = QTableWidgetItem(self._format_size(size_bytes))
size_item.setFlags(size_item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row_idx, self.SIZE_COL, size_item)
finally:
self._reloading_docs = False
# --- Signals -------------------------------------------------------------
def _on_project_changed(self, idx: int) -> None:
_ = idx
self._reload_documents()
def _on_add_clicked(self) -> None:
proj_id = self._current_project()
if proj_id is None:
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_no_project_selected"),
)
return
paths, _ = QFileDialog.getOpenFileNames(
self,
strings._("documents_add"),
"",
strings._("documents_file_filter_all"),
)
if not paths:
return
for path in paths:
try:
self._db.add_document_from_path(
proj_id, path, uploaded_at=self._current_date
)
except Exception as e: # pragma: no cover
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_add_failed").format(error=str(e)),
)
self._reload_documents()
def _selected_doc_meta(self) -> tuple[Optional[int], Optional[str]]:
row = self.table.currentRow()
if row < 0:
return None, None
file_item = self.table.item(row, self.FILE_COL)
if file_item is None:
return None, None
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
file_name = file_item.text()
return (int(doc_id) if doc_id is not None else None, file_name)
def _on_open_clicked(self, *args) -> None:
doc_id, file_name = self._selected_doc_meta()
if doc_id is None or not file_name:
return
self._open_document(doc_id, file_name)
def _on_delete_clicked(self) -> None:
doc_id, _file_name = self._selected_doc_meta()
if doc_id is None:
return
resp = QMessageBox.question(
self,
strings._("project_documents_title"),
strings._("documents_confirm_delete"),
)
if resp != QMessageBox.StandardButton.Yes:
return
self._db.delete_document(doc_id)
self._reload_documents()
def _on_item_changed(self, item: QTableWidgetItem) -> None:
"""
Handle inline edits to Description, Tags, and Added date.
"""
if self._reloading_docs or item is None:
return
row = item.row()
col = item.column()
file_item = self.table.item(row, self.FILE_COL)
if file_item is None:
return
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
if doc_id is None:
return
doc_id = int(doc_id)
# Description column
if col == self.DESC_COL:
desc = item.text().strip() or None
self._db.update_document_description(doc_id, desc)
return
# Tags column
if col == self.TAGS_COL:
raw = item.text()
# split on commas, strip, drop empties
names = [p.strip() for p in raw.split(",") if p.strip()]
self._db.set_tags_for_document(doc_id, names)
# Re-normalise text to the canonical tag names stored in DB
tags = self._db.get_tags_for_document(doc_id)
tag_names = [name for (_tid, name, _color) in tags]
tags_text = ", ".join(tag_names)
self._reloading_docs = True
try:
item.setText(tags_text)
# Reset / apply background based on first tag colour
if tags:
first_color = tags[0][2]
if first_color:
col = QColor(first_color)
item.setBackground(col)
if col.lightness() < 128:
item.setForeground(QColor("#ffffff"))
else:
item.setForeground(QColor("#000000"))
else:
# No tags: clear background / foreground to defaults
item.setBackground(QColor())
item.setForeground(QColor())
finally:
self._reloading_docs = False
return
# Added date column
if col == self.ADDED_COL:
date_str = item.text().strip()
# Validate date format (YYYY-MM-DD)
if not self._validate_date_format(date_str):
QMessageBox.warning(
self,
strings._("project_documents_title"),
(
strings._("documents_invalid_date_format")
if hasattr(strings, "_")
and callable(getattr(strings, "_"))
and "documents_invalid_date_format" in dir(strings)
else f"Invalid date format. Please use YYYY-MM-DD format.\nExample: {date_str[:4]}-01-15"
),
)
# Reload to reset the cell to its original value
self._reload_documents()
return
# Update the database
self._db.update_document_uploaded_at(doc_id, date_str)
return
# --- utils -------------------------------------------------------------
def _validate_date_format(self, date_str: str) -> bool:
"""
Validate that a date string is in YYYY-MM-DD format.
Returns True if valid, False otherwise.
"""
import re
from datetime import datetime
# Check basic format with regex
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
return False
# Validate it's a real date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return True
except ValueError:
return False
def _open_document(self, doc_id: int, file_name: str) -> None:
"""
Fetch BLOB from DB, write to a temporary file, and open with default app.
"""
from .document_utils import open_document_from_db
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
@staticmethod
def _format_size(size_bytes: int) -> str:
"""
Human-readable file size.
"""
if size_bytes < 1024:
return f"{size_bytes} B"
kb = size_bytes / 1024.0
if kb < 1024:
return f"{kb:.1f} KB"
mb = kb / 1024.0
if mb < 1024:
return f"{mb:.1f} MB"
gb = mb / 1024.0
return f"{gb:.1f} GB"

View file

@ -1,20 +1,15 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import (
QShortcut,
QTextCursor,
QTextCharFormat,
QTextDocument,
)
from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import (
QWidget,
QHBoxLayout,
QLineEdit,
QLabel,
QPushButton,
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QWidget,
)
from . import strings

View file

@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$

Binary file not shown.

Binary file not shown.

View file

@ -1,22 +1,29 @@
from __future__ import annotations
import difflib, re, html as _html
import difflib
import html as _html
import re
from datetime import datetime
from PySide6.QtCore import Qt, Slot
from PySide6.QtCore import QDate, Qt, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QCalendarWidget,
QDialog,
QVBoxLayout,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QMessageBox,
QTextBrowser,
QPushButton,
QTabWidget,
QAbstractItemView,
QTextBrowser,
QVBoxLayout,
)
from . import strings
from .theme import ThemeManager
def _markdown_to_text(s: str) -> str:
@ -70,16 +77,29 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
class HistoryDialog(QDialog):
"""Show versions for a date, preview, diff, and allow revert."""
def __init__(self, db, date_iso: str, parent=None):
def __init__(
self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
):
super().__init__(parent)
self.setWindowTitle(f"{strings._('history')}{date_iso}")
self._db = db
self._date = date_iso
self._themes = themes
self._versions = [] # list[dict] from DB
self._current_id = None # id of current
root = QVBoxLayout(self)
# --- Top: date label + change-date button
date_row = QHBoxLayout()
self.date_label = QLabel(strings._("date_label").format(date=date_iso))
date_row.addWidget(self.date_label)
date_row.addStretch(1)
self.change_date_btn = QPushButton(strings._("change_date"))
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
date_row.addWidget(self.change_date_btn)
root.addLayout(date_row)
# Top: list of versions
top = QHBoxLayout()
self.list = QListWidget()
@ -117,6 +137,53 @@ class HistoryDialog(QDialog):
self._load_versions()
@Slot()
def _on_change_date_clicked(self) -> None:
"""Let the user choose a different date and reload entries."""
# Start from current dialog date; fall back to today if invalid
current_qdate = QDate.fromString(self._date, Qt.ISODate)
if not current_qdate.isValid():
current_qdate = QDate.currentDate()
dlg = QDialog(self)
dlg.setWindowTitle(strings._("select_date_title"))
layout = QVBoxLayout(dlg)
calendar = QCalendarWidget(dlg)
calendar.setSelectedDate(current_qdate)
layout.addWidget(calendar)
# Apply the same theming as the main sidebar calendar
if self._themes is not None:
self._themes.register_calendar(calendar)
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
layout.addWidget(buttons)
if dlg.exec() != QDialog.Accepted:
return
new_qdate = calendar.selectedDate()
new_iso = new_qdate.toString(Qt.ISODate)
if new_iso == self._date:
# No change
return
# Update state
self._date = new_iso
# Update window title and header label
self.setWindowTitle(strings._("for").format(date=new_iso))
self.date_label.setText(strings._("date_label").format(date=new_iso))
# Reload entries for the newly selected date
self._load_versions()
# --- Data/UX helpers ---
def _load_versions(self):
# [{id,version_no,created_at,note,is_current}]

1511
bouquin/invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,13 +4,13 @@ from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QDialogButtonBox,
QFileDialog,
QVBoxLayout,
)
from . import strings

View file

@ -40,6 +40,8 @@
"next_day": "Next day",
"today": "Today",
"show": "Show",
"edit": "Edit",
"delete": "Delete",
"history": "History",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
@ -101,6 +103,7 @@
"autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images",
"images": "Images",
"reopen_failed": "Re-open failed",
@ -142,6 +145,7 @@
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"color_hex": "Colour",
"date": "Date",
"page_or_document": "Page / Document",
"add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
@ -151,6 +155,11 @@
"tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics",
"stats_group_pages": "Pages",
"stats_group_tags": "Tags",
"stats_group_documents": "Documents",
"stats_group_time_logging": "Time logging",
"stats_group_reminders": "Reminders",
"stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions",
@ -161,7 +170,18 @@
"stats_heatmap_metric": "Colour by",
"stats_metric_words": "Words",
"stats_metric_revisions": "Revisions",
"stats_metric_documents": "Documents",
"stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged",
"stats_time_project_most_hours": "Project with most hours logged",
"stats_time_activity_most_hours": "Activity with most hours logged",
"stats_total_reminders": "Total reminders",
"stats_date_most_reminders": "Day with most reminders",
"stats_metric_hours": "Hours",
"stats_metric_reminders": "Reminders",
"select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here",
@ -189,10 +209,19 @@
"add_project": "Add project",
"add_time_entry": "Add time entry",
"time_period": "Time period",
"dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
"date_range": "Date range",
"custom_range": "Custom",
"last_week": "Last week",
"last_month": "Last month",
"this_week": "This week",
"this_month": "This month",
"this_year": "This year",
"all_projects": "All projects",
"delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?",
@ -202,6 +231,7 @@
"delete_time_entry": "Delete time entry",
"group_by": "Group by",
"hours": "Hours",
"created_at": "Created at",
"invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
@ -220,6 +250,8 @@
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
@ -235,8 +267,10 @@
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"time_log_date_label": "Time log date: {date}",
"time_log_for": "Time log for {date}",
"date_label": "Date: {date}",
"change_date": "Change date",
"select_date_title": "Select date",
"for": "For {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
@ -259,28 +293,53 @@
"enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders",
"reminders_webhook_section_title": "Send Reminders to a webhook",
"reminders_webhook_url_label":"Webhook URL",
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
"enable_documents_feature": "Enable storing of documents",
"pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer",
"set_code_language": "Set code language",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"collapse": "Collapse",
"expand": "Expand",
"remove_collapse": "Remove collapse",
"collapse_selection": "Collapse selection",
"start": "Start",
"pause": "Pause",
"resume": "Resume",
"stop_and_log": "Stop and log",
"manage_reminders": "Manage Reminders",
"upcoming_reminders": "Upcoming Reminders",
"no_upcoming_reminders": "No upcoming reminders",
"once": "once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"set_reminder": "Set reminder",
"edit_reminder": "Edit reminder",
"add_reminder": "Add Reminder",
"set_reminder": "Set Reminder",
"edit_reminder": "Edit Reminder",
"delete_reminder": "Delete Reminder",
"delete_reminders": "Delete Reminders",
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
"reminder": "Reminder",
"reminders": "Reminders",
"time": "Time",
"once_today": "Once (today)",
"once": "Once",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
"every_fortnight": "Every 2 weeks",
"every_month": "Every month (same date)",
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
"week_in_month": "Week in month",
"fortnightly": "Fortnightly",
"monthly_same_date": "Monthly (same date)",
"monthly_nth_weekday": "Monthly (nth weekday)",
"repeat": "Repeat",
"monday": "Monday",
"tuesday": "Tuesday",
@ -289,5 +348,94 @@
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"day": "Day"
"monday_short": "Mon",
"tuesday_short": "Tue",
"wednesday_short": "Wed",
"thursday_short": "Thu",
"friday_short": "Fri",
"saturday_short": "Sat",
"sunday_short": "Sun",
"day": "Day",
"text": "Text",
"type": "Type",
"active": "Active",
"actions": "Actions",
"edit_code_block": "Edit code block",
"delete_code_block": "Delete code block",
"search_result_heading_document": "Document",
"toolbar_documents": "Documents Manager",
"project_documents_title": "Project documents",
"documents_col_file": "File",
"documents_col_description": "Description",
"documents_col_added": "Added",
"documents_col_tags": "Tags",
"documents_col_size": "Size",
"documents_add": "&Add",
"documents_open": "&Open",
"documents_delete": "&Delete",
"documents_no_project_selected": "Please choose a project first.",
"documents_file_filter_all": "All files (*)",
"documents_add_failed": "Could not add document: {error}",
"documents_open_failed": "Could not open document: {error}",
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
"documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)",
"documents_invalid_date_format": "Invalid date format",
"todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.",
"manage_invoices": "Manage Invoices",
"create_invoice": "Create Invoice",
"invoice_amount": "Amount",
"invoice_apply_tax": "Apply Tax",
"invoice_client_address": "Client Address",
"invoice_client_company": "Client Company",
"invoice_client_email": "Client E-mail",
"invoice_client_name": "Client Contact",
"invoice_currency": "Currency",
"invoice_dialog_title": "Create Invoice",
"invoice_due_date": "Due Date",
"invoice_hourly_rate": "Hourly Rate",
"invoice_hours": "Hours",
"invoice_issue_date": "Issue Date",
"invoice_mode_detailed": "Detailed mode",
"invoice_mode_summary": "Summary mode",
"invoice_number": "Invoice Number",
"invoice_save_and_export": "Save and export",
"invoice_save_pdf_title": "Save PDF",
"invoice_subtotal": "Subtotal",
"invoice_summary_default_desc": "Consultant services for the month of",
"invoice_summary_desc": "Summary description",
"invoice_summary_hours": "Summary hours",
"invoice_tax": "Tax details",
"invoice_tax_label": "Tax type",
"invoice_tax_rate": "Tax rate",
"invoice_tax_total": "Tax total",
"invoice_total": "Total",
"invoice_paid_at": "Paid on",
"invoice_payment_note": "Payment notes",
"invoice_project_required_title": "Project required",
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
"invoice_need_report_title": "Report required",
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
"invoice_company_profile": "Business Profile",
"invoice_company_name": "Business Name",
"invoice_company_address": "Address",
"invoice_company_phone": "Phone",
"invoice_company_email": "E-mail",
"invoice_company_tax_id": "Tax number",
"invoice_company_payment_details": "Payment details",
"invoice_company_logo": "Logo",
"invoice_company_logo_choose": "Choose logo",
"invoice_company_logo_set": "Logo has been set",
"invoice_company_logo_not_set": "Logo not set",
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
"invoice_invalid_amount": "The amount is invalid",
"invoice_invalid_date_format": "Invalid date format",
"invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
}

View file

@ -1,290 +1,437 @@
{
"db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher",
"db_issues_reported": "problème(s) signalé(s)",
"db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé",
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
"db_key_incorrect": "La clé est probablement incorrecte",
"db_database_error": "Erreur de base de données",
"database_maintenance": "Maintenance de la base de données",
"database_compact": "Compacter la base de données",
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
"database_compacted_successfully": "Base de données compactée avec succès !",
"encryption": "Chiffrement",
"remember_key": "Se souvenir de la clé",
"change_encryption_key": "Changer la clé de chiffrement",
"enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement",
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
"key_mismatch": "Les clés ne correspondent pas",
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
"empty_key": "La clé est vide",
"empty_key_explanation": "La clé ne peut pas être vide.",
"key_changed": "La clé a été modifiée",
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
"error": "Erreur",
"success": "Succès",
"close": "Fermer",
"find": "Rechercher",
"file": "Fichier",
"locale": "Langue",
"locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
"settings": "Paramètres",
"theme": "Thème",
"system": "Système",
"light": "Clair",
"dark": "Sombre",
"never": "Jamais",
"close_tab": "Fermer l'onglet",
"previous": "Précédent",
"previous_day": "Jour précédent",
"next": "Suivant",
"next_day": "Jour suivant",
"today": "Aujourd'hui",
"show": "Afficher",
"history": "Historique",
"export_accessible_flag": "E&xporter",
"export_entries": "Exporter les entrées",
"export_complete": "Exportation terminée",
"export_failed": "Échec de l'exportation",
"backup": "Sauvegarder",
"backup_complete": "Sauvegarde terminée",
"backup_failed": "Échec de la sauvegarde",
"quit": "Quitter",
"cancel": "Annuler",
"save": "Enregistrer",
"help": "Aide",
"saved": "Enregistré",
"saved_to": "Enregistré dans",
"documentation": "Documentation",
"couldnt_open": "Impossible d'ouvrir",
"report_a_bug": "Signaler un bug",
"version": "Version",
"update": "Mise à jour",
"check_for_updates": "Rechercher des mises à jour",
"could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
"update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
"you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
"there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
"download_the_appimage": "Télécharger l'AppImage ?",
"downloading": "Téléchargement en cours",
"download_cancelled": "Téléchargement annulé",
"failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
"could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
"could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
"gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
"downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
"navigate": "Naviguer",
"current": "actuel",
"selected": "sélectionné",
"find_on_page": "Rechercher dans la page",
"find_next": "Rechercher le suivant",
"find_previous": "Rechercher le précédent",
"find_bar_type_to_search": "Tapez pour rechercher",
"find_bar_match_case": "Respecter la casse",
"history_dialog_preview": "Aperçu",
"history_dialog_diff": "Différences",
"history_dialog_revert_to_selected": "Revenir à la sélection",
"history_dialog_revert_failed": "Échec de la restauration",
"history_dialog_delete": "Supprimer la révision",
"history_dialog_delete_failed": "Impossible de supprimer la révision",
"key_prompt_enter_key": "Saisir la clé",
"lock_overlay_locked": "Verrouillé",
"lock_overlay_unlock": "Déverrouiller",
"main_window_lock_screen_accessibility": "&Verrouiller l'écran",
"main_window_ready": "Prêt",
"main_window_save_a_version": "Enregistrer une version",
"main_window_settings_accessible_flag": "&Paramètres",
"set_an_encryption_key": "Définir une clé de chiffrement",
"set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !",
"unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré",
"unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"autosave": "enregistrement automatique",
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
"move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable",
"insert_images": "Insérer des images",
"images": "Images",
"reopen_failed": "Échec de la réouverture",
"unlock_failed": "Échec du déverrouillage",
"could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.",
"unencrypted_export": "Export non chiffré",
"unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.",
"unrecognised_extension": "Extension non reconnue !",
"backup_encrypted_notebook": "Sauvegarder le bouquin chiffré",
"enter_a_name_for_this_version": "Saisir un nom pour cette version",
"new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à",
"appearance": "Apparence",
"security": "Sécurité",
"features": "Fonctionnalités",
"database": "Base de données",
"save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
"lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité",
"autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
"font_size": "Taille de police",
"font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.",
"search_for_notes_here": "Recherchez des notes ici",
"toolbar_format": "Format",
"toolbar_bold": "Gras",
"toolbar_italic": "Italique",
"toolbar_strikethrough": "Barré",
"toolbar_normal_paragraph_text": "Texte de paragraphe normal",
"toolbar_font_smaller": "Texte plus petit",
"toolbar_font_larger": "Texte plus grand",
"toolbar_bulleted_list": "Liste à puces",
"toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code",
"toolbar_heading": "Titre",
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
"tags": "Étiquettes",
"tag": "Étiquette",
"manage_tags": "Gérer les étiquettes",
"add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée",
"tag_browser_title": "Navigateur d'étiquettes",
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
"color_hex": "Couleur",
"date": "Date",
"add_a_tag": "Ajouter une étiquette",
"edit_tag_name": "Modifier le nom de l'étiquette",
"new_tag_name": "Nouveau nom de l'étiquette :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
"statistics": "Statistiques",
"main_window_statistics_accessible_flag": "Stat&istiques",
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
"stats_total_revisions": "Nombre total de révisions",
"stats_page_most_revisions": "Page avec le plus de révisions",
"stats_total_words": "Nombre total de mots (versions actuelles)",
"stats_unique_tags": "Étiquettes uniques",
"stats_page_most_tags": "Page avec le plus d'étiquettes",
"stats_activity_heatmap": "Carte de chaleur d'activité",
"stats_heatmap_metric": "Colorer selon",
"stats_metric_words": "Mots",
"stats_metric_revisions": "Révisions",
"stats_no_data": "Aucune statistique disponible pour le moment.",
"select_notebook": "Sélectionner un bouquin",
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
"send": "Envoyer",
"reminder": "Rappel",
"set_reminder": "Définir le rappel",
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
"invalid_time_title": "Heure invalide",
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
"dismiss": "Ignorer",
"toolbar_alarm": "Régler l'alarme de rappel",
"activities": "Activités",
"activity": "Activité",
"note": "Note",
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
"activity_rename_error_title": "Problème lors du renommage de l'activité",
"activity_required_message": "Un nom d'activité est requis",
"activity_required_title": "Nom d'activité requis",
"add_activity": "Ajouter une activité",
"add_project": "Ajouter un projet",
"add_time_entry": "Ajouter une entrée de temps",
"time_period": "Période",
"by_day": "par jour",
"by_month": "par mois",
"by_week": "par semaine",
"date_range": "Plage de dates",
"delete_activity": "Supprimer l'activité",
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
"delete_project": "Supprimer le projet",
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
"delete_time_entry": "Supprimer l'entrée de temps",
"group_by": "Grouper par",
"hours": "Heures",
"invalid_activity_message": "L'activité est invalide",
"invalid_activity_title": "Activité invalide",
"invalid_project_message": "Le projet est invalide",
"invalid_project_title": "Projet invalide",
"manage_activities": "Gérer les activités",
"manage_projects": "Gérer les projets",
"manage_projects_activities": "Gérer les activités du projet",
"open_time_log": "Ouvrir le journal de temps",
"project": "Projet",
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
"project_delete_error_title": "Problème lors de la suppression du projet",
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
"project_rename_error_title": "Problème lors du renommage du projet",
"project_required_message": "Un projet est requis",
"project_required_title": "Projet requis",
"projects": "Projets",
"rename_activity": "Renommer l'activité",
"rename_project": "Renommer le projet",
"run_report": "Exécuter le rapport",
"add_activity_title": "Ajouter une activité",
"add_activity_label": "Ajouter une activité",
"rename_activity_label": "Renommer l'activité",
"add_project_title": "Ajouter un projet",
"add_project_label": "Ajouter un projet",
"rename_activity_title": "Renommer cette activité",
"rename_project_label": "Renommer le projet",
"rename_project_title": "Renommer ce projet",
"select_activity_message": "Sélectionner une activité",
"select_activity_title": "Sélectionner une activité",
"select_project_message": "Sélectionner un projet",
"select_project_title": "Sélectionner un projet",
"time_log": "Journal de temps",
"time_log_collapsed_hint": "Journal de temps",
"time_log_date_label": "Date du journal de temps : {date}",
"time_log_for": "Journal de temps pour {date}",
"time_log_no_date": "Journal de temps",
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
"time_log_report": "Rapport de temps",
"time_log_report_title": "Journal de temps pour {project}",
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
"update_time_entry": "Mettre à jour l'entrée de temps",
"time_report_total": "Total : {hours:.2f} heures",
"no_report_title": "Aucun rapport",
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
"total": "Total",
"export_csv": "Exporter en CSV",
"export_csv_error_title": "Échec de l'exportation",
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
"export_pdf": "Exporter en PDF",
"export_pdf_error_title": "Échec de l'exportation PDF",
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
"enable_tags_feature": "Activer les étiquettes",
"enable_time_log_feature": "Activer le journal de temps",
"enable_reminders_feature": "Activer les rappels",
"pomodoro_time_log_default_text": "Session de concentration",
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
"set_code_language": "Définir le langage du code",
"cut": "Couper",
"copy": "Copier",
"paste": "Coller",
"start": "Démarrer",
"pause": "Pause",
"resume": "Reprendre",
"stop_and_log": "Arrêter et enregistrer",
"once": "une fois",
"daily": "quotidien",
"weekdays": "jours de semaine",
"weekly": "hebdomadaire",
"edit_reminder": "Modifier le rappel",
"time": "Heure",
"once_today": "Une fois (aujourd'hui)",
"every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines",
"repeat": "Répéter",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche",
"day": "Jour"
"db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher",
"db_issues_reported": "problème(s) signalé(s)",
"db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé",
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
"db_key_incorrect": "La clé est probablement incorrecte",
"db_database_error": "Erreur de base de données",
"database_maintenance": "Maintenance de la base de données",
"database_compact": "Compacter la base de données",
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
"database_compacted_successfully": "Base de données compactée avec succès !",
"encryption": "Chiffrement",
"remember_key": "Se souvenir de la clé",
"change_encryption_key": "Changer la clé de chiffrement",
"enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement",
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
"key_mismatch": "Les clés ne correspondent pas",
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
"empty_key": "La clé est vide",
"empty_key_explanation": "La clé ne peut pas être vide.",
"key_changed": "La clé a été modifiée",
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
"error": "Erreur",
"success": "Succès",
"close": "Fermer",
"find": "Rechercher",
"file": "Fichier",
"locale": "Langue",
"locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
"settings": "Paramètres",
"theme": "Thème",
"system": "Système",
"light": "Clair",
"dark": "Sombre",
"never": "Jamais",
"close_tab": "Fermer l'onglet",
"previous": "Précédent",
"previous_day": "Jour précédent",
"next": "Suivant",
"next_day": "Jour suivant",
"today": "Aujourd'hui",
"show": "Afficher",
"edit": "Modifier",
"delete": "Supprimer",
"history": "Historique",
"export_accessible_flag": "E&xporter",
"export_entries": "Exporter les entrées",
"export_complete": "Exportation terminée",
"export_failed": "Échec de l'exportation",
"backup": "Sauvegarder",
"backup_complete": "Sauvegarde terminée",
"backup_failed": "Échec de la sauvegarde",
"quit": "Quitter",
"cancel": "Annuler",
"save": "Enregistrer",
"help": "Aide",
"saved": "Enregistré",
"saved_to": "Enregistré dans",
"documentation": "Documentation",
"couldnt_open": "Impossible d'ouvrir",
"report_a_bug": "Signaler un bug",
"version": "Version",
"update": "Mise à jour",
"check_for_updates": "Rechercher des mises à jour",
"could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
"update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
"you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
"there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
"download_the_appimage": "Télécharger l'AppImage ?",
"downloading": "Téléchargement en cours",
"download_cancelled": "Téléchargement annulé",
"failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
"could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
"could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
"gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
"downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
"navigate": "Naviguer",
"current": "actuel",
"selected": "sélectionné",
"find_on_page": "Rechercher dans la page",
"find_next": "Rechercher le suivant",
"find_previous": "Rechercher le précédent",
"find_bar_type_to_search": "Tapez pour rechercher",
"find_bar_match_case": "Respecter la casse",
"history_dialog_preview": "Aperçu",
"history_dialog_diff": "Différences",
"history_dialog_revert_to_selected": "Revenir à la sélection",
"history_dialog_revert_failed": "Échec de la restauration",
"history_dialog_delete": "Supprimer la révision",
"history_dialog_delete_failed": "Impossible de supprimer la révision",
"key_prompt_enter_key": "Saisir la clé",
"lock_overlay_locked": "Verrouillé",
"lock_overlay_unlock": "Déverrouiller",
"main_window_lock_screen_accessibility": "&Verrouiller l'écran",
"main_window_ready": "Prêt",
"main_window_save_a_version": "Enregistrer une version",
"main_window_settings_accessible_flag": "&Paramètres",
"set_an_encryption_key": "Définir une clé de chiffrement",
"set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !",
"unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré",
"unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"autosave": "enregistrement automatique",
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
"move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable",
"move_todos_include_weekends": "Autoriser le déplacement des TODO non cochées vers un week-end\nplutôt que vers le prochain jour ouvrable",
"insert_images": "Insérer des images",
"images": "Images",
"reopen_failed": "Échec de la réouverture",
"unlock_failed": "Échec du déverrouillage",
"could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.",
"unencrypted_export": "Export non chiffré",
"unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.",
"unrecognised_extension": "Extension non reconnue !",
"backup_encrypted_notebook": "Sauvegarder le bouquin chiffré",
"enter_a_name_for_this_version": "Saisir un nom pour cette version",
"new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à",
"appearance": "Apparence",
"security": "Sécurité",
"features": "Fonctionnalités",
"database": "Base de données",
"save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
"lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité",
"autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
"font_size": "Taille de police",
"font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.",
"search_for_notes_here": "Recherchez des notes ici",
"toolbar_format": "Format",
"toolbar_bold": "Gras",
"toolbar_italic": "Italique",
"toolbar_strikethrough": "Barré",
"toolbar_normal_paragraph_text": "Texte de paragraphe normal",
"toolbar_font_smaller": "Texte plus petit",
"toolbar_font_larger": "Texte plus grand",
"toolbar_bulleted_list": "Liste à puces",
"toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code",
"toolbar_heading": "Titre",
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
"tags": "Étiquettes",
"tag": "Étiquette",
"manage_tags": "Gérer les étiquettes",
"add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée",
"tag_browser_title": "Navigateur d'étiquettes",
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
"color_hex": "Couleur",
"date": "Date",
"page_or_document": "Page / Document",
"add_a_tag": "Ajouter une étiquette",
"edit_tag_name": "Modifier le nom de l'étiquette",
"new_tag_name": "Nouveau nom de l'étiquette :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
"statistics": "Statistiques",
"main_window_statistics_accessible_flag": "Stat&istiques",
"stats_group_pages": "Pages",
"stats_group_tags": "Étiquettes",
"stats_group_documents": "Documents",
"stats_group_time_logging": "Journal de temps",
"stats_group_reminders": "Rappels",
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
"stats_total_revisions": "Nombre total de révisions",
"stats_page_most_revisions": "Page avec le plus de révisions",
"stats_total_words": "Nombre total de mots (versions actuelles)",
"stats_unique_tags": "Étiquettes uniques",
"stats_page_most_tags": "Page avec le plus d'étiquettes",
"stats_activity_heatmap": "Carte de chaleur d'activité",
"stats_heatmap_metric": "Colorer selon",
"stats_metric_words": "Mots",
"stats_metric_revisions": "Révisions",
"stats_metric_documents": "Documents",
"stats_total_documents": "Total des documents",
"stats_date_most_documents": "Date avec le plus de documents",
"stats_no_data": "Aucune statistique disponible pour le moment.",
"stats_time_total_hours": "Total des heures enregistrées",
"stats_time_day_most_hours": "Jour avec le plus d'heures enregistrées",
"stats_time_project_most_hours": "Projet avec le plus d'heures enregistrées",
"stats_time_activity_most_hours": "Activité avec le plus d'heures enregistrées",
"stats_total_reminders": "Total des rappels",
"stats_date_most_reminders": "Jour avec le plus de rappels",
"stats_metric_hours": "Heures",
"stats_metric_reminders": "Rappels",
"select_notebook": "Sélectionner un bouquin",
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
"send": "Envoyer",
"reminder": "Rappel",
"set_reminder": "Définir le rappel",
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
"invalid_time_title": "Heure invalide",
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
"dismiss": "Ignorer",
"toolbar_alarm": "Régler l'alarme de rappel",
"activities": "Activités",
"activity": "Activité",
"note": "Note",
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
"activity_rename_error_title": "Problème lors du renommage de l'activité",
"activity_required_message": "Un nom d'activité est requis",
"activity_required_title": "Nom d'activité requis",
"add_activity": "Ajouter une activité",
"add_project": "Ajouter un projet",
"add_time_entry": "Ajouter une entrée de temps",
"time_period": "Période",
"dont_group": "Ne pas regrouper",
"by_activity": "par activité",
"by_day": "par jour",
"by_month": "par mois",
"by_week": "par semaine",
"date_range": "Plage de dates",
"custom_range": "Personnalisé",
"last_week": "La semaine dernière",
"last_month": "Le mois dernier",
"this_week": "Cette semaine",
"this_month": "Ce mois-ci",
"this_year": "Cette année",
"all_projects": "Tous les projets",
"delete_activity": "Supprimer l'activité",
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
"delete_project": "Supprimer le projet",
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
"delete_time_entry": "Supprimer l'entrée de temps",
"group_by": "Grouper par",
"hours": "Heures",
"created_at": "Créé le",
"invalid_activity_message": "L'activité est invalide",
"invalid_activity_title": "Activité invalide",
"invalid_project_message": "Le projet est invalide",
"invalid_project_title": "Projet invalide",
"manage_activities": "Gérer les activités",
"manage_projects": "Gérer les projets",
"manage_projects_activities": "Gérer les activités du projet",
"open_time_log": "Ouvrir le journal de temps",
"project": "Projet",
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
"project_delete_error_title": "Problème lors de la suppression du projet",
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
"project_rename_error_title": "Problème lors du renommage du projet",
"project_required_message": "Un projet est requis",
"project_required_title": "Projet requis",
"projects": "Projets",
"rename_activity": "Renommer l'activité",
"rename_project": "Renommer le projet",
"reporting": "Rapports",
"reporting_and_invoicing": "Rapports et facturation",
"run_report": "Exécuter le rapport",
"add_activity_title": "Ajouter une activité",
"add_activity_label": "Ajouter une activité",
"rename_activity_label": "Renommer l'activité",
"add_project_title": "Ajouter un projet",
"add_project_label": "Ajouter un projet",
"rename_activity_title": "Renommer cette activité",
"rename_project_label": "Renommer le projet",
"rename_project_title": "Renommer ce projet",
"select_activity_message": "Sélectionner une activité",
"select_activity_title": "Sélectionner une activité",
"select_project_message": "Sélectionner un projet",
"select_project_title": "Sélectionner un projet",
"time_log": "Journal de temps",
"time_log_collapsed_hint": "Journal de temps",
"date_label": "Date : {date}",
"change_date": "Modifier la date",
"select_date_title": "Sélectionner une date",
"for": "Pour {date}",
"time_log_no_date": "Journal de temps",
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
"time_log_report": "Rapport de temps",
"time_log_report_title": "Journal de temps pour {project}",
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
"update_time_entry": "Mettre à jour l'entrée de temps",
"time_report_total": "Total : {hours:.2f} heures",
"no_report_title": "Aucun rapport",
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
"total": "Total",
"export_csv": "Exporter en CSV",
"export_csv_error_title": "Échec de l'exportation",
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
"export_pdf": "Exporter en PDF",
"export_pdf_error_title": "Échec de l'exportation PDF",
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
"enable_tags_feature": "Activer les étiquettes",
"enable_time_log_feature": "Activer le journal de temps",
"enable_reminders_feature": "Activer les rappels",
"reminders_webhook_section_title": "Envoyer les rappels vers un webhook",
"reminders_webhook_url_label": "URL du webhook",
"reminders_webhook_secret_label": "Secret du webhook (envoyé dans l'en-tête\nX-Bouquin-Secret)",
"enable_documents_feature": "Activer le stockage des documents",
"pomodoro_time_log_default_text": "Session de concentration",
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
"set_code_language": "Définir le langage du code",
"cut": "Couper",
"copy": "Copier",
"paste": "Coller",
"collapse": "Replier",
"expand": "Déplier",
"remove_collapse": "Supprimer le pliage",
"collapse_selection": "Replier la sélection",
"start": "Démarrer",
"pause": "Pause",
"resume": "Reprendre",
"stop_and_log": "Arrêter et enregistrer",
"manage_reminders": "Gérer les rappels",
"upcoming_reminders": "Rappels à venir",
"no_upcoming_reminders": "Aucun rappel à venir",
"once": "Une fois (aujourd'hui)",
"daily": "quotidien",
"weekdays": "jours de semaine",
"weekly": "hebdomadaire",
"add_reminder": "Ajouter un rappel",
"edit_reminder": "Modifier le rappel",
"delete_reminder": "Supprimer le rappel",
"delete_reminders": "Supprimer les rappels",
"deleting_it_will_remove_all_future_occurrences": "La suppression supprimera toutes les occurrences futures.",
"this_is_a_reminder_of_type": "Note : il s'agit d'un rappel de type",
"this_will_delete_the_actual_reminders": "Note : cela supprimera les rappels eux-mêmes, pas seulement des occurrences individuelles.",
"reminders": "Rappels",
"time": "Heure",
"every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines",
"every_fortnight": "Toutes les 2 semaines",
"every_month": "Chaque mois (même date)",
"every_month_nth_weekday": "Chaque mois (ex. 3e lundi)",
"week_in_month": "Semaine du mois",
"fortnightly": "Toutes les deux semaines",
"monthly_same_date": "Mensuel (même date)",
"monthly_nth_weekday": "Mensuel (nᵉ jour de semaine)",
"repeat": "Répéter",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche",
"monday_short": "Lun",
"tuesday_short": "Mar",
"wednesday_short": "Mer",
"thursday_short": "Jeu",
"friday_short": "Ven",
"saturday_short": "Sam",
"sunday_short": "Dim",
"day": "Jour",
"text": "Texte",
"type": "Type",
"active": "Actif",
"actions": "Actions",
"edit_code_block": "Modifier le bloc de code",
"delete_code_block": "Supprimer le bloc de code",
"search_result_heading_document": "Document",
"toolbar_documents": "Gestionnaire de documents",
"project_documents_title": "Documents du projet",
"documents_col_file": "Fichier",
"documents_col_description": "Description",
"documents_col_added": "Ajouté",
"documents_col_tags": "Étiquettes",
"documents_col_size": "Taille",
"documents_add": "&Ajouter",
"documents_open": "&Ouvrir",
"documents_delete": "&Supprimer",
"documents_no_project_selected": "Veuillez d'abord choisir un projet.",
"documents_file_filter_all": "Tous les fichiers (*)",
"documents_add_failed": "Impossible d'ajouter le document : {error}",
"documents_open_failed": "Impossible d'ouvrir le document : {error}",
"documents_confirm_delete": "Retirer ce document du projet ?\n(Le fichier sur le disque ne sera pas supprimé.)",
"documents_search_label": "Rechercher",
"documents_search_placeholder": "Saisir pour rechercher des documents (tous les projets)",
"documents_invalid_date_format": "Format de date invalide",
"todays_documents": "Documents de ce jour",
"todays_documents_none": "Aucun document pour le moment.",
"manage_invoices": "Gérer les factures",
"create_invoice": "Créer une facture",
"invoice_amount": "Montant",
"invoice_apply_tax": "Appliquer la taxe",
"invoice_client_address": "Adresse du client",
"invoice_client_company": "Société cliente",
"invoice_client_email": "E-mail du client",
"invoice_client_name": "Contact client",
"invoice_currency": "Devise",
"invoice_dialog_title": "Créer une facture",
"invoice_due_date": "Date d'échéance",
"invoice_hourly_rate": "Taux horaire",
"invoice_hours": "Heures",
"invoice_issue_date": "Date d'émission",
"invoice_mode_detailed": "Mode détaillé",
"invoice_mode_summary": "Mode récapitulatif",
"invoice_number": "Numéro de facture",
"invoice_save_and_export": "Enregistrer et exporter",
"invoice_save_pdf_title": "Enregistrer le PDF",
"invoice_subtotal": "Sous-total",
"invoice_summary_default_desc": "Services de conseil pour le mois de",
"invoice_summary_desc": "Description du récapitulatif",
"invoice_summary_hours": "Heures du récapitulatif",
"invoice_tax": "Détails de la taxe",
"invoice_tax_label": "Type de taxe",
"invoice_tax_rate": "Taux de taxe",
"invoice_tax_total": "Total des taxes",
"invoice_total": "Total",
"invoice_paid_at": "Payée le",
"invoice_payment_note": "Notes de paiement",
"invoice_project_required_title": "Projet requis",
"invoice_project_required_message": "Veuillez sélectionner un projet spécifique avant d'essayer de créer une facture.",
"invoice_need_report_title": "Rapport requis",
"invoice_need_report_message": "Veuillez exécuter un rapport de temps avant d'essayer de créer une facture à partir de celui-ci.",
"invoice_due_before_issue": "La date d'échéance ne peut pas être antérieure à la date d'émission.",
"invoice_paid_before_issue": "La date de paiement ne peut pas être antérieure à la date d'émission.",
"enable_invoicing_feature": "Activer la facturation (nécessite le journal de temps)",
"invoice_company_profile": "Profil de l'entreprise",
"invoice_company_name": "Nom de l'entreprise",
"invoice_company_address": "Adresse",
"invoice_company_phone": "Téléphone",
"invoice_company_email": "E-mail",
"invoice_company_tax_id": "Numéro fiscal",
"invoice_company_payment_details": "Détails de paiement",
"invoice_company_logo": "Logo",
"invoice_company_logo_choose": "Choisir un logo",
"invoice_company_logo_set": "Le logo a été défini",
"invoice_company_logo_not_set": "Logo non défini",
"invoice_number_unique": "Le numéro de facture doit être unique. Ce numéro de facture existe déjà.",
"invoice_invalid_amount": "Le montant est invalide",
"invoice_invalid_date_format": "Format de date invalide",
"invoice_invalid_tax_rate": "Le taux de TVA est invalide",
"invoice_no_items": "La facture ne contient aucun article",
"invoice_number_required": "Un numéro de facture est requis",
"invoice_required": "Veuillez sélectionner une facture spécifique avant d'essayer de supprimer la facture."
}

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from PySide6.QtCore import Qt, QEvent
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from PySide6.QtCore import QEvent, Qt
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from . import strings
from .theme import ThemeManager

View file

@ -2,13 +2,14 @@ from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from .settings import APP_NAME, APP_ORG, get_settings
from .main_window import MainWindow
from .theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from . import strings
from .main_window import MainWindow
from .settings import APP_NAME, APP_ORG, get_settings
from .theme import Theme, ThemeConfig, ThemeManager
def main():

View file

@ -2,21 +2,21 @@ from __future__ import annotations
import datetime
import os
import sys
import re
import sys
from pathlib import Path
from PySide6.QtCore import (
QDate,
QTimer,
Qt,
QSettings,
Slot,
QUrl,
QEvent,
QSignalBlocker,
QDateTime,
QEvent,
QSettings,
QSignalBlocker,
Qt,
QTime,
QTimer,
QUrl,
Slot,
)
from PySide6.QtGui import (
QAction,
@ -27,43 +27,42 @@ from PySide6.QtGui import (
QFont,
QGuiApplication,
QKeySequence,
QTextCharFormat,
QTextCursor,
QTextListFormat,
)
from PySide6.QtWidgets import (
QApplication,
QCalendarWidget,
QDialog,
QFileDialog,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QTableView,
QTabWidget,
QVBoxLayout,
QWidget,
QLabel,
QPushButton,
QApplication,
)
from . import strings
from .bug_report_dialog import BugReportDialog
from .db import DBManager
from .documents import DocumentsDialog, TodaysDocumentsWidget
from .find_bar import FindBar
from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor
from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget
from .reminders import UpcomingRemindersWidget, ReminderWebHook
from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog
from . import strings
from .tags_widget import PageTagsWidget
from .theme import ThemeManager
from .time_log import TimeLogWidget
@ -108,15 +107,19 @@ class MainWindow(QMainWindow):
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
# Features
self.time_log = TimeLogWidget(self.db)
self.time_log = TimeLogWidget(self.db, themes=self.themes)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
self.pomodoro_manager = PomodoroManager(self.db, self)
# Lock the calendar to the left panel at the top to stop it stretching
@ -127,6 +130,8 @@ class MainWindow(QMainWindow):
left_layout.addWidget(self.calendar)
left_layout.addWidget(self.search)
left_layout.addWidget(self.upcoming_reminders)
self.todays_documents = TodaysDocumentsWidget(self.db, self._current_date_iso())
left_layout.addWidget(self.todays_documents)
left_layout.addWidget(self.time_log)
left_layout.addWidget(self.tags)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
@ -336,13 +341,15 @@ class MainWindow(QMainWindow):
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)
if not self.cfg.documents:
self.todays_documents.hide()
self.toolBar.actDocuments.setVisible(False)
# Restore window position from settings
self._restore_window_position()
# re-apply all runtime color tweaks when theme changes
self.themes.themeChanged.connect(lambda _t: self._retheme_overrides())
self._apply_calendar_text_colors()
# apply once on startup so links / calendar colors are set immediately
self._retheme_overrides()
@ -489,7 +496,7 @@ class MainWindow(QMainWindow):
idx = self._tab_index_for_date(date)
if idx != -1:
self.tab_widget.setCurrentIndex(idx)
# keep calendar selection in sync (dont trigger load)
# keep calendar selection in sync (don't trigger load)
from PySide6.QtCore import QSignalBlocker
with QSignalBlocker(self.calendar):
@ -512,7 +519,7 @@ class MainWindow(QMainWindow):
editor = MarkdownEditor(self.themes)
# Apply users preferred font size
# Apply user's preferred font size
self._apply_font_size(editor)
# Set up the editor's event connections
@ -814,9 +821,13 @@ class MainWindow(QMainWindow):
Given a 'new day' (system date), return the date we should move
unfinished todos *to*.
If the new day is Saturday or Sunday, we skip ahead to the next Monday.
Otherwise we just return the same day.
By default, if the new day is Saturday or Sunday we skip ahead to the
next Monday (i.e., "next available weekday"). If the optional setting
`move_todos_include_weekends` is enabled, we move to the very next day
even if it's a weekend.
"""
if getattr(self.cfg, "move_todos_include_weekends", False):
return day
# Qt: Monday=1 ... Sunday=7
dow = day.dayOfWeek()
if dow >= 6: # Saturday (6) or Sunday (7)
@ -859,6 +870,13 @@ class MainWindow(QMainWindow):
into the rollover target date (today, or next Monday if today
is a weekend).
In addition to moving the unchecked checkbox *line* itself, this also
moves any subsequent lines that belong to that unchecked item, stopping
at the next *checked* checkbox line **or** the next markdown heading.
This allows code fences, collapsed blocks, and notes under a todo to
travel with it without accidentally pulling in the next section.
Returns True if any items were moved, False otherwise.
"""
if not getattr(self.cfg, "move_todos", False):
@ -871,7 +889,232 @@ class MainWindow(QMainWindow):
target_date = self._rollover_target_date(today)
target_iso = target_date.toString("yyyy-MM-dd")
all_unchecked: list[str] = []
# Regexes for markdown headings and checkboxes
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
unchecked_re = re.compile(r"^(\s*)-\s*\[[\s☐]\]\s+(.*)$")
checked_re = re.compile(r"^(\s*)-\s*\[[xX☑]\]\s+(.*)$")
fence_re = re.compile(r"^\s*(`{3,}|~{3,})")
def _normalize_heading(text: str) -> str:
"""
Strip trailing closing hashes and whitespace, e.g.
"## Foo ###" -> "Foo"
"""
text = text.strip()
text = re.sub(r"\s+#+\s*$", "", text)
return text.strip()
def _update_fence_state(
line: str, in_fence: bool, fence_marker: str | None
) -> tuple[bool, str | None]:
"""
Track fenced code blocks (``` / ~~~). We ignore checkbox markers inside
fences so we don't accidentally split/move based on "- [x]" that appears
in code.
"""
m = fence_re.match(line)
if not m:
return in_fence, fence_marker
marker = m.group(1)
if not in_fence:
return True, marker
# Close only when we see a fence of the same char and >= length
if (
fence_marker
and marker[0] == fence_marker[0]
and len(marker) >= len(fence_marker)
):
return False, None
return in_fence, fence_marker
def _is_list_item(line: str) -> bool:
s = line.lstrip()
return bool(
re.match(r"^([-*+]\s+|\d+\.\s+)", s)
or unchecked_re.match(line)
or checked_re.match(line)
)
def _insert_blocks_under_heading(
target_lines: list[str],
heading_level: int,
heading_text: str,
blocks: list[list[str]],
) -> list[str]:
"""Ensure a heading exists and append blocks to the end of its section."""
normalized = _normalize_heading(heading_text)
# 1) Find existing heading with same text (any level)
start_idx = None
effective_level = None
for idx, line in enumerate(target_lines):
m = heading_re.match(line)
if not m:
continue
level = len(m.group(1))
text = _normalize_heading(m.group(2))
if text == normalized:
start_idx = idx
effective_level = level
break
# 2) If not found, create a new heading at the end
if start_idx is None:
if target_lines and target_lines[-1].strip():
target_lines.append("") # blank line before new heading
target_lines.append(f"{'#' * heading_level} {heading_text}")
start_idx = len(target_lines) - 1
effective_level = heading_level
# 3) Find the end of this heading's section
end_idx = len(target_lines)
for i in range(start_idx + 1, len(target_lines)):
m = heading_re.match(target_lines[i])
if m and len(m.group(1)) <= effective_level:
end_idx = i
break
# 4) Insert before any trailing blank lines in the section
insert_at = end_idx
while (
insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == ""
):
insert_at -= 1
# Insert blocks (preserve internal blank lines)
for block in blocks:
if not block:
continue
# Avoid gluing a paragraph to the new block unless both look like list items
if (
insert_at > start_idx + 1
and target_lines[insert_at - 1].strip() != ""
and block[0].strip() != ""
and not (
_is_list_item(target_lines[insert_at - 1])
and _is_list_item(block[0])
)
):
target_lines.insert(insert_at, "")
insert_at += 1
for line in block:
target_lines.insert(insert_at, line)
insert_at += 1
return target_lines
def _prune_empty_headings(src_lines: list[str]) -> list[str]:
"""Remove markdown headings whose section became empty.
The rollover logic removes unchecked todo *blocks* but intentionally keeps
headings on the source day so we can re-create the same section on the
target day. If a heading ends up with no remaining content (including
empty subheadings), we should remove it from the source day too.
Headings inside fenced code blocks are ignored.
"""
# Identify headings (outside fences) and their levels
heading_levels: dict[int, int] = {}
heading_indices: list[int] = []
in_f = False
f_mark: str | None = None
for idx, ln in enumerate(src_lines):
if not in_f:
m = heading_re.match(ln)
if m:
heading_indices.append(idx)
heading_levels[idx] = len(m.group(1))
in_f, f_mark = _update_fence_state(ln, in_f, f_mark)
if not heading_indices:
return src_lines
# Compute each heading's section boundary: next heading with level <= current
boundary: dict[int, int] = {}
stack: list[int] = []
for idx in heading_indices:
lvl = heading_levels[idx]
while stack and lvl <= heading_levels[stack[-1]]:
boundary[stack.pop()] = idx
stack.append(idx)
for idx in stack:
boundary[idx] = len(src_lines)
# Build parent/children relationships based on heading levels
children: dict[int, list[int]] = {}
parent_stack: list[int] = []
for idx in heading_indices:
lvl = heading_levels[idx]
while parent_stack and lvl <= heading_levels[parent_stack[-1]]:
parent_stack.pop()
if parent_stack:
children.setdefault(parent_stack[-1], []).append(idx)
parent_stack.append(idx)
# Determine whether each heading has any non-heading, non-blank content in its span
has_body: dict[int, bool] = {}
for h_idx in heading_indices:
end = boundary[h_idx]
body = False
in_f = False
f_mark = None
for j in range(h_idx + 1, end):
ln = src_lines[j]
if not in_f:
if ln.strip() and not heading_re.match(ln):
body = True
break
in_f, f_mark = _update_fence_state(ln, in_f, f_mark)
has_body[h_idx] = body
# Bottom-up: keep headings that have body content or any kept child headings
keep: dict[int, bool] = {}
for h_idx in reversed(heading_indices):
keep_child = any(keep.get(ch, False) for ch in children.get(h_idx, []))
keep[h_idx] = has_body[h_idx] or keep_child
remove_set = {idx for idx, k in keep.items() if not k}
if not remove_set:
return src_lines
# Remove empty headings and any immediate blank lines following them
out: list[str] = []
i = 0
while i < len(src_lines):
if i in remove_set:
i += 1
while i < len(src_lines) and src_lines[i].strip() == "":
i += 1
continue
out.append(src_lines[i])
i += 1
# Normalize excessive blank lines created by removals
cleaned: list[str] = []
prev_blank = False
for ln in out:
blank = ln.strip() == ""
if blank and prev_blank:
continue
cleaned.append(ln)
prev_blank = blank
while cleaned and cleaned[0].strip() == "":
cleaned.pop(0)
while cleaned and cleaned[-1].strip() == "":
cleaned.pop()
return cleaned
# Collect moved blocks as (heading_info, block_lines)
# heading_info is either None or (level, heading_text)
moved_blocks: list[tuple[tuple[int, str] | None, list[str]]] = []
any_moved = False
# Look back N days (yesterday = 1, up to `days_back`)
@ -885,20 +1128,89 @@ class MainWindow(QMainWindow):
lines = text.split("\n")
remaining_lines: list[str] = []
moved_from_this_day = False
current_heading: tuple[int, str] | None = None
for line in lines:
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
r"^\s*-\s*\[☐\]\s+", line
):
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
all_unchecked.append(f"- [ ] {item_text}")
moved_from_this_day = True
any_moved = True
else:
remaining_lines.append(line)
in_fence = False
fence_marker: str | None = None
i = 0
while i < len(lines):
line = lines[i]
# If we're not in a fenced code block, we can interpret headings/checkboxes
if not in_fence:
# Track the last seen heading (# / ## / ###)
m_head = heading_re.match(line)
if m_head:
level = len(m_head.group(1))
heading_text = _normalize_heading(m_head.group(2))
if level <= 3:
current_heading = (level, heading_text)
# Keep headings in the original day (only headings ABOVE a moved block are "carried")
remaining_lines.append(line)
in_fence, fence_marker = _update_fence_state(
line, in_fence, fence_marker
)
i += 1
continue
# Start of an unchecked checkbox block
m_unchecked = unchecked_re.match(line)
if m_unchecked:
indent = m_unchecked.group(1) or ""
item_text = m_unchecked.group(2)
block: list[str] = [f"{indent}- [ ] {item_text}"]
i += 1
# Consume subsequent lines until the next *checked* checkbox
# (ignoring any "- [x]" that appear inside fenced code blocks)
block_in_fence = in_fence
block_fence_marker = fence_marker
while i < len(lines):
nxt = lines[i]
# If we're not inside a fence, a checked checkbox ends the block,
# otherwise a new heading does as well.
if not block_in_fence and (
checked_re.match(nxt) or heading_re.match(nxt)
):
break
# Normalize any unchecked checkbox lines inside the block
m_inner_unchecked = (
unchecked_re.match(nxt) if not block_in_fence else None
)
if m_inner_unchecked:
inner_indent = m_inner_unchecked.group(1) or ""
inner_text = m_inner_unchecked.group(2)
block.append(f"{inner_indent}- [ ] {inner_text}")
else:
block.append(nxt)
# Update fence state after consuming the line
block_in_fence, block_fence_marker = _update_fence_state(
nxt, block_in_fence, block_fence_marker
)
i += 1
# Carry the last heading *above* the unchecked checkbox
moved_blocks.append((current_heading, block))
moved_from_this_day = True
any_moved = True
# We consumed the block; keep scanning from the checked checkbox (or EOF)
continue
# Default: keep the line on the original day
remaining_lines.append(line)
in_fence, fence_marker = _update_fence_state(
line, in_fence, fence_marker
)
i += 1
if moved_from_this_day:
remaining_lines = _prune_empty_headings(remaining_lines)
modified_text = "\n".join(remaining_lines)
# Save the cleaned-up source day
self.db.save_new_version(
@ -910,9 +1222,64 @@ class MainWindow(QMainWindow):
if not any_moved:
return False
# Append everything we collected to the *target* date
unchecked_str = "\n".join(all_unchecked) + "\n"
self._load_selected_date(target_iso, unchecked_str)
# --- Merge all moved blocks into the *target* date ---
target_text = self.db.get_entry(target_iso) or ""
# Treat a whitespace-only target note as truly empty; otherwise we can
# end up appending the new heading *after* leading blank lines (e.g. if
# a newly-created empty day was previously saved as just "\n").
if not target_text.strip():
target_lines = []
else:
target_lines = target_text.split("\n")
by_heading: dict[tuple[int, str], list[list[str]]] = {}
plain_blocks: list[list[str]] = []
for heading_info, block in moved_blocks:
if heading_info is None:
plain_blocks.append(block)
else:
by_heading.setdefault(heading_info, []).append(block)
# First insert all blocks that have headings
for (level, heading_text), blocks in by_heading.items():
target_lines = _insert_blocks_under_heading(
target_lines, level, heading_text, blocks
)
# Then append all blocks without headings at the end, like before
if plain_blocks:
if target_lines and target_lines[-1].strip():
target_lines.append("") # one blank line before the "unsectioned" todos
first = True
for block in plain_blocks:
if not block:
continue
if (
not first
and target_lines
and target_lines[-1].strip() != ""
and block[0].strip() != ""
and not (
_is_list_item(target_lines[-1]) and _is_list_item(block[0])
)
):
target_lines.append("")
target_lines.extend(block)
first = False
new_target_text = "\n".join(target_lines)
if not new_target_text.endswith("\n"):
new_target_text += "\n"
# Save the updated target date and load it into the editor
self.db.save_new_version(
target_iso,
new_target_text,
strings._("unchecked_checkbox_items_moved_to_next_day"),
)
self._load_selected_date(target_iso)
return True
def _on_date_changed(self):
@ -1020,20 +1387,10 @@ class MainWindow(QMainWindow):
save_db_config(cfg)
def _retheme_overrides(self):
self._apply_calendar_text_colors()
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
self.calendar.update()
self.editor.viewport().update()
def _apply_calendar_text_colors(self):
pal = self.palette()
txt = pal.windowText().color()
fmt = QTextCharFormat()
fmt.setForeground(txt)
# Use normal text color for weekends
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
# --------------- Search sidebar/results helpers ---------------- #
def _on_search_dates_changed(self, date_strs: list[str]):
@ -1103,6 +1460,7 @@ class MainWindow(QMainWindow):
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
self._tb_alarm = self._on_alarm_requested
self._tb_timer = self._on_timer_requested
self._tb_documents = self._on_documents_requested
self._tb_font_larger = self._on_font_larger_requested
self._tb_font_smaller = self._on_font_smaller_requested
@ -1116,6 +1474,7 @@ class MainWindow(QMainWindow):
tb.checkboxesRequested.connect(self._tb_checkboxes)
tb.alarmRequested.connect(self._tb_alarm)
tb.timerRequested.connect(self._tb_timer)
tb.documentsRequested.connect(self._tb_documents)
tb.insertImageRequested.connect(self._on_insert_image)
tb.historyRequested.connect(self._open_history)
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
@ -1124,46 +1483,58 @@ class MainWindow(QMainWindow):
self._toolbar_bound = True
def _sync_toolbar(self):
fmt = self.editor.currentCharFormat()
"""
Keep the toolbar "sticky" by reflecting the markdown state at the current caret/selection.
"""
c = self.editor.textCursor()
line = c.block().text()
# Inline styles (markdown-aware)
bold_on = bool(getattr(self.editor, "is_markdown_bold_active", lambda: False)())
italic_on = bool(
getattr(self.editor, "is_markdown_italic_active", lambda: False)()
)
strike_on = bool(
getattr(self.editor, "is_markdown_strike_active", lambda: False)()
)
# Block signals so setChecked() doesn't re-trigger actions
QSignalBlocker(self.toolBar.actBold)
QSignalBlocker(self.toolBar.actItalic)
QSignalBlocker(self.toolBar.actStrike)
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
self.toolBar.actItalic.setChecked(fmt.fontItalic())
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
self.toolBar.actBold.setChecked(bold_on)
self.toolBar.actItalic.setChecked(italic_on)
self.toolBar.actStrike.setChecked(strike_on)
# Headings: decide which to check by current point size
def _approx(a, b, eps=0.5): # small float tolerance
return abs(float(a) - float(b)) <= eps
cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF()
bH1 = _approx(cur_size, 24)
bH2 = _approx(cur_size, 18)
bH3 = _approx(cur_size, 14)
# Headings: infer from leading markdown markers
heading_level = 0
m = re.match(r"^\s*(#{1,3})\s+", line)
if m:
heading_level = len(m.group(1))
QSignalBlocker(self.toolBar.actH1)
QSignalBlocker(self.toolBar.actH2)
QSignalBlocker(self.toolBar.actH3)
QSignalBlocker(self.toolBar.actNormal)
self.toolBar.actH1.setChecked(bH1)
self.toolBar.actH2.setChecked(bH2)
self.toolBar.actH3.setChecked(bH3)
self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3))
self.toolBar.actH1.setChecked(heading_level == 1)
self.toolBar.actH2.setChecked(heading_level == 2)
self.toolBar.actH3.setChecked(heading_level == 3)
self.toolBar.actNormal.setChecked(heading_level == 0)
# Lists: infer from leading markers on the current line
bullets_on = bool(re.match(r"^\s*(?:•|-|\*)\s+", line))
numbers_on = bool(re.match(r"^\s*\d+\.\s+", line))
checkboxes_on = bool(re.match(r"^\s*[☐☑]\s+", line))
# Lists
lst = c.currentList()
bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
QSignalBlocker(self.toolBar.actBullets)
QSignalBlocker(self.toolBar.actNumbers)
self.toolBar.actBullets.setChecked(bool(bullets_on))
self.toolBar.actNumbers.setChecked(bool(numbers_on))
QSignalBlocker(self.toolBar.actCheckboxes)
self.toolBar.actBullets.setChecked(bullets_on)
self.toolBar.actNumbers.setChecked(numbers_on)
self.toolBar.actCheckboxes.setChecked(checkboxes_on)
def _change_font_size(self, delta: int) -> None:
"""Change font size for all editor tabs and save the setting."""
@ -1198,21 +1569,35 @@ class MainWindow(QMainWindow):
self.upcoming_reminders._add_reminder()
def _on_timer_requested(self):
"""Start a Pomodoro timer for the current line."""
editor = getattr(self, "editor", None)
if editor is None:
return
"""Toggle the embedded Pomodoro timer for the current line."""
action = self.toolBar.actTimer
# Get the current line text
line_text = editor.get_current_line_text().strip()
if not line_text:
line_text = strings._("pomodoro_time_log_default_text")
# Turned on -> start a new timer for the current line
if action.isChecked():
editor = getattr(self, "editor", None)
if editor is None:
# No editor; immediately reset the toggle
action.setChecked(False)
return
# Get current date
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Get the current line text
line_text = editor.get_current_line_task_text()
if not line_text:
line_text = strings._("pomodoro_time_log_default_text")
# Start the timer
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
# Get current date
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Start the timer embedded in the sidebar
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
else:
# Turned off -> cancel any running timer and remove the widget
self.pomodoro_manager.cancel_timer()
def _send_reminder_webhook(self, text: str):
if self.cfg.reminders and self.cfg.reminders_webhook_url:
reminder_webhook = ReminderWebHook(text)
reminder_webhook._send()
def _show_flashing_reminder(self, text: str):
"""
@ -1331,6 +1716,14 @@ class MainWindow(QMainWindow):
timer.start(msecs)
self._reminder_timers.append(timer)
# ----------- Documents handler ------------#
def _on_documents_requested(self):
documents_dlg = DocumentsDialog(self.db, self)
documents_dlg.exec()
# Refresh recent documents after any changes
if hasattr(self, "todays_documents"):
self.todays_documents.reload()
# ----------- History handler ------------#
def _open_history(self):
if hasattr(self.editor, "current_date"):
@ -1338,7 +1731,7 @@ class MainWindow(QMainWindow):
else:
date_iso = self._current_date_iso()
dlg = HistoryDialog(self.db, date_iso, self)
dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
if dlg.exec() == QDialog.Accepted:
# refresh editor + calendar (head pointer may have changed)
self._load_selected_date(date_iso)
@ -1365,6 +1758,8 @@ class MainWindow(QMainWindow):
self.tags.set_current_date(date_iso)
if hasattr(self, "time_log"):
self.time_log.set_current_date(date_iso)
if hasattr(self, "todays_documents"):
self.todays_documents.set_current_date(date_iso)
def _on_tag_added(self):
"""Called when a tag is added - trigger autosave for current page"""
@ -1429,9 +1824,22 @@ class MainWindow(QMainWindow):
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
self.cfg.move_todos_include_weekends = getattr(
new_cfg,
"move_todos_include_weekends",
getattr(self.cfg, "move_todos_include_weekends", False),
)
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
self.cfg.reminders_webhook_url = getattr(
new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url
)
self.cfg.reminders_webhook_secret = getattr(
new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret
)
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
@ -1469,6 +1877,12 @@ class MainWindow(QMainWindow):
else:
self.upcoming_reminders.show()
self.toolBar.actAlarm.setVisible(True)
if not self.cfg.documents:
self.todays_documents.hide()
self.toolBar.actDocuments.setVisible(False)
else:
self.todays_documents.show()
self.toolBar.actDocuments.setVisible(True)
# ------------ Statistics handler --------------- #

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ from PySide6.QtGui import (
QTextDocument,
)
from .theme import ThemeManager, Theme
from .theme import Theme, ThemeManager
class MarkdownHighlighter(QSyntaxHighlighter):
@ -78,17 +78,18 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.theme_manager.current() == Theme.DARK
or self.theme_manager._is_system_dark
):
# In dark mode, use a darker panel-like background
bg = pal.color(QPalette.AlternateBase)
fg = pal.color(QPalette.Text)
# In dark mode, use a darker panel-like background for codeblocks
code_bg = pal.color(QPalette.AlternateBase)
code_fg = pal.color(QPalette.Text)
else:
# Light mode: keep the existing light gray
bg = QColor(245, 245, 245)
fg = QColor( # pragma: no cover
# Light mode: keep the existing light gray for code blocks
code_bg = QColor(245, 245, 245)
code_fg = QColor( # pragma: no cover
0, 0, 0
) # avoiding using QPalette.Text as it can be white on macOS
self.code_block_format.setBackground(bg)
self.code_block_format.setForeground(fg)
self.code_block_format.setBackground(code_bg)
self.code_block_format.setForeground(code_fg)
# Headings
self.h1_format = QTextCharFormat()
@ -110,6 +111,23 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.link_format.setFontUnderline(True)
self.link_format.setAnchor(True)
# ---- Completed-task text (for checked checkboxes) ----
# Use the app palette so this works in both light and dark themes.
text_fg = pal.color(QPalette.Text)
text_bg = pal.color(QPalette.Base)
# Blend the text colour towards the background to "fade" it.
# t closer to 1.0 = closer to background / more faded.
t = 0.55
faded = QColor(
int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
)
self.completed_task_format = QTextCharFormat()
self.completed_task_format.setForeground(faded)
# Checkboxes
self.checkbox_format = QTextCharFormat()
self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle)
@ -140,8 +158,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
# Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat()
# Use the editor background color so they blend in
bg = pal.color(QPalette.Base)
hidden = QColor(bg)
hidden = QColor(text_bg)
hidden.setAlpha(0)
self.syntax_format.setForeground(hidden)
# Make the markers invisible by setting font size to 0.1 points
@ -339,6 +356,15 @@ class MarkdownHighlighter(QSyntaxHighlighter):
for m in re.finditer(r"[☐☑]", text):
self._overlay_range(m.start(), 1, self.checkbox_format)
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format)
# Completed checkbox lines: fade the text after the checkbox.
m = re.match(r"^(\s*☑\s+)(.+)$", text)
if m and hasattr(self, "completed_task_format"):
prefix = m.group(1)
content = m.group(2)
start = len(prefix)
length = len(content)
if length > 0:
self._overlay_range(start, length, self.completed_task_format)

View file

@ -5,11 +5,11 @@ from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
@ -18,16 +18,13 @@ from .db import DBManager
from .time_log import TimeLogDialog
class PomodoroTimer(QDialog):
"""A simple timer dialog for tracking work time on a specific task."""
class PomodoroTimer(QFrame):
"""A simple timer for tracking work time on a specific task."""
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
def __init__(self, task_text: str, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
self.setModal(False)
self.setMinimumWidth(300)
self._task_text = task_text
self._elapsed_seconds = 0
@ -43,7 +40,7 @@ class PomodoroTimer(QDialog):
# Timer display
self.time_label = QLabel("00:00:00")
font = self.time_label.font()
font.setPointSize(24)
font.setPointSize(20)
font.setBold(True)
self.time_label.setFont(font)
self.time_label.setAlignment(Qt.AlignCenter)
@ -103,7 +100,7 @@ class PomodoroTimer(QDialog):
self._timer.stop()
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
self.accept()
self.close()
class PomodoroManager:
@ -114,30 +111,99 @@ class PomodoroManager:
self._parent = parent_window
self._active_timer: Optional[PomodoroTimer] = None
def start_timer_for_line(self, line_text: str, date_iso: str):
"""Start a new timer for the given line of text."""
# Stop any existing timer
if self._active_timer and self._active_timer.isVisible():
self._active_timer.close()
@staticmethod
def _seconds_to_logged_hours(elapsed_seconds: int) -> float:
"""Convert elapsed seconds to decimal hours for logging.
# Create new timer
self._active_timer = PomodoroTimer(line_text, self._parent)
Rules:
- For very short runs (< 15 minutes), always round up to 0.25h (15 minutes).
- Otherwise, round to the closest 0.25h (15-minute) increment.
Halfway cases (e.g., 22.5 minutes) round up.
"""
if elapsed_seconds < 0:
elapsed_seconds = 0
# 15 minutes = 900 seconds
if elapsed_seconds < 900:
return 0.25
quarters = int(math.floor((elapsed_seconds / 900.0) + 0.5))
return quarters * 0.25
def start_timer_for_line(self, line_text: str, date_iso: str):
"""
Start a new timer for the given line of text and embed it into the
TimeLogWidget in the main window sidebar.
"""
# Cancel any existing timer first
self.cancel_timer()
# The timer lives inside the TimeLogWidget in the sidebar
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is None:
return
self._active_timer = PomodoroTimer(line_text, time_log_widget)
self._active_timer.timerStopped.connect(
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
)
self._active_timer.show()
# Ask the TimeLogWidget to own and display the widget
if hasattr(time_log_widget, "show_pomodoro_widget"):
time_log_widget.show_pomodoro_widget(self._active_timer)
else:
# Fallback - just attach it as a child widget
self._active_timer.setParent(time_log_widget)
self._active_timer.show()
def cancel_timer(self):
"""Cancel any running timer without logging and remove it from the sidebar."""
if not self._active_timer:
return
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is not None and hasattr(
time_log_widget, "clear_pomodoro_widget"
):
time_log_widget.clear_pomodoro_widget()
else:
# Fallback if the widget API doesn't exist
self._active_timer.setParent(None)
self._active_timer.deleteLater()
self._active_timer = None
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
"""Handle timer stop - open time log dialog with pre-filled data."""
# Convert seconds to decimal hours, rounded up
hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour
# Convert seconds to decimal hours, and handle rounding up or down
hours = self._seconds_to_logged_hours(elapsed_seconds)
# Ensure minimum of 0.25 hours
if hours < 0.25:
hours = 0.25
# Untoggle the toolbar button without retriggering the slot
tool_bar = getattr(self._parent, "toolBar", None)
if tool_bar is not None and hasattr(tool_bar, "actTimer"):
action = tool_bar.actTimer
was_blocked = action.blockSignals(True)
try:
action.setChecked(False)
finally:
action.blockSignals(was_blocked)
# Remove the embedded widget
self.cancel_timer()
# Open time log dialog
dlg = TimeLogDialog(self._db, date_iso, self._parent)
dlg = TimeLogDialog(
self._db,
date_iso,
self._parent,
True,
themes=self._parent.themes,
close_after_add=True,
)
# Pre-fill the hours
dlg.hours_spin.setValue(hours)
@ -147,3 +213,13 @@ class PomodoroManager:
# Show the dialog
dlg.exec()
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is not None:
# Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only:
# reload the summary so the TimeLogWidget in sidebar updates its totals
time_log_widget._reload_summary()
if not time_log_widget.toggle_btn.isChecked():
time_log_widget.summary_label.setText(
strings._("time_log_collapsed_hint")
)

View file

@ -4,32 +4,37 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLineEdit,
QAbstractItemView,
QComboBox,
QTimeEdit,
QPushButton,
QDateEdit,
QDialog,
QFormLayout,
QFrame,
QWidget,
QToolButton,
QHBoxLayout,
QHeaderView,
QLineEdit,
QListWidget,
QListWidgetItem,
QStyle,
QSizePolicy,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QStyle,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
QTimeEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .settings import load_db_config
import requests
class ReminderType(Enum):
@ -37,6 +42,9 @@ class ReminderType(Enum):
DAILY = strings._("daily")
WEEKDAYS = strings._("weekdays") # Mon-Fri
WEEKLY = strings._("weekly") # specific day of week
FORTNIGHTLY = strings._("fortnightly") # every 2 weeks
MONTHLY_DATE = strings._("monthly_same_date") # same calendar date
MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday
@dataclass
@ -64,13 +72,29 @@ class ReminderDialog(QDialog):
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
form = QFormLayout()
self.form = QFormLayout()
# Reminder text
self.text_edit = QLineEdit()
if reminder:
self.text_edit.setText(reminder.text)
form.addRow("&" + strings._("reminder") + ":", self.text_edit)
self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
# Date
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
if reminder and reminder.date_iso:
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if d.isValid():
self.date_edit.setDate(d)
else:
self.date_edit.setDate(QDate.currentDate())
else:
self.date_edit.setDate(QDate.currentDate())
self.form.addRow("&" + strings._("date") + ":", self.date_edit)
# Time
self.time_edit = QTimeEdit()
@ -79,15 +103,22 @@ class ReminderDialog(QDialog):
parts = reminder.time_str.split(":")
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
else:
self.time_edit.setTime(QTime.currentTime())
form.addRow("&" + strings._("time") + ":", self.time_edit)
# Default to 5 minutes in the future
future = QTime.currentTime().addSecs(5 * 60)
self.time_edit.setTime(future)
self.form.addRow("&" + strings._("time") + ":", self.time_edit)
# Recurrence type
self.type_combo = QComboBox()
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
self.type_combo.addItem(strings._("once"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY)
self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE)
self.type_combo.addItem(
strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY
)
if reminder:
for i in range(self.type_combo.count()):
@ -96,7 +127,7 @@ class ReminderDialog(QDialog):
break
self.type_combo.currentIndexChanged.connect(self._on_type_changed)
form.addRow("&" + strings._("repeat") + ":", self.type_combo)
self.form.addRow("&" + strings._("repeat") + ":", self.type_combo)
# Weekday selector (for weekly reminders)
self.weekday_combo = QComboBox()
@ -115,11 +146,32 @@ class ReminderDialog(QDialog):
if reminder and reminder.weekday is not None:
self.weekday_combo.setCurrentIndex(reminder.weekday)
else:
self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1)
self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1)
form.addRow("&" + strings._("day") + ":", self.weekday_combo)
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
day_label = self.form.labelForField(self.weekday_combo)
day_label.setVisible(False)
layout.addLayout(form)
self.nth_spin = QSpinBox()
self.nth_spin.setRange(1, 5) # up to 5th Monday, etc.
self.nth_spin.setValue(1)
# If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso
if (
reminder
and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
and reminder.date_iso
):
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if anchor.isValid():
nth_index = (anchor.day() - 1) // 7 # 0-based
self.nth_spin.setValue(nth_index + 1)
self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin)
nth_label = self.form.labelForField(self.nth_spin)
nth_label.setVisible(False)
self.nth_spin.setVisible(False)
layout.addLayout(self.form)
# Buttons
btn_layout = QHBoxLayout()
@ -139,9 +191,31 @@ class ReminderDialog(QDialog):
self._on_type_changed()
def _on_type_changed(self):
"""Show/hide weekday selector based on reminder type."""
"""Show/hide weekday / nth selectors based on reminder type."""
reminder_type = self.type_combo.currentData()
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
show_weekday = reminder_type in (
ReminderType.WEEKLY,
ReminderType.MONTHLY_NTH_WEEKDAY,
)
self.weekday_combo.setVisible(show_weekday)
day_label = self.form.labelForField(self.weekday_combo)
day_label.setVisible(show_weekday)
show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
nth_label = self.form.labelForField(self.nth_spin)
self.nth_spin.setVisible(show_nth)
nth_label.setVisible(show_nth)
# For new reminders, when switching to a type that uses a weekday,
# snap the weekday to match the currently selected date.
if reminder_type in (
ReminderType.WEEKLY,
ReminderType.MONTHLY_NTH_WEEKDAY,
) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
if 0 <= dow < self.weekday_combo.count():
self.weekday_combo.setCurrentIndex(dow)
def get_reminder(self) -> Reminder:
"""Get the configured reminder."""
@ -150,13 +224,39 @@ class ReminderDialog(QDialog):
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
weekday = None
if reminder_type == ReminderType.WEEKLY:
if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY):
weekday = self.weekday_combo.currentData()
date_iso = None
anchor_date = self.date_edit.date()
if reminder_type == ReminderType.ONCE:
# Right now this just means "today at the chosen time".
date_iso = QDate.currentDate().toString("yyyy-MM-dd")
# Fire once, on the chosen calendar date at the chosen time
date_iso = anchor_date.toString("yyyy-MM-dd")
elif reminder_type == ReminderType.FORTNIGHTLY:
# Anchor: the chosen calendar date. Every 14 days from this date.
date_iso = anchor_date.toString("yyyy-MM-dd")
elif reminder_type == ReminderType.MONTHLY_DATE:
# Anchor: the chosen calendar date. "Same date each month"
date_iso = anchor_date.toString("yyyy-MM-dd")
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
# Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
weekday = self.weekday_combo.currentData()
nth_index = self.nth_spin.value() - 1 # 0-based
first = QDate(anchor_date.year(), anchor_date.month(), 1)
target_dow = weekday + 1 # Qt: Monday=1
offset = (target_dow - first.dayOfWeek() + 7) % 7
anchor = first.addDays(offset + nth_index * 7)
# If nth weekday doesn't exist in this month, fall back to the last such weekday
if anchor.month() != anchor_date.month():
anchor = anchor.addDays(-7)
date_iso = anchor.toString("yyyy-MM-dd")
return Reminder(
id=self._reminder.id if self._reminder else None,
@ -183,7 +283,7 @@ class UpcomingRemindersWidget(QFrame):
# Header with toggle button
self.toggle_btn = QToolButton()
self.toggle_btn.setText("Upcoming Reminders")
self.toggle_btn.setText(strings._("upcoming_reminders"))
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
@ -192,7 +292,7 @@ class UpcomingRemindersWidget(QFrame):
self.add_btn = QToolButton()
self.add_btn.setText("")
self.add_btn.setToolTip("Add Reminder")
self.add_btn.setToolTip(strings._("add_reminder"))
self.add_btn.setAutoRaise(True)
self.add_btn.clicked.connect(self._add_reminder)
@ -200,7 +300,7 @@ class UpcomingRemindersWidget(QFrame):
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip("Manage All Reminders")
self.manage_btn.setToolTip(strings._("manage_reminders"))
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._manage_reminders)
@ -235,43 +335,36 @@ class UpcomingRemindersWidget(QFrame):
main.addWidget(self.body)
# Timer to check and fire reminders
# Start by syncing to the next minute boundary
self._check_timer = QTimer(self)
self._check_timer.timeout.connect(self._check_reminders)
#
# We tick once per second, but only hit the DB when the clock is
# exactly on a :00 second. That way a reminder for HH:MM fires at
# HH:MM:00, independent of when it was created.
self._tick_timer = QTimer(self)
self._tick_timer.setInterval(1000) # 1 second
self._tick_timer.timeout.connect(self._on_tick)
self._tick_timer.start()
# Calculate milliseconds until next minute (HH:MM:00)
# Also check once on startup so we don't miss reminders that
# should have fired a moment ago when the app wasn't running.
QTimer.singleShot(0, self._check_reminders)
def _on_tick(self) -> None:
"""Called every second; run reminder check only on exact minute boundaries."""
now = QDateTime.currentDateTime()
current_second = now.time().second()
current_msec = now.time().msec()
# Milliseconds until next minute
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
# Start with a single-shot to sync to the minute
self._sync_timer = QTimer(self)
self._sync_timer.setSingleShot(True)
self._sync_timer.timeout.connect(self._start_regular_timer)
self._sync_timer.start(ms_until_next_minute)
# Also check immediately in case there are pending reminders
QTimer.singleShot(1000, self._check_reminders)
if now.time().second() == 0:
# Only do the heavier DB work once per minute, at HH:MM:00,
# so reminders are aligned to the clock and not to when they
# were created.
self._check_reminders(now)
def __del__(self):
"""Cleanup timers when widget is destroyed."""
try:
if hasattr(self, "_check_timer") and self._check_timer:
self._check_timer.stop()
if hasattr(self, "_sync_timer") and self._sync_timer:
self._sync_timer.stop()
except:
if hasattr(self, "_tick_timer") and self._tick_timer:
self._tick_timer.stop()
except Exception:
pass # Ignore any cleanup errors
def _start_regular_timer(self):
"""Start the regular check timer after initial sync."""
# Now we're at a minute boundary, check and start regular timer
self._check_reminders()
self._check_timer.start(60000) # Check every minute
def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list."""
self.body.setVisible(checked)
@ -324,41 +417,99 @@ class UpcomingRemindersWidget(QFrame):
self.reminder_list.addItem(item)
if not upcoming:
item = QListWidgetItem("No upcoming reminders")
item = QListWidgetItem(strings._("no_upcoming_reminders"))
item.setFlags(Qt.NoItemFlags)
self.reminder_list.addItem(item)
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
"""Check if a reminder should fire on a given date."""
if reminder.reminder_type == ReminderType.ONCE:
rtype = reminder.reminder_type
if rtype == ReminderType.ONCE:
if reminder.date_iso:
return date.toString("yyyy-MM-dd") == reminder.date_iso
return False
elif reminder.reminder_type == ReminderType.DAILY:
if rtype == ReminderType.DAILY:
return True
elif reminder.reminder_type == ReminderType.WEEKDAYS:
if rtype == ReminderType.WEEKDAYS:
# Monday=1, Sunday=7
return 1 <= date.dayOfWeek() <= 5
elif reminder.reminder_type == ReminderType.WEEKLY:
if rtype == ReminderType.WEEKLY:
# Qt: Monday=1, reminder: Monday=0
return date.dayOfWeek() - 1 == reminder.weekday
if rtype == ReminderType.FORTNIGHTLY:
if not reminder.date_iso:
return False
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if not anchor.isValid() or date < anchor:
return False
days = anchor.daysTo(date)
return days % 14 == 0
if rtype == ReminderType.MONTHLY_DATE:
if not reminder.date_iso:
return False
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if not anchor.isValid():
return False
anchor_day = anchor.day()
# Clamp to the last day of this month (for 29/30/31)
first_of_month = QDate(date.year(), date.month(), 1)
last_of_month = first_of_month.addMonths(1).addDays(-1)
target_day = min(anchor_day, last_of_month.day())
return date.day() == target_day
if rtype == ReminderType.MONTHLY_NTH_WEEKDAY:
if not reminder.date_iso or reminder.weekday is None:
return False
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if not anchor.isValid():
return False
# Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.)
anchor_n = (anchor.day() - 1) // 7
target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7)
# Compute the anchor_n-th target weekday in this month
first = QDate(date.year(), date.month(), 1)
offset = (target_dow - first.dayOfWeek() + 7) % 7
candidate = first.addDays(offset + anchor_n * 7)
# If that nth weekday doesn't exist this month (e.g. 5th Monday), skip
if candidate.month() != date.month():
return False
return date == candidate
return False
def _check_reminders(self):
"""Check if any reminders should fire now."""
def _check_reminders(self, now: QDateTime | None = None):
"""
Check and trigger due reminders.
This uses absolute clock time, so a reminder for HH:MM will fire
when the system clock reaches HH:MM:00, independent of when the
reminder was created.
"""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
if now is None:
now = QDateTime.currentDateTime()
today = now.date()
reminders = self._db.get_all_reminders()
# Small grace window (in seconds) so we still fire reminders if
# the app was just opened or the event loop was briefly busy.
GRACE_WINDOW_SECS = 120 # 2 minutes
for reminder in reminders:
if not reminder.active:
continue
@ -366,28 +517,35 @@ class UpcomingRemindersWidget(QFrame):
if not self._should_fire_on_date(reminder, today):
continue
# Parse time
# Parse time: stored as "HH:MM", we treat that as HH:MM:00
hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0))
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
seconds_diff = current_minute.secsTo(target)
if -120 <= seconds_diff <= 0:
# Check if we haven't already fired this one
# Skip if this reminder is still in the future
if now < target:
continue
# How long ago should this reminder have fired?
seconds_late = target.secsTo(now) # target -> now
if 0 <= seconds_late <= GRACE_WINDOW_SECS:
# Check if we haven't already fired this occurrence
if not hasattr(self, "_fired_reminders"):
self._fired_reminders = {}
reminder_key = (reminder.id, target.toString())
# Only fire once per reminder per target time
if reminder_key not in self._fired_reminders:
self._fired_reminders[reminder_key] = current_minute
self.reminderTriggered.emit(reminder.text)
if reminder_key in self._fired_reminders:
continue
# For ONCE reminders, deactivate after firing
if reminder.reminder_type == ReminderType.ONCE:
self._db.update_reminder_active(reminder.id, False)
self.refresh() # Refresh the list to show deactivated reminder
# Mark as fired and emit
self._fired_reminders[reminder_key] = now
self.reminderTriggered.emit(reminder.text)
# For ONCE reminders, deactivate after firing
if reminder.reminder_type == ReminderType.ONCE:
self._db.update_reminder_active(reminder.id, False)
self.refresh() # Refresh the list to show deactivated reminder
@Slot()
def _add_reminder(self):
@ -418,8 +576,8 @@ class UpcomingRemindersWidget(QFrame):
if not selected_items:
return
from PySide6.QtWidgets import QMenu
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu
menu = QMenu(self)
@ -427,7 +585,7 @@ class UpcomingRemindersWidget(QFrame):
if len(selected_items) == 1:
reminder = selected_items[0].data(Qt.UserRole)
if reminder:
edit_action = QAction("Edit", self)
edit_action = QAction(strings._("edit"), self)
edit_action.triggered.connect(
lambda: self._edit_reminder(selected_items[0])
)
@ -435,9 +593,13 @@ class UpcomingRemindersWidget(QFrame):
# Delete option for any selection
if len(selected_items) == 1:
delete_text = "Delete"
delete_text = strings._("delete")
else:
delete_text = f"Delete {len(selected_items)} Reminders"
delete_text = (
strings._("delete")
+ f" {len(selected_items)} "
+ strings._("reminders")
)
delete_action = QAction(delete_text, self)
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
@ -464,15 +626,31 @@ class UpcomingRemindersWidget(QFrame):
# Confirmation message
if len(unique_reminders) == 1:
reminder = list(unique_reminders.values())[0]
msg = f"Delete reminder '{reminder.text}'?"
msg = (
strings._("delete")
+ " "
+ strings._("reminder")
+ f" '{reminder.text}'?"
)
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
msg += (
"\n\n"
+ strings._("this_is_a_reminder_of_type")
+ f" '{reminder.reminder_type.value}'. "
+ strings._("deleting_it_will_remove_all_future_occurrences")
)
else:
msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
msg = (
strings._("delete")
+ f"{len(unique_reminders)} "
+ strings._("reminders")
+ " ?\n\n"
+ strings._("this_will_delete_the_actual_reminders")
)
reply = QMessageBox.question(
self,
"Delete Reminders",
strings._("delete_reminders"),
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
@ -485,13 +663,18 @@ class UpcomingRemindersWidget(QFrame):
def _delete_reminder(self, reminder):
"""Delete a single reminder after confirmation."""
msg = f"Delete reminder '{reminder.text}'?"
msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
msg += (
"\n\n"
+ strings._("this_is_a_reminder_of_type")
+ f" '{reminder.reminder_type.value}'. "
+ strings._("deleting_it_will_remove_all_future_occurrences")
)
reply = QMessageBox.question(
self,
"Delete Reminder",
strings._("delete_reminder"),
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
@ -516,32 +699,40 @@ class ManageRemindersDialog(QDialog):
super().__init__(parent)
self._db = db
self.setWindowTitle("Manage Reminders")
self.setWindowTitle(strings._("manage_reminders"))
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels(
["Text", "Time", "Type", "Active", "Actions"]
[
strings._("text"),
strings._("date"),
strings._("time"),
strings._("type"),
strings._("active"),
strings._("actions"),
]
)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
layout.addWidget(self.table)
# Buttons
btn_layout = QHBoxLayout()
add_btn = QPushButton("Add Reminder")
add_btn = QPushButton(strings._("add_reminder"))
add_btn.clicked.connect(self._add_reminder)
btn_layout.addWidget(add_btn)
btn_layout.addStretch()
close_btn = QPushButton("Close")
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
@ -565,48 +756,105 @@ class ManageRemindersDialog(QDialog):
text_item.setData(Qt.UserRole, reminder)
self.table.setItem(row, 0, text_item)
# Date
date_display = ""
if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso:
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if d.isValid():
date_display = d.toString("yyyy-MM-dd")
else:
date_display = reminder.date_iso
date_item = QTableWidgetItem(date_display)
self.table.setItem(row, 1, date_item)
# Time
time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 1, time_item)
self.table.setItem(row, 2, time_item)
# Type
type_str = {
base_type_strs = {
ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays",
ReminderType.WEEKLY: "Weekly",
}.get(reminder.reminder_type, "Unknown")
ReminderType.FORTNIGHTLY: "Fortnightly",
ReminderType.MONTHLY_DATE: "Monthly (date)",
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
}
type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
if (
reminder.reminder_type == ReminderType.WEEKLY
and reminder.weekday is not None
):
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
type_str += f" ({days[reminder.weekday]})"
# Short day names we can reuse
days_short = [
strings._("monday_short"),
strings._("tuesday_short"),
strings._("wednesday_short"),
strings._("thursday_short"),
strings._("friday_short"),
strings._("saturday_short"),
strings._("sunday_short"),
]
if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
# Show something like: Monthly (3rd Mon)
day_name = ""
if reminder.weekday is not None and 0 <= reminder.weekday < len(
days_short
):
day_name = days_short[reminder.weekday]
nth_label = ""
if reminder.date_iso:
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if anchor.isValid():
nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
if 0 <= nth_index < len(ordinals):
nth_label = ordinals[nth_index]
parts = []
if nth_label:
parts.append(nth_label)
if day_name:
parts.append(day_name)
if parts:
type_str = f"Monthly ({' '.join(parts)})"
# else: fall back to the generic "Monthly (nth weekday)"
else:
# For weekly / fortnightly types, still append the day name
if (
reminder.reminder_type
in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY)
and reminder.weekday is not None
and 0 <= reminder.weekday < len(days_short)
):
type_str += f" ({days_short[reminder.weekday]})"
type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 2, type_item)
self.table.setItem(row, 3, type_item)
# Active
active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 3, active_item)
self.table.setItem(row, 4, active_item)
# Actions
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(2, 2, 2, 2)
edit_btn = QPushButton("Edit")
edit_btn = QPushButton(strings._("edit"))
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
actions_layout.addWidget(edit_btn)
delete_btn = QPushButton("Delete")
delete_btn = QPushButton(strings._("delete"))
delete_btn.clicked.connect(
lambda checked, r=reminder: self._delete_reminder(r)
)
actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 4, actions_widget)
self.table.setCellWidget(row, 5, actions_widget)
def _add_reminder(self):
"""Add a new reminder."""
@ -628,8 +876,8 @@ class ManageRemindersDialog(QDialog):
"""Delete a reminder."""
reply = QMessageBox.question(
self,
"Delete Reminder",
f"Delete reminder '{reminder.text}'?",
strings._("delete_reminder"),
strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
@ -637,3 +885,33 @@ class ManageRemindersDialog(QDialog):
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self._load_reminders()
class ReminderWebHook:
def __init__(self, text):
self.text = text
self.cfg = load_db_config()
def _send(self):
payload: dict[str, str] = {
"reminder": self.text,
}
url = self.cfg.reminders_webhook_url
secret = self.cfg.reminders_webhook_secret
_headers = {}
if secret:
_headers["X-Bouquin-Secret"] = secret
if url:
try:
requests.post(
url,
json=payload,
timeout=10,
headers=_headers,
)
except Exception:
# We did our best
pass

View file

@ -3,13 +3,7 @@ from __future__ import annotations
import datetime
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QDialogButtonBox,
)
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
from . import strings

View file

@ -6,19 +6,19 @@ from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QSizePolicy,
QHBoxLayout,
QVBoxLayout,
QWidget,
)
from . import strings
Row = Tuple[str, str]
Row = Tuple[str, str, str, str, str | None]
class Search(QWidget):
@ -52,9 +52,27 @@ class Search(QWidget):
lay.addWidget(self.results)
def _open_selected(self, item: QListWidgetItem):
date_str = item.data(Qt.ItemDataRole.UserRole)
if date_str:
self.openDateRequested.emit(date_str)
data = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(data, dict):
return
kind = data.get("kind")
if kind == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
elif kind == "document":
doc_id = data.get("doc_id")
file_name = data.get("file_name") or "document"
if doc_id is None:
return
self._open_document(int(doc_id), file_name)
def _open_document(self, doc_id: int, file_name: str) -> None:
"""Open the selected document in the user's default app."""
from bouquin.document_utils import open_document_from_db
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
def _search(self, text: str):
"""
@ -80,28 +98,28 @@ class Search(QWidget):
self.resultDatesChanged.emit([]) # clear highlights
return
self.resultDatesChanged.emit(sorted({d for d, _ in rows}))
# Only highlight calendar dates for page results
page_dates = sorted(
{key for (kind, key, _title, _text, _aux) in rows if kind == "page"}
)
self.resultDatesChanged.emit(page_dates)
self.results.show()
for date_str, content in rows:
# Build an HTML fragment around the match and whether to show ellipses
frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90)
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
for kind, key, title, text, aux in rows:
# Build an HTML fragment around the match
frag_html = self._make_html_snippet(text, query, radius=30, maxlen=90)
container = QWidget()
outer = QVBoxLayout(container)
outer.setContentsMargins(8, 6, 8, 6)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(2)
# Date label (plain text)
date_lbl = QLabel()
date_lbl.setTextFormat(Qt.TextFormat.RichText)
date_lbl.setText(f"<h3><i>{date_str}</i></h3>")
date_f = date_lbl.font()
date_f.setPointSizeF(date_f.pointSizeF() + 1)
date_lbl.setFont(date_f)
outer.addWidget(date_lbl)
# ---- Heading (date for pages, "Document" for docs) ----
heading = QLabel(title)
heading.setStyleSheet("font-weight:bold;")
outer.addWidget(heading)
# Preview row with optional ellipses
# ---- Preview row ----
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
@ -117,9 +135,9 @@ class Search(QWidget):
else "<span style='color:#888'>(no preview)</span>"
)
h.addWidget(preview, 1)
outer.addWidget(row)
# Separator line
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
@ -127,9 +145,22 @@ class Search(QWidget):
# ---- Add to list ----
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, date_str)
item.setSizeHint(container.sizeHint())
if kind == "page":
item.setData(
Qt.ItemDataRole.UserRole,
{"kind": "page", "date": key},
)
else: # document
item.setData(
Qt.ItemDataRole.UserRole,
{
"kind": "document",
"doc_id": int(key),
"file_name": aux or "",
},
)
item.setSizeHint(container.sizeHint())
self.results.addItem(item)
self.results.setItemWidget(item, container)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig
@ -41,9 +42,16 @@ def load_db_config() -> DBConfig:
idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool)
move_todos_include_weekends = s.value(
"ui/move_todos_include_weekends", False, type=bool
)
tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str)
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
documents = s.value("ui/documents", True, type=bool)
invoicing = s.value("ui/invoicing", False, type=bool)
locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int)
return DBConfig(
@ -52,9 +60,14 @@ def load_db_config() -> DBConfig:
idle_minutes=idle,
theme=theme,
move_todos=move_todos,
move_todos_include_weekends=move_todos_include_weekends,
tags=tags,
time_log=time_log,
reminders=reminders,
reminders_webhook_url=reminders_webhook_url,
reminders_webhook_secret=reminders_webhook_secret,
documents=documents,
invoicing=invoicing,
locale=locale,
font_size=font_size,
)
@ -67,8 +80,13 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme))
s.setValue("ui/move_todos", str(cfg.move_todos))
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url))
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
s.setValue("ui/documents", str(cfg.documents))
s.setValue("ui/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -2,34 +2,37 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QFrame,
QGroupBox,
QLabel,
QHBoxLayout,
QVBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QDialogButtonBox,
QRadioButton,
QSizePolicy,
QSpinBox,
QMessageBox,
QWidget,
QTabWidget,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from .db import DBConfig, DBManager
from .settings import load_db_config, save_db_config
from .theme import Theme
from .key_prompt import KeyPrompt
from . import strings
from .db import DBConfig, DBManager
from .key_prompt import KeyPrompt
from .settings import load_db_config, save_db_config
from .theme import Theme
class SettingsDialog(QDialog):
@ -42,7 +45,7 @@ class SettingsDialog(QDialog):
self.current_settings = load_db_config()
self.setMinimumWidth(480)
self.setMinimumWidth(600)
self.setSizeGripEnabled(True)
# --- Tabs ----------------------------------------------------------
@ -166,6 +169,25 @@ class SettingsDialog(QDialog):
self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos)
# Optional: allow moving to the very next day even if it is a weekend.
self.move_todos_include_weekends = QCheckBox(
strings._("move_todos_include_weekends")
)
self.move_todos_include_weekends.setChecked(
getattr(self.current_settings, "move_todos_include_weekends", False)
)
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
move_todos_opts = QWidget()
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
move_todos_opts_layout.setSpacing(4)
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
features_layout.addWidget(move_todos_opts)
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
self.tags = QCheckBox(strings._("enable_tags_feature"))
self.tags.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor)
@ -176,12 +198,145 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log)
self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
invoicing_enabled = getattr(self.current_settings, "invoicing", False)
self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
self.invoicing.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.invoicing)
# Invoicing only if time_log is enabled
if not self.current_settings.time_log:
self.invoicing.setChecked(False)
self.invoicing.setEnabled(False)
self.time_log.toggled.connect(self._on_time_log_toggled)
# --- Reminders feature + webhook options -------------------------
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders)
self.reminders.toggled.connect(self._on_reminders_toggled)
self.reminders.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.reminders)
# Container for reminder-specific options, indented under the checkbox
self.reminders_options_container = QWidget()
reminders_options_layout = QVBoxLayout(self.reminders_options_container)
reminders_options_layout.setContentsMargins(24, 0, 0, 0)
reminders_options_layout.setSpacing(4)
self.reminders_options_toggle = QToolButton()
self.reminders_options_toggle.setText(
strings._("reminders_webhook_section_title")
)
self.reminders_options_toggle.setCheckable(True)
self.reminders_options_toggle.setChecked(False)
self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
self.reminders_options_toggle.clicked.connect(
self._on_reminders_options_toggled
)
toggle_row = QHBoxLayout()
toggle_row.addWidget(self.reminders_options_toggle)
toggle_row.addStretch()
reminders_options_layout.addLayout(toggle_row)
# Actual options (labels + QLineEdits)
self.reminders_options_widget = QWidget()
options_form = QFormLayout(self.reminders_options_widget)
options_form.setContentsMargins(0, 0, 0, 0)
options_form.setSpacing(4)
self.reminders_webhook_url = QLineEdit(
self.current_settings.reminders_webhook_url or ""
)
self.reminders_webhook_secret = QLineEdit(
self.current_settings.reminders_webhook_secret or ""
)
self.reminders_webhook_secret.setEchoMode(QLineEdit.Password)
options_form.addRow(
strings._("reminders_webhook_url_label") + ":",
self.reminders_webhook_url,
)
options_form.addRow(
strings._("reminders_webhook_secret_label") + ":",
self.reminders_webhook_secret,
)
reminders_options_layout.addWidget(self.reminders_options_widget)
features_layout.addWidget(self.reminders_options_container)
self.reminders_options_container.setVisible(self.reminders.isChecked())
self.reminders_options_widget.setVisible(False)
self.documents = QCheckBox(strings._("enable_documents_feature"))
self.documents.setChecked(self.current_settings.documents)
self.documents.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.documents)
layout.addWidget(features_group)
# --- Invoicing / company profile section -------------------------
self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
invoicing_layout = QFormLayout(self.invoicing_group)
profile = self._db.get_company_profile() or (
None,
None,
None,
None,
None,
None,
None,
)
name, address, phone, email, tax_id, payment_details, logo_bytes = profile
self.company_name_edit = QLineEdit(name or "")
self.company_address_edit = QTextEdit(address or "")
self.company_phone_edit = QLineEdit(phone or "")
self.company_email_edit = QLineEdit(email or "")
self.company_tax_id_edit = QLineEdit(tax_id or "")
self.company_payment_details_edit = QTextEdit()
self.company_payment_details_edit.setPlainText(payment_details or "")
invoicing_layout.addRow(
strings._("invoice_company_name") + ":", self.company_name_edit
)
invoicing_layout.addRow(
strings._("invoice_company_address") + ":", self.company_address_edit
)
invoicing_layout.addRow(
strings._("invoice_company_phone") + ":", self.company_phone_edit
)
invoicing_layout.addRow(
strings._("invoice_company_email") + ":", self.company_email_edit
)
invoicing_layout.addRow(
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
)
invoicing_layout.addRow(
strings._("invoice_company_payment_details") + ":",
self.company_payment_details_edit,
)
# Logo picker - store bytes on self._logo_bytes
self._logo_bytes = logo_bytes
logo_row = QHBoxLayout()
self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
if logo_bytes:
self.logo_label.setText(strings._("invoice_company_logo_set"))
logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
logo_btn.clicked.connect(self._on_choose_logo)
logo_row.addWidget(self.logo_label)
logo_row.addWidget(logo_btn)
invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
# Show/hide this whole block based on invoicing checkbox
self.invoicing_group.setVisible(self.invoicing.isChecked())
self.invoicing.toggled.connect(self.invoicing_group.setVisible)
layout.addWidget(self.invoicing_group)
layout.addStretch()
return page
@ -305,17 +460,92 @@ class SettingsDialog(QDialog):
idle_minutes=self.idle_spin.value(),
theme=selected_theme.value,
move_todos=self.move_todos.isChecked(),
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
reminders_webhook_url=self.reminders_webhook_url.text().strip() or None,
reminders_webhook_secret=self.reminders_webhook_secret.text().strip()
or None,
documents=self.documents.isChecked(),
invoicing=(
self.invoicing.isChecked() if self.time_log.isChecked() else False
),
locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(),
)
save_db_config(self._cfg)
# Save company profile only if invoicing is enabled
if self.invoicing.isChecked() and self.time_log.isChecked():
self._db.save_company_profile(
name=self.company_name_edit.text().strip() or None,
address=self.company_address_edit.toPlainText().strip() or None,
phone=self.company_phone_edit.text().strip() or None,
email=self.company_email_edit.text().strip() or None,
tax_id=self.company_tax_id_edit.text().strip() or None,
payment_details=self.company_payment_details_edit.toPlainText().strip()
or None,
logo=getattr(self, "_logo_bytes", None),
)
self.parent().themes.set(selected_theme)
self.accept()
def _on_reminders_options_toggled(self, checked: bool) -> None:
"""
Expand/collapse the advanced reminders options (webhook URL/secret).
"""
if checked:
self.reminders_options_toggle.setArrowType(Qt.DownArrow)
self.reminders_options_widget.show()
else:
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
self.reminders_options_widget.hide()
def _on_reminders_toggled(self, checked: bool) -> None:
"""
Conditionally show reminder webhook options depending
on if the reminders feature is toggled on or off.
"""
if hasattr(self, "reminders_options_container"):
self.reminders_options_container.setVisible(checked)
# When turning reminders off, also collapse the section
if not checked and hasattr(self, "reminders_options_toggle"):
self.reminders_options_toggle.setChecked(False)
self._on_reminders_options_toggled(False)
def _on_time_log_toggled(self, checked: bool) -> None:
"""
Enforce 'invoicing depends on time logging'.
"""
if not checked:
# Turn off + disable invoicing if time logging is disabled
self.invoicing.setChecked(False)
self.invoicing.setEnabled(False)
else:
# Let the user enable invoicing when time logging is enabled
self.invoicing.setEnabled(True)
def _on_choose_logo(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
strings._("invoice_company_logo_choose"),
"",
"Images (*.png *.jpg *.jpeg *.bmp)",
)
if not path:
return
try:
with open(path, "rb") as f:
self._logo_bytes = f.read()
self.logo_label.setText(Path(path).name)
except OSError as exc:
QMessageBox.warning(self, strings._("error"), str(exc))
def _change_key(self):
p1 = KeyPrompt(
self,

View file

@ -3,24 +3,24 @@ from __future__ import annotations
import datetime as _dt
from typing import Dict
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QVBoxLayout,
QFormLayout,
QLabel,
QGroupBox,
QHBoxLayout,
QComboBox,
QLabel,
QScrollArea,
QWidget,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .settings import load_db_config
# ---------- Activity heatmap ----------
@ -150,7 +150,7 @@ class DateHeatmap(QWidget):
fm = painter.fontMetrics()
# --- weekday labels on left -------------------------------------
# Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
# Python's weekday(): Monday=0 ... Sunday=6
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
for dow in range(7):
@ -215,7 +215,7 @@ class DateHeatmap(QWidget):
col = int((x - self._margin_left) // cell_span) # week index
row = int((y - self._margin_top) // cell_span) # dow (0..6)
# Only 7 rows (MonSun)
# Only 7 rows (Mon-Sun)
if not (0 <= row < 7):
return
@ -248,8 +248,9 @@ class StatisticsDialog(QDialog):
self._db = db
self.setWindowTitle(strings._("statistics"))
self.setMinimumWidth(600)
self.setMinimumHeight(400)
self.setMinimumWidth(650)
self.setMinimumHeight(650)
root = QVBoxLayout(self)
(
@ -263,50 +264,212 @@ class StatisticsDialog(QDialog):
page_most_tags,
page_most_tags_count,
revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
) = self._gather_stats()
# --- Numeric summary at the top ----------------------------------
form = QFormLayout()
root.addLayout(form)
self.cfg = load_db_config()
form.addRow(
# Optional: per-date document counts for the heatmap.
documents_by_date: Dict[_dt.date, int] = {}
total_documents = 0
date_most_documents: _dt.date | None = None
date_most_documents_count = 0
if self.cfg.documents:
try:
documents_by_date = self._db.documents_by_date() or {}
except Exception:
documents_by_date = {}
if documents_by_date:
total_documents = sum(documents_by_date.values())
# Choose the date with the highest count, tie-breaking by earliest date.
date_most_documents, date_most_documents_count = sorted(
documents_by_date.items(),
key=lambda item: (-item[1], item[0]),
)[0]
# For the heatmap
self._documents_by_date = documents_by_date
self._time_by_date = time_minutes_by_date
self._reminders_by_date = reminders_by_date
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
# ------------------------------------------------------------------
# Feature groups
# ------------------------------------------------------------------
# --- Pages / words / revisions -----------------------------------
pages_group = QGroupBox(strings._("stats_group_pages"))
pages_form = QFormLayout(pages_group)
pages_form.addRow(
strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)),
)
form.addRow(
pages_form.addRow(
strings._("stats_total_revisions"),
QLabel(str(total_revisions)),
)
if page_most_revisions:
form.addRow(
pages_form.addRow(
strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
)
else:
form.addRow(strings._("stats_page_most_revisions"), QLabel(""))
pages_form.addRow(
strings._("stats_page_most_revisions"),
QLabel(""),
)
form.addRow(
pages_form.addRow(
strings._("stats_total_words"),
QLabel(str(total_words)),
)
# Unique tag names
form.addRow(
strings._("stats_unique_tags"),
QLabel(str(unique_tags)),
)
root.addWidget(pages_group)
if page_most_tags:
form.addRow(
strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
# --- Tags ---------------------------------------------------------
if self.cfg.tags:
tags_group = QGroupBox(strings._("stats_group_tags"))
tags_form = QFormLayout(tags_group)
tags_form.addRow(
strings._("stats_unique_tags"),
QLabel(str(unique_tags)),
)
else:
form.addRow(strings._("stats_page_most_tags"), QLabel(""))
# --- Heatmap with switcher ---------------------------------------
if words_by_date or revisions_by_date:
if page_most_tags:
tags_form.addRow(
strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
)
else:
tags_form.addRow(
strings._("stats_page_most_tags"),
QLabel(""),
)
root.addWidget(tags_group)
# --- Documents ----------------------------------------------------
if self.cfg.documents:
docs_group = QGroupBox(strings._("stats_group_documents"))
docs_form = QFormLayout(docs_group)
docs_form.addRow(
strings._("stats_total_documents"),
QLabel(str(total_documents)),
)
if date_most_documents:
doc_most_label = (
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
)
else:
doc_most_label = ""
docs_form.addRow(
strings._("stats_date_most_documents"),
QLabel(doc_most_label),
)
root.addWidget(docs_group)
# --- Time logging -------------------------------------------------
if self.cfg.time_log:
time_group = QGroupBox(strings._("stats_group_time_logging"))
time_form = QFormLayout(time_group)
total_hours = total_time_minutes / 60.0 if total_time_minutes else 0.0
time_form.addRow(
strings._("stats_time_total_hours"),
QLabel(f"{total_hours:.2f}h"),
)
if day_most_time:
day_hours = (
day_most_time_minutes / 60.0 if day_most_time_minutes else 0.0
)
day_label = f"{day_most_time} ({day_hours:.2f}h)"
else:
day_label = ""
time_form.addRow(
strings._("stats_time_day_most_hours"),
QLabel(day_label),
)
if project_most_minutes_name:
proj_hours = (
project_most_minutes / 60.0 if project_most_minutes else 0.0
)
proj_label = f"{project_most_minutes_name} ({proj_hours:.2f}h)"
else:
proj_label = ""
time_form.addRow(
strings._("stats_time_project_most_hours"),
QLabel(proj_label),
)
if activity_most_minutes_name:
act_hours = (
activity_most_minutes / 60.0 if activity_most_minutes else 0.0
)
act_label = f"{activity_most_minutes_name} ({act_hours:.2f}h)"
else:
act_label = ""
time_form.addRow(
strings._("stats_time_activity_most_hours"),
QLabel(act_label),
)
root.addWidget(time_group)
# --- Reminders ----------------------------------------------------
if self.cfg.reminders:
rem_group = QGroupBox(strings._("stats_group_reminders"))
rem_form = QFormLayout(rem_group)
rem_form.addRow(
strings._("stats_total_reminders"),
QLabel(str(total_reminders)),
)
if day_most_reminders:
rem_label = f"{day_most_reminders} ({day_most_reminders_count})"
else:
rem_label = ""
rem_form.addRow(
strings._("stats_date_most_reminders"),
QLabel(rem_label),
)
root.addWidget(rem_group)
# ------------------------------------------------------------------
# Heatmap with metric switcher
# ------------------------------------------------------------------
if (
words_by_date
or revisions_by_date
or documents_by_date
or time_minutes_by_date
or reminders_by_date
):
group = QGroupBox(strings._("stats_activity_heatmap"))
group_layout = QVBoxLayout(group)
@ -315,14 +478,30 @@ class StatisticsDialog(QDialog):
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
self.metric_combo = QComboBox()
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
self.metric_combo.addItem(
strings._("stats_metric_revisions"),
"revisions",
)
if documents_by_date:
self.metric_combo.addItem(
strings._("stats_metric_documents"),
"documents",
)
if self.cfg.time_log and time_minutes_by_date:
self.metric_combo.addItem(
strings._("stats_metric_hours"),
"hours",
)
if self.cfg.reminders and reminders_by_date:
self.metric_combo.addItem(
strings._("stats_metric_reminders"),
"reminders",
)
combo_row.addWidget(self.metric_combo)
combo_row.addStretch(1)
group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap()
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@ -339,11 +518,19 @@ class StatisticsDialog(QDialog):
else:
root.addWidget(QLabel(strings._("stats_no_data")))
self.resize(self.sizeHint().width(), self.sizeHint().height())
# ---------- internal helpers ----------
def _apply_metric(self, metric: str) -> None:
if metric == "revisions":
self._heatmap.set_data(self._revisions_by_date)
elif metric == "documents":
self._heatmap.set_data(self._documents_by_date)
elif metric == "hours":
self._heatmap.set_data(self._time_by_date)
elif metric == "reminders":
self._heatmap.set_data(self._reminders_by_date)
else:
self._heatmap.set_data(self._words_by_date)

View file

@ -1,5 +1,5 @@
from importlib.resources import files
import json
from importlib.resources import files
# Get list of locales
root = files("bouquin") / "locales"

View file

@ -1,21 +1,22 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QColorDialog,
QDialog,
QVBoxLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
QLabel,
QColorDialog,
QMessageBox,
QInputDialog,
QVBoxLayout,
)
from sqlcipher4.dbapi2 import IntegrityError
from .db import DBManager
from . import strings
from sqlcipher3.dbapi2 import IntegrityError
from .db import DBManager
from .settings import load_db_config
class TagBrowserDialog(QDialog):
@ -25,6 +26,7 @@ class TagBrowserDialog(QDialog):
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
super().__init__(parent)
self._db = db
self.cfg = load_db_config()
self.setWindowTitle(
strings._("tag_browser_title") + " / " + strings._("manage_tags")
)
@ -38,9 +40,18 @@ class TagBrowserDialog(QDialog):
layout.addWidget(instructions)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
if not self.cfg.documents:
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
else:
self.tree.setHeaderLabels(
[
strings._("tag"),
strings._("color_hex"),
strings._("page_or_document"),
]
)
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 100)
self.tree.itemActivated.connect(self._on_item_activated)
@ -119,6 +130,7 @@ class TagBrowserDialog(QDialog):
self.tree.addTopLevelItem(root)
# Pages with this tag
pages = self._db.get_pages_for_tag(name)
for date_iso, _content in pages:
child = QTreeWidgetItem(["", "", date_iso])
@ -127,6 +139,21 @@ class TagBrowserDialog(QDialog):
)
root.addChild(child)
# Documents with this tag
if self.cfg.documents:
docs = self._db.get_documents_for_tag(name)
for doc_id, project_name, file_name in docs:
label = file_name
if project_name:
label = f"{file_name} ({project_name})"
child = QTreeWidgetItem(["", "", label])
child.setData(
0,
Qt.ItemDataRole.UserRole,
{"type": "document", "id": doc_id},
)
root.addChild(child)
if focus_tag and name.lower() == focus_tag.lower():
focus_item = root
@ -153,12 +180,25 @@ class TagBrowserDialog(QDialog):
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "page":
item_type = data.get("type")
if item_type == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
self.accept()
elif item_type == "document":
doc_id = data.get("id")
if doc_id is not None:
self._open_document(int(doc_id), str(data.get("file_name")))
def _open_document(self, doc_id: int, file_name: str) -> None:
"""Open a tagged document from the list."""
from bouquin.document_utils import open_document_from_db
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
def _add_a_tag(self):
"""Add a new tag"""

View file

@ -4,16 +4,16 @@ from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCompleter,
QFrame,
QHBoxLayout,
QVBoxLayout,
QWidget,
QToolButton,
QLabel,
QLineEdit,
QSizePolicy,
QStyle,
QCompleter,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings

View file

@ -1,11 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from PySide6.QtGui import QPalette, QColor, QGuiApplication
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from PySide6.QtCore import QObject, Signal
from weakref import WeakSet
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
class Theme(Enum):
SYSTEM = "system"
@ -174,6 +176,14 @@ class ThemeManager(QObject):
cal.setPalette(app_pal)
cal.setStyleSheet("")
# --- Normalise weekend colours on *all* themed calendars -------------
# Qt's default is red for weekends; we want them to match normal text.
weekday_color = app_pal.windowText().color()
weekend_fmt = QTextCharFormat()
weekend_fmt.setForeground(weekday_color)
cal.setWeekdayTextFormat(Qt.Saturday, weekend_fmt)
cal.setWeekdayTextFormat(Qt.Sunday, weekend_fmt)
cal.update()
def _calendar_qss(self, highlight_css: str) -> str:

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence
from PySide6.QtWidgets import QToolBar
from . import strings
@ -20,6 +20,7 @@ class ToolBar(QToolBar):
insertImageRequested = Signal()
alarmRequested = Signal()
timerRequested = Signal()
documentsRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
@ -95,8 +96,9 @@ class ToolBar(QToolBar):
self.actNumbers.setToolTip(strings._("toolbar_numbered_list"))
self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self)
self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
self.actCheckboxes.setCheckable(True)
self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images
@ -106,7 +108,7 @@ class ToolBar(QToolBar):
self.actInsertImg.triggered.connect(self.insertImageRequested)
# History button
self.actHistory = QAction("🔁", self)
self.actHistory = QAction("", self)
self.actHistory.setToolTip(strings._("history"))
self.actHistory.triggered.connect(self.historyRequested)
@ -118,23 +120,21 @@ class ToolBar(QToolBar):
# Focus timer
self.actTimer = QAction("", self)
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
self.actTimer.setCheckable(True)
self.actTimer.triggered.connect(self.timerRequested)
# Set exclusive buttons in QActionGroups
# Documents
self.actDocuments = QAction("📁", self)
self.actDocuments.setToolTip(strings._("toolbar_documents"))
self.actDocuments.triggered.connect(self.documentsRequested)
# Headings are mutually exclusive (like radio buttons)
self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True)
for a in (
self.actBold,
self.actItalic,
self.actStrike,
self.actH1,
self.actH2,
self.actH3,
self.actNormal,
):
for a in (self.actH1, self.actH2, self.actH3, self.actNormal):
a.setCheckable(True)
a.setActionGroup(self.grpHeadings)
# List types are mutually exclusive
self.grpLists = QActionGroup(self)
self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
@ -159,6 +159,7 @@ class ToolBar(QToolBar):
self.actInsertImg,
self.actAlarm,
self.actTimer,
self.actDocuments,
self.actHistory,
]
)
@ -182,12 +183,13 @@ class ToolBar(QToolBar):
# Lists
self._style_letter_button(self.actBullets, "")
self._style_letter_button(self.actNumbers, "1.")
self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actTimer, "")
self._style_letter_button(self.actDocuments, "📁")
# History
self._style_letter_button(self.actHistory, "🔁")
self._style_letter_button(self.actHistory, "")
def _style_letter_button(
self,

View file

@ -5,23 +5,17 @@ import os
import re
import subprocess # nosec
import tempfile
from importlib.resources import files
from pathlib import Path
import requests
from importlib.resources import files
from PySide6.QtCore import QStandardPaths, Qt
from PySide6.QtWidgets import (
QApplication,
QMessageBox,
QWidget,
QProgressDialog,
)
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
from .settings import APP_NAME
from . import strings
from .settings import APP_NAME
# Where to fetch the latest version string from
VERSION_URL = "https://mig5.net/bouquin/version.txt"
@ -101,6 +95,9 @@ class VersionChecker:
"""
return self._parse_version(available) > self._parse_version(current)
def _running_in_appimage(self) -> bool:
return "APPIMAGE" in os.environ
# ---------- Public entrypoint for Help → Version ---------- #
def show_version_dialog(self) -> None:
@ -120,8 +117,8 @@ class VersionChecker:
check_button = box.addButton(
strings._("check_for_updates"), QMessageBox.ActionRole
)
box.addButton(QMessageBox.Close)
box.addButton(QMessageBox.Close)
box.exec()
if box.clickedButton() is check_button:
@ -165,21 +162,32 @@ class VersionChecker:
return
# Newer version is available
reply = QMessageBox.question(
self._parent,
strings._("update"),
(
strings._("there_is_a_new_version_available")
+ available_raw
+ "\n\n"
+ strings._("download_the_appimage")
),
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._download_and_verify_appimage(available_raw)
if self._running_in_appimage():
# If running in an AppImage, offer to download the new AppImage
reply = QMessageBox.question(
self._parent,
strings._("update"),
(
strings._("there_is_a_new_version_available")
+ available_raw
+ "\n\n"
+ strings._("download_the_appimage")
),
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._download_and_verify_appimage(available_raw)
else:
# If not running in an AppImage, just report that there's a new version.
QMessageBox.information(
self._parent,
strings._("update"),
(strings._("there_is_a_new_version_available") + available_raw),
)
return
# ---------- Download + verification helpers ---------- #
def _download_file(

13
debian/bouquin.desktop vendored Normal file
View file

@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Name=Bouquin
GenericName=Journal
Comment=Daily planner with calendar, keyword searching, version control, time logging and document management
Exec=bouquin
Icon=bouquin
Terminal=false
Type=Application
Categories=Office;Calendar;
Keywords=Journal;Diary;Notes;Notebook;
StartupNotify=true
X-GNOME-Gettext-Domain=bouquin

2
debian/bouquin.install vendored Normal file
View file

@ -0,0 +1,2 @@
debian/bouquin.desktop usr/share/applications/
bouquin/icons/bouquin.svg usr/share/icons/hicolor/scalable/apps/

45
debian/changelog vendored Normal file
View file

@ -0,0 +1,45 @@
bouquin (0.8.3) unstable; urgency=medium
* Update urllib3 dependency to resolve CVE-2026-21441
* Fix carrying over data to next day from over-capturing data belonging to next header section
* Other dependency updates
-- Miguel Jacq <mig@mig5.net> Fri, 30 Jan 2026 16:48:00 +1100
bouquin (0.8.2) unstable; urgency=medium
* Add ability to delete an invoice via 'Manage Invoices' dialog
-- Miguel Jacq <mig@mig5.net> Wed, 31 Dec 2025 16:00:00 +1100
bouquin (0.8.1) unstable; urgency=medium
* Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
* Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
-- Miguel Jacq <mig@mig5.net> Tue, 26 Dec 2025 18:00:00 +1100
bouquin (0.8.0) unstable; urgency=medium
* Add .desktop file for Debian
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down)
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
* Add ability to collapse/expand sections of text.
* Add 'Last Month' date range for timesheet reports
* Add missing strings (for English and French)
* Don't offer to download latest AppImage unless we are running as an AppImage already
-- Miguel Jacq <mig@mig5.net> Tue, 23 Dec 2025 17:30:00 +1100
bouquin (0.7.5) unstable; urgency=medium
* Add libxcb-cursor0 dependency
-- Miguel Jacq <mig@mig5.net> Sun, 21 Dec 2025 15:30:00 +1100
bouquin (0.7.4) unstable; urgency=medium
* Initial build for Debian
-- Miguel Jacq <mig@mig5.net> Sun, 21 Dec 2025 13:00:00 +1100

39
debian/control vendored Normal file
View file

@ -0,0 +1,39 @@
Source: bouquin
Section: office
Priority: optional
Maintainer: Miguel Jacq <mig@mig5.net>
Rules-Requires-Root: no
Build-Depends:
debhelper-compat (= 13),
dh-python,
pybuild-plugin-pyproject,
python3-poetry-core,
python3-all,
python3-sqlcipher4,
python3-pyside6.qtwidgets,
python3-pyside6.qtcore,
python3-pyside6.qtgui,
python3-pyside6.qtsvg,
python3-pyside6.qtprintsupport,
python3-requests,
python3-markdown,
libxcb-cursor0,
fonts-noto-core
Standards-Version: 4.6.2
Homepage: https://git.mig5.net/mig5/bouquin
Package: bouquin
Architecture: amd64
Depends: ${misc:Depends}, ${python3:Depends},
python3-pyside6.qtwidgets,
python3-pyside6.qtcore,
python3-pyside6.qtgui,
python3-pyside6.qtsvg,
python3-pyside6.qtprintsupport,
python3-sqlcipher4,
python3-requests,
python3-markdown,
libxcb-cursor0,
fonts-noto-core
Description: A simple, opinionated notebook application written in Python, PyQt and SQLCipher.
Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.

6
debian/rules vendored Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/make -f
export PYBUILD_NAME=bouquin
export PYBUILD_SYSTEM=pyproject
%:
dh $@ --with python3 --buildsystem=pybuild

1
debian/source/format vendored Normal file
View file

@ -0,0 +1 @@
3.0 (quilt)

6
debian/source/options vendored Normal file
View file

@ -0,0 +1,6 @@
tar-ignore = ".git"
tar-ignore = ".venv"
tar-ignore = "__pycache__"
tar-ignore = ".pytest_cache"
tar-ignore = "dist"
tar-ignore = "build"

526
poetry.lock generated
View file

@ -1,14 +1,26 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "bouquin-sqlcipher4"
version = "4.12.0"
description = "DB-API 2.0 interface for SQLCipher 4.x, for use with Bouquin"
optional = false
python-versions = "<4.0,>=3.10"
files = [
{file = "bouquin_sqlcipher4-4.12.0-cp313-cp313-manylinux_2_41_x86_64.whl", hash = "sha256:75e8bcee58301f0d5d81d293e7cf930f1cab172984c0edb82328d271f03ed172"},
{file = "bouquin_sqlcipher4-4.12.0-py3-none-any.whl", hash = "sha256:140c009acfcbbeb315d6eb3bd4aaedffd37176ce1cec04341c7cc3c5330744ec"},
{file = "bouquin_sqlcipher4-4.12.0.tar.gz", hash = "sha256:2e7bc0a5fe84b26474fde99e9ad0d96e91eb984313c6695ae1d48daba3c96f07"},
]
[[package]]
name = "certifi"
version = "2025.11.12"
version = "2026.1.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
{file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
]
[[package]]
@ -146,103 +158,103 @@ files = [
[[package]]
name = "coverage"
version = "7.12.0"
version = "7.13.2"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
files = [
{file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
{file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
{file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
{file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
{file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
{file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
{file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
{file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
{file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
{file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
{file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
{file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
{file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
{file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
{file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
{file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
{file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
{file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
{file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
{file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
{file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
{file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
{file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
{file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
{file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
{file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
{file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
{file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
{file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
{file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
{file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
{file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
{file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
{file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
{file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
{file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
{file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
{file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
{file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
{file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
{file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
{file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
{file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
{file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
{file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
{file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
{file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
{file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
{file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
{file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
{file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
{file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
{file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
{file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
{file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
{file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
{file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"},
{file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"},
{file = "coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"},
{file = "coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"},
{file = "coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"},
{file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"},
{file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"},
{file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"},
{file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"},
{file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"},
{file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"},
{file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"},
{file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"},
{file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"},
{file = "coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"},
{file = "coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"},
{file = "coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"},
{file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"},
{file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"},
{file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"},
{file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"},
{file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"},
{file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"},
{file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"},
{file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"},
{file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"},
{file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"},
{file = "coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"},
{file = "coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"},
{file = "coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"},
{file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"},
{file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"},
{file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"},
{file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"},
{file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"},
{file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"},
{file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"},
{file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"},
{file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"},
{file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"},
{file = "coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"},
{file = "coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"},
{file = "coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"},
{file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"},
{file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"},
{file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"},
{file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"},
{file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"},
{file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"},
{file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"},
{file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"},
{file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"},
{file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"},
{file = "coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"},
{file = "coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"},
{file = "coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"},
{file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"},
{file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"},
{file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"},
{file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"},
{file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"},
{file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"},
{file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"},
{file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"},
{file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"},
{file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"},
{file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"},
{file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"},
{file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"},
{file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"},
{file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"},
{file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"},
{file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"},
{file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"},
{file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"},
{file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"},
{file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"},
{file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"},
{file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"},
{file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"},
{file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"},
{file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"},
{file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"},
{file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"},
{file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"},
{file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"},
{file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"},
{file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"},
{file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"},
{file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"},
{file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"},
{file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"},
]
[package.dependencies]
@ -267,13 +279,13 @@ xdg-desktop-portal = ["jeepney"]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
version = "1.3.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
]
[package.dependencies]
@ -309,28 +321,28 @@ files = [
[[package]]
name = "markdown"
version = "3.10"
version = "3.10.1"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.10"
files = [
{file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"},
{file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"},
{file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"},
{file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "packaging"
version = "25.0"
version = "26.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
]
[[package]]
@ -380,57 +392,57 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6"
version = "6.10.0"
version = "6.10.1"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.14,>=3.9"
python-versions = "<3.15,>=3.9"
files = [
{file = "pyside6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"},
{file = "pyside6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01"},
{file = "pyside6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820"},
{file = "pyside6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f"},
{file = "pyside6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339"},
{file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"},
{file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"},
{file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"},
{file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"},
{file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"},
]
[package.dependencies]
PySide6_Addons = "6.10.0"
PySide6_Essentials = "6.10.0"
shiboken6 = "6.10.0"
PySide6_Addons = "6.10.1"
PySide6_Essentials = "6.10.1"
shiboken6 = "6.10.1"
[[package]]
name = "pyside6-addons"
version = "6.10.0"
version = "6.10.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.14,>=3.9"
python-versions = "<3.15,>=3.9"
files = [
{file = "pyside6_addons-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c"},
{file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464"},
{file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0"},
{file = "pyside6_addons-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"},
{file = "pyside6_addons-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1"},
{file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"},
{file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"},
{file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"},
{file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"},
{file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"},
]
[package.dependencies]
PySide6_Essentials = "6.10.0"
shiboken6 = "6.10.0"
PySide6_Essentials = "6.10.1"
shiboken6 = "6.10.1"
[[package]]
name = "pyside6-essentials"
version = "6.10.0"
version = "6.10.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.14,>=3.9"
python-versions = "<3.15,>=3.9"
files = [
{file = "pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a"},
{file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8"},
{file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998"},
{file = "pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"},
{file = "pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1"},
{file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"},
{file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"},
{file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"},
{file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"},
{file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"},
]
[package.dependencies]
shiboken6 = "6.10.0"
shiboken6 = "6.10.1"
[[package]]
name = "pytest"
@ -534,198 +546,72 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "shiboken6"
version = "6.10.0"
version = "6.10.1"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.14,>=3.9"
python-versions = "<3.15,>=3.9"
files = [
{file = "shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543"},
{file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"},
{file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148"},
{file = "shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717"},
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"},
]
[[package]]
name = "sqlcipher3-wheels"
version = "0.5.5.post0"
description = "DB-API 2.0 interface for SQLCipher 3.x"
optional = false
python-versions = "*"
files = [
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:836cff85673ab9bdfe0f3e2bc38aefddb5f3a4c0de397b92f83546bb94ea38aa"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3fde9076a8810d19044f65fdfeeee5a9d044176ce91adc2258c8b18cb945474"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ad3ccb27f3fc9260b1bcebfd33fc5af1c2a1bf6a50e8e1bf7991d492458b438"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94bb8ab8cf7ae3dc0d51dcb75bf242ae4bd2f18549bfc975fd696c181e9ea8ca"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0bf0a169b480615ea2021e7266e1154990762216d1fd8105b93d1fee336f49"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79cc1af145345e9bd625c961e4efc8fc6c6eefcaec90fbcf1c6b981492c08031"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d8b9f1c6d283acc5a0da16574c0f7690ba5b14cb5935f3078ccf8404a530075"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:952a23069a149a192a5eb8a9e552772b38c012825238175bc810f445a3aa8000"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24f1a57a4aa18d9ecd38cfce69dd06e58cfb521151a8316e18183e603e7108f4"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6568c64adc55f9882ba36c11a446810bd5d4c03796aab8ecb9024f3bca9eb2cd"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26c2b58d2f2a9dd23ad4c310fb6c0f0c82ca4f36a0d4177a70f0efeb332798ee"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46827ffc7e705c5ecdf23ec69f56dd55b20857dc3c3c4893e360de8a38b4e708"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4033bbe2f0342936736ce7b8b2626f532509315576d5376764b410deae181cad"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win32.whl", hash = "sha256:bfb26dbba945860427bd3f82c132e6d2ef409baa062d315b952dd5a930b25870"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_amd64.whl", hash = "sha256:168270b8fb295314aa4ee9f74435ceee42207bd16fe908f646a829b3a9daedad"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_arm64.whl", hash = "sha256:1f1bb2c4c6defa812eb0238055a283cf3c2f400e956a57c25cf65cbdbac6783f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:55d557376a90f14baf0f35e917f8644c3a8cf48897947fcd7ecf51d490dd689f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1739264a971901088fe1670befb8a8a707543186c8eecc58158ca287e309b2"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:920e19345b6c5335b61e9fbed2625f96dbc3b0269ab5120baeae2c9288f0be01"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462734f6d0703f863f5968419d229de75bbf2a829f762bfb257b6df2355f977"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c397305b65612da76f254c692ff866571aa98fd3817ed0e40fce6d568d704966"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf3467fe834075b58215c50f9db7355ef86a73d256ac8ba5fffb8c946741a5dc"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a76d783c095a3c95185757c418e3bad3eab69cbf986970d422cce5431e84d7f5"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf8d78895ee0f04dc525942a1f40796fa7c3d7d7fb36c987f55c243ce34192d"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d583a10dbe9a1752968788c2d6438461ec7068608ceaa72e6468d80727c3152e"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8c3156b39bb8f24dfbe17a49126d8fa404b00c01d7aa84e64a2293db1dae1a38"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:15c3cf77b31973aa008174518fa439d8620a093964c2d9edcb8d23d543345839"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5743db0f3492359c2ab3a56b6bed00ecba193f2c75c74e8e3d78a45f1eb7c95"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc40a213a3633f19c96432304a16f0cff7c4aeca1a3d2042d4be36e576e64a70"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win32.whl", hash = "sha256:433456ce962ae50887d6428d55bad46e5748a2cdd3d036180eb0bcdbe8bae9f9"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_amd64.whl", hash = "sha256:ca4332b1890cc4f80587be8bd529e20475bd3291e07f11115b1fc773947b264a"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_arm64.whl", hash = "sha256:a4634300cb2440baf17a78d6481d10902de4a2a6518f83a5ab2fe081e6b20b42"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8df43c11d767c6aac5cc300c1957356a9fd1b25f1946891003cf20a0146241"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:797653f08ecffcef2948dfd907fb20dab402d9efde6217c96befafc236e96c5b"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dca428fde0d1a522473f766465324d6539d324f219f4f7c159a3eb0d4f9983c5"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e97922240a04b44637eabf39f86d243fe61fe7db1bd2ad219eb4053158f263"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8d3a366e52a6732b1ccff14f9ca77ecbee53abfce87c417bf05d4301484584f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dce28a2431260251d7acf253ea1950983e48dfec64245126b39a770d5a88f507"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea64cce27152cae453c353038336bda0dc1f885e5e8e30b5cd28b8c9b498bbeb"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02d9e6120a496f083c525efc34408d4f2ca282da05bebcc967a0aa1e12a0d6ca"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:875cfc61bbf694b8327c2485e5ed40573e8b715f4e583502f12c51c8d5a92dd5"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0e9ed3ff9c00ba3888f8dbc0c7c84377ef66f21c5f4ac373fc690dcf5e9bd594"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08ad6d767502429e497b6d03b5ae11e43e896d36f05ac8e60c12d8f124378bc1"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecdefdcf7ab8cb14b3147a59af83e8e3e5e3bed46fc43ab86a657f5c306a83d2"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75ffe5677407bf20a32486eb6facfbb07a353ce7c9aecc9fefd4e9d3275605d7"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win32.whl", hash = "sha256:97b6c6556b430b5c0dff53e8f709f90ba53294c2a3958a8c38f573c6dbf467d9"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_amd64.whl", hash = "sha256:248cae211991f1ffb3a885a1223e62abee70c6c208fc2224a8dbf73d4e825baa"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_arm64.whl", hash = "sha256:5a49fc3a859a53fd025dc2fa08410292d267018897fc63198de6c92860fa8be7"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f798e1591fa5ba14d9da08a54f18e7000fd74973cde12eb862a3928a69b7996"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:369011b8dc68741313a8b77bb68a70b76052390eaf819e4cd6e13d0acbea602d"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5658990462a728c1f4b472d23c1f7f577eb2bced5bbbf7c2b45158b8340484bd"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907a166e4e563da67fe22c480244459512e32d3e00853b3f1e6fdb9da6aa2da6"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ba972405e9f16042e37cbcb4fef47248339c8410847390d41268bd45dc3f6ca"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5f1b380fe3b869f701f9d2a8c09e9edfeec261573c8bb009a3336717260d65"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2978c9d964ad643b0bc61e19d8d608a515ff270e9a2f1f6c5aeb8ad56255def"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29457feb1516a2542aa7676e6d03bf913191690bf1ed6c82353782a380388508"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:021af568414741a45bfca41d682da64916a7873498a31d896cc34ad540939c6b"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7471d12eef489eea60cc3806bae0690f6d0733f7aea371a3ad5c5642f3bc04a9"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f5faf683db9ade192a870e28b1eeeec2eb0aeca18e88fa52195a4639974c7cb"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:64fe6bac67d2b807b91102eef41d7f389e008ded80575ba597b454e05f9522e5"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63ef1e23eb9729e79783e2ab4b19f64276c155ba0a85ba1eeb21e248c6ce0956"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win32.whl", hash = "sha256:4eafde00138dd3753085b4f5eab0811247207b699de862589f886a94ad3628a4"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_amd64.whl", hash = "sha256:909864f275460646d0bf5475dc42e9c2cadd79cd40805ea32fe9a69300595301"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_arm64.whl", hash = "sha256:a831846cc6b01d7f99576efbf797b61a269dffa6885f530b6957573ce1a24f10"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:aaad03f3eb099401306fead806908c85b923064a9da7a99c33a72c3b7c9143bf"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d4b4cde184d9e354152fd1867bcbaee468529865703ad863840a0ce4eb60cd"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac3ad3cf9e1d0f08e8d22a65115368f2b22b9e96403fa644e146e1221c65c454"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c4d3dca4acdbc5543bb00aee1e0715db797aa2819db5b7ca3feed3ab3366ff"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf7026851ea60d63c1a88f62439da78b68bfbfec192c781255e3cfb34b6efc12"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ae4a83678c41c2cdbf3c2b18fc46be32225260c7b4807087bdb43793ee90fa"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:678c67d60b35eced29777fe9398b6e6a6638156f143c80662a0c7c99ce115be7"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:9830af5aef2c17686d6e7c78c20b92c7b57c9d7921a03e4c549b48fe0e98c5c0"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:08651e17c868a6a22124b6ab71e939a5bb4737e0535f381ce35077dc8116c4b3"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:b58b4c944d7ce20cd7b426ae8834433b5b1152391960960b778b37803f0ffc1c"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2b38818468ddb0c8fc4b172031d65ced3be22ba82360c45909a0546b2857d3e4"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win32.whl", hash = "sha256:91d1f2284d13b68f213d05b51cd6242f4cfa065d291af6f353f9cbedd28d8c0d"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:a5c724f95366ba7a2895147b0690609b6774384fa8621aa46a66cf332e4b612f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e37b33263fad4accdba45c8566566d45fc01f47fd4afa3e265df9e0e3581d4f4"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f1e29495bc968e37352c315d23a38462d7e77fcfa1597d005d17ed93f9f3103"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad8a774f545eb5471587e0389fca4f855f36d58901c65547796d59fc13aee458"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a5a0251e4ceca127b26d18f0965b7f3c820a2dd2c51c25015c819300fd5859"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e40535f0f57e8b605e1cbce1399c96bcd5ab99e60992d2c7669c689d0cbe5"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e04e1dd62d019cde936d18fcd21361f6c4695e0e73fd6dc509c4ccd9446d26d"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2df377e3d04f5c427c9f79ef95bdf0b982bde76c1dbd4441f83268f3f1993a53"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:5f3cb8db19e7462ccb2e34b56feaccb2aac675ad8f77e28f8222b3e7c47d1f92"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:b40860b3e6d6108473836a29d3600e1b335192637e16e9421b43b58147ced3c1"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:ca4fd9e8b669e81bb74479bde61ee475d7a6832d667e2ce80e6136ddd7a0fedd"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:771e74a22f48c40b6402d0ca1d569ced5a796e118d4472da388744b5aa0ebd3f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win32.whl", hash = "sha256:4589bfca18ecf787598262327f7329fe1f4fc2655c04899d84451562e2099a57"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:f646ab958a601bad8925a876f5aa68bdf0ec3584630143ed1ad8e9df4e447044"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dfb8106a05af1cb1eadeea996171b52c80f18851972e49ffe91539e4fc064b0f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b8b9b77a898b721fc634858fc43552119d3d303485adc6f28f3e76f028d5ea04"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c65efab1a0ab14f75314039694ac35d3181a5c8cf43584bd537b36caf2a6ccf9"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b450eee7e201c48aae58e2d45ef5d309a19cd49952cfb58d546fefbeef0a100"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8f0997202d7628c4312f0398122bdc5ada7fa79939d248652af40d9da689ef8"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae69bef7628236d426e408fb14a40f0027bac1658a06efd29549b26ba369372"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef48e874bcc3ebf623672ec99f9aaa7b8a4f62fb270e33dad6db3739ea111086"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9006dc1a73e2b2a53421aa72decbcff08cb109f67a20f7d15a64ab140e0a1d2"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:96c07a345740fa71c0d8fc5fa7ea182ee24f62ebbf33d4d10c8c72d866dc332d"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:85a78af6f6e782e0df36f93c9e7c2dd568204f60a2ea55025c21d1837dea95ec"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:19eadc55bf69f9e9799a808cdcfc6657cf30511cb32849235d555adfa048a99f"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:377e8ad3bb3c17c43f860b570fd15e048246ade92babc9b310f2c417500aca57"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f8e07aec529d6fa31516201c524b0cfac108a9a6044a148f236291aae7991195"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win32.whl", hash = "sha256:703ab55b77b1c1ebb80eb0b27574a8eadf109739d252de7f646dc41cb82f1a65"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win_amd64.whl", hash = "sha256:b4f4b2e019c6d1ad33d5fc3163d31d0f731a073a5a52cdfae7b85408548ce342"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a84e3b098a29b8c298b01291cf8bc850a507ca45507d43674a84a8d33b7595b2"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9905b580cfdbd6945e44d81332483deace167d33e956ffae5c4b27eddeb676e7"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c403a7418631dc7185ef8053acc765101f4f64cc0bf50d1bc44ae7d40fc28e"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d63f89bf28de4ce82a7c324275ce733bf31eb29ec1121e48261af89b5b7f30b"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc6dc782f5be4883279079c79fa88578258a0fd24651f6d69b0f4be2716f7d7e"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743c177822c60e66c5c9579b4f384bd98e60fd4a2abe0eacdec0af4747d925bc"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5eeb87220e4d2abf6faad1ecb3b3ee88c4d9caad6cf2ce4c0a73a91c4c7ad9"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9318b814363b4bc062e54852ea62f58b69e7da9e51211afd6c55e9170e1ae9a0"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7a1f58a2614f2ad9fcb4822f6da56313cbb88309880512bf5d01bd3d9142b87"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ae9427ddde670f605c84f704c12d840628467cc0f0a8e9ce6489577eef6a0479"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:be75ae311433254af3c6fe6eb68bf80ac6ef547ec0cf4594f5b301a044682186"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:73e1438437bbe67d453e2908b90b17b357a992a9ac0011ad20af1ea7c2b4cd58"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:393c0a9af080c1c1a0801cca9448eff3633dafc1a7934fdce58a8d1c15d8bd2b"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win32.whl", hash = "sha256:13a79fc8e9b69bf6d70e7fa5a53bd42fab83dc3e6e93da7fa82871ec40874e43"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_amd64.whl", hash = "sha256:b1648708e5bf599b4455bf330faa4777c3963669acb2f3fa25240123a01f8b2b"},
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_arm64.whl", hash = "sha256:4c3dd2f54bdd518b90e28b07c31cdfe34c8bd182c5107a30a9c2ef9569cd6cf9"},
{file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"},
{file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"},
{file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"},
{file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"},
{file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"},
{file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"},
]
[[package]]
name = "tomli"
version = "2.3.0"
version = "2.4.0"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
{file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"},
{file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"},
{file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"},
{file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"},
{file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"},
{file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"},
{file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"},
{file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"},
{file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"},
{file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"},
{file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"},
{file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"},
{file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"},
{file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"},
{file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"},
{file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"},
{file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"},
{file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"},
{file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"},
{file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"},
{file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"},
{file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"},
{file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"},
{file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"},
{file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"},
{file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"},
{file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"},
{file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"},
{file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"},
{file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"},
{file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"},
{file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"},
{file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"},
{file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"},
{file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"},
{file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"},
{file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"},
{file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"},
{file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"},
{file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"},
{file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"},
{file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"},
{file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"},
{file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"},
{file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"},
{file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"},
{file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"},
]
[[package]]
@ -741,22 +627,22 @@ files = [
[[package]]
name = "urllib3"
version = "2.5.0"
version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
zstd = ["backports-zstd (>=1.0.0)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.14"
content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df"
content-hash = "0ce61476b8c35f864d395ab3a4ee4645a1ad2e16aa800cba5e77e5bfee23d6a6"

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.5.2"
version = "0.8.3"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"
@ -12,9 +12,9 @@ include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/Not
[tool.poetry.dependencies]
python = ">=3.10,<3.14"
pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0"
requests = "^2.32.5"
markdown = "^3.10"
bouquin-sqlcipher4 = "^4.12.0"
requests = "^2.32.3"
markdown = "^3.7"
[tool.poetry.scripts]
bouquin = "bouquin.__main__:main"

View file

@ -2,18 +2,117 @@
set -eo pipefail
rm -rf dist
# Parse the args
while getopts "v:" OPTION
do
case $OPTION in
v)
VERSION=$OPTARG
;;
?)
usage
exit
;;
esac
done
if [[ -z "${VERSION}" ]]; then
echo "You forgot to pass -v [version]!"
exit 1
fi
set +e
sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml
git add pyproject.toml
git commit -m "Bump to ${VERSION}"
git push origin main
set -e
# Clean caches etc
filedust -y .
# Publish to Pypi
poetry build
poetry publish
# Make AppImage
sudo apt-get install libfuse-dev
sudo apt-get -y install libfuse-dev
poetry run pyproject-appimage
mv Bouquin.AppImage dist/
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
echo "Don't forget to update version string on remote server."
# Deb stuff
DISTS=(
debian:trixie
)
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
mkdir -p dist/${release}
docker build -f Dockerfile.debbuild -t bouquin-deb:${release} \
--no-cache \
--progress=plain \
--build-arg BASE_IMAGE=${dist} .
docker run --rm \
-e SUITE="${release}" \
-v "$PWD":/src \
-v "$PWD/dist/${release}":/out \
bouquin-deb:${release}
debfile=$(ls -1 dist/${release}/*.deb)
reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}"
done
# RPM
sudo apt-get -y install createrepo-c rpm
BUILD_OUTPUT="${HOME}/git/bouquin/dist"
KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
REPO_ROOT="${HOME}/git/repo_rpm"
REMOTE="letessier.mig5.net:/opt/repo_rpm"
DISTS=(
fedora:42
)
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
REPO_RELEASE_ROOT="${REPO_ROOT}/${release}"
RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64"
mkdir -p "$RPM_REPO"
docker build \
--no-cache \
-f Dockerfile.rpmbuild \
-t bouquin-rpm:${release} \
--progress=plain \
--build-arg BASE_IMAGE=${dist} \
.
docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:${release}
sudo chown -R "${USER}" "$PWD/dist"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file"
done
cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/"
createrepo_c "$RPM_REPO"
echo "==> Signing repomd.xml..."
qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
done
echo "==> Syncing repo to server..."
rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/"
echo "Done!"
ssh lupin.mig5.net "echo ${VERSION} | tee /var/www/bouquin/version.txt"

95
rpm/bouquin.spec Normal file
View file

@ -0,0 +1,95 @@
# bouquin Fedora 42 RPM spec using Fedora's pyproject RPM macros (Poetry backend).
#
# NOTE: Bouquin depends on "bouquin-sqlcipher4" project, but the RPM actually
# provides the Python distribution/module as "sqlcipher4". To keep Fedora's
# auto-generated python3dist() Requires correct, we rewrite the dependency key in
# pyproject.toml at build time.
%global upstream_version 0.8.3
Name: bouquin
Version: %{upstream_version}
Release: 1%{?dist}.bouquin1
Summary: A simple, opinionated notebook application (Python/Qt/SQLCipher)
License: GPL-3.0-or-later
URL: https://git.mig5.net/mig5/bouquin
Source0: %{name}-%{version}.tar.gz
BuildArch: noarch
BuildRequires: pyproject-rpm-macros
BuildRequires: python3-devel
BuildRequires: python3-poetry-core
BuildRequires: desktop-file-utils
# Non-Python runtime dep (Fedora equivalent of Debian's libxcb-cursor0)
Requires: xcb-util-cursor
# Make sure private repo dependency is pulled in by package name as well.
Requires: python3-sqlcipher4 >= 4.12.0
%description
Bouquin is a simple, opinionated notebook application written in Python and Qt,
storing data using SQLCipher.
%prep
%autosetup -n bouquin
# Patch dependency name so Fedora's python dependency generator targets the
# provider from bouquin-sqlcipher4 RPM (python3dist(sqlcipher4)).
%{python3} - <<'PY'
from pathlib import Path
import re
p = Path("pyproject.toml")
txt = p.read_text(encoding="utf-8")
pattern = re.compile(r'(?ms)(^\[tool\.poetry\.dependencies\]\n.*?)(^\[|\Z)')
m = pattern.search(txt)
if not m:
raise SystemExit("Could not locate [tool.poetry.dependencies] in pyproject.toml")
deps_block = m.group(1)
deps_block2 = re.sub(r'(?m)^bouquin-sqlcipher4\s*=\s*(".*?")\s*$', r'sqlcipher4 = \1', deps_block)
if deps_block == deps_block2:
raise SystemExit("Did not find bouquin-sqlcipher4 dependency to rewrite")
p.write_text(txt[:m.start(1)] + deps_block2 + txt[m.end(1):], encoding="utf-8")
PY
desktop-file-validate debian/bouquin.desktop
%generate_buildrequires
%pyproject_buildrequires
%build
%pyproject_wheel
%install
%pyproject_install
%pyproject_save_files bouquin
# Desktop integration (mirrors debian/bouquin.install)
install -Dpm 0644 debian/bouquin.desktop %{buildroot}%{_datadir}/applications/bouquin.desktop
install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
%files -f %{pyproject_files}
%license LICENSE
%doc README.md CHANGELOG.md
%{_bindir}/bouquin
%{_datadir}/applications/bouquin.desktop
%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
%changelog
* Fri Jan 30 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Update urllib3 dependency to resolve CVE-2026-21441
- Fix carrying over data to next day from over-capturing data belonging to next header section
- Other dependency updates
* Wed Dec 31 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Add ability to delete an invoice via 'Manage Invoices' dialog
* Fri Dec 26 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
- Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
* Wed Dec 24 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Initial RPM packaging for Fedora 42

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Before After
Before After

View file

@ -58,3 +58,65 @@ def fresh_db(tmp_db_cfg):
assert ok, "DB connect() should succeed"
yield db
db.close()
@pytest.fixture(autouse=True)
def _stub_code_block_editor_dialog(monkeypatch):
"""
In tests, replace the interactive CodeBlockEditorDialog with a tiny stub
that never shows a real QDialog and never blocks on exec().
"""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog
class _TestCodeBlockEditorDialog:
def __init__(
self, code: str, language: str | None, parent=None, allow_delete=False
):
# Simulate what the real dialog would “start with”
self._code = code
self._language = language
def exec(self) -> int:
# Pretend the user clicked OK immediately.
# (If you prefer “Cancel by default”, return Rejected instead.)
return QDialog.DialogCode.Accepted
def code(self) -> str:
# In tests we just return the initial code unchanged.
return self._code
def language(self) -> str | None:
# Ditto for language.
return self._language
# MarkdownEditor imported CodeBlockEditorDialog into its own module,
# so patch that name everything in MarkdownEditor will use this stub.
monkeypatch.setattr(
markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog
)
# --- Freeze Qt time helper (for alarm parsing tests) ---
@pytest.fixture
def freeze_qt_time(monkeypatch):
"""Freeze QDateTime.currentDateTime/QTime.currentTime to midday today.
This avoids flakiness when tests run close to midnight, so that
QTime.currentTime().addSecs(3600) is still the same calendar day.
"""
import bouquin.main_window as _mwmod
from PySide6.QtCore import QDate, QDateTime, QTime
today = QDate.currentDate()
fixed_time = QTime(12, 0)
fixed_dt = QDateTime(today, fixed_time)
# Patch the *imported* Qt symbols that main_window uses
monkeypatch.setattr(
_mwmod.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt))
)
monkeypatch.setattr(
_mwmod.QTime, "currentTime", staticmethod(lambda: QTime(fixed_time))
)
yield

View file

@ -1,8 +1,8 @@
import bouquin.bug_report_dialog as bugmod
from bouquin.bug_report_dialog import BugReportDialog
from bouquin import strings
from PySide6.QtWidgets import QMessageBox
from bouquin.bug_report_dialog import BugReportDialog
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QMessageBox
def test_bug_report_truncates_text_to_max_chars(qtbot):

View file

@ -0,0 +1,364 @@
from bouquin import strings
from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog,
CodeEditorWithLineNumbers,
)
from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QFont, QPaintEvent, QTextCursor
from PySide6.QtWidgets import QPushButton
def _find_button_by_text(widget, text):
for btn in widget.findChildren(QPushButton):
if text.lower() in btn.text().lower():
return btn
return None
def test_code_block_dialog_delete_flow(qtbot):
dlg = CodeBlockEditorDialog("print(1)", "python", allow_delete=True)
qtbot.addWidget(dlg)
delete_txt = strings._("delete_code_block")
delete_btn = _find_button_by_text(dlg, delete_txt)
assert delete_btn is not None
assert not dlg.was_deleted()
with qtbot.waitSignal(dlg.finished, timeout=2000):
delete_btn.click()
assert dlg.was_deleted()
def test_code_block_dialog_language_and_code(qtbot):
dlg = CodeBlockEditorDialog("x = 1", "not-a-lang", allow_delete=False)
qtbot.addWidget(dlg)
delete_txt = strings._("delete_code_block")
assert _find_button_by_text(dlg, delete_txt) is None
assert dlg.code() == "x = 1"
assert dlg.language() is None
def test_line_number_area_size_hint(qtbot, app):
"""Test _LineNumberArea.sizeHint() method."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
line_area = editor._line_number_area
size_hint = line_area.sizeHint()
# Should return a QSize with width from editor
assert isinstance(size_hint, QSize)
assert size_hint.width() > 0
assert size_hint.height() == 0
def test_line_number_area_paint_event(qtbot, app):
"""Test _LineNumberArea.paintEvent() method."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Line 1\nLine 2\nLine 3")
editor.show()
# Trigger a paint event on the line number area
line_area = editor._line_number_area
paint_event = QPaintEvent(QRect(0, 0, line_area.width(), line_area.height()))
line_area.paintEvent(paint_event)
# Should not crash
def test_line_number_font_pixel_size_fallback(qtbot, app):
"""Test _line_number_font() with pixel-sized font."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Set a pixel-sized font (pointSize will be -1)
font = QFont()
font.setPixelSize(14)
editor.setFont(font)
# Get line number font - should use the fallback
line_font = editor._line_number_font()
# Should have calculated a size
assert line_font.pointSizeF() > 0 or line_font.pixelSize() > 0
def test_code_editor_resize_event(qtbot, app):
"""Test CodeEditorWithLineNumbers.resizeEvent() method."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.show()
# Resize the editor
editor.resize(400, 300)
# Line number area should be repositioned
line_area = editor._line_number_area
assert line_area.geometry().width() > 0
assert line_area.geometry().height() == editor.contentsRect().height()
def test_code_editor_update_with_scroll(qtbot, app):
"""Test _update_line_number_area with dy (scroll) parameter."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Add enough text to enable scrolling
text = "\n".join([f"Line {i}" for i in range(100)])
editor.setPlainText(text)
editor.show()
# Trigger update with scroll offset
rect = QRect(0, 0, 100, 100)
editor._update_line_number_area(rect, dy=10)
# Should not crash
def test_code_editor_update_without_scroll(qtbot, app):
"""Test _update_line_number_area without scroll (dy=0)."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Line 1\nLine 2")
editor.show()
# Trigger update without scroll
rect = QRect(0, 0, 100, 100)
editor._update_line_number_area(rect, dy=0)
# Should not crash
def test_code_editor_update_contains_viewport(qtbot, app):
"""Test _update_line_number_area when rect contains viewport."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Test")
editor.show()
# Trigger update with rect that contains viewport
viewport_rect = editor.viewport().rect()
editor._update_line_number_area(viewport_rect, dy=0)
# Should trigger width update (covers line 82)
def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
"""Test line_number_area_paint_event with multiple text blocks."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Add multiple lines
text = "\n".join([f"Line {i}" for i in range(20)])
editor.setPlainText(text)
editor.show()
# Force a paint event
line_area = editor._line_number_area
rect = QRect(0, 0, line_area.width(), line_area.height())
paint_event = QPaintEvent(rect)
# This should exercise the painting loop
editor.line_number_area_paint_event(paint_event)
# Should not crash
def test_line_number_area_paint_with_long_file(qtbot, app):
"""Test line_number_area_paint_event with many lines."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Add 1000+ lines to test digit calculation and painting
text = "\n".join([f"Line {i}" for i in range(1000)])
editor.setPlainText(text)
editor.show()
# Trigger paint event
line_area = editor._line_number_area
paint_event = QPaintEvent(line_area.rect())
editor.line_number_area_paint_event(paint_event)
# Line number width should accommodate 4 digits
width = editor.line_number_area_width()
assert width > 30 # Should be wider for 4-digit numbers
def test_code_block_editor_dialog_with_delete(qtbot, app):
"""Test CodeBlockEditorDialog with allow_delete=True."""
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=True)
qtbot.addWidget(dialog)
# Should have delete button functionality
assert hasattr(dialog, "_delete_requested")
assert dialog._delete_requested is False
# Simulate delete click
dialog._on_delete_clicked()
assert dialog._delete_requested is True
assert dialog.was_deleted() is True
def test_code_block_editor_dialog_without_delete(qtbot, app):
"""Test CodeBlockEditorDialog with allow_delete=False."""
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=False)
qtbot.addWidget(dialog)
# Should not have been deleted
assert dialog.was_deleted() is False
def test_code_block_editor_dialog_language_selection(qtbot, app):
"""Test language selection in dialog."""
dialog = CodeBlockEditorDialog("test", "javascript")
qtbot.addWidget(dialog)
# Should have selected javascript
assert dialog.language() == "javascript"
# Change language
dialog._lang_combo.setCurrentText("python")
assert dialog.language() == "python"
# Empty language
dialog._lang_combo.setCurrentText("")
assert dialog.language() is None
def test_code_block_editor_dialog_code_retrieval(qtbot, app):
"""Test getting code from dialog."""
original_code = "def foo():\n pass"
dialog = CodeBlockEditorDialog(original_code, None)
qtbot.addWidget(dialog)
# Should return the code
assert dialog.code() == original_code
# Modify code
new_code = "def bar():\n return 42"
dialog._code_edit.setPlainText(new_code)
assert dialog.code() == new_code
def test_code_editor_with_empty_text(qtbot, app):
"""Test editor with no text."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.show()
# Should still paint line numbers
line_area = editor._line_number_area
paint_event = QPaintEvent(line_area.rect())
editor.line_number_area_paint_event(paint_event)
# Should not crash
def test_code_editor_block_count_changed(qtbot, app):
"""Test that block count changes trigger width updates."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
initial_width = editor.line_number_area_width()
# Add lots of lines (should require more digits)
text = "\n".join([f"Line {i}" for i in range(1000)])
editor.setPlainText(text)
new_width = editor.line_number_area_width()
# Width should increase for more digits
assert new_width > initial_width
def test_code_editor_cursor_position_changed(qtbot, app):
"""Test that cursor position changes update line number area."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Line 1\nLine 2\nLine 3")
editor.show()
# Move cursor
cursor = editor.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
editor.setTextCursor(cursor)
# Should trigger line number area update (via signal connection)
# Just verify it doesn't crash
def test_line_number_area_width_calculation(qtbot, app):
"""Test line number area width calculation with various block counts."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Test with 1 line (should use minimum 2 digits)
editor.setPlainText("One line")
width_1 = editor.line_number_area_width()
assert width_1 > 0
# Test with 10 lines (2 digits)
editor.setPlainText("\n".join(["Line"] * 10))
width_10 = editor.line_number_area_width()
assert width_10 >= width_1
# Test with 100 lines (3 digits)
editor.setPlainText("\n".join(["Line"] * 100))
width_100 = editor.line_number_area_width()
assert width_100 > width_10
def test_code_editor_viewport_margins(qtbot, app):
"""Test that viewport margins are set correctly."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Test")
editor.show()
# Left margin should equal line number area width
margins = editor.viewportMargins()
line_width = editor.line_number_area_width()
assert margins.left() == line_width
assert margins.top() == 0
assert margins.right() == 0
assert margins.bottom() == 0
def test_code_editor_retains_indentation_on_enter(qtbot, app):
"""Pressing Enter on an indented line retains indentation in code editor."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("\tfoo")
editor.show()
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
def test_code_editor_double_enter_on_empty_indent_resets(qtbot, app):
"""Second Enter on an indentation-only line clears the indent in code editor."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("\tfoo")
editor.show()
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\n")
assert editor.textCursor().block().text() == ""

View file

@ -1,5 +1,5 @@
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
from PySide6.QtGui import QTextCharFormat, QFont
from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter
from PySide6.QtGui import QFont, QTextCharFormat
def test_get_language_patterns_python(app):

View file

@ -1,10 +1,12 @@
import pytest
import json, csv
import csv
import datetime as dt
from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager
import json
from datetime import date, timedelta
import pytest
from bouquin.db import DBManager
from sqlcipher4 import dbapi2 as sqlite
def _today():
return dt.date.today().isoformat()
@ -61,8 +63,10 @@ def test_dates_with_content_and_search(fresh_db):
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
hits = list(fresh_db.search_entries("alpha"))
assert any(d == _today() for d, _ in hits)
assert any(d == _tomorrow() for d, _ in hits)
# search_entries now returns (kind, key, title, text, aux)
page_dates = [key for (kind, key, _title, _text, _aux) in hits if kind == "page"]
assert _today() in page_dates
assert _tomorrow() in page_dates
def test_get_all_entries_and_export(fresh_db, tmp_path):
@ -369,7 +373,7 @@ def test_db_gather_stats_empty_database(fresh_db):
"""Test gather_stats on empty database."""
stats = fresh_db.gather_stats()
assert len(stats) == 10
assert len(stats) == 22
(
pages_with_content,
total_revisions,
@ -381,6 +385,18 @@ def test_db_gather_stats_empty_database(fresh_db):
page_most_tags,
page_most_tags_count,
revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
) = stats
assert pages_with_content == 0
@ -417,6 +433,7 @@ def test_db_gather_stats_with_content(fresh_db):
page_most_tags,
page_most_tags_count,
revisions_by_date,
*_rest,
) = stats
assert pages_with_content == 2
@ -433,7 +450,7 @@ def test_db_gather_stats_word_counting(fresh_db):
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
assert total_words == 5
@ -459,7 +476,7 @@ def test_db_gather_stats_with_tags(fresh_db):
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
assert unique_tags == 3
assert page_most_tags == "2024-01-01"
@ -475,7 +492,7 @@ def test_db_gather_stats_revisions_by_date(fresh_db):
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
assert date(2024, 1, 1) in revisions_by_date
assert revisions_by_date[date(2024, 1, 1)] == 3
@ -490,7 +507,7 @@ def test_db_gather_stats_handles_malformed_dates(fresh_db):
fresh_db.save_new_version("2024-01-15", "Test", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
# Should have parsed the date correctly
assert date(2024, 1, 15) in revisions_by_date
@ -503,7 +520,7 @@ def test_db_gather_stats_current_version_only(fresh_db):
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
# Should count words from current version (5 words), not old version
assert total_words == 5
@ -515,7 +532,7 @@ def test_db_gather_stats_no_tags(fresh_db):
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
assert unique_tags == 0
assert page_most_tags is None

View file

@ -0,0 +1,289 @@
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox, QWidget
def test_open_document_from_db_success(qtbot, app, fresh_db):
"""Test successfully opening a document."""
# Import here to avoid circular import issues
from bouquin.document_utils import open_document_from_db
# Add a project and document
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test content for document")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
# Mock QDesktopServices.openUrl
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
# Call the function
success = open_document_from_db(
fresh_db, doc_id, doc_path.name, parent_widget=None
)
# Verify success
assert success is True
# Verify openUrl was called with a QUrl
assert mock_open.called
args = mock_open.call_args[0]
assert isinstance(args[0], QUrl)
# Verify the URL points to a local file
url_string = args[0].toString()
assert url_string.startswith("file://")
assert "bouquin_doc_" in url_string
assert doc_path.suffix in url_string
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db):
"""Test opening a document with a parent widget provided."""
from bouquin.document_utils import open_document_from_db
# Create a parent widget
parent = QWidget()
qtbot.addWidget(parent)
# Add a project and document
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".pdf"))
doc_path.write_text("PDF content")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(
fresh_db, doc_id, doc_path.name, parent_widget=parent
)
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db):
"""Test opening a non-existent document returns False."""
from bouquin.document_utils import open_document_from_db
# Try to open a document that doesn't exist
success = open_document_from_db(
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
)
# Should return False
assert success is False
def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db):
"""Test that error dialog is shown when parent widget is provided."""
from bouquin.document_utils import open_document_from_db
parent = QWidget()
qtbot.addWidget(parent)
# Mock QMessageBox.warning
with patch.object(QMessageBox, "warning") as mock_warning:
success = open_document_from_db(
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent
)
# Should return False and show warning
assert success is False
assert mock_warning.called
# Verify warning was shown with correct parent
call_args = mock_warning.call_args[0]
assert call_args[0] is parent
def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db):
"""Test that no error dialog is shown when parent widget is None."""
from bouquin.document_utils import open_document_from_db
with patch.object(QMessageBox, "warning") as mock_warning:
success = open_document_from_db(
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
)
# Should return False but NOT show warning
assert success is False
assert not mock_warning.called
def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db):
"""Test that the temporary file has the correct extension."""
from bouquin.document_utils import open_document_from_db
# Test various file extensions
extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"]
for ext in extensions:
proj_id = fresh_db.add_project(f"Project for {ext}")
doc_path = Path(tempfile.mktemp(suffix=ext))
doc_path.write_text(f"content for {ext}")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(
QDesktopServices, "openUrl", return_value=True
) as mock_open:
open_document_from_db(fresh_db, doc_id, doc_path.name)
# Get the URL that was opened
url = mock_open.call_args[0][0]
url_string = url.toString()
# Verify the extension is preserved
assert ext in url_string, f"Extension {ext} not found in {url_string}"
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db):
"""Test opening a document without a file extension."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp()) # No suffix
doc_path.write_text("content without extension")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
# Should still succeed
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db):
"""Test handling when QDesktopServices.openUrl returns False."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test content")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
# Mock openUrl to return False (failure)
with patch.object(QDesktopServices, "openUrl", return_value=False):
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
# Should return False
assert success is False
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_binary_content(qtbot, app, fresh_db):
"""Test opening a document with binary content."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".bin"))
# Write some binary data
binary_data = bytes([0, 1, 2, 3, 255, 254, 253])
doc_path.write_bytes(binary_data)
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_large_file(qtbot, app, fresh_db):
"""Test opening a large document."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".bin"))
# Create a 1MB file
large_data = b"x" * (1024 * 1024)
doc_path.write_bytes(large_data)
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db):
"""Test that temporary files have the correct prefix."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
open_document_from_db(fresh_db, doc_id, doc_path.name)
url = mock_open.call_args[0][0]
url_path = url.toLocalFile()
# Verify the temp file has the bouquin_doc_ prefix
assert "bouquin_doc_" in url_path
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db):
"""Test opening the same document multiple times."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test content")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
# Open the same document 3 times
for _ in range(3):
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
assert success is True
# Should have been called 3 times
assert mock_open.call_count == 3
# Each call should create a different temp file
call_urls = [call[0][0].toString() for call in mock_open.call_args_list]
# All URLs should be different (different temp files)
assert len(set(call_urls)) == 3
finally:
doc_path.unlink(missing_ok=True)

1060
tests/test_documents.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,9 @@
import pytest
from bouquin.find_bar import FindBar
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.find_bar import FindBar
@pytest.fixture

View file

@ -1,7 +1,6 @@
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
from PySide6.QtCore import Qt, QTimer
from bouquin.history_dialog import HistoryDialog
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
def test_history_dialog_lists_and_revert(qtbot, fresh_db):

1346
tests/test_invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFileDialog, QLineEdit
@ -97,7 +96,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
"""Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
"""Test KeyPrompt with show_db_change but no initial_db_path"""
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
qtbot.addWidget(prompt)
@ -168,7 +167,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path):
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
"""Test browsing when initial_db_path is set - covers line 57 with non-None path"""
"""Test browsing when initial_db_path is set"""
initial_db = tmp_path / "initial.db"
initial_db.touch()
@ -180,7 +179,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
# Mock the file dialog to return a different file
def mock_get_open_filename(*args, **kwargs):
# Verify that start_dir was passed correctly (line 57)
# Verify that start_dir was passed correctly
return str(new_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)

View file

@ -1,7 +1,7 @@
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_lock_overlay_reacts_to_theme(app, qtbot):

View file

@ -1,5 +1,6 @@
import importlib
import runpy
import pytest

View file

@ -1,22 +1,19 @@
import pytest
import importlib.metadata
from datetime import date, timedelta
from pathlib import Path
import bouquin.main_window as mwmod
from bouquin.main_window import MainWindow
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt
from bouquin.db import DBConfig, DBManager
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
from unittest.mock import Mock, patch
import bouquin.main_window as mwmod
import bouquin.version_check as version_check
import pytest
from bouquin.db import DBConfig, DBManager
from bouquin.key_prompt import KeyPrompt
from bouquin.main_window import MainWindow
from bouquin.settings import get_settings
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
@ -1861,44 +1858,76 @@ def test_main_window_update_tag_views_no_tags_widget(
assert True
def test_main_window_with_tags_disabled(qtbot, app, tmp_path):
"""Test MainWindow with tags disabled in config - covers line 319"""
db_path = tmp_path / "notebook.db"
def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
"""Test main window when tags feature is disabled."""
s = get_settings()
s.setValue("db/default_db", str(db_path))
s.setValue("db/key", "test-key")
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/tags", False) # Disable tags
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", False) # Disabled
s.setValue("ui/time_log", True)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
# Tags widget should be hidden
assert w.tags.isHidden()
# Verify tags widget is hidden
assert window.tags.isHidden()
def test_main_window_with_time_log_disabled(qtbot, app, tmp_path):
"""Test MainWindow with time_log disabled in config - covers line 321"""
db_path = tmp_path / "notebook.db"
def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
"""Test main window when time_log feature is disabled."""
s = get_settings()
s.setValue("db/default_db", str(db_path))
s.setValue("db/key", "test-key")
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", False) # Disable time log
s.setValue("ui/time_log", False) # Disabled
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
# Time log widget should be hidden
assert w.time_log.isHidden()
# Verify time_log widget is hidden
assert window.time_log.isHidden()
assert not window.toolBar.actTimer.isVisible()
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
"""Test main window when documents feature is disabled."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", True)
s.setValue("ui/reminders", True)
s.setValue("ui/documents", False) # Disabled
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
# Verify documents widget is hidden
assert window.todays_documents.isHidden()
assert not window.toolBar.actDocuments.isVisible()
def test_export_csv_format(qtbot, app, tmp_path, monkeypatch):
@ -2161,53 +2190,6 @@ def test_main_window_without_reminders(qtbot, app, tmp_db_cfg):
assert not window.toolBar.actAlarm.isVisible()
def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
"""Test main window when time_log feature is disabled."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", False) # Disabled
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
# Verify time_log widget is hidden
assert window.time_log.isHidden()
assert not window.toolBar.actTimer.isVisible()
def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
"""Test main window when tags feature is disabled."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", False) # Disabled
s.setValue("ui/time_log", True)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
# Verify tags widget is hidden
assert window.tags.isHidden()
def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
"""Test closing the current tab via _close_current_tab."""
s = get_settings()
@ -2244,7 +2226,7 @@ def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
assert window.tab_widget.count() == initial_count - 1
def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
"""Test parsing inline alarms from markdown (⏰ HH:MM format)."""
from PySide6.QtCore import QTime
@ -2270,7 +2252,7 @@ def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
window._open_date_in_tab(today_qdate)
# Set content with a future alarm
future_time = QTime.currentTime().addSecs(3600) # 1 hour from now
future_time = QTime.currentTime().addSecs(3600)
alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
# Set the editor's current_date attribute
@ -2326,7 +2308,7 @@ def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
assert len(window._reminder_timers) == 0
def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
"""Test that past alarms are skipped."""
from PySide6.QtCore import QTime
@ -2368,7 +2350,7 @@ def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
assert len(window._reminder_timers) == 0
def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db):
def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
"""Test alarm with no text before emoji uses fallback."""
from PySide6.QtCore import QTime

View file

@ -1,21 +1,21 @@
import base64
import pytest
from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl
from PySide6.QtGui import (
QImage,
QColor,
QKeyEvent,
QTextCursor,
QTextDocument,
QFont,
QTextCharFormat,
)
from PySide6.QtWidgets import QApplication, QTextEdit
import re
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl
from PySide6.QtGui import (
QColor,
QFont,
QImage,
QKeyEvent,
QTextCharFormat,
QTextCursor,
QTextDocument,
)
from PySide6.QtWidgets import QApplication, QTextEdit
def _today():
@ -73,8 +73,13 @@ def test_apply_styles_and_headings(editor, qtbot):
editor.apply_italic()
editor.apply_strikethrough()
editor.apply_heading(24)
md = editor.to_markdown()
assert "**" in md and "*~~~~*" in md
md = editor.to_markdown().strip()
assert md.startswith("# ")
assert "~~hello world~~" in md
assert re.search(
r"\*{2,3}~~hello world~~\*{2,3}", md
) # bold or bold+italic wrapping strike
def test_toggle_lists_and_checkboxes(editor):
@ -151,6 +156,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
assert "\n\u2022 " in txt
def test_tab_indentation_is_retained_on_newline(editor, qtbot):
"""Pressing Enter on an indented line should retain the indentation."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("\tfoo")
editor.moveCursor(QTextCursor.End)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
def test_double_enter_on_empty_indented_line_resets_indent(editor, qtbot):
"""A second Enter on an indentation-only line should reset to column 0."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("\tfoo")
editor.moveCursor(QTextCursor.End)
# First Enter inserts a new indented line
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
# Second Enter on the now-empty indented line removes the indent
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\n")
# Cursor should be on a fresh unindented blank line
assert editor.textCursor().block().text() == ""
def test_nested_list_continuation_preserves_indentation(editor, qtbot):
"""Enter on an indented bullet should keep indent + bullet prefix."""
qtbot.addWidget(editor)
editor.show()
editor.from_markdown("\t- item")
editor.moveCursor(QTextCursor.End)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert "\n\t\u2022 " in editor.toPlainText()
def test_enter_on_empty_list_marks_empty(qtbot, editor):
qtbot.addWidget(editor)
editor.show()
@ -164,81 +216,132 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
assert editor.toPlainText().startswith("\u2022 \n")
def test_triple_backtick_autoexpands(editor, qtbot):
def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot):
# Start empty
editor.from_markdown("")
press_backtick(qtbot, editor, 2)
press_backtick(qtbot, editor, 1) # triggers expansion
press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut
qtbot.wait(0)
t = text(editor)
assert t.count("```") == 2
assert t.startswith("```\n\n```")
assert t.endswith("\n")
# caret is on the blank line inside the block
assert editor.textCursor().blockNumber() == 1
assert lines_keep(editor)[1] == ""
# The two typed backticks should have been removed
assert "`" not in t
# With the new dialog-based implementation, and our test stub that accepts
# the dialog with empty code, no fenced code block is inserted.
assert "```" not in t
assert t == ""
def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
editor.from_markdown("hello")
editor.moveCursor(QTextCursor.End)
editor.apply_code() # </> action inserts fenced code block
def _find_first_block(doc, predicate):
b = doc.begin()
while b.isValid():
if predicate(b):
return b
b = b.next()
return None
def test_collapse_selection_wraps_and_hides_blocks(editor, qtbot):
"""Collapsing a selection should insert header/end marker and hide content."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("a\nb\nc\n")
doc = editor.document()
# Select lines b..c
b_block = doc.findBlockByNumber(1)
c_block = doc.findBlockByNumber(2)
cur = editor.textCursor()
cur.setPosition(b_block.position())
cur.setPosition(c_block.position() + c_block.length() - 1, QTextCursor.KeepAnchor)
editor.setTextCursor(cur)
editor.collapse_selection()
qtbot.wait(0)
t = text(editor)
assert "hello```" not in t # never inline
assert t.startswith("hello\n```")
assert t.endswith("```\n")
# caret inside block (blank line)
assert editor.textCursor().blockNumber() == 2
assert lines_keep(editor)[2] == ""
# Header and end marker should exist as their own blocks
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith(""))
assert header is not None
assert "" in header.text() and "expand" in header.text()
end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text())
assert end_marker is not None
# Inner blocks should be hidden; end marker always hidden
inner1 = header.next()
inner2 = inner1.next()
assert inner1.text() == "b"
assert inner2.text() == "c"
assert inner1.isVisible() is False
assert inner2.isVisible() is False
assert end_marker.isVisible() is False
def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot):
editor.from_markdown("")
editor.apply_code() # create a block (caret now on blank line inside)
def test_toggle_collapse_expands_and_updates_header(editor, qtbot):
"""Toggling a collapse header should reveal hidden blocks and flip label."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("a\nb\nc\n")
doc = editor.document()
# Select b..c and collapse
b_block = doc.findBlockByNumber(1)
c_block = doc.findBlockByNumber(2)
cur = editor.textCursor()
cur.setPosition(b_block.position(), QTextCursor.MoveMode.MoveAnchor)
cur.setPosition(
c_block.position() + c_block.length() - 1, QTextCursor.MoveMode.KeepAnchor
)
editor.setTextCursor(cur)
editor.collapse_selection()
qtbot.wait(0)
pos_before = editor.textCursor().position()
t_before = text(editor)
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith(""))
assert header is not None
editor.apply_code() # pressing </> inside should be a no-op
# Toggle to expand
editor._toggle_collapse_at_block(header)
qtbot.wait(0)
assert text(editor) == t_before
assert editor.textCursor().position() == pos_before
header2 = doc.findBlock(header.position())
assert "" in header2.text() and "collapse" in header2.text()
assert header2.next().isVisible() is True
assert header2.next().next().isVisible() is True
def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot):
editor.from_markdown("")
editor.apply_code()
def test_collapse_selection_without_trailing_newline_keeps_marker_on_own_line(
editor, qtbot
):
"""Selections reaching EOF without a trailing newline should still fold correctly."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("a\nb\nc") # no trailing newline
doc = editor.document()
# Bottom-up selection of last two lines (c..b)
b_block = doc.findBlockByNumber(1)
c_block = doc.findBlockByNumber(2)
cur = editor.textCursor()
cur.setPosition(c_block.position() + len(c_block.text()))
cur.setPosition(b_block.position(), QTextCursor.KeepAnchor)
editor.setTextCursor(cur)
editor.collapse_selection()
qtbot.wait(0)
# Go to opening fence (line 0)
editor.moveCursor(QTextCursor.Start)
editor.apply_code() # should jump inside the block
qtbot.wait(0)
end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text())
assert end_marker is not None
assert editor.textCursor().blockNumber() == 1
assert lines_keep(editor)[1] == ""
# End marker is its own block, and remains hidden
assert end_marker.text().strip() == "<!-- bouquin:collapse:end -->"
assert end_marker.isVisible() is False
def test_toolbar_on_closing_fence_jumps_out(editor, qtbot):
editor.from_markdown("")
editor.apply_code()
qtbot.wait(0)
# Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after)
editor.moveCursor(QTextCursor.End) # blank-after
editor.moveCursor(QTextCursor.Up) # closing fence
editor.moveCursor(QTextCursor.StartOfLine)
editor.apply_code() # jump to the line after the fence
qtbot.wait(0)
# Now on the blank line after the block
assert editor.textCursor().block().text() == ""
assert editor.textCursor().block().previous().text().strip() == "```"
# The last content line should be hidden (folded)
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith(""))
assert header is not None
assert header.next().isVisible() is False
def test_down_escapes_from_last_code_line(editor, qtbot):
@ -522,25 +625,6 @@ def test_apply_italic_and_strike(editor):
assert editor.textCursor().position() == len(editor.toPlainText()) - 2
def test_apply_code_inline_block_navigation(editor):
# Selection case -> fenced block around selection
editor.setPlainText("code")
c = editor.textCursor()
c.select(QTextCursor.SelectionType.Document)
editor.setTextCursor(c)
editor.apply_code()
assert "```\ncode\n```\n" in editor.toPlainText()
# No selection, at EOF with no following block -> creates block and extra newline path
editor.setPlainText("before")
editor.moveCursor(QTextCursor.MoveOperation.End)
editor.apply_code()
t = editor.toPlainText()
assert t.endswith("before\n```\n\n```\n")
# Caret should be inside the code block blank line
assert editor.textCursor().position() == len("before\n") + 4
def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
# Non-existent path should just return (early exit)
bad = tmp_path / "missing.png"
@ -2006,7 +2090,7 @@ def test_editor_delete_operations(qtbot, app):
def test_markdown_highlighter_dark_theme(qtbot, app):
"""Test markdown highlighter with dark theme - covers lines 74-75"""
"""Test markdown highlighter with dark theme"""
# Create theme manager with dark theme
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
@ -2371,7 +2455,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
# Force rehighlight
editor.highlighter.rehighlight()
# Verify syntax highlighting was applied (lines 186-193)
# Verify syntax highlighting was applied
# We can't easily verify the exact formatting, but we ensure no crash
@ -2383,13 +2467,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
# Force rehighlight
editor.highlighter.rehighlight()
# The overlap detection (lines 252, 264) should prevent issues
def test_highlighter_italic_edge_cases(editor, qtbot):
"""Test italic formatting edge cases."""
# Test edge case: avoiding stealing markers that are part of double
# This tests lines 267-270
editor.setPlainText("**not italic* text**")
# Force rehighlight

View file

@ -0,0 +1,932 @@
"""
Additional tests for markdown_editor.py to improve test coverage.
These tests should be added to test_markdown_editor.py.
"""
import pytest
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import (
QColor,
QImage,
QKeyEvent,
QMouseEvent,
QTextCursor,
QTextDocument,
)
def text(editor) -> str:
return editor.toPlainText()
def lines_keep(editor):
"""Split preserving a trailing empty line if the text ends with '\\n'."""
return text(editor).split("\n")
def press_backtick(qtbot, widget, n=1):
"""Send physical backtick key events (avoid IME/dead-key issues)."""
for _ in range(n):
qtbot.keyClick(widget, Qt.Key_QuoteLeft)
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ed = MarkdownEditor(themes)
qtbot.addWidget(ed)
ed.show()
qtbot.waitExposed(ed)
ed.setFocus()
return ed
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
"""Test _update_code_block_row_backgrounds when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
editor = MarkdownEditor(themes)
qtbot.addWidget(editor)
# Create a new empty document to replace the current one
new_doc = QTextDocument()
editor.setDocument(new_doc)
editor.setDocument(None)
# Should not crash even with no document
editor._update_code_block_row_backgrounds()
def test_find_code_block_bounds_invalid_block(editor):
"""Test _find_code_block_bounds with invalid block."""
editor.setPlainText("some text")
# Create an invalid block
doc = editor.document()
invalid_block = doc.findBlockByNumber(999) # doesn't exist
result = editor._find_code_block_bounds(invalid_block)
assert result is None
def test_find_code_block_bounds_on_closing_fence(editor):
"""Test _find_code_block_bounds when on a closing fence."""
editor.setPlainText("```\ncode\n```")
doc = editor.document()
closing_fence = doc.findBlockByNumber(2) # the closing ```
result = editor._find_code_block_bounds(closing_fence)
assert result is not None
open_block, close_block = result
assert open_block.blockNumber() == 0
assert close_block.blockNumber() == 2
def test_find_code_block_bounds_on_opening_fence(editor):
"""Test _find_code_block_bounds when on an opening fence."""
editor.setPlainText("```\ncode\n```")
doc = editor.document()
opening_fence = doc.findBlockByNumber(0)
result = editor._find_code_block_bounds(opening_fence)
assert result is not None
open_block, close_block = result
assert open_block.blockNumber() == 0
assert close_block.blockNumber() == 2
def test_find_code_block_bounds_no_closing_fence(editor):
"""Test _find_code_block_bounds when closing fence is missing."""
editor.setPlainText("```\ncode without closing")
doc = editor.document()
opening_fence = doc.findBlockByNumber(0)
result = editor._find_code_block_bounds(opening_fence)
assert result is None
def test_find_code_block_bounds_no_opening_fence(editor):
"""Test _find_code_block_bounds from inside code block with no opening."""
# Simulate a malformed block (shouldn't happen in practice)
editor.setPlainText("code\n```")
doc = editor.document()
code_line = doc.findBlockByNumber(0)
result = editor._find_code_block_bounds(code_line)
assert result is None
def test_edit_code_block_checks_document(app, qtbot):
"""Test _edit_code_block when editor has no document."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
editor = MarkdownEditor(themes)
qtbot.addWidget(editor)
# Set up editor with code block
editor.setPlainText("```\ncode\n```")
doc = editor.document()
block = doc.findBlockByNumber(1)
# Now remove the document
editor.setDocument(None)
# The method will try to work but should handle gracefully
# It actually returns True because it processes the block from the old doc
# This tests that it doesn't crash
editor._edit_code_block(block)
# Just verify it doesn't crash - return value is implementation dependent
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
"""Test _edit_code_block when dialog is cancelled."""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog
class CancelledDialog:
def __init__(self, code, language, parent=None, allow_delete=False):
self._code = code
self._language = language
def exec(self):
return QDialog.DialogCode.Rejected
def code(self):
return self._code
def language(self):
return self._language
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", CancelledDialog)
editor.setPlainText("```python\ncode\n```")
doc = editor.document()
block = doc.findBlockByNumber(1)
result = editor._edit_code_block(block)
# Should return True (event handled) even though cancelled
assert result is True
def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
"""Test _edit_code_block when user deletes the block."""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog
class DeleteDialog:
def __init__(self, code, language, parent=None, allow_delete=False):
self._code = code
self._language = language
self._deleted = True
def exec(self):
return QDialog.DialogCode.Accepted
def code(self):
return self._code
def language(self):
return self._language
def was_deleted(self):
return self._deleted
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", DeleteDialog)
editor.setPlainText("```python\noriginal code\n```\nafter")
editor.toPlainText()
doc = editor.document()
block = doc.findBlockByNumber(1)
result = editor._edit_code_block(block)
assert result is True
# Code block should be deleted
new_text = editor.toPlainText()
assert "original code" not in new_text
def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
"""Test _edit_code_block with language change."""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog
class LanguageChangeDialog:
def __init__(self, code, language, parent=None, allow_delete=False):
self._code = code
self._language = "javascript" # Change from python
def exec(self):
return QDialog.DialogCode.Accepted
def code(self):
return self._code
def language(self):
return self._language
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", LanguageChangeDialog)
editor.setPlainText("```python\ncode\n```")
doc = editor.document()
block = doc.findBlockByNumber(1)
result = editor._edit_code_block(block)
assert result is True
# Verify metadata was updated
assert hasattr(editor, "_code_metadata")
lang = editor._code_metadata.get_language(0)
assert lang == "javascript"
def test_delete_code_block_no_bounds(editor):
"""Test _delete_code_block when bounds can't be found."""
editor.setPlainText("not a code block")
doc = editor.document()
block = doc.findBlockByNumber(0)
result = editor._delete_code_block(block)
assert result is False
def test_delete_code_block_checks_document(app, qtbot):
"""Test _delete_code_block when editor has no document."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
editor = MarkdownEditor(themes)
qtbot.addWidget(editor)
# Set up with code block
editor.setPlainText("```\ncode\n```")
doc = editor.document()
block = doc.findBlockByNumber(1)
# Remove the document
editor.setDocument(None)
# The method will attempt to work but should handle gracefully
# Just verify it doesn't crash
editor._delete_code_block(block)
def test_delete_code_block_at_end_of_document(editor):
"""Test _delete_code_block when code block is at end of document."""
editor.setPlainText("```\ncode\n```") # No trailing newline
doc = editor.document()
block = doc.findBlockByNumber(1)
result = editor._delete_code_block(block)
assert result is True
# Should be empty or minimal
assert "code" not in editor.toPlainText()
def test_delete_code_block_with_text_after(editor):
"""Test _delete_code_block when there's text after the block."""
editor.setPlainText("```\ncode\n```\ntext after")
doc = editor.document()
block = doc.findBlockByNumber(1)
result = editor._delete_code_block(block)
assert result is True
# Code should be gone, text after should remain
new_text = editor.toPlainText()
assert "code" not in new_text
assert "text after" in new_text
def test_apply_line_spacing_no_document(app):
"""Test _apply_line_spacing when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
editor = MarkdownEditor(themes)
editor.setDocument(None)
# Should not crash
editor._apply_line_spacing(125.0)
def test_apply_code_block_spacing(editor):
"""Test _apply_code_block_spacing applies correct spacing."""
editor.setPlainText("```\nline1\nline2\n```")
# Apply spacing
editor._apply_code_block_spacing()
# Verify blocks have spacing applied
doc = editor.document()
for i in range(doc.blockCount()):
block = doc.findBlockByNumber(i)
assert block.isValid()
def test_to_markdown_with_code_metadata(editor):
"""Test to_markdown includes code block metadata."""
editor.setPlainText("```python\ncode\n```")
# Set some metadata
editor._code_metadata.set_language(0, "python")
md = editor.to_markdown()
# Should include metadata comment
assert "code-langs" in md or "code" in md
def test_from_markdown_creates_code_metadata(app):
"""Test from_markdown creates _code_metadata if missing."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
editor = MarkdownEditor(themes)
# Remove the attribute
if hasattr(editor, "_code_metadata"):
delattr(editor, "_code_metadata")
# Should recreate it
editor.from_markdown("# test")
assert hasattr(editor, "_code_metadata")
def test_embed_images_preserves_original_size(editor, tmp_path):
"""Test that embedded images preserve their original dimensions."""
# Create a test image
img = tmp_path / "test.png"
qimg = QImage(100, 50, QImage.Format_RGBA8888)
qimg.fill(QColor(255, 0, 0))
qimg.save(str(img))
# Create markdown with image
import base64
with open(img, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
md = f"![test](data:image/png;base64,{b64})"
editor.from_markdown(md)
# Image should be embedded with original size
doc = editor.document()
assert doc is not None
def test_trim_list_prefix_no_selection(editor):
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
editor.setPlainText("- item")
cursor = editor.textCursor()
cursor.clearSelection()
editor.setTextCursor(cursor)
# Should not crash
editor._maybe_trim_list_prefix_from_line_selection()
def test_trim_list_prefix_multiline_selection(editor):
"""Test _maybe_trim_list_prefix_from_line_selection across multiple lines."""
editor.setPlainText("- item1\n- item2")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
editor.setTextCursor(cursor)
# Should not trim multi-line selections
editor._maybe_trim_list_prefix_from_line_selection()
def test_trim_list_prefix_not_full_line(editor):
"""Test _maybe_trim_list_prefix_from_line_selection with partial selection."""
editor.setPlainText("- item text here")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 5)
editor.setTextCursor(cursor)
# Partial line selection should not be trimmed
editor._maybe_trim_list_prefix_from_line_selection()
def test_trim_list_prefix_already_after_prefix(editor):
"""Test _maybe_trim_list_prefix when selection already after prefix."""
editor.setPlainText("- item text")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3) # After "- "
cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
editor.setTextCursor(cursor)
# Should not need adjustment
editor._maybe_trim_list_prefix_from_line_selection()
def test_trim_list_prefix_during_adjustment(editor):
"""Test _maybe_trim_list_prefix re-entry guard."""
editor.setPlainText("- item")
editor._adjusting_selection = True
# Should return early due to guard
editor._maybe_trim_list_prefix_from_line_selection()
editor._adjusting_selection = False
def test_detect_list_type_checkbox_checked(editor):
"""Test _detect_list_type with checked checkbox."""
list_type, prefix = editor._detect_list_type(
f"{editor._CHECK_CHECKED_DISPLAY} done"
)
assert list_type == "checkbox"
assert editor._CHECK_UNCHECKED_DISPLAY in prefix
def test_detect_list_type_numbered(editor):
"""Test _detect_list_type with numbered list."""
list_type, prefix = editor._detect_list_type("1. item")
assert list_type == "number"
# The prefix will be "2. " because it increments for the next item
assert "." in prefix
def test_detect_list_type_markdown_bullet(editor):
"""Test _detect_list_type with markdown bullet."""
list_type, prefix = editor._detect_list_type("- item")
assert list_type == "bullet"
def test_detect_list_type_not_a_list(editor):
"""Test _detect_list_type with regular text."""
list_type, prefix = editor._detect_list_type("regular text")
assert list_type is None
assert prefix == ""
def test_list_prefix_length_numbered(editor):
"""Test _list_prefix_length_for_block with numbered list."""
editor.setPlainText("123. item")
doc = editor.document()
block = doc.findBlockByNumber(0)
length = editor._list_prefix_length_for_block(block)
assert length > 0
def test_key_press_ctrl_home(editor, qtbot):
"""Test Ctrl+Home key combination."""
editor.setPlainText("line1\nline2\nline3")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.ControlModifier, "")
editor.keyPressEvent(event)
# Should move to start of document
assert editor.textCursor().position() == 0
def test_key_press_ctrl_left(editor, qtbot):
"""Test Ctrl+Left key combination."""
editor.setPlainText("word1 word2 word3")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.ControlModifier, "")
editor.keyPressEvent(event)
# Should move left by word
def test_key_press_home_in_list(editor, qtbot):
"""Test Home key in list item."""
editor.setPlainText("- item text")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.NoModifier, "")
editor.keyPressEvent(event)
# Should jump to after "- "
pos = editor.textCursor().position()
assert pos > 0
def test_key_press_left_in_list_prefix(editor, qtbot):
"""Test Left key when in list prefix region."""
editor.setPlainText("- item")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Right) # Inside "- "
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.NoModifier, "")
editor.keyPressEvent(event)
# Should snap to after prefix
def test_key_press_up_in_code_block(editor, qtbot):
"""Test Up key inside code block."""
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Down)
cursor.movePosition(QTextCursor.Down) # On "code line 2"
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Up, Qt.NoModifier, "")
editor.keyPressEvent(event)
# Should move up normally in code block
def test_key_press_down_in_list_item(editor, qtbot):
"""Test Down key in list item."""
editor.setPlainText("- item1\n- item2")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Right) # In prefix of first item
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Down, Qt.NoModifier, "")
editor.keyPressEvent(event)
# Should snap to after prefix on next line
def test_key_press_enter_after_markers(editor, qtbot):
"""Test Enter key after style markers."""
editor.setPlainText("text **")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(event)
# Should handle markers
def test_key_press_enter_on_closing_fence(editor, qtbot):
"""Test Enter key on closing fence line."""
editor.setPlainText("```\ncode\n```")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
cursor.movePosition(QTextCursor.StartOfLine)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(event)
# Should create new line after fence
def test_key_press_backspace_empty_checkbox(editor, qtbot):
"""Test Backspace in empty checkbox item."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "")
editor.keyPressEvent(event)
# Should remove checkbox
def test_key_press_backspace_numbered_list(editor, qtbot):
"""Test Backspace at start of numbered list item."""
editor.setPlainText("1. ")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "")
editor.keyPressEvent(event)
def test_key_press_tab_in_bullet_list(editor, qtbot):
"""Test Tab key in bullet list."""
editor.setPlainText("- item")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t")
editor.keyPressEvent(event)
# Should indent
def test_key_press_shift_tab_in_bullet_list(editor, qtbot):
"""Test Shift+Tab in indented bullet list."""
editor.setPlainText(" - item")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.ShiftModifier, "")
editor.keyPressEvent(event)
# Should unindent
def test_key_press_tab_in_checkbox(editor, qtbot):
"""Test Tab in checkbox item."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t")
editor.keyPressEvent(event)
def test_apply_weight_to_selection(editor, qtbot):
"""Test apply_weight makes text bold."""
editor.setPlainText("text to bold")
cursor = editor.textCursor()
cursor.select(QTextCursor.Document)
editor.setTextCursor(cursor)
editor.apply_weight()
md = editor.to_markdown()
assert "**" in md
def test_apply_italic_to_selection(editor, qtbot):
"""Test apply_italic makes text italic."""
editor.setPlainText("text to italicize")
cursor = editor.textCursor()
cursor.select(QTextCursor.Document)
editor.setTextCursor(cursor)
editor.apply_italic()
md = editor.to_markdown()
assert "*" in md or "_" in md
def test_apply_strikethrough_to_selection(editor, qtbot):
"""Test apply_strikethrough."""
editor.setPlainText("text to strike")
cursor = editor.textCursor()
cursor.select(QTextCursor.Document)
editor.setTextCursor(cursor)
editor.apply_strikethrough()
md = editor.to_markdown()
assert "~~" in md
def test_apply_code_on_selection(editor, qtbot):
"""Test apply_code with selected text."""
editor.setPlainText("some code")
cursor = editor.textCursor()
cursor.select(QTextCursor.Document)
editor.setTextCursor(cursor)
# apply_code opens dialog - with test stub it accepts
editor.apply_code()
# The stub dialog will create a code block
editor.toPlainText()
# May contain code block elements depending on dialog behavior
def test_toggle_numbers_on_plain_text(editor, qtbot):
"""Test toggle_numbers converts text to numbered list."""
editor.setPlainText("item 1")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
editor.toggle_numbers()
text = editor.toPlainText()
assert "1." in text
def test_toggle_bullets_on_plain_text(editor, qtbot):
"""Test toggle_bullets converts text to bullet list."""
editor.setPlainText("item 1")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
editor.toggle_bullets()
text = editor.toPlainText()
# Will have unicode bullet
assert editor._BULLET_DISPLAY in text
def test_toggle_bullets_removes_bullets(editor, qtbot):
"""Test toggle_bullets removes existing bullets."""
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
editor.toggle_bullets()
text = editor.toPlainText()
# Should have removed bullet
assert text.strip() == "item 1"
def test_toggle_checkboxes_on_bullets(editor, qtbot):
"""Test toggle_checkboxes converts bullets to checkboxes."""
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
editor.toggle_checkboxes()
text = editor.toPlainText()
# Should have checkbox characters
assert editor._CHECK_UNCHECKED_DISPLAY in text
def test_apply_heading_various_levels(editor, qtbot):
"""Test apply_heading with different levels."""
test_cases = [
(24, "#"), # H1
(18, "##"), # H2
(14, "###"), # H3
(12, ""), # Normal (no heading)
]
for size, expected_marker in test_cases:
editor.setPlainText("heading text")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
editor.apply_heading(size)
text = editor.toPlainText()
if expected_marker:
assert text.startswith(expected_marker)
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
"""Test insert_image_from_path with invalid extension."""
invalid_file = tmp_path / "file.txt"
invalid_file.write_text("not an image")
# Should not crash
editor.insert_image_from_path(invalid_file)
def test_insert_image_from_path_nonexistent(editor, tmp_path):
"""Test insert_image_from_path with nonexistent file."""
nonexistent = tmp_path / "doesnt_exist.png"
# Should not crash
editor.insert_image_from_path(nonexistent)
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
"""Test clicking checkbox toggles it from unchecked to checked."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
rect = editor.cursorRect()
pos = QPoint(rect.left() + 2, rect.center().y())
event = QMouseEvent(
QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier
)
editor.mousePressEvent(event)
text = editor.toPlainText()
# Should toggle to checked
assert editor._CHECK_CHECKED_DISPLAY in text
def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
"""Test clicking checked checkbox toggles it to unchecked."""
editor.setPlainText(f"{editor._CHECK_CHECKED_DISPLAY} completed task")
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
rect = editor.cursorRect()
pos = QPoint(rect.left() + 2, rect.center().y())
event = QMouseEvent(
QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier
)
editor.mousePressEvent(event)
text = editor.toPlainText()
# Should toggle to unchecked
assert editor._CHECK_UNCHECKED_DISPLAY in text
def test_mouse_double_click_suppression(editor, qtbot):
"""Test double-click suppression for checkboxes."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
# Simulate the suppression flag being set
editor._suppress_next_checkbox_double_click = True
pos = QPoint(10, 10)
event = QMouseEvent(
QMouseEvent.MouseButtonDblClick,
pos,
Qt.LeftButton,
Qt.LeftButton,
Qt.NoModifier,
)
editor.mouseDoubleClickEvent(event)
# Flag should be cleared
assert not editor._suppress_next_checkbox_double_click
def test_context_menu_in_code_block(editor, qtbot):
"""Test context menu when in code block."""
editor.setPlainText("```python\ncode\n```")
from PySide6.QtGui import QContextMenuEvent
# Position in the code block
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Down)
editor.setTextCursor(cursor)
rect = editor.cursorRect()
QContextMenuEvent(QContextMenuEvent.Mouse, rect.center())
# Should not crash
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
def test_set_code_block_language(editor, qtbot):
"""Test _set_code_block_language sets metadata."""
editor.setPlainText("```\ncode\n```")
doc = editor.document()
block = doc.findBlockByNumber(1)
editor._set_code_block_language(block, "python")
# Metadata should be set
lang = editor._code_metadata.get_language(0)
assert lang == "python"
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
"""Test get_current_line_task_text removes list/checkbox prefixes."""
test_cases = [
(f"{editor._CHECK_UNCHECKED_DISPLAY} task text", "task text"),
(f"{editor._BULLET_DISPLAY} bullet text", "bullet text"),
("- markdown bullet", "markdown bullet"),
("1. numbered item", "numbered item"),
]
for input_text, expected in test_cases:
editor.setPlainText(input_text)
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
result = editor.get_current_line_task_text()
assert result == expected
# Test for selection changed event
def test_selection_changed_in_list(editor, qtbot):
"""Test selectionChanged event in list items."""
editor.setPlainText("- item one\n- item two")
# Select text in first item
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3)
cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
editor.setTextCursor(cursor)
# Trigger selection changed
editor.selectionChanged.emit()
# Should handle gracefully

View file

@ -1,5 +1,55 @@
from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
class DummyTimeLogWidget(QWidget):
"""Minimal stand-in for the real TimeLogWidget used by PomodoroManager."""
def __init__(self, parent=None):
super().__init__(parent)
self.layout = QVBoxLayout(self)
self.summary_label = QLabel(self)
# toggle_btn and _reload_summary are used by PomodoroManager._on_timer_stopped
self.toggle_btn = Mock()
self.toggle_btn.isChecked.return_value = True
def show_pomodoro_widget(self, widget):
# Manager calls this when embedding the timer
if widget is not None:
self.layout.addWidget(widget)
def clear_pomodoro_widget(self):
# Manager calls this when removing the embedded timer
while self.layout.count():
item = self.layout.takeAt(0)
w = item.widget()
if w is not None:
w.setParent(None)
def _reload_summary(self):
# Called after TimeLogDialog closes; no-op is fine for tests
pass
class DummyMainWindow(QWidget):
"""Minimal stand-in for MainWindow that PomodoroManager expects."""
def __init__(self, app, parent=None):
super().__init__(parent)
# Sidebar time log widget
self.time_log = DummyTimeLogWidget(self)
# Toolbar with an actTimer QAction so QSignalBlocker works
self.toolBar = QToolBar(self)
self.toolBar.actTimer = QAction(self)
self.toolBar.addAction(self.toolBar.actTimer)
# Themes attribute used when constructing TimeLogDialog
self.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def test_pomodoro_timer_init(qtbot, app, fresh_db):
@ -147,15 +197,6 @@ def test_pomodoro_timer_modal_state(qtbot, app):
assert timer.isModal() is False
def test_pomodoro_timer_window_title(qtbot, app):
"""Test timer window title."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Window title should contain some reference to timer/pomodoro
assert len(timer.windowTitle()) > 0
def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization."""
parent = Mock()
@ -168,10 +209,10 @@ def test_pomodoro_manager_init(app, fresh_db):
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
"""Test starting a timer through the manager."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
line_text = "Important task"
@ -181,15 +222,16 @@ def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
assert manager._active_timer is not None
assert manager._active_timer._task_text == line_text
qtbot.addWidget(manager._active_timer)
# Timer should be embedded in the sidebar time log widget
assert manager._active_timer.parent() is parent.time_log
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
"""Test that starting a new timer closes the previous one."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
"""Test that starting a new timer closes/replaces the previous one."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
# Start first timer
@ -205,16 +247,20 @@ def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
assert first_timer is not second_timer
assert second_timer._task_text == "Task 2"
assert second_timer.parent() is parent.time_log
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped with very short time logs minimum hours."""
parent = Mock()
"""Timer stopped with very short time logs should enforce minimum hours."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid actually showing it
# Mock TimeLogDialog to avoid showing it
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
@ -230,8 +276,11 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours(
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
"""Test that elapsed time is properly rounded to decimal hours."""
parent = Mock()
"""Elapsed time should be rounded to a 0.25-hour increment."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
@ -240,21 +289,50 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
# Test with 1800 seconds (30 minutes)
# 1800 seconds (30 min) should round up to 0.5
manager._on_timer_stopped(1800, "Task", "2024-01-15")
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
# Should round up and be a multiple of 0.25
assert hours_set > 0
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
# Should be a multiple of 0.25
assert hours_set * 4 == int(hours_set * 4)
def test_seconds_to_logged_hours_nearest_quarter_rounding():
"""Seconds -> hours uses nearest-quarter rounding with a 15-min minimum."""
# Import the pure conversion helper directly (no Qt required)
from bouquin.pomodoro_timer import PomodoroManager
# <15 minutes always rounds up to 0.25
assert PomodoroManager._seconds_to_logged_hours(1) == 0.25
assert PomodoroManager._seconds_to_logged_hours(899) == 0.25
# 15 minutes exact
assert PomodoroManager._seconds_to_logged_hours(900) == 0.25
# Examples from the spec: closest quarter-hour
# 33 minutes -> closer to 0.50 than 0.75
assert PomodoroManager._seconds_to_logged_hours(33 * 60) == 0.50
# 40 minutes -> closer to 0.75 than 0.50
assert PomodoroManager._seconds_to_logged_hours(40 * 60) == 0.75
# Halfway case: 22.5 min is exactly between 0.25 and 0.50 -> round up
assert PomodoroManager._seconds_to_logged_hours(int(22.5 * 60)) == 0.50
# Sanity: 1 hour stays 1.0
assert PomodoroManager._seconds_to_logged_hours(60 * 60) == 1.00
def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped pre-fills the note in time log dialog."""
parent = Mock()
"""Timer stopped should pre-fill the note in the time log dialog."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
@ -273,11 +351,11 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note(
def test_pomodoro_manager_timer_stopped_signal_connection(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped signal is properly connected."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
"""Timer's stop button should result in TimeLogDialog being executed."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog
@ -291,11 +369,12 @@ def test_pomodoro_manager_timer_stopped_signal_connection(
timer = manager._active_timer
qtbot.addWidget(timer)
# Simulate timer stopped
# Simulate timer having run for a bit
timer._elapsed_seconds = 1000
# Clicking "Stop and log" should emit timerStopped and open the dialog
timer._stop_and_log()
# TimeLogDialog should have been created
assert mock_dialog.exec.called

View file

@ -1,15 +1,42 @@
from unittest.mock import patch
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import pytest
from bouquin.reminders import (
Reminder,
ReminderType,
ReminderDialog,
UpcomingRemindersWidget,
ManageRemindersDialog,
Reminder,
ReminderDialog,
ReminderType,
UpcomingRemindersWidget,
)
from PySide6.QtCore import QDate, QTime
from PySide6.QtCore import QDate, QDateTime, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
from datetime import date, timedelta
@pytest.fixture
def freeze_reminders_time(monkeypatch):
# Freeze 'now' used inside bouquin.reminders to 12:00 today
import bouquin.reminders as rem
today = QDate.currentDate()
fixed_time = QTime(12, 0)
fixed_dt = QDateTime(today, fixed_time)
monkeypatch.setattr(
rem.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt))
)
yield
def _add_daily_reminder(db, text="Standup", time_str="23:59"):
r = Reminder(
id=None,
text=text,
time_str=time_str,
reminder_type=ReminderType.DAILY,
active=True,
)
r.id = db.save_reminder(r)
return r
def test_reminder_type_enum(app):
@ -274,20 +301,24 @@ def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New reminder",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
new_reminder = Reminder(
id=None,
text="New reminder",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
widget._add_reminder()
# Mock the entire ReminderDialog class to avoid Qt parent issues
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
mock_dialog.get_reminder.return_value = new_reminder
# Reminder should be saved
reminders = fresh_db.get_all_reminders()
assert len(reminders) > 0
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
widget._add_reminder()
# Reminder should be saved
reminders = fresh_db.get_all_reminders()
assert len(reminders) > 0
def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
@ -310,17 +341,20 @@ def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
mock_get.return_value = updated
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
widget._edit_reminder(item)
# Mock the entire ReminderDialog class to avoid Qt parent issues
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
mock_dialog.get_reminder.return_value = updated
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
widget._edit_reminder(item)
def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db):
@ -380,17 +414,6 @@ def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
widget._check_reminders()
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
"""Test starting the regular check timer."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget._start_regular_timer()
# Timer should be running
assert widget._check_timer.isActive()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db)
@ -435,19 +458,23 @@ def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
initial_count = dialog.table.rowCount()
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
new_reminder = Reminder(
id=None,
text="New",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
dialog._add_reminder()
# Mock the entire ReminderDialog class to avoid Qt parent issues
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
mock_dialog.get_reminder.return_value = new_reminder
# Table should have one more row
assert dialog.table.rowCount() == initial_count + 1
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
dialog._add_reminder()
# Table should have one more row
assert dialog.table.rowCount() == initial_count + 1
def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
@ -464,16 +491,20 @@ def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
dialog._edit_reminder(reminder)
# Mock the entire ReminderDialog class to avoid Qt parent issues
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
mock_dialog.get_reminder.return_value = updated
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
dialog._edit_reminder(reminder)
def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db):
@ -544,7 +575,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
qtbot.addWidget(dialog)
# Check that the type column shows the day
type_item = dialog.table.item(0, 2)
type_item = dialog.table.item(0, 3)
assert "Wed" in type_item.text()
@ -599,7 +630,11 @@ def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
with patch.object(ManageRemindersDialog, "exec"):
# Mock the entire ManageRemindersDialog class to avoid Qt parent issues
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
with patch("bouquin.reminders.ManageRemindersDialog", return_value=mock_dialog):
widget._manage_reminders()
@ -799,3 +834,104 @@ def test_edit_reminder_dialog(qtbot, fresh_db):
# Verify fields are populated
assert dlg.text_edit.text() == "Original text"
assert dlg.time_edit.time().toString("HH:mm") == "14:30"
def test_upcoming_reminders_context_menu_shows(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
):
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
from PySide6 import QtGui, QtWidgets
from PySide6.QtCore import QPoint
# Add a future reminder for today
r = Reminder(
id=None,
text="Ping",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=True,
)
r.id = fresh_db.save_reminder(r)
w = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(w)
w.refresh()
# Select first upcoming item so context menu code path runs
assert w.reminder_list.count() > 0
w.reminder_list.setCurrentItem(w.reminder_list.item(0))
called = {"exec": False, "actions": []}
class DummyAction:
def __init__(self, text, parent=None):
self._text = text
class _Sig:
def connect(self, fn):
pass
self.triggered = _Sig()
class DummyMenu:
def __init__(self, parent=None):
pass
def addAction(self, action):
called["actions"].append(getattr(action, "_text", str(action)))
def exec(self, *_, **__):
called["exec"] = True
# Patch the modules that the inline imports will read from
monkeypatch.setattr(QtWidgets, "QMenu", DummyMenu, raising=True)
monkeypatch.setattr(QtGui, "QAction", DummyAction, raising=True)
# Invoke directly (normally via right-click)
w._show_reminder_context_menu(QPoint(5, 5))
assert called["exec"] is True
assert len(called["actions"]) >= 2 # at least Edit/Deactivate/Delete
def test_upcoming_reminders_delete_selected_dedupes(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
):
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
from PySide6.QtCore import QItemSelectionModel
from PySide6.QtWidgets import QMessageBox
r = Reminder(
id=None,
text="Duplicate target",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=True,
)
r.id = fresh_db.save_reminder(r)
w = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(w)
w.refresh()
assert w.reminder_list.count() >= 2 # daily -> multiple upcoming occurrences
# First selects & clears; second adds to selection
w.reminder_list.setCurrentRow(0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
w.reminder_list.setCurrentRow(1, QItemSelectionModel.SelectionFlag.Select)
deleted_ids = []
def fake_delete(rem_id):
deleted_ids.append(rem_id)
# Auto-confirm deletion
monkeypatch.setattr(
QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.Yes)
)
monkeypatch.setattr(fresh_db, "delete_reminder", fake_delete)
w._delete_selected_reminders()
# Should de-duplicate to a single DB delete call
assert deleted_ids == [r.id]

View file

@ -33,7 +33,10 @@ def test_open_selected_with_data(qtbot, fresh_db):
it = QListWidgetItem("dummy")
from PySide6.QtCore import Qt
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
it.setData(
Qt.ItemDataRole.UserRole,
{"kind": "page", "date": "1999-12-31"},
)
s.results.addItem(it)
s._open_selected(it)
assert seen == ["1999-12-31"]
@ -95,6 +98,6 @@ def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
qtbot.addWidget(s)
s.show()
long = "X" * 40 + "alpha" + "Y" * 40
rows = [("2000-01-01", long)]
rows = [("page", "2000-01-01", "2000-01-01", long, None)]
s._populate_results("alpha", rows)
assert s.results.count() >= 1

View file

@ -1,9 +1,5 @@
from bouquin.settings import (
get_settings,
load_db_config,
save_db_config,
)
from bouquin.db import DBConfig
from bouquin.settings import get_settings, load_db_config, save_db_config
def _clear_db_settings():

View file

@ -1,11 +1,11 @@
from bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt
import bouquin.settings_dialog as sd
from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.db import DBConfig, DBManager
from bouquin.key_prompt import KeyPrompt
from bouquin.settings import get_settings
from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):

View file

@ -1,13 +1,11 @@
import datetime as _dt
from datetime import datetime, timedelta, date
from datetime import date, datetime, timedelta
from bouquin import strings
from PySide6.QtCore import Qt, QPoint, QDate
from PySide6.QtWidgets import QLabel, QWidget
from PySide6.QtTest import QTest
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QLabel, QWidget
class FakeStatsDB:
@ -16,6 +14,7 @@ class FakeStatsDB:
def __init__(self):
d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2)
self.stats = (
2, # pages_with_content
5, # total_revisions
@ -27,7 +26,20 @@ class FakeStatsDB:
"2024-01-02", # page_most_tags
2, # page_most_tags_count
{d1: 1, d2: 2}, # revisions_by_date
{d1: 60, d2: 120}, # time_minutes_by_date
180, # total_time_minutes
"2024-01-02", # day_most_time
120, # day_most_time_minutes
"Project A", # project_most_minutes_name
120, # project_most_minutes
"Activity A", # activity_most_minutes_name
120, # activity_most_minutes
{d1: 1, d2: 3}, # reminders_by_date
4, # total_reminders
"2024-01-02", # day_most_reminders
3, # day_most_reminders_count
)
self.called = False
def gather_stats(self):
@ -59,7 +71,7 @@ def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
# Heatmap is created and uses "words" by default
words_by_date = db.stats[4]
revisions_by_date = db.stats[-1]
revisions_by_date = db.stats[9]
assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date
@ -82,13 +94,25 @@ class EmptyStatsDB:
0, # pages_with_content
0, # total_revisions
None, # page_most_revisions
0,
0, # page_most_revisions_count
{}, # words_by_date
0, # total_words
0, # unique_tags
None, # page_most_tags
0,
0, # page_most_tags_count
{}, # revisions_by_date
{}, # time_minutes_by_date
0, # total_time_minutes
None, # day_most_time
0, # day_most_time_minutes
None, # project_most_minutes_name
0, # project_most_minutes
None, # activity_most_minutes_name
0, # activity_most_minutes
{}, # reminders_by_date
0, # total_reminders
None, # day_most_reminders
0, # day_most_reminders_count
)
@ -632,5 +656,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
# Force a repaint to execute paintEvent
heatmap.repaint()
# The month continuation logic (line 175) should prevent duplicate labels
# The month continuation logic should prevent duplicate labels
# We can't easily test the visual output, but we ensure no crash

View file

@ -1,12 +1,11 @@
import types
from PySide6.QtWidgets import QFileDialog
from PySide6.QtGui import QTextCursor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from bouquin.main_window import MainWindow
from bouquin.history_dialog import HistoryDialog
from bouquin.main_window import MainWindow
from bouquin.settings import get_settings
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QFileDialog
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):

View file

@ -1,23 +1,20 @@
import bouquin.strings as strings
import pytest
from PySide6.QtCore import Qt, QPoint, QEvent, QDate
from PySide6.QtGui import QMouseEvent, QColor
from bouquin.db import DBManager
from bouquin.flow_layout import FlowLayout
from bouquin.strings import load_strings
from bouquin.tag_browser import TagBrowserDialog
from bouquin.tags_widget import PageTagsWidget, TagChip
from PySide6.QtCore import QDate, QEvent, QPoint, Qt
from PySide6.QtGui import QColor, QMouseEvent
from PySide6.QtWidgets import (
QApplication,
QMessageBox,
QInputDialog,
QColorDialog,
QDialog,
QInputDialog,
QMessageBox,
)
from bouquin.db import DBManager
from bouquin.strings import load_strings
from bouquin.tags_widget import PageTagsWidget, TagChip
from bouquin.tag_browser import TagBrowserDialog
from bouquin.flow_layout import FlowLayout
from sqlcipher3.dbapi2 import IntegrityError
import bouquin.strings as strings
from sqlcipher4.dbapi2 import IntegrityError
# ============================================================================
# DB Layer Tag Tests
@ -1649,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db):
def test_flow_layout_take_at_invalid_index(app):
"""Test FlowLayout.takeAt with out-of-bounds index"""
from PySide6.QtWidgets import QWidget, QLabel
from PySide6.QtWidgets import QLabel, QWidget
widget = QWidget()
layout = FlowLayout(widget)
@ -1673,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app):
def test_flow_layout_take_at_boundary(app):
"""Test FlowLayout.takeAt at exact boundary"""
from PySide6.QtWidgets import QWidget, QLabel
from PySide6.QtWidgets import QLabel, QWidget
widget = QWidget()
layout = FlowLayout(widget)

View file

@ -1,8 +1,7 @@
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from bouquin.theme import Theme, ThemeConfig, ThemeManager
def test_theme_manager_apply_light_and_dark(app):
cfg = ThemeConfig(theme=Theme.LIGHT)

View file

@ -1,21 +1,18 @@
import pytest
from datetime import date, timedelta
from PySide6.QtCore import Qt, QDate
from PySide6.QtWidgets import (
QMessageBox,
QInputDialog,
QFileDialog,
)
from sqlcipher3.dbapi2 import IntegrityError
from unittest.mock import MagicMock, patch
from bouquin.theme import ThemeManager, ThemeConfig, Theme
import bouquin.strings as strings
import pytest
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.time_log import (
TimeLogWidget,
TimeLogDialog,
TimeCodeManagerDialog,
TimeLogDialog,
TimeLogWidget,
TimeReportDialog,
)
import bouquin.strings as strings
from PySide6.QtCore import QDate, Qt
from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox
from sqlcipher4.dbapi2 import IntegrityError
@pytest.fixture
@ -1187,8 +1184,8 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 0
assert dialog.granularity.count() == 3 # day, week, month
assert dialog.project_combo.count() == 1
assert dialog.granularity.count() == 5
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
@ -1199,21 +1196,41 @@ def test_time_report_dialog_loads_projects(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 2
assert dialog.project_combo.count() == 3
def test_time_report_dialog_default_date_range(qtbot, fresh_db):
"""Dialog defaults to last 7 days."""
"""Dialog defaults to start of month."""
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
today = QDate.currentDate()
week_ago = today.addDays(-7)
start_of_month = QDate(today.year(), today.month(), 1)
assert dialog.from_date.date() == week_ago
assert dialog.from_date.date() == start_of_month
assert dialog.to_date.date() == today
def test_time_report_dialog_last_month_preset_sets_full_previous_month(qtbot, fresh_db):
"""Selecting 'Last month' sets the date range to the previous calendar month."""
strings.load_strings("en")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
idx = dialog.range_preset.findData("last_month")
assert idx != -1
today = QDate.currentDate()
start_of_this_month = QDate(today.year(), today.month(), 1)
expected_start = start_of_this_month.addMonths(-1)
expected_end = start_of_this_month.addDays(-1)
dialog.range_preset.setCurrentIndex(idx)
assert dialog.from_date.date() == expected_start
assert dialog.to_date.date() == expected_end
def test_time_report_dialog_run_report(qtbot, fresh_db):
"""Run a time report."""
strings.load_strings("en")
@ -1227,12 +1244,14 @@ def test_time_report_dialog_run_report(qtbot, fresh_db):
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(0) # day
idx_day = dialog.granularity.findData("day")
assert idx_day != -1
dialog.granularity.setCurrentIndex(idx_day)
dialog._run_report()
assert dialog.table.rowCount() == 1
assert "Activity" in dialog.table.item(0, 1).text()
assert "Activity" in dialog.table.item(0, 2).text()
assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text()
@ -1414,13 +1433,18 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db):
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week
idx_week = dialog.granularity.findData("week")
assert idx_week != -1
dialog.granularity.setCurrentIndex(idx_week)
dialog._run_report()
# Should aggregate to single week
assert dialog.table.rowCount() == 1
# In grouped modes the Note column is hidden → hours are in column 3
hours_text = dialog.table.item(0, 3).text()
assert "2.5" in hours_text or "2.50" in hours_text
@ -1442,13 +1466,17 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db):
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(2) # month
idx_month = dialog.granularity.findData("month")
assert idx_month != -1
dialog.granularity.setCurrentIndex(idx_month)
dialog._run_report()
# Should aggregate to single month
assert dialog.table.rowCount() == 1
hours_text = dialog.table.item(0, 3).text()
assert "2.5" in hours_text or "2.50" in hours_text
@ -1497,40 +1525,6 @@ def test_time_log_widget_calculates_per_project_totals(qtbot, fresh_db):
assert "1.50h" in summary
def test_time_report_dialog_csv_export_handles_os_error(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""CSV export handles OSError gracefully."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog._run_report()
# Use a path that will cause an error (e.g., directory instead of file)
bad_path = str(tmp_path)
def mock_get_save_filename(*args, **kwargs):
return bad_path, "CSV Files (*.csv)"
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._export_csv()
assert warning_shown["shown"]
# ============================================================================
# Additional TimeLogWidget Edge Cases
# ============================================================================
@ -1968,10 +1962,13 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.project_combo.setCurrentIndex(1)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week
idx_week = dialog.granularity.findData("week")
assert idx_week != -1
dialog.granularity.setCurrentIndex(idx_week)
dialog._run_report()
@ -2007,7 +2004,10 @@ def test_time_report_dialog_pdf_export_with_multiple_periods(
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(0) # day
idx_day = dialog.granularity.findData("day")
assert idx_day != -1
dialog.granularity.setCurrentIndex(idx_day)
dialog._run_report()
@ -2185,10 +2185,10 @@ def test_full_workflow_add_project_activity_log_report(
# Verify report
assert report_dialog.table.rowCount() == 1
assert "Test Activity" in report_dialog.table.item(0, 1).text()
assert "Test Activity" in report_dialog.table.item(0, 2).text()
assert (
"2.5" in report_dialog.table.item(0, 3).text()
or "2.50" in report_dialog.table.item(0, 3).text()
"2.5" in report_dialog.table.item(0, 4).text()
or "2.50" in report_dialog.table.item(0, 4).text()
)
# 5. Export CSV
@ -2596,3 +2596,437 @@ def test_time_log_with_entry(qtbot, fresh_db):
# Widget should have been created successfully
assert widget is not None
def test_time_log_widget_open_dialog_log_only_when_no_date(qtbot, app, fresh_db):
"""Test _open_dialog_log_only when _current_date is None."""
widget = TimeLogWidget(fresh_db, themes=None)
qtbot.addWidget(widget)
# Set current date to None
widget._current_date = None
# Click should return early without crashing
widget._open_dialog_log_only()
# No dialog should be shown
def test_time_log_widget_open_dialog_log_only_opens_dialog(qtbot, app, fresh_db):
"""Test _open_dialog_log_only opens TimeLogDialog."""
widget = TimeLogWidget(fresh_db, themes=None)
qtbot.addWidget(widget)
# Set a valid date
widget._current_date = "2024-01-15"
# Mock TimeLogDialog
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog):
widget._open_dialog_log_only()
# Dialog should have been created with correct parameters
assert mock_dialog.exec.called
def test_time_log_widget_open_dialog_log_only_refreshes_when_collapsed(
qtbot, app, fresh_db
):
"""Test that opening dialog updates summary when widget is collapsed."""
widget = TimeLogWidget(fresh_db, themes=None)
qtbot.addWidget(widget)
widget._current_date = "2024-01-15"
# Collapse the widget
widget.toggle_btn.setChecked(False)
# Mock TimeLogDialog
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog):
widget._open_dialog_log_only()
# Should show collapsed hint
assert (
"collapsed" in widget.summary_label.text().lower()
or widget.summary_label.text() != ""
)
def test_time_log_dialog_log_entry_only_mode(qtbot, app, fresh_db):
"""Test TimeLogDialog in log_entry_only mode."""
dialog = TimeLogDialog(
fresh_db, "2024-01-15", log_entry_only=True, themes=None, close_after_add=True
)
qtbot.addWidget(dialog)
# In log_entry_only mode, these should be hidden
assert not dialog.delete_btn.isVisible()
assert not dialog.report_btn.isVisible()
assert not dialog.table.isVisible()
def test_time_log_dialog_log_entry_only_false(qtbot, app, fresh_db):
"""Test TimeLogDialog in normal mode (log_entry_only=False)."""
dialog = TimeLogDialog(
fresh_db, "2024-01-15", log_entry_only=False, themes=None, close_after_add=False
)
qtbot.addWidget(dialog)
dialog.show()
qtbot.waitExposed(dialog)
# In normal mode, these should be visible
assert dialog.delete_btn.isVisible()
assert dialog.report_btn.isVisible()
assert dialog.table.isVisible()
def test_time_log_dialog_change_date_cancelled(qtbot, app, fresh_db):
"""Test _on_change_date_clicked when user cancels."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Mock exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
original_date = dialog._date_iso
dialog._on_change_date_clicked()
# Date should not change when cancelled
assert dialog._date_iso == original_date
def test_time_log_dialog_change_date_accepted(qtbot, app, fresh_db):
"""Test _on_change_date_clicked when user accepts (covers lines 410-450)."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Mock exec to return accepted - the dialog will use whatever date is in the calendar
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
# Just verify it doesn't crash - actual date may or may not change
# depending on what the real QCalendarWidget selects
dialog._on_change_date_clicked()
# Dialog should still be functional
assert dialog._date_iso is not None
def test_time_log_dialog_change_date_with_invalid_current_date(qtbot, app, fresh_db):
"""Test _on_change_date_clicked when current date is invalid (covers lines 410-412)."""
dialog = TimeLogDialog(fresh_db, "invalid-date", themes=None)
qtbot.addWidget(dialog)
# Should fall back to current date without crashing
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
dialog._on_change_date_clicked()
def test_time_log_dialog_change_date_with_themes(qtbot, app, fresh_db):
"""Test _on_change_date_clicked with theme manager (covers line 423-424)."""
themes_mock = MagicMock()
themes_mock.register_calendar = MagicMock()
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=themes_mock)
qtbot.addWidget(dialog)
# Mock exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
dialog._on_change_date_clicked()
# Theme should have been applied to calendar
assert themes_mock.register_calendar.called
def test_time_log_dialog_table_item_changed_incomplete_row(qtbot, app, fresh_db):
"""Test _on_table_item_changed with incomplete row."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add incomplete row
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
# Only add project item, missing others
proj_item = QTableWidgetItem("Project")
dialog.table.setItem(0, 0, proj_item)
# Call _on_table_item_changed
dialog._on_table_item_changed(proj_item)
dialog.table.blockSignals(False)
# Should return early without crashing (covers lines 556-558)
def test_time_log_dialog_table_item_changed_creates_new_project(qtbot, app, fresh_db):
"""Test _on_table_item_changed creating a new project on the fly."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add a complete row with new project name
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Brand New Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID
act_item = QTableWidgetItem("Activity")
note_item = QTableWidgetItem("Note")
hours_item = QTableWidgetItem("2.5")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 2, note_item)
dialog.table.setItem(0, 3, hours_item)
# Call _on_table_item_changed
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Should have created project and called add/update
projects = fresh_db.list_projects()
project_names = [name for _, name in projects]
assert "Brand New Project" in project_names
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_without_note(qtbot, app, fresh_db):
"""Test _on_table_item_changed when note_item is None."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row without note
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None)
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("1.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
# Note: Don't set note_item (leave as None)
dialog.table.setItem(0, 3, hours_item)
# Call _on_table_item_changed
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Should handle None note gracefully (covers line 567)
assert dialog.note.text() == ""
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_sets_button_state_for_new_entry(
qtbot, app, fresh_db
):
"""Test that _on_table_item_changed sets correct button state for new entry."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row without entry ID (new entry)
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("1.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 3, hours_item)
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Delete button should be disabled for new entry (covers lines 601-603)
assert not dialog.delete_btn.isEnabled()
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_sets_button_state_for_existing_entry(
qtbot, app, fresh_db
):
"""Test that _on_table_item_changed sets correct button state for existing entry."""
# Add a time log entry first
proj_id = fresh_db.add_project("Test Project")
act_id = fresh_db.add_activity("Activity")
entry_id = fresh_db.add_time_log(
"2024-01-15", proj_id, act_id, 120, "Note"
) # 120 minutes = 2 hours
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row with entry ID
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Test Project")
proj_item.setData(Qt.ItemDataRole.UserRole, entry_id)
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("2.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 3, hours_item)
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Delete button should be enabled for existing entry (covers lines 604-606)
assert dialog.delete_btn.isEnabled()
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_finds_existing_project_by_name(
qtbot, app, fresh_db
):
"""Test _on_table_item_changed finding existing project by name."""
proj_id = fresh_db.add_project("Existing Project")
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row with existing project name
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Existing Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None)
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("1.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 3, hours_item)
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Should find and select existing project (covers lines 571-580)
assert dialog.project_combo.currentData() == proj_id
dialog.table.blockSignals(False)
def test_time_report_dialog_initialization(qtbot, app, fresh_db):
"""Test TimeReportDialog initialization."""
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
# Should initialize without crashing
assert dialog is not None
def test_time_code_manager_dialog_initialization(qtbot, app, fresh_db):
"""Test TimeCodeManagerDialog initialization."""
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
# Should initialize without crashing
assert dialog is not None
def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db):
"""Test TimeCodeManagerDialog with initial tab focus."""
# Test with projects tab
dialog = TimeCodeManagerDialog(fresh_db, focus_tab="projects")
qtbot.addWidget(dialog)
assert dialog.tabs.currentIndex() == 0
# Test with activities tab
dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
qtbot.addWidget(dialog2)
assert dialog2.tabs.currentIndex() == 1
def test_time_report_no_grouping_returns_each_entry_and_note(fresh_db):
"""Granularity 'none' returns one row per entry and includes notes."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
date = _today()
fresh_db.add_time_log(date, proj_id, act_id, 60, note="First")
fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second")
report = fresh_db.time_report(proj_id, date, date, "none")
# Two separate rows, not aggregated.
assert len(report) == 2
# Each row is (period, activity_name, note, total_minutes)
periods = {r[0] for r in report}
activities = {r[1] for r in report}
notes = {r[2] for r in report}
minutes = sorted(r[3] for r in report)
assert periods == {date}
assert activities == {"Activity"}
assert notes == {"First", "Second"}
assert minutes == [30, 60]
def test_time_report_dialog_granularity_none_shows_each_entry_and_notes(
qtbot, fresh_db
):
"""'Don't group' granularity shows one row per log entry and includes notes."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
date = _today()
fresh_db.add_time_log(date, proj_id, act_id, 60, note="First")
fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
# Select the concrete project (index 0 is "All projects")
dialog.project_combo.setCurrentIndex(1)
dialog.from_date.setDate(QDate.fromString(date, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date, "yyyy-MM-dd"))
idx_none = dialog.granularity.findData("none")
assert idx_none != -1
dialog.granularity.setCurrentIndex(idx_none)
dialog._run_report()
# Two rows, not aggregated
assert dialog.table.rowCount() == 2
# Notes in column 3
notes = {dialog.table.item(row, 3).text() for row in range(dialog.table.rowCount())}
assert "First" in notes
assert "Second" in notes
# Hours in last column (index 4) when not grouped
hours = [dialog.table.item(row, 4).text() for row in range(dialog.table.rowCount())]
assert any("1.00" in h or "1.0" in h for h in hours)
assert any("0.50" in h or "0.5" in h for h in hours)

View file

@ -1,8 +1,8 @@
import pytest
from PySide6.QtWidgets import QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.toolbar import ToolBar
from PySide6.QtWidgets import QWidget
@pytest.fixture

View file

@ -1,9 +1,10 @@
import pytest
from unittest.mock import Mock, patch
import subprocess
from unittest.mock import Mock, patch
import pytest
from bouquin.version_check import VersionChecker
from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QMessageBox, QWidget
def test_version_checker_init(app):
@ -172,45 +173,6 @@ def test_check_for_updates_already_latest(qtbot, app):
assert mock_info.called
def test_check_for_updates_new_version_available_declined(qtbot, app):
"""Test check for updates when new version is available but user declines."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "2.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
# Should not proceed to download
checker.check_for_updates()
def test_check_for_updates_new_version_available_accepted(qtbot, app):
"""Test check for updates when new version is available and user accepts."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "2.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
with patch.object(
checker, "_download_and_verify_appimage"
) as mock_download:
checker.check_for_updates()
# Should call download
mock_download.assert_called_once_with("2.0.0")
def test_download_file_success(qtbot, app, tmp_path):
"""Test downloading a file successfully."""
checker = VersionChecker()