Compare commits

..

232 commits

Author SHA1 Message Date
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
1d94c04551
Add argparse to find_unused_strings.py so we can pass in --locale rather than hardcoded
All checks were successful
CI / test (push) Successful in 5m20s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 24s
2025-11-27 11:02:33 +11:00
9a82831e87
Remove table tool 2025-11-27 11:02:20 +11:00
81cf878ffd
0.5.2
Some checks failed
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s
CI / test (push) Failing after 5m24s
2025-11-27 09:57:49 +11:00
73aa536a32
Small README tweak 2025-11-27 09:57:39 +11:00
219b358569
remove problematic interactive test 2025-11-27 09:57:26 +11:00
5a54d809ed
Fix HTML export of markdown (with newlines, tables and other styling preserved) 2025-11-27 09:57:14 +11:00
917e51aa0b
Use same alarm icon for the Reminders sidebar widget 2025-11-27 09:56:29 +11:00
576dc435ef
Adjust History icon and reorder toolbar items. Try to address checkbox/bullet size issues (again) 2025-11-27 09:45:15 +11:00
fdc72a1146
Update icon again to remove background 2025-11-27 08:55:28 +11:00
9435800910
More tests
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 25s
2025-11-26 17:12:58 +11:00
cb78d9f783
Black
All checks were successful
CI / test (push) Successful in 4m55s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 22s
2025-11-26 14:11:13 +11:00
46aed33cf7
Ensure toolbar is always loaded at end of MainWindow init (weird random bug)
Some checks failed
CI / test (push) Successful in 5m43s
Lint / test (push) Failing after 29s
Trivy / test (push) Successful in 24s
2025-11-26 13:37:13 +11:00
808b878658
Fix code blocks so reserved keywords inside strings don't get painted as reserved
All checks were successful
CI / test (push) Successful in 4m56s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 23s
2025-11-26 13:25:44 +11:00
52c1836c9c
Tweak README screenshots
All checks were successful
CI / test (push) Successful in 4m54s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 23s
2025-11-26 11:53:36 +11:00
10fcd28e23
0.5.1
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-26 11:52:57 +11:00
aafff413fe
Changelog 2025-11-26 11:52:41 +11:00
c0a9c5be2b
Improve size of reminder dialog 2025-11-26 11:52:18 +11:00
770fd0e9ee
Update main screenshot 2025-11-26 11:51:40 +11:00
d070907e6c
Update French translations
All checks were successful
CI / test (push) Successful in 5m23s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 24s
2025-11-26 11:31:22 +11:00
989680845b
Replace icon 2025-11-26 11:19:27 +11:00
17560af249
Add in NotoSans regular. Use it as default font, switch to symbols font for checkboxes/bullets 2025-11-26 11:19:18 +11:00
41227e181f
Pass extra arg just in case
All checks were successful
CI / test (push) Successful in 5m30s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 22s
2025-11-25 16:27:46 +11:00
c0206bd626
Remove old alarm logic
All checks were successful
CI / test (push) Successful in 4m51s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2025-11-25 15:43:22 +11:00
e0169db52a
Many changes and new features:
All checks were successful
CI / test (push) Successful in 5m17s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 25s
* Make reminders be its own dataset rather than tied to current string.
 * Add support for repeated reminders
 * Make reminders be a feature that can be turned on and off
 * Add syntax highlighting for code blocks (right-click to set it)
 * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
 * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
2025-11-25 14:52:26 +11:00
26737fbfb2
Set locked status on window title when locked. Don't exit on incorrect key, let it be tried again
All checks were successful
CI / test (push) Successful in 4m35s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2025-11-25 10:46:11 +11:00
648031786a
Add more Italian translations (thank you @mdaleo404)
All checks were successful
CI / test (push) Successful in 5m7s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 22s
2025-11-25 09:19:11 +11:00
5b42e921a5
0.4.5
All checks were successful
CI / test (push) Successful in 4m27s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2025-11-24 15:42:25 +11:00
164aa1eb83
Undo statistics dialog tweak and just add more height.. Fix exception handling in version check 2025-11-24 15:36:14 +11:00
632cdf5176
Revert "More tweaks to statistics dialog size"
This reverts commit 4ec8c64994.
2025-11-24 15:19:15 +11:00
32bf2e1187
Fix pyflakes
All checks were successful
CI / test (push) Successful in 4m33s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2025-11-24 11:04:07 +11:00
47a380ad38
Make it possible to force-lock the screen even if idle timer hasn't tripped. Add shortcuts for lock and unlock of screen 2025-11-24 11:03:03 +11:00
807a21e9af
Make it possible to delete revisions in the history dialog
Some checks failed
CI / test (push) Successful in 4m57s
Lint / test (push) Failing after 31s
Trivy / test (push) Successful in 24s
2025-11-24 10:56:04 +11:00
4ec8c64994
More tweaks to statistics dialog size 2025-11-24 10:55:47 +11:00
054ad88991
Fix version test 2025-11-24 10:47:54 +11:00
78a06e8a73
0.4.4.1
Some checks failed
CI / test (push) Failing after 4m37s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 24s
2025-11-23 20:54:36 +11:00
05879131b4
* Adjust some widget heights/settings text wrap
* Adjust shortcuts
 * History unicode symbol
 * Icon in version dialog
2025-11-23 20:54:19 +11:00
a0153a370b
0.4.4
All checks were successful
CI / test (push) Successful in 4m34s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 23s
2025-11-23 18:34:30 +11:00
5bf6d4c4d6
Improve moving unchecked TODOs to next weekday and from last 7 days. New version checker. Remove newline after headings
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-23 18:34:02 +11:00
ab0a9400c9
Tweak README/release
All checks were successful
CI / test (push) Successful in 4m42s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 21s
2025-11-22 20:54:40 +11:00
0960a8587e
Use Ctrl+Shift+G for settings
All checks were successful
CI / test (push) Successful in 4m23s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-11-22 20:37:15 +11:00
777d27ecb9
Ship specific font
All checks were successful
CI / test (push) Successful in 4m23s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 24s
2025-11-22 20:13:40 +11:00
511adb1c34
Fix test and appimage icon stuff
All checks were successful
CI / test (push) Successful in 4m15s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-22 17:52:39 +11:00
f8c4abd899
more screenshots
Some checks failed
CI / test (push) Failing after 4m23s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 21s
2025-11-22 17:28:58 +11:00
75432ca35d
align
Some checks failed
CI / test (push) Failing after 4m18s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-22 17:18:37 +11:00
de23c75478
Fix img
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-22 17:17:25 +11:00
44e357dd66
Adjust other images
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-22 17:15:07 +11:00
40c218674f
Increase width
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-22 17:11:02 +11:00
6820e3b3ab
reduce width, center
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-22 17:10:30 +11:00
6becae6eac
Add .desktop file, icons
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-22 17:09:08 +11:00
2a78027afc
Bump version
All checks were successful
CI / test (push) Successful in 4m40s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-22 09:20:58 +11:00
3711df0206
Try to force Noto Sans Regular font if present. Stop increasing the checkbox/bullet sizes separately
Some checks are pending
CI / test (push) Waiting to run
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
2025-11-22 09:20:11 +11:00
ca3c839c7d
More tests
All checks were successful
CI / test (push) Successful in 4m40s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2025-11-21 14:30:38 +11:00
e8db5bcf7d
Fix not accidentally forcing key to saved config when font size is saved
All checks were successful
CI / test (push) Successful in 3m57s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2025-11-21 13:35:11 +11:00
4f8d916346
Black
All checks were successful
CI / test (push) Successful in 4m0s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 20s
2025-11-21 12:35:45 +11:00
4fb5be96b1
Add script to detect obsolete or undefined locale strings 2025-11-21 12:35:17 +11:00
e7ef615053
Fix newline bug in idle timer explanation in settings
All checks were successful
CI / test (push) Successful in 4m4s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 20s
2025-11-21 12:00:19 +11:00
4adccc3d95
Refactored settings dialog to use tabs to reduce its size
All checks were successful
CI / test (push) Successful in 4m6s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 26s
2025-11-21 11:45:05 +11:00
151d053d95
Add ctrl+shift+-/+ shortcuts for increasing/decreasing font size 2025-11-21 10:42:44 +11:00
0e5d622a4e
Make it possible to change the font size for regular text
All checks were successful
CI / test (push) Successful in 4m14s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 20s
2025-11-21 10:30:40 +11:00
0923fb4395
Remove needless try/catch stuff 2025-11-21 09:56:33 +11:00
3e91f158c3
Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
All checks were successful
CI / test (push) Successful in 4m31s
Lint / test (push) Successful in 1m2s
Trivy / test (push) Successful in 23s
2025-11-21 08:50:06 +11:00
f9ee150a23
Improve Statistics widget height, improve SaveDialog widget width 2025-11-21 08:18:44 +11:00
cff5f864e4
Add 'Close tab' nav item and shortcut. Add extra newline after headings
All checks were successful
CI / test (push) Successful in 3m57s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 20s
2025-11-20 17:19:03 +11:00
243980e006
Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) 2025-11-20 17:02:41 +11:00
511e7ae7b8
Add keyboard shortcuts for tag and time log dialogs, remove reset of note text 2025-11-20 17:01:58 +11:00
01963ed6a7
Fix indentation in locales file
All checks were successful
CI / test (push) Successful in 4m10s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 23s
2025-11-20 14:34:32 +11:00
a7d2c5500e
Allow time log entries to be edited directly in their table cells 2025-11-20 14:33:32 +11:00
0bc5a37605
Bump version 2025-11-19 21:21:40 +11:00
6bc6fe4b83
Fix formatting of total hours in timesheet report (2 decimals)
All checks were successful
CI / test (push) Successful in 4m16s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 21s
2025-11-19 21:20:58 +11:00
119d326eea
Expose note in time log dialog and reports
All checks were successful
CI / test (push) Successful in 3m58s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 22s
2025-11-19 20:50:04 +11:00
f41ec9a5a9
Add missing labels, correct typo
All checks were successful
CI / test (push) Successful in 3m52s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-19 17:25:06 +11:00
fc05231268
Wider statistics widget
All checks were successful
CI / test (push) Successful in 3m51s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-19 15:35:15 +11:00
985541a1d8
remove time graph visualiser. More tests. Other fixes
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-19 15:33:31 +11:00
0b3249c7ef
Fix reloading summary on collapsed Time Log widget after closing Time Log dialog
All checks were successful
CI / test (push) Successful in 3m29s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-19 12:46:38 +11:00
4db40e6b4b
Fix tag graph test
All checks were successful
CI / test (push) Successful in 3m30s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-11-19 12:38:35 +11:00
85e2a93199
Add PDF reporting for time log tool, improve totals in widget 2025-11-19 12:38:25 +11:00
ef10e0aab7
shortcut for time log. adjust checkbox size again. Use icon for Images in toolbar
Some checks failed
CI / test (push) Failing after 3m47s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 21s
2025-11-19 11:47:27 +11:00
55b78833ac
Initial work on time logging
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Has been cancelled
Trivy / test (push) Has been cancelled
2025-11-18 21:51:04 +11:00
83f25405db
Add ability to set alarm reminders
Some checks failed
CI / test (push) Failing after 3m25s
Lint / test (push) Failing after 25s
Trivy / test (push) Successful in 22s
2025-11-18 20:38:39 +11:00
63cf561bfe
Improve size of checkboxes. Convert bullet '-' to actual unicode bullets
All checks were successful
CI / test (push) Successful in 3m30s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 20s
2025-11-18 18:21:09 +11:00
a375be629c
Ignore .db 2025-11-18 17:32:53 +11:00
01997aee90
Add tag relationship visualisation graph tool
All checks were successful
CI / test (push) Successful in 3m43s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 24s
2025-11-18 17:29:57 +11:00
90d871246b
Improve width of bug report dialog 2025-11-18 16:26:49 +11:00
931d08d9d7
Remove screenshot tool 2025-11-18 16:22:07 +11:00
a4d47edba5
Revert "Add ability to take screenshots in-app and insert them into the page"
This reverts commit 34349c6133.
2025-11-18 16:21:11 +11:00
c2b2eee022
Allow clicking on a date in the Statistics heatmap and have it open that page
All checks were successful
CI / test (push) Successful in 3m14s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 23s
2025-11-17 17:48:33 +11:00
34349c6133
Add ability to take screenshots in-app and insert them into the page
All checks were successful
CI / test (push) Successful in 3m21s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 21s
2025-11-17 17:43:23 +11:00
eedf48dc6a
Add ability to send a bug report from within the app
Some checks failed
CI / test (push) Successful in 3m23s
Lint / test (push) Failing after 27s
Trivy / test (push) Successful in 21s
2025-11-17 16:06:33 +11:00
6bc5b66d3f
Add the ability to choose the database path at startup. Add more tests. Add bandit
All checks were successful
CI / test (push) Successful in 3m49s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 21s
2025-11-17 15:15:00 +11:00
8c7226964a
Add weekday letters on left axis of statistics page 2025-11-17 14:02:45 +11:00
31a547e2a0
Bump version 2025-11-15 12:35:07 +11:00
741d2cc79e
Remove unused attribute, update Vulture allow list for Heat Map
All checks were successful
CI / test (push) Successful in 3m10s
Lint / test (push) Successful in 15s
Trivy / test (push) Successful in 20s
2025-11-15 12:34:42 +11:00
22901d0e51
Restore link styling and clickability
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-11-15 12:32:45 +11:00
97e723ce34
Remove export to txt, the .md markdown is really the same
Some checks failed
CI / test (push) Successful in 3m14s
Lint / test (push) Failing after 14s
Trivy / test (push) Successful in 20s
2025-11-15 12:12:18 +11:00
7ef79c495b
Add a statistics dialog with heatmap
Some checks failed
CI / test (push) Successful in 3m20s
Lint / test (push) Failing after 15s
Trivy / test (push) Successful in 23s
2025-11-15 12:06:06 +11:00
b1ba599e99
Make it possible to add a tag from the Tag Browser
All checks were successful
CI / test (push) Successful in 3m46s
Lint / test (push) Successful in 17s
Trivy / test (push) Successful in 21s
2025-11-15 11:22:40 +11:00
b1ae56270a
Add a test for update_tag integrity guard
All checks were successful
CI / test (push) Successful in 3m18s
Lint / test (push) Successful in 15s
Trivy / test (push) Successful in 23s
2025-11-14 17:57:23 +11:00
1becb7900e
Prevent traceback on trying to edit a tag with the same name as another tag. Various other tweaks. Bump version
All checks were successful
CI / test (push) Successful in 3m31s
Lint / test (push) Successful in 16s
Trivy / test (push) Successful in 21s
2025-11-14 17:30:58 +11:00
02a60ca656
Fix tests, add vulture_ignorelist.py, fix markdown_editor highlighter bug 2025-11-14 16:16:27 +11:00
f6e10dccac
Tags working 2025-11-14 14:54:04 +11:00
3263788415
Merge branch 'main' into tags 2025-11-14 13:54:46 +11:00
4552537121
Various bug fixes.
All checks were successful
CI / test (push) Successful in 2m19s
Lint / test (push) Successful in 14s
Trivy / test (push) Successful in 20s
* Prevent being able to left-click a date and have it load in current tab if it is already open in another tab
 * Avoid second checkbox/bullet on second newline after first newline
 * Avoid Home/left arrow jumping to the left side of a list symbol
2025-11-14 13:32:29 +11:00
5e283ecf17
WIP 2025-11-14 13:18:58 +11:00
df7ae0b42d
Merge branch 'main' into tags 2025-11-14 12:12:38 +11:00
07c8e31c66
Add Italian translations (thanks @mdaleo404)
All checks were successful
CI / test (push) Successful in 2m27s
Lint / test (push) Successful in 15s
Trivy / test (push) Successful in 20s
2025-11-14 11:41:55 +11:00
0773084ec5
Increase line spacing between lines (except for code blocks) 2025-11-14 11:26:09 +11:00
0a04b25fe5
Early work on tags 2025-11-13 20:37:02 +11:00
8cd9538a50
Add --diff and --check to black
All checks were successful
CI / test (push) Successful in 2m23s
Lint / test (push) Successful in 13s
Trivy / test (push) Successful in 20s
2025-11-13 16:32:21 +11:00
d338033333
Add version info. Add linter
All checks were successful
CI / test (push) Successful in 2m22s
Lint / test (push) Successful in 13s
Trivy / test (push) Successful in 21s
2025-11-13 16:26:35 +11:00
c191d9f35c
tweaks
All checks were successful
CI / test (push) Successful in 2m26s
Trivy / test (push) Successful in 22s
2025-11-13 14:58:04 +11:00
e6841cd5f8
try trivy again
Some checks failed
CI / test (push) Has been cancelled
Trivy / test (push) Successful in 21s
2025-11-13 14:56:19 +11:00
09cfdd3d88
meh
All checks were successful
CI / test (push) Successful in 2m18s
2025-11-13 14:49:34 +11:00
8e987275b5
try again
Some checks failed
CI / test (push) Has been cancelled
Trivy / test (push) Failing after 0s
2025-11-13 14:49:00 +11:00
dd115ca58d
Force a run
Some checks failed
CI / test (push) Has been cancelled
Trivy / test (push) Failing after 56s
2025-11-13 14:43:55 +11:00
93d7a676a5
Add trivy workflow
Some checks failed
CI / test (push) Has been cancelled
2025-11-13 14:43:07 +11:00
c18f0f6f36
Make locales dynamically detected from the locales dir rather than hardcoded
All checks were successful
CI / test (push) Successful in 2m21s
2025-11-13 14:33:34 +11:00
a4e9af4444 Attempt to add forgejo workflow (!2)
All checks were successful
CI / test (push) Successful in 2m21s
Reviewed-on: #2
2025-11-12 21:25:01 -06:00
db0476f9ad
Well, 95% test coverage is okay I guess 2025-11-13 11:52:21 +11:00
ab5ec2bfae
Remove code we won't reach in strings.py because we always pass a default locale 2025-11-12 16:45:07 +11:00
e9f1f97934
Update screenshot 2025-11-12 14:09:52 +11:00
f6bcc2b789
Fix hiding status bar (and find bar) when locked 2025-11-12 14:09:47 +11:00
f578d562e6
Add translation capability, offer English and French as options 2025-11-12 13:58:58 +11:00
54a6be835f
Bump version 2025-11-12 10:54:42 +11:00
494b14136b
Move lock_overlay/calendar theming to the ThemeManager 2025-11-12 10:51:08 +11:00
118e192639
Whoops, QColor 2025-11-12 10:21:58 +11:00
8d5f8b6951
One more fix for code blocks in dark system mode on macOS 2025-11-12 10:21:12 +11:00
b6e603b2bf
Fixes for system dark theme 2025-11-12 10:18:36 +11:00
1527937f8b
Fix being able to set bold, italic and strikethrough at the same time. 2025-11-12 10:11:38 +11:00
37332b5618
Add AppImage 2025-11-12 10:00:21 +11:00
43c31a1d97
More fixes to code blocks 2025-11-11 15:47:25 +11:00
1b706dec18
Editor tweaks 2025-11-11 14:59:48 +11:00
bfd0314109
Code cleanup, more tests 2025-11-11 13:12:30 +11:00
1c0052a0cf
Tweaks 2025-11-10 17:43:12 +11:00
61e37b7669
Fix tests 2025-11-10 10:54:17 +11:00
fd70ec9777
new screenshots 2025-11-10 10:49:30 +11:00
366822dcb7
Bump version 2025-11-10 10:39:48 +11:00
2942f244a0
Fix auto-save of a tab if we are moving to another tab and it has not yet saved 2025-11-10 10:31:26 +11:00
eac37d8843
DRY up some code 2025-11-10 10:25:46 +11:00
77eec9cc84
Increase font size of normal text 2025-11-10 10:00:02 +11:00
4f4735cfb6
Fix code backticks to not show but still be able to type code easily 2025-11-10 08:40:09 +11:00
bc9fa86281
Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line 2025-11-10 08:25:51 +11:00
dfde0d6e6c
Ensure tabs are ordered by calendar date, and some other code cleanups 2025-11-10 08:05:17 +11:00
ab1af80d10
Fix history pane, some small cleanups 2025-11-09 19:09:56 +11:00
f023224074
0.2.1 with tabs 2025-11-09 16:19:51 +11:00
fa23cf4da9
Fix chomping images when TODO is typed and converts to a checkbox 2025-11-08 18:13:56 +11:00
39576ac7f3 convert to markdown (#1)
Reviewed-on: #1
2025-11-08 00:30:46 -06:00
31604a0cd2
Fix newline after URL keeps URL formatting 2025-11-08 14:52:22 +11:00
1373c4ab06
Fix release script 2025-11-07 17:04:21 +11:00
5489854d58
Add find bar for searching for text in the editor 2025-11-07 17:03:14 +11:00
cf594487bc
Remove dist before signing 2025-11-07 14:03:17 +11:00
4bb0cc2880
Update tests example command, add release.sh 2025-11-07 13:59:23 +11:00
fde80bc271
Bump version 2025-11-07 13:55:03 +11:00
aad1ba5d7d
Fix focusing on editor after leaving the app and returning. More code coverage and removing obsolete bits of code 2025-11-07 13:53:27 +11:00
74177f2cd3
Code cleanup/comments, more test coverage (92%) 2025-11-07 11:42:29 +11:00
66950eeff5
More test coverage, remove unused functions from editor.py 2025-11-07 11:13:04 +11:00
ada1d8ffad
Some code consolidation in editor 2025-11-07 09:38:25 +11:00
71b6ee2651
Add missing export extensions to export_by_extension 2025-11-07 09:14:26 +11:00
b5563c18a4
Fix for code blocks in dark mode 2025-11-06 18:56:07 +11:00
107 changed files with 36698 additions and 4069 deletions

50
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,50 @@
name: CI
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3-venv pipx libgl1 libxcb-cursor0 libxkbcommon-x11-0 libegl1 libdbus-1-3 \
libopengl0 libx11-6 libxext6 libxi6 libxrender1 libxrandr2 \
libxcb1 libxcb-render0 libxcb-keysyms1 libxcb-image0 libxcb-shm0 \
libxcb-icccm4 libxcb-xfixes0 libxcb-shape0 libxcb-randr0 libxcb-xinerama0 \
libxkbcommon0
- name: Install Poetry
run: |
pipx install poetry==1.8.3
/root/.local/bin/poetry --version
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install project deps (including test extras)
run: |
poetry install --with test
- name: Run test script
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

@ -0,0 +1,41 @@
name: Lint
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
black pyflakes3 vulture python3-bandit
- name: Run linters
run: |
black --diff --check bouquin/*
black --diff --check tests/*
pyflakes3 bouquin/*
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

@ -0,0 +1,40 @@
name: Trivy
on:
schedule:
- cron: '0 1 * * *'
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
# 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"

4
.gitignore vendored
View file

@ -4,3 +4,7 @@ __pycache__
.pytest_cache
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,258 @@
# 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
* Adjust History icon and reorder toolbar items
* Try to address checkbox/bullet size issues (again)
* Fix HTML export of markdown (with newlines, tables and other styling preserved)
* Remove table tool
# 0.5.1
* Try to address Noto Sans font issue that works for both numbers and checkbox/bullets.
* Update icon
* Update French translations
* Improve size of flashing reminder dialog
# 0.5
* More Italian translations, thank you @mdaleo404
* Set locked status on window title when locked
* Don't exit on incorrect key, let it be tried again
* Make reminders be its own dataset rather than tied to current string.
* Add support for repeated reminders
* Make reminders be a feature that can be turned on and off
* Add syntax highlighting for code blocks (right-click to set it)
* Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
* Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
# 0.4.5
* Make it possible to delete revisions
* Make it possible to force-lock the screen even if idle timer hasn't tripped
* Add shortcuts for lock and unlock of screen
* Other misc bug fixes
# 0.4.4.1
* Adjust some widget heights/settings text wrap
* Adjust shortcuts
* History unicode symbol
* Icon in version dialog
# 0.4.4
* Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
* Moving unchecked TODOs now skips placing them on weekends.
* Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
* Check for new version / download new AppImage via the Help -> Version screen.
* Remove extra newline after headings
# 0.4.3
* Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora
# 0.4.2
* Improve Statistics widget height
* Improve SaveDialog widget width
* Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
* Make it possible to change regular text size
* Refactored Settings dialog to use tabs to reduce its size
# 0.4.1
* Allow time log entries to be edited directly in their table cells
* Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline)
* Add 'Close tab' nav item and shortcut
# 0.4
* Remove screenshot tool
* Improve width of bug report dialog
* Improve size of checkboxes
* Convert bullet - to actual unicode bullets
* Add alarm option to set reminders
* Add time logging and reporting
# 0.3.2
* Add weekday letters on left axis of Statistics page
* Allow clicking on a date in the Statistics heatmap and have it open that page
* Add the ability to choose the database path at startup
* Add in-app bug report functionality
# 0.3.1
* Make it possible to add a tag from the Tag Browser
* Add a statistics dialog with heatmap
* Remove export to .txt (just use .md)
* Restore link styling and clickability
# 0.3
* Introduce Tags
* Make translations dynamically detected from the locales dir rather than hardcoded
* Add Italian translations (thanks @mdaleo404)
* Add version information in the navigation
* Increase line spacing between lines (except for code blocks)
* Prevent being able to left-click a date and have it load in current tab if it is already open in another tab
* Avoid second checkbox/bullet on second newline after first newline
* Avoid Home/left arrow jumping to the left side of a list symbol
* Various test additions/fixes
# 0.2.1.8
* Translate all strings, add French, add locale choice in settings
* Fix hiding status bar (including find bar) when locked
# 0.2.1.7
* Fix being able to set bold, italic and strikethrough at the same time.
* Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager
* Add AppImage
# 0.2.1.6
* Some code cleanup and more coverage
* Improve code block styling / escaping out of the block in various scenarios
# 0.2.1.5
* Go back to font size 10 (I might add a switcher later)
* Fix bug with not syncing the right calendar date on search (History item would then be wrong too)
# 0.2.1.4
* Increase font size of normal text
* Fix auto-save of a tab if we are moving to another tab and it has not yet saved
* DRY up some code
# 0.2.1.3
* Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line
* Fix code backticks to not show but still be able to type code easily
# 0.2.1.2
* Ensure tabs are ordered by calendar date
* Some other code cleanups
# 0.2.1.1
* Fix history preview pane to be in markdown
* Some other code cleanups
# 0.2.1
* Introduce tabs!
# 0.2.0.1
* Fix chomping images when TODO is typed and converts to a checkbox
# 0.2.0
* Switch back to Markdown editor
# 0.1.12.1
* Fix newline after URL keeps URL style formatting
# 0.1.12
* Add find bar for searching for text in the editor
# 0.1.11
* Add missing export extensions to export_by_extension
* Fix focusing on editor after leaving the app and returning
* More code coverage and removing obsolete bits of code
# 0.1.10.2
* Fix for code blocks in dark mode
# 0.1.10.1
* Small bugfix for a debug message left in

View file

@ -1,9 +1,16 @@
# Bouquin
<div align="center">
<img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/bouquin/icons/bouquin.svg" alt="Bouquin logo" width="240" />
</div>
## Introduction
Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
Bouquin ("Book-ahn") is a notebook and planner application written in Python, Qt and SQLCipher.
It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
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.
@ -11,42 +18,77 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
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.
There is deliberately no network connectivity or syncing intended.
There is deliberately no network connectivity or syncing intended, other than the option to send a bug
report from within the app, or optionally to check for new versions to upgrade to.
## Screenshot
## Screenshots
![Screenshot of Bouquin](./screenshot.png)
### General view
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/screenshot.png" alt="Bouquin screenshot" /></a>
</div>
![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
### History panes
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png" alt="Screenshot of Bouquin History Preview Pane" width="500" style="margin: 0 10px;" /></a>
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png" alt="Screenshot of Bouquin History Diff Pane" width="500" style="margin: 0 10px;" /></a>
</div>
## Features
### Tags
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/tags.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/tags.png" alt="Screenshot of Bouquin Tag Manager screen" width="500" style="margin: 0 10px;" /></a>
</div>
### Time Logging
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/time.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/time.png" alt="Screenshot of Bouquin Time Log screens" width="500" style="margin: 0 10px;" /></a>
</div>
### Statistics
<div align="center">
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/statistics.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/statistics.png" alt="Bouquin statistics" /></a>
</div>
## Some of the features
* Data is encrypted at rest
* Encryption key is prompted for and never stored, unless user chooses to via Settings
* Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert
* Text is HTML with basic styling
* 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.
* Images are supported
* Search
* Search all pages, or find text on current page
* Add and manage tags
* Automatic periodic saving (or explicitly save)
* Transparent integrity checking of the database when it opens
* Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password)
* Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
* Export the database to json, html, csv, markdown or .sql (for sqlite3)
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
* Dark and light themes
* Dark and light theme support
* Automatically generate checkboxes when typing 'TODO'
* Optionally automatically move unchecked checkboxes from yesterday to today, on startup
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday.
* 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 store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
## How to install
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
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).
### From PyPi/pip
* `pip install bouquin`
### From AppImage
* Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it.
### From source
* Clone this repo or download the tarball from the releases page
@ -63,4 +105,4 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
* Clone the repo
* Ensure you have poetry installed
* Run `poetry install --with test`
* Run `poetry run pytest -vvvv --cov=bouquin`
* Run `./tests.sh`

6
bouquin.desktop Normal file
View file

@ -0,0 +1,6 @@
[Desktop Entry]
Type=Application
Name=Bouquin
Exec=Bouquin.AppImage
Icon=bouquin
Categories=Office

View file

@ -0,0 +1,119 @@
from __future__ import annotations
import importlib.metadata
import requests
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QLabel,
QMessageBox,
QTextEdit,
QVBoxLayout,
)
from . import strings
BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs"
class BugReportDialog(QDialog):
"""
Dialog to collect a bug report
"""
MAX_CHARS = 5000
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("report_a_bug"))
layout = QVBoxLayout(self)
header = QLabel(strings._("bug_report_explanation"))
header.setWordWrap(True)
layout.addWidget(header)
self.text_edit = QTextEdit()
self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
layout.addWidget(self.text_edit)
self.text_edit.textChanged.connect(self._enforce_max_length)
# Buttons: Cancel / Send
button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
button_box.accepted.connect(self._send)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setMinimumWidth(560)
self.text_edit.setFocus()
# ------------Helpers ------------ #
def _enforce_max_length(self):
text = self.text_edit.toPlainText()
if len(text) <= self.MAX_CHARS:
return
# Remember cursor position
cursor = self.text_edit.textCursor()
pos = cursor.position()
# Trim and restore without re-entering this slot
self.text_edit.blockSignals(True)
self.text_edit.setPlainText(text[: self.MAX_CHARS])
self.text_edit.blockSignals(False)
cursor.setPosition(pos)
self.text_edit.setTextCursor(cursor)
def _send(self):
text = self.text_edit.toPlainText().strip()
if not text:
QMessageBox.warning(
self,
strings._("report_a_bug"),
strings._("bug_report_empty"),
)
return
# Get current app version
version = importlib.metadata.version("bouquin")
payload: dict[str, str] = {
"message": text,
"version": version,
}
# POST as JSON
try:
resp = requests.post(
f"{BUG_REPORT_HOST}/{ROUTE}",
json=payload,
timeout=10,
)
except Exception as e:
QMessageBox.critical(
self,
strings._("report_a_bug"),
strings._("bug_report_send_failed") + f"\n{e}",
)
return
if resp.status_code == 201:
QMessageBox.information(
self,
strings._("report_a_bug"),
strings._("bug_report_sent_ok"),
)
self.accept()
else:
QMessageBox.critical(
self,
strings._("report_a_bug"),
strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
)

View file

@ -0,0 +1,208 @@
from __future__ import annotations
from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
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)
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
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

373
bouquin/code_highlighter.py Normal file
View file

@ -0,0 +1,373 @@
from __future__ import annotations
import re
from typing import Dict, Optional
from PySide6.QtGui import QColor, QFont, QTextCharFormat
class CodeHighlighter:
"""Syntax highlighter for different programming languages."""
# Language keywords
KEYWORDS = {
"python": [
"False",
"None",
"True",
"and",
"as",
"assert",
"async",
"await",
"break",
"class",
"continue",
"def",
"del",
"elif",
"else",
"except",
"finally",
"for",
"from",
"global",
"if",
"import",
"in",
"is",
"lambda",
"nonlocal",
"not",
"or",
"pass",
"pprint",
"print",
"raise",
"return",
"try",
"while",
"with",
"yield",
],
"javascript": [
"abstract",
"arguments",
"await",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"double",
"else",
"enum",
"eval",
"export",
"extends",
"false",
"final",
"finally",
"float",
"for",
"function",
"goto",
"if",
"implements",
"import",
"in",
"instanceof",
"int",
"interface",
"let",
"long",
"native",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"true",
"try",
"typeof",
"var",
"void",
"volatile",
"while",
"with",
"yield",
],
"php": [
"abstract",
"and",
"array",
"as",
"break",
"callable",
"case",
"catch",
"class",
"clone",
"const",
"continue",
"declare",
"default",
"die",
"do",
"echo",
"else",
"elseif",
"empty",
"enddeclare",
"endfor",
"endforeach",
"endif",
"endswitch",
"endwhile",
"eval",
"exit",
"extends",
"final",
"for",
"foreach",
"function",
"global",
"goto",
"if",
"implements",
"include",
"include_once",
"instanceof",
"insteadof",
"interface",
"isset",
"list",
"namespace",
"new",
"or",
"print",
"print_r",
"private",
"protected",
"public",
"require",
"require_once",
"return",
"static",
"syslog",
"switch",
"throw",
"trait",
"try",
"unset",
"use",
"var",
"var_dump",
"while",
"xor",
"yield",
],
"bash": [
"if",
"then",
"echo",
"else",
"elif",
"fi",
"case",
"esac",
"for",
"select",
"while",
"until",
"do",
"done",
"in",
"function",
"time",
"coproc",
],
"html": [
"DOCTYPE",
"html",
"head",
"title",
"meta",
"link",
"style",
"script",
"body",
"div",
"span",
"p",
"a",
"img",
"ul",
"ol",
"li",
"table",
"tr",
"td",
"th",
"form",
"input",
"button",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"br",
"hr",
],
"css": [
"color",
"background",
"background-color",
"border",
"margin",
"padding",
"width",
"height",
"font",
"font-size",
"font-weight",
"display",
"position",
"top",
"left",
"right",
"bottom",
"float",
"clear",
"overflow",
"z-index",
"opacity",
],
}
@staticmethod
def get_language_patterns(language: str) -> list:
"""Get highlighting patterns for a language."""
patterns = []
keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
if language.lower() in ["python", "bash", "php"]:
# Comments (#)
patterns.append((r"#.*$", "comment"))
if language.lower() in ["javascript", "php", "css"]:
# Comments (//)
patterns.append((r"//.*$", "comment"))
# Multi-line comments (/* */)
patterns.append((r"/\*.*?\*/", "comment"))
if language.lower() in ["html", "xml"]:
# HTML/XML tags
patterns.append((r"<[^>]+>", "tag"))
# HTML comments
patterns.append((r"<!--.*?-->", "comment"))
# Numbers
patterns.append((r"\b\d+\.?\d*\b", "number"))
# Keywords
for keyword in keywords:
patterns.append((r"\b" + keyword + r"\b", "keyword"))
# Do strings last so they override any of the above (e.g reserved keywords in strings)
# Strings (double quotes)
patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
# Strings (single quotes)
patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
return patterns
@staticmethod
def get_format_for_type(
format_type: str, base_format: QTextCharFormat
) -> QTextCharFormat:
"""Get text format for a specific syntax type."""
fmt = QTextCharFormat(base_format)
if format_type == "keyword":
fmt.setForeground(QColor(86, 156, 214)) # Blue
fmt.setFontWeight(QFont.Weight.Bold)
elif format_type == "string":
fmt.setForeground(QColor(206, 145, 120)) # Orange
elif format_type == "comment":
fmt.setForeground(QColor(106, 153, 85)) # Green
fmt.setFontItalic(True)
elif format_type == "number":
fmt.setForeground(QColor(181, 206, 168)) # Light green
elif format_type == "tag":
fmt.setForeground(QColor(78, 201, 176)) # Cyan
return fmt
class CodeBlockMetadata:
"""Stores metadata about code blocks (language, etc.) for a document."""
def __init__(self):
self._block_languages: Dict[int, str] = {} # block_number -> language
def set_language(self, block_number: int, language: str):
"""Set the language for a code block."""
self._block_languages[block_number] = language.lower()
def get_language(self, block_number: int) -> Optional[str]:
"""Get the language for a code block."""
return self._block_languages.get(block_number)
def serialize(self) -> str:
"""Serialize metadata to a string."""
# Store as JSON-like format in a comment at the end
if not self._block_languages:
return ""
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
return "<!-- code-langs: " + ",".join(items) + " -->\n"
def deserialize(self, text: str):
"""Deserialize metadata from text."""
self._block_languages.clear()
# Look for metadata comment at the end
match = re.search(r"<!-- code-langs: ([^>]+) -->", text)
if match:
pairs = match.group(1).split(",")
for pair in pairs:
if ":" in pair:
block_num, lang = pair.split(":", 1)
try:
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,897 +0,0 @@
from __future__ import annotations
from pathlib import Path
import base64, re
from PySide6.QtGui import (
QColor,
QDesktopServices,
QFont,
QFontDatabase,
QImage,
QImageReader,
QPalette,
QPixmap,
QTextCharFormat,
QTextCursor,
QTextFrameFormat,
QTextListFormat,
QTextBlockFormat,
QTextImageFormat,
QTextDocument,
)
from PySide6.QtCore import (
Qt,
QUrl,
Signal,
Slot,
QRegularExpression,
QBuffer,
QByteArray,
QIODevice,
QTimer,
)
from PySide6.QtWidgets import QTextEdit, QApplication
from .theme import Theme, ThemeManager
class Editor(QTextEdit):
linkActivated = Signal(str)
_URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
_CODE_BG = QColor(245, 245, 245)
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
_HEADING_SIZES = (24.0, 18.0, 14.0)
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
# --- Checkbox hack --- #
_CHECK_UNCHECKED = "\u2610" # ☐
_CHECK_CHECKED = "\u2611" # ☑
_CHECK_RX = re.compile(r"^\s*([\u2610\u2611])\s") # ☐/☑ plus a space
_CHECKBOX_SCALE = 1.35
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
self.setTabStopDistance(tab_w)
self.setTextInteractionFlags(
Qt.TextInteractionFlag.TextEditorInteraction
| Qt.TextInteractionFlag.LinksAccessibleByMouse
| Qt.TextInteractionFlag.LinksAccessibleByKeyboard
)
self.setAcceptRichText(True)
# If older docs have a baked-in color, normalize once:
self._retint_anchors_to_palette()
self._themes = theme_manager
# Refresh on theme change
self._themes.themeChanged.connect(self._on_theme_changed)
self._linkifying = False
self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True)
def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
return abs(float(a) - float(b)) <= eps
def _is_heading_typing(self) -> bool:
"""Is the current *insertion* format using a heading size?"""
bf = self.textCursor().blockFormat()
if bf.headingLevel() > 0:
return True
def _apply_normal_typing(self):
"""Switch the *insertion* format to Normal (default size, normal weight)."""
nf = QTextCharFormat()
nf.setFontPointSize(self.font().pointSizeF())
nf.setFontWeight(QFont.Weight.Normal)
self.mergeCurrentCharFormat(nf)
def _find_code_frame(self, cursor=None):
"""Return the nearest ancestor frame that's one of our code frames, else None."""
if cursor is None:
cursor = self.textCursor()
f = cursor.currentFrame()
while f:
if f.frameFormat().property(self._CODE_FRAME_PROP):
return f
f = f.parentFrame()
return None
def _trim_url_end(self, url: str) -> str:
# strip common trailing punctuation not part of the URL
trimmed = url.rstrip(".,;:!?\"'")
# drop an unmatched closing ) or ] at the very end
if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
trimmed = trimmed[:-1]
if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
trimmed = trimmed[:-1]
return trimmed
def _linkify_document(self):
if self._linkifying:
return
self._linkifying = True
try:
block = self.textCursor().block()
start_pos = block.position()
text = block.text()
cur = QTextCursor(self.document())
cur.beginEditBlock()
it = self._URL_RX.globalMatch(text)
while it.hasNext():
m = it.next()
s = start_pos + m.capturedStart()
raw = m.captured(0)
url = self._trim_url_end(raw)
if not url:
continue
e = s + len(url)
cur.setPosition(s)
cur.setPosition(e, QTextCursor.KeepAnchor)
if url.startswith("www."):
href = "https://" + url
else:
href = url
fmt = QTextCharFormat()
fmt.setAnchor(True)
fmt.setAnchorHref(href) # always refresh to the latest full URL
fmt.setFontUnderline(True)
fmt.setForeground(self.palette().brush(QPalette.Link))
cur.mergeCharFormat(fmt) # merge so we dont clobber other styling
cur.endEditBlock()
finally:
self._linkifying = False
def _to_qimage(self, obj) -> QImage | None:
if isinstance(obj, QImage):
return None if obj.isNull() else obj
if isinstance(obj, QPixmap):
qi = obj.toImage()
return None if qi.isNull() else qi
if isinstance(obj, (bytes, bytearray)):
qi = QImage.fromData(obj)
return None if qi.isNull() else qi
return None
def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.WriteOnly)
img.save(buf, fmt.upper())
b64 = base64.b64encode(bytes(ba)).decode("ascii")
mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
return f"data:{mime};base64,{b64}"
def _image_name_to_qimage(self, name: str) -> QImage | None:
res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
return res if isinstance(res, QImage) and not res.isNull() else None
def to_html_with_embedded_images(self) -> str:
"""
Return the document HTML with all image src's replaced by data: URLs,
so it is self-contained for storage in the DB.
"""
# 1) Walk the document collecting name -> data: URL
name_to_data = {}
cur = QTextCursor(self.document())
cur.movePosition(QTextCursor.Start)
while True:
cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
fmt = cur.charFormat()
if fmt.isImageFormat():
imgfmt = QTextImageFormat(fmt)
name = imgfmt.name()
if name and name not in name_to_data:
img = self._image_name_to_qimage(name)
if img:
name_to_data[name] = self._qimage_to_data_url(img, "PNG")
if cur.atEnd():
break
cur.clearSelection()
# 2) Serialize and replace names with data URLs
html = self.document().toHtml()
for old, data_url in name_to_data.items():
html = html.replace(f'src="{old}"', f'src="{data_url}"')
html = html.replace(f"src='{old}'", f"src='{data_url}'")
return html
def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
c = self.textCursor()
# Dont drop inside a code frame
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
# Start a fresh paragraph if mid-line
if c.positionInBlock() != 0:
c.insertBlock()
if autoscale and self.viewport():
max_w = int(self.viewport().width() * 0.92)
if img.width() > max_w:
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
c.insertImage(img)
c.insertBlock() # one blank line after the image
def _image_info_at_cursor(self):
"""
Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
"""
# Try current position (select 1 char forward)
tc = QTextCursor(self.textCursor())
tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
fmt = tc.charFormat()
if fmt.isImageFormat():
imgfmt = QTextImageFormat(fmt)
img = self._resolve_image_resource(imgfmt)
return tc, imgfmt, img
# Try previous char (if caret is just after the image)
tc = QTextCursor(self.textCursor())
if tc.position() > 0:
tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
fmt = tc.charFormat()
if fmt.isImageFormat():
imgfmt = QTextImageFormat(fmt)
img = self._resolve_image_resource(imgfmt)
return tc, imgfmt, img
return None, None, None
def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
"""
Fetch the original QImage backing the inline image, if available.
"""
name = imgfmt.name()
if name:
try:
img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
if isinstance(img, QImage) and not img.isNull():
return img
except Exception:
pass
return None # fallback handled by callers
def _apply_image_size(
self,
tc: QTextCursor,
imgfmt: QTextImageFormat,
new_w: float,
orig_img: QImage | None,
):
# compute height proportionally
if orig_img and orig_img.width() > 0:
ratio = new_w / orig_img.width()
new_h = max(1.0, orig_img.height() * ratio)
else:
# fallback: keep current aspect ratio if we have it
cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
ratio = new_w / max(1.0, cur_w)
new_h = max(1.0, cur_h * ratio)
imgfmt.setWidth(max(1.0, new_w))
imgfmt.setHeight(max(1.0, new_h))
tc.mergeCharFormat(imgfmt)
def _scale_image_at_cursor(self, factor: float):
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt:
return
base_w = imgfmt.width()
if base_w <= 0 and orig:
base_w = orig.width()
if base_w <= 0:
return
self._apply_image_size(tc, imgfmt, base_w * factor, orig)
def _fit_image_to_editor_width(self):
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt:
return
if not self.viewport():
return
target = int(self.viewport().width() * 0.92)
self._apply_image_size(tc, imgfmt, target, orig)
def _set_image_width_dialog(self):
from PySide6.QtWidgets import QInputDialog
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt:
return
# propose current display width or original width
cur_w = (
int(imgfmt.width())
if imgfmt.width() > 0
else (orig.width() if orig else 400)
)
w, ok = QInputDialog.getInt(
self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
)
if ok:
self._apply_image_size(tc, imgfmt, float(w), orig)
def _reset_image_size(self):
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt or not orig:
return
self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
def contextMenuEvent(self, e):
menu = self.createStandardContextMenu()
tc, imgfmt, orig = self._image_info_at_cursor()
if imgfmt:
menu.addSeparator()
sub = menu.addMenu("Image size")
sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
sub.addAction("Set width…", self._set_image_width_dialog)
sub.addAction("Reset to original", self._reset_image_size)
menu.exec(e.globalPos())
def insertFromMimeData(self, source):
# 1) Direct image from clipboard
if source.hasImage():
img = self._to_qimage(source.imageData())
if img is not None:
self._insert_qimage_at_cursor(img, autoscale=True)
return
# 2) File URLs (drag/drop or paste)
if source.hasUrls():
paths = []
non_local_urls = []
for url in source.urls():
if url.isLocalFile():
path = url.toLocalFile()
if path.lower().endswith(self._IMAGE_EXTS):
paths.append(path)
else:
# Non-image file: insert as link
self.textCursor().insertHtml(
f'<a href="{url.toString()}">{Path(path).name}</a>'
)
self.textCursor().insertBlock()
else:
non_local_urls.append(url)
if paths:
self.insert_images(paths)
for url in non_local_urls:
self.textCursor().insertHtml(
f'<a href="{url.toString()}">{url.toString()}</a>'
)
self.textCursor().insertBlock()
if paths or non_local_urls:
return
# 3) HTML with data: image
if source.hasHtml():
html = source.html()
m = self._DATA_IMG_RX.search(html or "")
if m:
try:
data = base64.b64decode(m.group(1))
img = QImage.fromData(data)
if not img.isNull():
self._insert_qimage_at_cursor(self, img, autoscale=True)
return
except Exception:
pass # fall through
# 4) Everything else → default behavior
super().insertFromMimeData(source)
@Slot(list)
def insert_images(self, paths: list[str], autoscale=True):
"""
Insert one or more images at the cursor. Large images can be auto-scaled
to fit the viewport width while preserving aspect ratio.
"""
c = self.textCursor()
# Avoid dropping images inside a code frame
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
# Ensure there's a paragraph break if we're mid-line
if c.positionInBlock() != 0:
c.insertBlock()
for path in paths:
reader = QImageReader(path)
img = reader.read()
if img.isNull():
continue
if autoscale and self.viewport():
max_w = int(self.viewport().width() * 0.92) # ~92% of editor width
if img.width() > max_w:
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
c.insertImage(img)
c.insertBlock() # put each image on its own line
def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
href = self.anchorAt(e.pos())
if href:
QDesktopServices.openUrl(QUrl.fromUserInput(href))
self.linkActivated.emit(href)
return
super().mouseReleaseEvent(e)
def mouseMoveEvent(self, e):
if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
self.viewport().setCursor(Qt.PointingHandCursor)
else:
self.viewport().setCursor(Qt.IBeamCursor)
super().mouseMoveEvent(e)
def mousePressEvent(self, e):
if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier):
cur = self.cursorForPosition(e.pos())
b = cur.block()
state, pref = self._checkbox_info_for_block(b)
if state is not None:
col = cur.position() - b.position()
if col <= max(1, pref): # clicked on ☐/☑ (and the following space)
self._set_block_checkbox_state(b, not state)
return
return super().mousePressEvent(e)
def keyPressEvent(self, e):
key = e.key()
if key in (Qt.Key_Space, Qt.Key_Tab):
c = self.textCursor()
b = c.block()
pos_in_block = c.position() - b.position()
if (
pos_in_block >= 4
and b.text().startswith("TODO")
and b.text()[:pos_in_block] == "TODO"
and self._checkbox_info_for_block(b)[0] is None
):
tcur = QTextCursor(self.document())
tcur.setPosition(b.position()) # start of block
tcur.setPosition(
b.position() + 4, QTextCursor.KeepAnchor
) # select "TODO"
tcur.beginEditBlock()
tcur.removeSelectedText()
tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ "
tcur.endEditBlock()
# visuals: size bump
if hasattr(self, "_style_checkbox_glyph"):
self._style_checkbox_glyph(b)
# caret after the inserted prefix; swallow the key (we already added a space)
c.setPosition(b.position() + 2)
self.setTextCursor(c)
return
# not a TODO-at-start case
self._break_anchor_for_next_char()
return super().keyPressEvent(e)
if key in (Qt.Key_Return, Qt.Key_Enter):
c = self.textCursor()
# If we're on an empty line inside a code frame, consume Enter and jump out
if c.block().length() == 1:
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition()) # after the frame's contents
self.setTextCursor(out)
super().insertPlainText("\n") # start a normal paragraph
return
# --- CHECKBOX handling: continue on Enter; "escape" on second Enter ---
b = c.block()
state, pref = self._checkbox_info_for_block(b)
if state is not None and not c.hasSelection():
text_after = b.text()[pref:].strip()
if c.atBlockEnd() and text_after == "":
# Empty checkbox item -> remove the prefix and insert a plain new line
cur = QTextCursor(self.document())
cur.setPosition(b.position())
cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
cur.removeSelectedText()
return super().keyPressEvent(e)
else:
# Normal continuation: new checkbox on the next line
super().keyPressEvent(e) # make the new block
super().insertPlainText(self._CHECK_UNCHECKED + " ")
if hasattr(self, "_style_checkbox_glyph"):
self._style_checkbox_glyph(self.textCursor().block())
return
# Follow-on style: if we typed a heading and press Enter at end of block,
# new paragraph should revert to Normal.
if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing():
super().keyPressEvent(e) # insert the new paragraph
self._apply_normal_typing() # make the *new* paragraph Normal for typing
return
# otherwise default handling
return super().keyPressEvent(e)
def _break_anchor_for_next_char(self):
"""
Ensure the *next* typed character is not part of a hyperlink.
Only strips link-specific attributes; leaves bold/italic/underline etc intact.
"""
# What we're about to type with
ins_fmt = self.currentCharFormat()
# What the cursor is sitting on
cur_fmt = self.textCursor().charFormat()
# Do nothing unless either side indicates we're in/propagating an anchor
if not (
ins_fmt.isAnchor()
or cur_fmt.isAnchor()
or ins_fmt.fontUnderline()
or ins_fmt.foreground().style() != Qt.NoBrush
):
return
nf = QTextCharFormat(ins_fmt)
# stop the link itself
nf.setAnchor(False)
nf.setAnchorHref("")
# also stop the link *styling*
nf.setFontUnderline(False)
nf.clearForeground()
self.setCurrentCharFormat(nf)
def merge_on_sel(self, fmt):
"""
Sets the styling on the selected characters or the insertion position.
"""
cursor = self.textCursor()
if cursor.hasSelection():
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
# ====== Checkbox core ======
def _base_point_size_for_block(self, block) -> float:
# Try the blocks char format, then editor font
sz = block.charFormat().fontPointSize()
if sz <= 0:
sz = self.fontPointSize()
if sz <= 0:
sz = self.font().pointSizeF() or 12.0
return float(sz)
def _style_checkbox_glyph(self, block):
"""Apply larger size (and optional symbol font) to the single ☐/☑ char."""
state, _ = self._checkbox_info_for_block(block)
if state is None:
return
doc = self.document()
c = QTextCursor(doc)
c.setPosition(block.position())
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only
base = self._base_point_size_for_block(block)
fmt = QTextCharFormat()
fmt.setFontPointSize(base * self._CHECKBOX_SCALE)
# keep the glyph centered on the text baseline
fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle)
c.mergeCharFormat(fmt)
def _checkbox_info_for_block(self, block):
"""Return (state, prefix_len): state in {None, False, True}, prefix_len in chars."""
text = block.text()
m = self._CHECK_RX.match(text)
if not m:
return None, 0
ch = m.group(1)
state = True if ch == self._CHECK_CHECKED else False
return state, m.end()
def _set_block_checkbox_present(self, block, present: bool):
state, pref = self._checkbox_info_for_block(block)
doc = self.document()
c = QTextCursor(doc)
c.setPosition(block.position())
c.beginEditBlock()
try:
if present and state is None:
c.insertText(self._CHECK_UNCHECKED + " ")
state = False
self._style_checkbox_glyph(block)
else:
if state is not None:
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
c.removeSelectedText()
state = None
finally:
c.endEditBlock()
return state
def _set_block_checkbox_state(self, block, checked: bool):
"""Switch ☐/☑ at the start of the block."""
state, pref = self._checkbox_info_for_block(block)
if state is None:
return
doc = self.document()
c = QTextCursor(doc)
c.setPosition(block.position())
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol
c.beginEditBlock()
try:
c.removeSelectedText()
c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED)
self._style_checkbox_glyph(block)
finally:
c.endEditBlock()
# Public API used by toolbar
def toggle_checkboxes(self):
"""
Toggle checkbox prefix on/off for the current block(s).
If all targeted blocks already have a checkbox, remove them; otherwise add.
"""
c = self.textCursor()
doc = self.document()
if c.hasSelection():
start = doc.findBlock(c.selectionStart())
end = doc.findBlock(c.selectionEnd() - 1)
else:
start = end = c.block()
# Decide intent: add or remove?
b = start
all_have = True
while True:
state, _ = self._checkbox_info_for_block(b)
if state is None:
all_have = False
break
if b == end:
break
b = b.next()
# Apply
b = start
while True:
self._set_block_checkbox_present(b, present=not all_have)
if b == end:
break
b = b.next()
def toggle_current_checkbox_state(self):
"""Tick/untick the current line if it starts with a checkbox."""
b = self.textCursor().block()
state, _ = self._checkbox_info_for_block(b)
if state is None:
return
self._set_block_checkbox_state(b, not state)
@Slot()
def apply_weight(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
weight = (
QFont.Weight.Normal
if cur.fontWeight() == QFont.Weight.Bold
else QFont.Weight.Bold
)
fmt.setFontWeight(weight)
self.merge_on_sel(fmt)
@Slot()
def apply_italic(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
fmt.setFontItalic(not cur.fontItalic())
self.merge_on_sel(fmt)
@Slot()
def apply_underline(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
fmt.setFontUnderline(not cur.fontUnderline())
self.merge_on_sel(fmt)
@Slot()
def apply_strikethrough(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
fmt.setFontStrikeOut(not cur.fontStrikeOut())
self.merge_on_sel(fmt)
@Slot()
def apply_code(self):
c = self.textCursor()
if not c.hasSelection():
c.select(QTextCursor.BlockUnderCursor)
# Wrap the selection in a single frame (no per-block padding/margins).
ff = QTextFrameFormat()
ff.setBackground(self._CODE_BG)
ff.setPadding(6) # visual padding for the WHOLE block
ff.setBorder(0)
ff.setLeftMargin(0)
ff.setRightMargin(0)
ff.setTopMargin(0)
ff.setBottomMargin(0)
ff.setProperty(self._CODE_FRAME_PROP, True)
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
c.beginEditBlock()
try:
c.insertFrame(ff) # with a selection, this wraps the selection
# Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping
frame = self._find_code_frame(c)
bc = QTextCursor(self.document())
bc.setPosition(frame.firstPosition())
while bc.position() < frame.lastPosition():
bc.select(QTextCursor.BlockUnderCursor)
bf = QTextBlockFormat()
bf.setTopMargin(0)
bf.setBottomMargin(0)
bf.setLeftMargin(12)
bf.setRightMargin(12)
bf.setNonBreakableLines(True)
cf = QTextCharFormat()
cf.setFont(mono)
cf.setFontFixedPitch(True)
bc.mergeBlockFormat(bf)
bc.mergeBlockCharFormat(cf)
bc.setPosition(bc.block().position() + bc.block().length())
finally:
c.endEditBlock()
@Slot(int)
def apply_heading(self, size: int):
"""
Set heading point size for typing. If there's a selection, also apply bold
to that selection (for H1..H3). "Normal" clears bold on the selection.
"""
# Map toolbar's sizes to heading levels
level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0
c = self.textCursor()
# On-screen look
ins = QTextCharFormat()
if size:
ins.setFontPointSize(float(size))
ins.setFontWeight(QFont.Weight.Bold)
else:
ins.setFontPointSize(self.font().pointSizeF())
ins.setFontWeight(QFont.Weight.Normal)
self.mergeCurrentCharFormat(ins)
# Apply heading level to affected block(s)
def set_level_for_block(cur):
bf = cur.blockFormat()
if hasattr(bf, "setHeadingLevel"):
bf.setHeadingLevel(level) # 0 clears heading
cur.mergeBlockFormat(bf)
if c.hasSelection():
start, end = c.selectionStart(), c.selectionEnd()
bc = QTextCursor(self.document())
bc.setPosition(start)
while True:
set_level_for_block(bc)
if bc.position() >= end:
break
bc.movePosition(QTextCursor.EndOfBlock)
if bc.position() >= end:
break
bc.movePosition(QTextCursor.NextBlock)
else:
bc = QTextCursor(c)
set_level_for_block(bc)
def toggle_bullets(self):
c = self.textCursor()
lst = c.currentList()
if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
lst.remove(c.block())
return
fmt = QTextListFormat()
fmt.setStyle(QTextListFormat.Style.ListDisc)
c.createList(fmt)
def toggle_numbers(self):
c = self.textCursor()
lst = c.currentList()
if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
lst.remove(c.block())
return
fmt = QTextListFormat()
fmt.setStyle(QTextListFormat.Style.ListDecimal)
c.createList(fmt)
@Slot(Theme)
def _on_theme_changed(self, _theme: Theme):
# Defer one event-loop tick so widgets have the new palette
QTimer.singleShot(0, self._retint_anchors_to_palette)
@Slot()
def _retint_anchors_to_palette(self, *_):
# Always read from the *application* palette to avoid stale widget palette
app = QApplication.instance()
link_brush = app.palette().brush(QPalette.Link)
doc = self.document()
cur = QTextCursor(doc)
cur.beginEditBlock()
block = doc.firstBlock()
while block.isValid():
it = block.begin()
while not it.atEnd():
frag = it.fragment()
if frag.isValid():
fmt = frag.charFormat()
if fmt.isAnchor():
new_fmt = QTextCharFormat(fmt)
new_fmt.setForeground(link_brush) # force palette link color
cur.setPosition(frag.position())
cur.setPosition(
frag.position() + frag.length(), QTextCursor.KeepAnchor
)
cur.setCharFormat(new_fmt)
it += 1
block = block.next()
cur.endEditBlock()
self.viewport().update()
def setHtml(self, html: str) -> None:
super().setHtml(html)
doc = self.document()
block = doc.firstBlock()
while block.isValid():
self._style_checkbox_glyph(block) # Apply checkbox styling to each block
block = block.next()
# Ensure anchors adopt the palette color on startup
self._retint_anchors_to_palette()

201
bouquin/find_bar.py Normal file
View file

@ -0,0 +1,201 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import (
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QWidget,
)
from . import strings
class FindBar(QWidget):
"""Widget for finding text in the Editor"""
# emitted when the bar is hidden (Esc/✕), so caller can refocus editor
closed = Signal()
def __init__(
self,
editor: QTextEdit,
shortcut_parent: QWidget | None = None,
parent: QWidget | None = None,
):
super().__init__(parent)
# store how to get the current editor
self._editor_getter = editor if callable(editor) else (lambda: editor)
self.shortcut_parent = shortcut_parent
# UI (build ONCE)
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 0, 6, 0)
layout.addWidget(QLabel(strings._("find")))
self.edit = QLineEdit(self)
self.edit.setPlaceholderText(strings._("find_bar_type_to_search"))
layout.addWidget(self.edit)
self.case = QCheckBox(strings._("find_bar_match_case"), self)
layout.addWidget(self.case)
self.prevBtn = QPushButton(strings._("previous"), self)
self.nextBtn = QPushButton(strings._("next"), self)
self.closeBtn = QPushButton("", self)
self.closeBtn.setFlat(True)
layout.addWidget(self.prevBtn)
layout.addWidget(self.nextBtn)
layout.addWidget(self.closeBtn)
self.setVisible(False)
# Shortcut (press Esc to hide bar)
sp = (
self.shortcut_parent
if self.shortcut_parent is not None
else (self.parent() or self)
)
QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
# Signals (connect ONCE)
self.edit.returnPressed.connect(self.find_next)
self.edit.textChanged.connect(self._update_highlight)
self.case.toggled.connect(self._update_highlight)
self.nextBtn.clicked.connect(self.find_next)
self.prevBtn.clicked.connect(self.find_prev)
self.closeBtn.clicked.connect(self.hide_bar)
@property
def editor(self) -> QTextEdit | None:
"""Get the current editor"""
return self._editor_getter()
# ----- Public API -----
def show_bar(self):
"""Show the bar, seed with current selection if sensible, focus the line edit."""
if not self.editor:
return
tc = self.editor.textCursor()
sel = tc.selectedText().strip()
if sel and "\u2029" not in sel: # ignore multi-paragraph selections
self.edit.setText(sel)
self.setVisible(True)
self.edit.setFocus(Qt.ShortcutFocusReason)
self.edit.selectAll()
self._update_highlight()
def hide_bar(self):
self.setVisible(False)
self._clear_highlight()
self.closed.emit()
def refresh(self):
"""Recompute highlights"""
self._update_highlight()
# ----- Internals -----
def _maybe_hide(self):
if self.isVisible():
self.hide_bar()
def _flags(self, backward: bool = False) -> QTextDocument.FindFlags:
flags = QTextDocument.FindFlags()
if backward:
flags |= QTextDocument.FindBackward
if self.case.isChecked():
flags |= QTextDocument.FindCaseSensitively
return flags
def find_next(self):
txt = self.edit.text()
if not txt:
return
# If current selection == query, bump caret to the end so we don't re-match it.
c = self.editor.textCursor()
if c.hasSelection():
sel = c.selectedText()
same = (
(sel == txt)
if self.case.isChecked()
else (sel.casefold() == txt.casefold())
)
if same:
end = max(c.position(), c.anchor())
c.setPosition(end, QTextCursor.MoveAnchor)
self.editor.setTextCursor(c)
if not self.editor.find(txt, self._flags(False)):
cur = self.editor.textCursor()
cur.movePosition(QTextCursor.Start)
self.editor.setTextCursor(cur)
self.editor.find(txt, self._flags(False))
self.editor.ensureCursorVisible()
self._update_highlight()
def find_prev(self):
txt = self.edit.text()
if not txt:
return
# If current selection == query, bump caret to the start so we don't re-match it.
c = self.editor.textCursor()
if c.hasSelection():
sel = c.selectedText()
same = (
(sel == txt)
if self.case.isChecked()
else (sel.casefold() == txt.casefold())
)
if same:
start = min(c.position(), c.anchor())
c.setPosition(start, QTextCursor.MoveAnchor)
self.editor.setTextCursor(c)
if not self.editor.find(txt, self._flags(True)):
cur = self.editor.textCursor()
cur.movePosition(QTextCursor.End)
self.editor.setTextCursor(cur)
self.editor.find(txt, self._flags(True))
self.editor.ensureCursorVisible()
self._update_highlight()
def _update_highlight(self):
if not self.editor:
return
txt = self.edit.text()
if not txt:
self._clear_highlight()
return
doc = self.editor.document()
flags = self._flags(False)
cur = QTextCursor(doc)
cur.movePosition(QTextCursor.Start)
fmt = QTextCharFormat()
hl = self.palette().highlight().color()
hl.setAlpha(90)
fmt.setBackground(hl)
selections = []
while True:
cur = doc.find(txt, cur, flags)
if cur.isNull():
break
sel = QTextEdit.ExtraSelection()
sel.cursor = cur
sel.format = fmt
selections.append(sel)
self.editor.setExtraSelections(selections)
def _clear_highlight(self):
if self.editor:
self.editor.setExtraSelections([])

88
bouquin/flow_layout.py Normal file
View file

@ -0,0 +1,88 @@
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout
class FlowLayout(QLayout):
def __init__(
self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4
):
super().__init__(parent)
self._items = []
self._hspace = hspacing
self._vspace = vspacing
self.setContentsMargins(margin, margin, margin, margin)
def addItem(self, item):
self._items.append(item)
def itemAt(self, index):
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index):
if 0 <= index < len(self._items):
return self._items.pop(index)
return None
def count(self):
return len(self._items)
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width: int) -> int:
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def sizeHint(self) -> QSize:
return self.minimumSize()
def minimumSize(self) -> QSize:
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
left, top, right, bottom = self.getContentsMargins()
size += QSize(left + right, top + bottom)
return size
def _do_layout(self, rect: QRect, test_only: bool) -> int:
x = rect.x()
y = rect.y()
line_height = 0
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(+left, +top, -right, -bottom)
x = effective_rect.x()
y = effective_rect.y()
max_right = effective_rect.right()
for item in self._items:
wid = item.widget()
if wid is None or not wid.isVisible():
continue
space_x = self._hspace
space_y = self._vspace
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > max_right and line_height > 0:
# Wrap
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom

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.

View file

@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
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 THE
COPYRIGHT HOLDER 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.

Binary file not shown.

View file

@ -1,47 +1,61 @@
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,
QTextBrowser,
QVBoxLayout,
)
from . import strings
from .theme import ThemeManager
def _html_to_text(s: str) -> str:
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
IMG_RE = re.compile(r"(?is)<img\b[^>]*>")
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
BR_RE = re.compile(r"(?i)<br\s*/?>")
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
TAG_RE = re.compile(r"<[^>]+>")
MULTINL_RE = re.compile(r"\n{3,}")
s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
s = STYLE_SCRIPT_RE.sub("", s)
s = COMMENT_RE.sub("", s)
s = BR_RE.sub("\n", s)
s = BLOCK_END_RE.sub("\n", s)
s = TAG_RE.sub("", s)
s = _html.unescape(s)
s = MULTINL_RE.sub("\n\n", s)
def _markdown_to_text(s: str) -> str:
"""Convert markdown to plain text for diff comparison."""
# Remove images
s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
# Remove inline code formatting
s = re.sub(r"`([^`]+)`", r"\1", s)
# Remove bold/italic markers
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
s = re.sub(r"__([^_]+)__", r"\1", s)
s = re.sub(r"\*([^*]+)\*", r"\1", s)
s = re.sub(r"_([^_]+)_", r"\1", s)
# Remove strikethrough
s = re.sub(r"~~([^~]+)~~", r"\1", s)
# Remove heading markers
s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
# Remove list markers
s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
# Remove checkbox markers
s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
return s.strip()
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
a = _html_to_text(old_html).splitlines()
b = _html_to_text(new_html).splitlines()
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
a = _markdown_to_text(old_md).splitlines()
b = _markdown_to_text(new_md).splitlines()
ud = difflib.unified_diff(
a, b, fromfile=strings._("current"), tofile=strings._("selected"), lineterm=""
)
lines = []
for line in ud:
if line.startswith("+") and not line.startswith("+++"):
@ -63,19 +77,33 @@ def _colored_unified_diff_html(old_html: str, new_html: 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"History — {date_iso}")
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()
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.list.setMinimumSize(500, 650)
self.list.currentItemChanged.connect(self._on_select)
top.addWidget(self.list, 1)
@ -86,8 +114,8 @@ class HistoryDialog(QDialog):
self.preview.setOpenExternalLinks(True)
self.diff = QTextBrowser()
self.diff.setOpenExternalLinks(False)
self.tabs.addTab(self.preview, "Preview")
self.tabs.addTab(self.diff, "Diff")
self.tabs.addTab(self.preview, strings._("history_dialog_preview"))
self.tabs.addTab(self.diff, strings._("history_dialog_diff"))
self.tabs.setMinimumSize(500, 650)
top.addWidget(self.tabs, 2)
@ -96,39 +124,85 @@ class HistoryDialog(QDialog):
# Buttons
row = QHBoxLayout()
row.addStretch(1)
self.btn_revert = QPushButton("Revert to Selected")
self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected"))
self.btn_revert.clicked.connect(self._revert)
self.btn_close = QPushButton("Close")
self.btn_delete = QPushButton(strings._("history_dialog_delete"))
self.btn_delete.clicked.connect(self._delete)
self.btn_close = QPushButton(strings._("close"))
self.btn_close.clicked.connect(self.reject)
row.addWidget(self.btn_revert)
row.addWidget(self.btn_delete)
row.addWidget(self.btn_close)
root.addLayout(row)
self._load_versions()
# --- Data/UX helpers ---
def _fmt_local(self, iso_utc: str) -> str:
"""
Convert UTC in the database to user's local tz
"""
dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
local = dt.astimezone()
return local.strftime("%Y-%m-%d %H:%M:%S %Z")
@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):
self._versions = self._db.list_versions(
self._date
) # [{id,version_no,created_at,note,is_current}]
# [{id,version_no,created_at,note,is_current}]
self._versions = self._db.list_versions(self._date)
self._current_id = next(
(v["id"] for v in self._versions if v["is_current"]), None
)
self.list.clear()
for v in self._versions:
label = f"v{v['version_no']}{self._fmt_local(v['created_at'])}"
created_at = datetime.fromisoformat(
v["created_at"].replace("Z", "+00:00")
).astimezone()
created_at_local = created_at.strftime("%Y-%m-%d %H:%M:%S %Z")
label = f"v{v['version_no']}{created_at_local}"
if v.get("note"):
label += f" · {v['note']}"
if v["is_current"]:
label += " **(current)**"
label += " **(" + strings._("current") + ")**"
it = QListWidgetItem(label)
it.setData(Qt.UserRole, v["id"])
self.list.addItem(it)
@ -143,34 +217,53 @@ class HistoryDialog(QDialog):
@Slot()
def _on_select(self):
selected_items = self.list.selectedItems()
item = self.list.currentItem()
if not item:
if not item or len(selected_items) > 1:
self.preview.clear()
self.diff.clear()
self.btn_revert.setEnabled(False)
return
sel_id = item.data(Qt.UserRole)
# Preview selected as HTML
sel = self._db.get_version(version_id=sel_id)
self.preview.setHtml(sel["content"])
self.preview.setMarkdown(sel["content"])
# Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
# Enable revert only if selecting a non-current
# Enable revert and delete buttons only if selecting a non-current version
self.btn_revert.setEnabled(sel_id != self._current_id)
self.btn_delete.setEnabled(sel_id != self._current_id)
@Slot()
def _revert(self):
item = self.list.currentItem()
if not item:
return
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
# Flip head pointer
# Flip head pointer to the older version
try:
self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e:
QMessageBox.critical(self, "Revert failed", str(e))
QMessageBox.critical(
self, strings._("history_dialog_revert_failed"), str(e)
)
return
self.accept()
@Slot()
def _delete(self):
selected_items = self.list.selectedItems()
for item in selected_items:
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
try:
self._db.delete_version(version_id=sel_id)
except Exception as e:
QMessageBox.critical(
self, strings._("history_dialog_delete_failed"), str(e)
)
return
return self._load_versions()

53
bouquin/icons/bouquin.svg Normal file
View file

@ -0,0 +1,53 @@
<svg
width="512"
height="512"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Book cover -->
<rect
x="116"
y="76"
width="280"
height="360"
rx="48"
ry="48"
fill="#FACC15"
/>
<!-- Book spine -->
<rect
x="116"
y="76"
width="64"
height="360"
rx="40"
ry="40"
fill="#F59E0B"
/>
<!-- Folded page corner (top-right triangle) -->
<path
d="M396 76 L356 76 L396 116 Z"
fill="#FEF9C3"
/>
<!-- Keyhole: circular top -->
<circle
cx="256"
cy="256"
r="34"
fill="#0F172A"
/>
<!-- Keyhole: stem -->
<rect
x="238"
y="290"
width="36"
height="90"
rx="12"
ry="12"
fill="#0F172A"
/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

1445
bouquin/invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,107 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QDialogButtonBox,
QVBoxLayout,
)
from . import strings
class KeyPrompt(QDialog):
def __init__(
self,
parent=None,
title: str = "Enter key",
message: str = "Enter key",
title: str = strings._("key_prompt_enter_key"),
message: str = strings._("key_prompt_enter_key"),
initial_db_path: str | Path | None = None,
show_db_change: bool = False,
):
"""
Prompt the user for the key required to decrypt the database.
Used when opening the app, unlocking the idle locked screen,
or when rekeying.
If show_db_change is true, also show a QFileDialog allowing to
select a database file, else the default from settings is used.
"""
super().__init__(parent)
self.setWindowTitle(title)
self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None
v = QVBoxLayout(self)
v.addWidget(QLabel(message))
self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.Password)
v.addWidget(self.edit)
toggle = QPushButton("Show")
# DB chooser
self.path_edit: QLineEdit | None = None
if show_db_change:
path_row = QHBoxLayout()
self.path_edit = QLineEdit()
if self._db_path is not None:
self.path_edit.setText(str(self._db_path))
browse_btn = QPushButton(strings._("select_notebook"))
def _browse():
start_dir = str(self._db_path or "")
fname, _ = QFileDialog.getOpenFileName(
self,
strings._("select_notebook"),
start_dir,
"SQLCipher DB (*.db);;All files (*)",
)
if fname:
self._db_path = Path(fname)
if self.path_edit is not None:
self.path_edit.setText(fname)
browse_btn.clicked.connect(_browse)
path_row.addWidget(self.path_edit, 1)
path_row.addWidget(browse_btn)
v.addLayout(path_row)
# Key entry
self.key_entry = QLineEdit()
self.key_entry.setEchoMode(QLineEdit.Password)
v.addWidget(self.key_entry)
toggle = QPushButton(strings._("show"))
toggle.setCheckable(True)
toggle.toggled.connect(
lambda c: self.edit.setEchoMode(
lambda c: self.key_entry.setEchoMode(
QLineEdit.Normal if c else QLineEdit.Password
)
)
v.addWidget(toggle)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
v.addWidget(bb)
self.key_entry.setFocus()
self.resize(500, self.sizeHint().height())
def key(self) -> str:
return self.edit.text()
return self.key_entry.text()
def db_path(self) -> Path | None:
"""Return the chosen DB path (or None if unchanged/not shown)."""
p = self._db_path
if self.path_edit is not None:
text = self.path_edit.text().strip()
if text:
p = Path(text)
return p

109
bouquin/keys/mig5.asc Normal file
View file

@ -0,0 +1,109 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGQiioEBEAD2hJIaDsfkURHpA9KUXQQezeNhSiUcIheT3vP7Tb8nU2zkIgdy
gvwvuUcXKjUn22q+paqbQu+skYEjtLEFo59ZlS2VOQ6f9ukTGu2O6HWqFWncH3Vv
Pf0UeitNOoWi+qA14mtC7c/SxuHtMG4hmlHILGZg9mlSZfpt7oyczFtV7YG9toRe
gvyM8h2BRSi3EXigsymVMgpYcW3bESVxOnNJdNEFP8fKzR9Bu7rc99abRPm5p6gw
cYo9FAdLoiE8QcNU79hQ5UTAULWXFo3hduQfAs3y0f+g8FGJZUF40Gb8YJDtarRA
J7B9/XdfDNDZE00/QxV2gUGbLVTbVjqn6dKhEOTfuvSmfQxqNNy2a1ewpJrNnsvh
XGvSzZVLNy/c4CEROisRqDCa8xUb/snnHy7gGEuD5DXqQL3wnbTXu92N8gVxLegS
fr9NW2I6/eXWrlXhWJdP5ZH9yq7FVkWha2gTByP6bcxDBvQCzKyYg4JbY9bQDtJf
z7W2W9V6QHMiGJ9/ApfgTjKn0peiouGS8GGCPqLLyVGblEIJmSfEU+0BPq9PurRH
RR/T7E4wVi3bgOfj9G5Z8dMBWh5BzN7PqxQvO1lCx7ZZteNkt/wXglLHB0eghnD0
BCxuZ7lN12NW+lTf9s/kc0PS8YgZ0/AIFv45PHX1sVcxXizT49HQUbHa1wARAQAB
tBpNaWd1ZWwgSmFjcSA8bWlnQG1pZzUubmV0PokCVAQTAQoAPhYhBACugXwkoQwl
QEYanB183gI020WNBQJkIoqBAhsDBQkFo5qABQsJCAcDBRUKCQgLBRYCAwEAAh4B
AheAAAoJEB183gI020WN+2AQALJ58Qr4P3/lON50ulG/RgIYxXlPnyy4Ai1bDJiI
t3pLOWGQkGza6lw07rEh8Bs6w9sQ7WrpfzLRaYgqhfkBNbMtim8hRNZUuE/8O+v3
k9GRVYCe9RWazKhno+RljJy4TaqiqBeGxnryDJWxk8O4dXmQAnsFPF09xNpktgOC
mGbclA+rM8dY3bgq5wJ5Bh10zW4psfoAT1wFYX/oV19vlHbhRx3bavoWDS4lmXYv
oWy9xwacDVoZYcbGPif3xbMbttdKH7ijf+asM3wYUsIrHeOPdHl+YK45e6AGdjwL
mvp0P4YQo8Yk3yfH3L/km/no8rwcrPbk7+lX06x2GEjOiM2OIKAZYMZnL0BREgt4
XsD2hcQpuowxHmI2X2CHk8TnPhAXyNdX7Ss/geQ6Zx/q1Ts+mhhfQVa9AIRS+HDm
LURQRdZKBD1mB2hJsuF2WCyczuJ8jhBc+wSX/WXnQHLi2cG3OAC1udxrdDIckWb8
4CojEbk05cnMLR3dPV/g1JeXunib569RNTAijaTr39VRBZepYJX/sO46iag2+0A4
q41FgId2BwUS3GoyaIFZc5+MwLn65uYMgbIkfVlNkWEujoWV/aVLMrRa0udq4ZRE
ymPU8pfMhEWb3uvYCv+ed7sVxsVUMWeuQpyBQuPP1qlIzmsrEkRKryYH+ij4Vzri
OWvbuQINBGQiizkBEAC07TI3uqEUWKivXf5Mg1HnMAhpmv6yimr8PDfh3Dczy0eP
oCB6iq5wKCjYsp12E3kv3dcW4Ox8T+5U/B5ZP2lro63yeLSORUSz+jMq27rgtGmV
QFZNdKkzBzfPyzjKiZz4KaYE7Pn6v15In65SRqwqAXYUTkEoii+Ykk32qzZWIVCR
ixpRQGbBi+/XipONp8KCQANOSWSzTf8s7U1y4yhW1yCeUOK67LsSRlCtBpDWD7ki
MfX/nzSQyaXHDOrhkfVshU8eiln2Qf3mYg8gJmfFOb0zILhvCf3Sk312GtdxJo1m
B95TrDY8/7+1+l0wVrTq69tJXjQjBSmk1PBvNthSXCvuADnF8NxQlQuZtyI+rC4T
VInuLTr58YrmRIbGzOrFz+z6c532SB9F2PZvezjJ8LPDGCwW8dM6ADQxIw5cV0YE
hb5liFpeIX/NOnd1kus8Q6jyS0vzFqfgZC9kBFUTaXBM+mpDg1GYB4WS7baBQn3P
Z+7wvcN7VkfSBT2B79gJK0vfutJWBuK3p2435/KkD4PcAm6uBYL52b+Za06PQfgu
GaKxXRLREq/KCbYm4IKBkD8HRH9dmdd2U8YsApNWQ/oAHCfWvimhYUD9YOJimDwp
hX7FkaF/xHdi1/8hG8h2lok4cCtbaZPGXAUKuKHDhDFAI/OiIgv4nxq+A5kzfwAR
AQABiQRyBBgBCgAmFiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQiizkCGwIFCQWj
moACQAkQHXzeAjTbRY3BdCAEGQEKAB0WIQQ4BFZaXvpsEa/aDlNZs/DCQTXGqQUC
ZCKLOQAKCRBZs/DCQTXGqTv6D/9eFMA3ReSg1sfPsyEFj9JiJ3H3aOJX5R5/2xdI
QZLTjH0iapgGm3h8v+bFdr4+y3xWHpcaxBJsccyOZxzr0xjr+qt5t6OZrE+e1pQh
Hw/Kt7m5SiCmbGM6I3aECv8zU4EpGUf/FXLcaBaot4eR4uPRjBLatngzLw+5Mjk1
ZBjmyA5OaAqQzrDXPhFBItsSlHJeBOrpbzqxdjQi2AHD+L50itgfsoDOfVtmELZN
heW7xn83U2iqgu3bEq4Ug8lqh2KVBHELoxErQR+wTAIxgj/CwhVDQdrKhQ4ypbLh
O/oPlMmGFcBoMhCATNWitdqQUu7EHAECGyWCns8hm1OksqHMnbNhOzmRkl18UroZ
a1CJPFpeaEC25U37+yPEUiG4dJE8iiZAfyjv0AN1TbXzov5g9g/Xz+BmVALtOYBJ
fWKH/aTg5CU2GY9ts+bYDz+mli39h7FQQfcW+zjVWft2P4R7FvG0DBEJkbyw053R
++CEO1ARsMyygy2ukwkA06nYPlbaH5wEpQl2NV5PeYt66eU4epgL7y89/DhOSBig
JJJk+OASEh3o7rC/EkrlF/GQD8ZwO1oBO11ueDft7QU6P/TAzNqyywqZiy76kzdw
1qU77vhXlGtZQCuxbfgvpLin1ivhOaR/6gfDmsfUlSne5kp+uUrgoRhhEc/krOci
fGSFcutPD/4pziVea31UcngwJRo/s9AfHkjviVMpGJIQo3vtejq53UQu8yWWc/uW
G5z+pxOuK3QdTjtzrmOiCGj1bWZ+I33K+fBbZcf7C+o4HV9KaexW1db3wBtwUFWO
7TFezkBDaKbgxgaryh1+RcetQP7cdN2Chcy0EWf10S8/N8whj2ZyAcIuIoT8wM7i
xWmnQRiI2l2+7AhQfqGFUk+PEYRvRyRtjF8X9buYVBh/9rFrScH6aK+gicCcU1gJ
Zpc51QEDDSfAYF6wV8pWnILKcXqdDZhEh1hnTUitUL9mlZEaenGjSPCtcGVg3s9l
CuXJij89s74IyfCdjJsmy9K5GxQyhUJb0nyy5wOpGPGmDueTiP32JuXOxNeEp+gY
3rxygMNzAmL2QjLajLpE6kj+mEMBYSTWyni1W7c5i0PnOsi22yXV+2W+XaeC+9Pm
424uM8e2Y0+C9lI6AqDziL58fP2V6FxJTpbzBxANqKwSh5N0we1Cfw/ZPC0LyebZ
KbmPcNoSoqaOYXo3h0LFsDL2aA0PTJroAV1p/xxVoxDeGkX+hJXh+6ErVhEOb+gv
+LiUabBFtHTa7yPVtQWLFWf4njFQIytt8iDTpFDfK1OApe25xilrTRZT147KtKwL
5tDl33hFKbspcqALa7ozwE1Tr8/yrddainGQSIfx4CAfk8P5aqi19LkCDQRkIotT
ARAAxjaJMoCvKYNWaJ5m9K9KsfoKss8CXiy3SEhbcqh/Yy4osiODjoWjS+lsz58G
uyPphLXjdhIn9DWPnYKKoV7sB1y2RTCLsZ9jJaqHBL3e+gL78zS8hNHcq3HxWEwb
SYRHr8pBKWL7/X4m+2cuMC/wnK+QWIGB4S03yMZGMbC8GTfuj6tdO4GZYfCGVWHi
gv1ERGaArlqmXk+TkQQmTUpfhdqNBKWllZK56/oUMDNGsRrgEP8TzU4z+YbJK0FJ
7V9dY1j28K8oqLDgA+/aiLv2gpS+qsmowMhxKN/axvF+FCZbGS3+/h4subZMIcbI
xxDHSPqPgA+f0GQHIHsy9gELMQtkXTP5xzZuoDGX+F2LFb68wHd3jCNpfFVEfTP2
8CcyLbjciyY8wod6WLa7q0VNDlSGEXH5thaNnidCwynNCF+NaFQMVf027jThp6S/
nWtUZFPCMGx9jj8mbopkSsfF7E9fErRtCI8dAnmcE/ottvueAN7Q3XAUlsilLM8M
HhkSZobaUBynewcEIpHSY4vOfRWnhQI60WGfD7x7dMuIakao9euSg9g/u7WMCV6U
ShElJdYdpZA/H/jMFb17zuH9yp5cGNNMeUP2WvEWtUHA36nGI4+oE3SszOSRF4+E
YAozF6Hh1MrC/hXe3NShoDq68hG5e1SsndLZ1B9Gt/nAqiEAEQEAAYkCPAQYAQoA
JhYhBACugXwkoQwlQEYanB183gI020WNBQJkIotTAhsMBQkFo5qAAAoJEB183gI0
20WNldAP/17KozqrwUA8mlYU3zpc/P0HdBtL/rn5Fx87MZ2E8RPuVMyNg6I4KoU5
Kmh0vy6cL8vG7fqYXM1ieiy9wTMxiGaWDL7QZY3LBXQ2mFfGd2rAAhwloTEcPn6i
Ro/X0C5aBGGy5iACOfpRA774XsNQG6cgBY/Jq0/D2Jom78Vv0k3H0oD1L5BrRO/H
5L9TriBW9el4F/USpaQDjR/KiSfsBr6HLpht1OQJ+21kUbGgvse7DdTtZeK4q3wR
1v4OV9EX1m09WUL+7Cra1OFSc9bZ0fcVY98zGXm8LTtipiBc//ZrDjMutRdOj4ct
RHDiKHBEYFxHGeAj87Xwc9q6ph2MspjXS4qHVJRWtyx5DQcrf6gY3bH73SByhOXj
SVDpfeDvO4BpQ+8q4d9AjcGa6NqGTXR8P5Y8jnZG68buwGstBbz2J2fHBs0SrBMg
3T6HSB3z4gD/WkPE8bT/9oMpSLD0mdHQAYJviOa39rRGII6Jzkd1EL9tVDU9QenX
hVx2v3ZWL8Iq1Bm8zwiDAGsiHcHmxY8sQmfuwWQdYXhxXBcG0kBNKz+158uyFr9u
Skp8e1INBDShReAQuQ5PAGBIrZ5aElPaK/2puNeAmd3cholvpeu0CuEaxpLi0Tq3
y/xhPPFMdZ4llt90sotKeYnHmvsYUJe2on8afl9bwotz8On484vVuQINBGQii2cB
EAC/YnmAiKO05oN129GedPTDrvJk6PbXHUYb5UtNisAwLVXeKSpo5OWyckDZ1IoV
9xvOdH+TWJvgX5x7gPZoD9COYHfMQRZeysZ89wCocH55PsAwmvjM87rAKLbkyZl8
sehgsri09amBlMoSeTVN49U5lt9EZWVKZeACtDk9D86OX7r154NM7uSxvQVeydth
Bj/Rdh15RUfsKTZYxmzZ/1x3FnHzOLTDkX5QmBIBlthVN2IaT8U8pfKpoStOlBza
j1MdrdhtkDH4YAFi2X9KlkoP3Z2fYCefVcLJw+k3D8nwPyXmGuJhG0oHsPyesQGz
FSnIM6ZWhqh76yS1EQxK125NKu9FeHJBAEOg0RISpe/LhNNLjUQ0dC9gRx9l+p46
hIMUXwMPNENMFihNqP4tRLvF/0KI1oj7634rei+dZKWuja6yk/QaOcztmcyS2Aca
n3llExISb3beNncQHaAYg8ADHR+852RZQ81yUFUF7yrxclSJmF5zO4fJAedacClA
FuGnQvIQZv01YULOtDn3fTq8eY912VZx+SxpO2IwTObYCdnSBHigQBp13UTcg5WV
HhmfwJKI328GaPkBa0eIqxc5gR7X6PmrLvxlCbrMC9IHjlwd203eKMhqRoIJYXEv
Ebsx02Zceh4tMH9RDH2XNpHLt604rCLJTReRORXsAH/zBQARAQABiQI8BBgBCgAm
FiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQii2cCGyAFCQWjmoAACgkQHXzeAjTb
RY1TiA/+N4dIfoHMsEZ53DwrbRqDXlzTfkfqWd99tE72Lecsns2ih4/4wHOgzV7z
SV6002SZK/PHRYikmxSSxmoNbx5yNMp9vI8j031YShAJd6QU+NVjY3oB4ivF6wRa
vP2OYO0vamwTw54e5quKmg+ZntFhWY55YNWCqqcYZdHI4GtvbhsCEuS/ceZ1XoXY
xbtaNJHAn5yG+/VLNu2fiAiu+e4+xEQ2UjV8rC60MU9tZafMbALlHUXGDY0tUCzv
/BF3GDQk3dxN+fEBnassVXgZm30dOB2XqVIF5g+l6iufmT9WcDTbnXyYbEBRVTJ1
DpTbmtwUpuYdSX41NPPojK3XcesP+PR8x7tWU7AEWzV827I4sx54HjJVMj2TWSGB
X+xDgthbqqtm1VZPNL2yHJzxHgIPqo6iQLaAGphR/L+ULFeJnFNjgOatt7vcG7pr
ZVLK1Kq+gc0X+73grlm89XC5R3mNFNOUMWXJ7YniqzCzsTiOwyGP40pvY1vP8v61
509UcUjfXyIhls6vAl1jo/BA0jLuUODQ9P4QqWm4wy7MzMfWBmWKsaubCiiHuala
rXFaJVtIgM/bl089klXVzxD3Beo0PCnuU/6qBgkM6ulS+/wxqU7chW6ClHwdY8U0
NU3X/uocFtQrI3WLcE0vMc0IHa8VjDb8r6ztC9Vsti6iPMdScOM=
=IfFs
-----END PGP PUBLIC KEY BLOCK-----

432
bouquin/locales/en.json Normal file
View file

@ -0,0 +1,432 @@
{
"db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed",
"db_issues_reported": "issue(s) reported",
"db_reopen_failed_after_rekey": "Re-open failed after rekey",
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error",
"database_maintenance": "Database maintenance",
"database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
"database_compacted_successfully": "Database compacted successfully!",
"encryption": "Encryption",
"remember_key": "Remember key",
"change_encryption_key": "Change encryption key",
"enter_a_new_encryption_key": "Enter a new encryption key",
"reenter_the_new_key": "Re-enter the new key",
"key_mismatch": "Key mismatch",
"key_mismatch_explanation": "The two entries did not match.",
"empty_key": "Empty key",
"empty_key_explanation": "The key cannot be empty.",
"key_changed": "Key changed",
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
"error": "Error",
"success": "Success",
"close": "&Close",
"find": "Find",
"file": "File",
"locale": "Language",
"locale_restart": "Please restart the application to load the new language.",
"settings": "Settings",
"theme": "Theme",
"system": "System",
"light": "Light",
"dark": "Dark",
"never": "Never",
"close_tab": "Close tab",
"previous": "Previous",
"previous_day": "Previous day",
"next": "Next",
"next_day": "Next day",
"today": "Today",
"show": "Show",
"edit": "Edit",
"delete": "Delete",
"history": "History",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
"export_complete": "Export complete",
"export_failed": "Export failed",
"backup": "Backup",
"backup_complete": "Backup complete",
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
"save": "Save",
"help": "Help",
"saved": "Saved",
"saved_to": "Saved to",
"documentation": "Documentation",
"couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug",
"version": "Version",
"update": "Update",
"check_for_updates": "Check for updates",
"could_not_check_for_updates": "Could not check for updates:\n",
"update_server_returned_an_empty_version_string": "Update server returned an empty version string",
"you_are_running_the_latest_version": "You are running the latest version:\n",
"there_is_a_new_version_available": "There is a new version available:\n",
"download_the_appimage": "Download the AppImage?",
"downloading": "Downloading",
"download_cancelled": "Download cancelled",
"failed_to_download_update": "Failed to download update:\n",
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
"gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate",
"current": "current",
"selected": "selected",
"find_on_page": "Find on page",
"find_next": "Find next",
"find_previous": "Find previous",
"find_bar_type_to_search": "Type to search",
"find_bar_match_case": "Match case",
"history_dialog_preview": "Preview",
"history_dialog_diff": "Diff",
"history_dialog_revert_to_selected": "&Revert to selected",
"history_dialog_revert_failed": "Revert failed",
"history_dialog_delete": "&Delete revision",
"history_dialog_delete_failed": "Could not delete revision",
"key_prompt_enter_key": "Enter key",
"lock_overlay_locked": "Locked",
"lock_overlay_unlock": "Unlock",
"main_window_lock_screen_accessibility": "&Lock screen",
"main_window_ready": "Ready",
"main_window_save_a_version": "Save a version",
"main_window_settings_accessible_flag": "Settin&gs",
"set_an_encryption_key": "Set an encryption key",
"set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!",
"unlock_encrypted_notebook": "Unlock encrypted notebook",
"unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook",
"open_in_new_tab": "Open in new tab",
"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",
"unlock_failed": "Unlock failed",
"could_not_unlock_database_at_new_path": "Could not unlock database at new path.",
"unencrypted_export": "Unencrypted export",
"unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.",
"unrecognised_extension": "Unrecognised extension!",
"backup_encrypted_notebook": "Backup encrypted notebook",
"enter_a_name_for_this_version": "Enter a name for this version",
"new_version_i_saved_at": "New version I saved at",
"appearance": "Appearance",
"security": "Security",
"features": "Features",
"database": "Database",
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
"lock_screen_when_idle": "Lock screen when idle",
"autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
"font_size": "Font size",
"font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
"search_for_notes_here": "Search for notes here",
"toolbar_format": "Format",
"toolbar_bold": "Bold",
"toolbar_italic": "Italic",
"toolbar_strikethrough": "Strikethrough",
"toolbar_normal_paragraph_text": "Normal paragraph text",
"toolbar_font_smaller": "Smaller text",
"toolbar_font_larger": "Larger text",
"toolbar_bulleted_list": "Bulleted list",
"toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block",
"toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"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:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"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",
"stats_total_words": "Total words (current versions)",
"stats_unique_tags": "Unique tags",
"stats_page_most_tags": "Page with most tags",
"stats_activity_heatmap": "Activity heatmap",
"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",
"bug_report_empty": "Please enter some details about the bug before sending.",
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
"reminder": "Reminder",
"set_reminder": "Set reminder prompt",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
"dismiss": "Dismiss",
"toolbar_alarm": "Set reminder alarm",
"activities": "Activities",
"activity": "Activity",
"note": "Note",
"activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity",
"activity_rename_error_title": "Problem renaming activity",
"activity_required_message": "An activity name is required",
"activity_required_title": "Activity name required",
"add_activity": "Add activity",
"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",
"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?",
"delete_project": "Delete project",
"delete_project_confirm": "Are you sure you want to delete this project?",
"delete_project_title": "Delete project - are you sure?",
"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",
"invalid_project_title": "Invalid project",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
"open_time_log": "Open time log",
"project": "Project",
"project_delete_error_message": "A problem occurred deleting the project",
"project_delete_error_title": "Problem deleting project",
"project_rename_error_message": "A problem occurred renaming the project",
"project_rename_error_title": "Problem renaming project",
"project_required_message": "A project is required",
"project_required_title": "Project required",
"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",
"rename_activity_label": "Rename activity",
"add_project_title": "Add project",
"add_project_label": "Add a project",
"rename_activity_title": "Rename this activity",
"rename_project_label": "Rename project",
"rename_project_title": "Rename this project",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a project",
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"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",
"time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",
"no_report_message": "Please run a report before exporting.",
"total": "Total",
"export_csv": "Export CSV",
"export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}",
"export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}",
"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",
"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",
"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": "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",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"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_path": "Path",
"documents_col_tags": "Tags",
"documents_col_size": "Size",
"documents_add": "&Add",
"documents_add_document": "Add a document",
"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_missing_file": "The file does not exist:\n{path}",
"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)",
"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."
}

290
bouquin/locales/fr.json Normal file
View file

@ -0,0 +1,290 @@
{
"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": "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"
}

161
bouquin/locales/it.json Normal file
View file

@ -0,0 +1,161 @@
{
"db_sqlcipher_integrity_check_failed": "Controllo di integrità SQLCipher fallito",
"db_issues_reported": "problema/i segnalato/i",
"db_reopen_failed_after_rekey": "Riapertura fallita dopo il cambio chiave",
"db_version_id_does_not_belong_to_the_given_date": "version_id non appartiene alla data indicata",
"db_database_error": "Errore del database",
"db_key_incorrect": "La chiave è probabilmente errata",
"database_maintenance": "Manutenzione del database",
"database_compact": "Compatta il database",
"database_compact_explanation": "La compattazione esegue VACUUM sul database. Può aiutare a ridurne le dimensioni.",
"database_compacted_successfully": "Database compattato con successo!",
"encryption": "Crittografia",
"remember_key": "Ricorda la chiave",
"change_encryption_key": "Cambia chiave di crittografia",
"enter_a_new_encryption_key": "Inserisci una nuova chiave di crittografia",
"reenter_the_new_key": "Reinserisci la nuova chiave",
"key_mismatch": "Le chiavi non corrispondono",
"key_mismatch_explanation": "Le due chiavi inserite non corrispondono.",
"empty_key": "Chiave vuota",
"empty_key_explanation": "La chiave non può essere vuota.",
"key_changed": "Chiave cambiata",
"key_changed_explanation": "Il blocco note è stato criptato nuovamente con la nuova chiave!",
"error": "Errore",
"success": "Successo",
"close": "Chiudi",
"find": "Trova",
"file": "File",
"locale": "Lingua",
"locale_restart": "Per favore riavvia l'applicazione per caricare la nuova lingua.",
"settings": "Impostazioni",
"theme": "Tema",
"system": "Sistema",
"light": "Chiaro",
"dark": "Scuro",
"never": "Mai",
"previous": "Precedente",
"previous_day": "Giorno precedente",
"next": "Successivo",
"next_day": "Giorno successivo",
"today": "Oggi",
"show": "Mostra",
"history": "Cronologia",
"export_accessible_flag": "&Esporta",
"export_entries": "Esporta voci",
"export_complete": "Esportazione completata",
"export_failed": "Esportazione fallita",
"backup": "Backup",
"backup_complete": "Backup completato",
"backup_failed": "Backup fallito",
"quit": "Esci",
"help": "Aiuto",
"saved": "Salvato",
"saved_to": "Salvato in",
"documentation": "Documentazione",
"couldnt_open": "Impossibile aprire",
"report_a_bug": "Segnala un bug",
"version": "Versione",
"navigate": "Naviga",
"current": "corrente",
"selected": "selezionato",
"find_on_page": "Trova nella pagina",
"find_next": "Trova successivo",
"find_previous": "Trova precedente",
"find_bar_type_to_search": "Digita per cercare",
"find_bar_match_case": "Distingui maiuscole/minuscole",
"history_dialog_preview": "Anteprima",
"history_dialog_diff": "Differenze",
"history_dialog_revert_to_selected": "Ripristina alla versione selezionata",
"history_dialog_revert_failed": "Ripristino fallito",
"key_prompt_enter_key": "Inserisci la chiave",
"lock_overlay_locked": "Bloccato",
"lock_overlay_unlock": "Sblocca",
"main_window_ready": "Pronto",
"main_window_save_a_version": "Salva versione",
"main_window_settings_accessible_flag": "Impo&stazioni",
"set_an_encryption_key": "Imposta una chiave di crittografia",
"set_an_encryption_key_explanation": "Bouquin cripta i tuoi dati.\n\nCrea una passphrase sicura per criptare il blocco note.\n\nPuoi sempre cambiarla in seguito!",
"unlock_encrypted_notebook": "Sblocca il blocco note criptato",
"unlock_encrypted_notebook_explanation": "Inserisci la chiave per sbloccare il blocco note",
"open_in_new_tab": "Apri in una nuova scheda",
"autosave": "salvataggio automatico",
"unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo",
"move_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati a oggi all'avvio",
"insert_images": "Inserisci immagini",
"images": "Immagini",
"reopen_failed": "Riapertura fallita",
"unlock_failed": "Sblocco fallito",
"could_not_unlock_database_at_new_path": "Impossibile sbloccare il database nel nuovo percorso.",
"unencrypted_export": "Esportazione non criptata",
"unencrypted_export_warning": "L'esportazione del database sarà non criptata!\nVuoi davvero continuare?\nSe desideri un backup criptato, scegli Backup invece di Esporta.",
"unrecognised_extension": "Estensione non riconosciuta!",
"backup_encrypted_notebook": "Backup del blocco note criptato",
"enter_a_name_for_this_version": "Inserisci un nome per questa versione",
"new_version_i_saved_at": "Nuova versione salvata il",
"save_key_warning": "Se non vuoi che ti venga richiesta la chiave di crittografia, seleziona questa opzione per ricordarla.\nATTENZIONE: la chiave viene salvata sul disco e potrebbe essere recuperabile se il disco fosse compromesso.",
"lock_screen_when_idle": "Blocca lo schermo quando inattivo",
"autolock_explanation": "Bouquin bloccherà automaticamente il blocco note dopo questo intervallo di tempo, dopodiché sarà necessario reinserire la chiave per sbloccarlo.\nImposta a 0 (mai) per non bloccarlo mai.",
"search_for_notes_here": "Cerca note qui",
"toolbar_format": "Formato",
"toolbar_bold": "Grassetto",
"toolbar_italic": "Corsivo",
"toolbar_strikethrough": "Barrato",
"toolbar_normal_paragraph_text": "Testo normale",
"toolbar_bulleted_list": "Elenco puntato",
"toolbar_numbered_list": "Elenco numerato",
"toolbar_code_block": "Blocco di codice",
"toolbar_heading": "Titolo",
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo",
"tags": "Tag",
"manage_tags": "Gestisci tag",
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
"color_hex": "Colore",
"date": "Data",
"add_a_tag": "Aggiungi un tag",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",
"change_color": "Cambia colore",
"delete_tag": "Elimina tag",
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.",
"tag_already_exists_with_that_name": "Esiste già un tag con questo nome",
"cancel": "Annulla",
"select_notebook": "Seleziona blocco note",
"save": "Salva",
"history_dialog_delete": "Cancella versione",
"check_for_updates": "Controlla aggiornamenti",
"close": "Chiudi",
"send": "Invia",
"time_log": "Registro Attività",
"time_log_no_entries": "Nessuna Attività",
"close_tab": "Chiudi scheda",
"toolbar_font_smaller": "Rimpicciolisci testo",
"toolbar_font_larger": "Ingrandisci testo",
"toolbar_alarm": "Imposta promemoria",
"statistics": "Statistiche",
"main_window_statistics_accessible_flag": "Stat&istiche",
"main_window_lock_screen_accessibility": "B&locca Schermo",
"font_size": "Dimensione carattere",
"font_size_explanation": "Cambiare questo valore camberà la dimensione di tutto il testo in tutte le schede. Dimensione di titoli e blocchi di codice rimarranno invariati",
"enable_tags_feature": "Abilita Tags",
"enable_time_log_feature": "Abilita Traccuamento del tempo",
"appearance": "Interfaccia",
"features": "Funzionalità",
"security": "Sicurezza",
"bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.",
"bug_report_placeholder": "Scrivi la tua segnalazione qui",
"update": "Aggiornamento",
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n",
"cut": "Taglia",
"copy": "Copia",
"paste": "Incolla",
"monday": "Lunedì",
"tuesday": "Martedì",
"wednesday": "Mercoledì",
"thursday": "Giovedì",
"friday": "Venerdì",
"saturday": "Sabato",
"sunday": "Domenica",
"day": "Giorno"
}

View file

@ -1,30 +1,33 @@
from __future__ import annotations
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPalette
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
class LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable):
def __init__(self, parent: QWidget, on_unlock: callable, themes: ThemeManager):
"""
Widget that 'locks' the screen after a configured idle time.
"""
super().__init__(parent)
self.setObjectName("LockOverlay")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
self._styling = False # <-- reentrancy guard
self._last_dark: bool | None = None
lay = QVBoxLayout(self)
lay.addStretch(1)
msg = QLabel("Locked due to inactivity", self)
msg = QLabel(strings._("lock_overlay_locked"), self)
msg.setObjectName("lockLabel")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton("Unlock", self)
self._btn = QPushButton(strings._("lock_overlay_unlock"), self)
self._btn.setObjectName("unlockButton")
self._btn.setShortcut("Ctrl+Shift+U")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
@ -35,88 +38,9 @@ class LockOverlay(QWidget):
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
self._apply_overlay_style()
themes.register_lock_overlay(self)
self.hide()
def _is_dark(self, pal: QPalette) -> bool:
c = pal.color(QPalette.Window)
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
return luma < 0.5
def _apply_overlay_style(self):
if self._styling:
return
dark = self._is_dark(self.palette())
if dark == self._last_dark:
return
self._styling = True
try:
if dark:
link = self.palette().color(QPalette.Link)
accent_hex = link.name() # e.g. "#FFA500"
r, g, b = link.red(), link.green(), link.blue()
self.setStyleSheet(
f"""
#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
)
else:
# (light mode unchanged)
self.setStyleSheet(
"""
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
)
self._last_dark = dark
finally:
self._styling = False
def changeEvent(self, ev):
super().changeEvent(ev)
# Only re-style on palette flips
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
self._apply_overlay_style()
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())

View file

@ -1,10 +1,14 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from .settings import APP_NAME, APP_ORG, get_settings
from . import strings
from .main_window import MainWindow
from .settings import APP_NAME, APP_ORG, get_settings
from .theme import Theme, ThemeConfig, ThemeManager
@ -12,6 +16,11 @@ def main():
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG)
# Icon
BASE_DIR = Path(__file__).resolve().parent
ICON_PATH = BASE_DIR / "icons" / "bouquin.svg"
icon = QIcon(str(ICON_PATH))
app.setWindowIcon(icon)
s = get_settings()
theme_str = s.value("ui/theme", "system")
@ -19,6 +28,7 @@ def main():
themes = ThemeManager(app, cfg)
themes.apply(cfg.theme)
strings.load_strings(s.value("ui/locale", "en"))
win = MainWindow(themes=themes)
win.show()
sys.exit(app.exec())

File diff suppressed because it is too large Load diff

1845
bouquin/markdown_editor.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,370 @@
from __future__ import annotations
import re
from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
QFontMetrics,
QGuiApplication,
QPalette,
QSyntaxHighlighter,
QTextCharFormat,
QTextDocument,
)
from .theme import Theme, ThemeManager
class MarkdownHighlighter(QSyntaxHighlighter):
"""Live syntax highlighter for markdown that applies formatting as you type."""
def __init__(
self, document: QTextDocument, theme_manager: ThemeManager, editor=None
):
super().__init__(document)
self.theme_manager = theme_manager
self._editor = editor # Reference to the MarkdownEditor
self._setup_formats()
# Recompute formats whenever the app theme changes
self.theme_manager.themeChanged.connect(self._on_theme_changed)
def _on_theme_changed(self, *_):
self._setup_formats()
self.rehighlight()
def refresh_for_font_change(self) -> None:
"""
Called when the editor's base font changes (zoom / settings).
It rebuilds any formats that depend on the editor font metrics.
"""
self._setup_formats()
self.rehighlight()
def _setup_formats(self):
"""Setup text formats for different markdown elements."""
# Bold: **text** or __text__
self.bold_format = QTextCharFormat()
self.bold_format.setFontWeight(QFont.Weight.Bold)
# Italic: *text* or _text_
self.italic_format = QTextCharFormat()
self.italic_format.setFontItalic(True)
# Allow combination of bold/italic
self.bold_italic_format = QTextCharFormat()
self.bold_italic_format.setFontWeight(QFont.Weight.Bold)
self.bold_italic_format.setFontItalic(True)
# Strikethrough: ~~text~~
self.strike_format = QTextCharFormat()
self.strike_format.setFontStrikeOut(True)
# Inline code: `code`
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self.code_format = QTextCharFormat()
self.code_format.setFont(mono)
self.code_format.setFontFixedPitch(True)
# Code block: ```
self.code_block_format = QTextCharFormat()
self.code_block_format.setFont(mono)
self.code_block_format.setFontFixedPitch(True)
pal = QGuiApplication.palette()
if (
self.theme_manager.current() == Theme.DARK
or self.theme_manager._is_system_dark
):
# 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 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(code_bg)
self.code_block_format.setForeground(code_fg)
# Headings
self.h1_format = QTextCharFormat()
self.h1_format.setFontPointSize(24.0)
self.h1_format.setFontWeight(QFont.Weight.Bold)
self.h2_format = QTextCharFormat()
self.h2_format.setFontPointSize(18.0)
self.h2_format.setFontWeight(QFont.Weight.Bold)
self.h3_format = QTextCharFormat()
self.h3_format.setFontPointSize(14.0)
self.h3_format.setFontWeight(QFont.Weight.Bold)
# Hyperlinks
self.link_format = QTextCharFormat()
link_color = pal.color(QPalette.Link)
self.link_format.setForeground(link_color)
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)
# Bullets
self.bullet_format = QTextCharFormat()
# Use Symbols font for checkbox and bullet glyphs if present
if self._editor is not None and hasattr(self._editor, "symbols_font_family"):
base_font = QFont(self._editor.qfont) # copy of editor font
symbols_font = QFont(self._editor.symbols_font_family)
symbols_font.setPointSizeF(base_font.pointSizeF())
base_metrics = QFontMetrics(base_font)
sym_metrics = QFontMetrics(symbols_font)
# If Symbols glyphs are noticeably shorter than the text,
# scale them up so the visual heights roughly match.
if sym_metrics.height() > 0:
ratio = base_metrics.height() / sym_metrics.height()
if ratio > 1.05: # more than ~5% smaller
ratio = min(ratio, 1.4) # Oh, Tod, Tod. Don't overdo it.
symbols_font.setPointSizeF(symbols_font.pointSizeF() * ratio)
self.checkbox_format.setFont(symbols_font)
self.bullet_format.setFont(symbols_font)
# Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat()
# Use the editor background color so they blend in
hidden = QColor(text_bg)
hidden.setAlpha(0)
self.syntax_format.setForeground(hidden)
# Make the markers invisible by setting font size to 0.1 points
self.syntax_format.setFontPointSize(0.1)
def _overlay_range(
self, start: int, length: int, overlay_fmt: QTextCharFormat
) -> None:
"""Merge overlay_fmt onto the existing format for each char in [start, start+length)."""
end = start + length
i = start
while i < end:
base = QTextCharFormat(self.format(i)) # current format at this position
base.merge(overlay_fmt) # add only the properties we set
self.setFormat(i, 1, base) # write back one char
i += 1
def highlightBlock(self, text: str):
"""Apply formatting to a block of text based on markdown syntax."""
# Track if we're in a code block (multiline)
prev_state = self.previousBlockState()
in_code_block = prev_state == 1
# Check for code block fences
if text.strip().startswith("```"):
# background for the whole fence line (so block looks continuous)
self.setFormat(0, len(text), self.code_block_format)
# hide the three backticks themselves
idx = text.find("```")
if idx != -1:
self.setFormat(idx, 3, self.syntax_format)
# toggle code-block state and stop; next line picks up state
in_code_block = not in_code_block
self.setCurrentBlockState(1 if in_code_block else 0)
return
if in_code_block:
# inside code: apply block bg and language rules
self.setFormat(0, len(text), self.code_block_format)
# Try to apply language-specific highlighting
if self._editor and hasattr(self._editor, "_code_metadata"):
from .code_highlighter import CodeHighlighter
# Find the opening fence block
prev_block = self.currentBlock().previous()
fence_block_num = None
temp_inside = in_code_block
while prev_block.isValid():
if prev_block.text().strip().startswith("```"):
temp_inside = not temp_inside
if not temp_inside:
fence_block_num = prev_block.blockNumber()
break
prev_block = prev_block.previous()
if fence_block_num is not None:
language = self._editor._code_metadata.get_language(fence_block_num)
if language:
patterns = CodeHighlighter.get_language_patterns(language)
for pattern, syntax_type in patterns:
for match in re.finditer(pattern, text):
start, end = match.span()
fmt = CodeHighlighter.get_format_for_type(
syntax_type, self.code_block_format
)
self.setFormat(start, end - start, fmt)
self.setCurrentBlockState(1)
return
# ---- Normal markdown (outside code)
self.setCurrentBlockState(0)
# If the line is empty and not in a code block, nothing else to do
if not text:
return
# Headings (must be at start of line)
heading_match = re.match(r"^(#{1,3})\s+", text)
if heading_match:
level = len(heading_match.group(1))
marker_len = len(heading_match.group(0))
# Format the # markers
self.setFormat(0, marker_len, self.syntax_format)
# Format the heading text
heading_fmt = (
self.h1_format
if level == 1
else self.h2_format if level == 2 else self.h3_format
)
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
return
# Bold+Italic (*** or ___): do these first and record occupied spans.
# --- Triple emphasis: detect first, hide markers now, but DEFER applying content style
triple_contents: list[tuple[int, int]] = [] # (start, length) for content only
occupied: list[tuple[int, int]] = (
[]
) # full spans including markers, for overlap checks
for m in re.finditer(
r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*|(?<!_)___(.+?)(?<!_)___", text
):
start, end = m.span()
content_start, content_end = start + 3, end - 3
# hide the *** / ___ markers now
self.setFormat(start, 3, self.syntax_format)
self.setFormat(end - 3, 3, self.syntax_format)
# remember the full occupied span and the content span
occupied.append((start, end))
triple_contents.append((content_start, content_end - content_start))
def _overlaps(a, b): # a, b are (start, end)
return not (a[1] <= b[0] or b[1] <= a[0])
# --- Bold (**) or (__): skip if it overlaps any triple
for m in re.finditer(
r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)|(?<!_)__(?!_)(.+?)(?<!_)__(?!_)",
text,
):
start, end = m.span()
if any(_overlaps((start, end), occ) for occ in occupied):
continue # pragma: no cover
content_start, content_end = start + 2, end - 2
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
self.setFormat(content_start, content_end - content_start, self.bold_format)
# --- Italic (*) or (_): skip if it overlaps any triple
for m in re.finditer(
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
):
start, end = m.span()
if any(_overlaps((start, end), occ) for occ in occupied):
continue # pragma: no cover
# avoid stealing a single marker that is part of a double
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
continue # pragma: no cover
if end < len(text) and text[end : end + 1] in ("*", "_"):
continue # pragma: no cover
content_start, content_end = start + 1, end - 1
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
self.setFormat(
content_start, content_end - content_start, self.italic_format
)
# --- NOW overlay bold+italic for triple contents LAST (so nothing clobbers it)
for cs, length in triple_contents:
self._overlay_range(cs, length, self.bold_italic_format)
# Strikethrough: ~~text~~
for m in re.finditer(r"~~(.+?)~~", text):
start, end = m.span()
content_start, content_end = start + 2, end - 2
# Fade the markers
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
# Merge strikeout with whatever is already applied (bold, italic, both, links, etc.)
self._overlay_range(
content_start, content_end - content_start, self.strike_format
)
# Inline code: `code`
for match in re.finditer(r"`([^`]+)`", text):
start, end = match.span()
content_start = start + 1
content_end = end - 1
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
self.setFormat(content_start, content_end - content_start, self.code_format)
# Hyperlinks
url_pattern = re.compile(r"(https?://[^\s<>()]+)")
for m in url_pattern.finditer(text):
start, end = m.span(1)
url = m.group(1)
# Clone link format so we can attach a per-link href
fmt = QTextCharFormat(self.link_format)
fmt.setAnchorHref(url)
# Overlay link attributes on top of whatever formatting is already there
self._overlay_range(start, end - start, fmt)
# Make checkbox glyphs bigger
for m in re.finditer(r"[☐☑]", text):
self._overlay_range(m.start(), 1, self.checkbox_format)
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)

207
bouquin/pomodoro_timer.py Normal file
View file

@ -0,0 +1,207 @@
from __future__ import annotations
import math
from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .time_log import TimeLogDialog
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._task_text = task_text
self._elapsed_seconds = 0
self._running = False
layout = QVBoxLayout(self)
# Task label
task_label = QLabel(task_text)
task_label.setWordWrap(True)
layout.addWidget(task_label)
# Timer display
self.time_label = QLabel("00:00:00")
font = self.time_label.font()
font.setPointSize(20)
font.setBold(True)
self.time_label.setFont(font)
self.time_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.time_label)
# Control buttons
btn_layout = QHBoxLayout()
self.start_pause_btn = QPushButton(strings._("start"))
self.start_pause_btn.clicked.connect(self._toggle_timer)
btn_layout.addWidget(self.start_pause_btn)
self.stop_btn = QPushButton(strings._("stop_and_log"))
self.stop_btn.clicked.connect(self._stop_and_log)
self.stop_btn.setEnabled(False)
btn_layout.addWidget(self.stop_btn)
layout.addLayout(btn_layout)
# Internal timer (ticks every second)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
@Slot()
def _toggle_timer(self):
"""Start or pause the timer."""
if self._running:
# Pause
self._running = False
self._timer.stop()
self.start_pause_btn.setText(strings._("resume"))
else:
# Start/Resume
self._running = True
self._timer.start(1000) # 1 second
self.start_pause_btn.setText(strings._("pause"))
self.stop_btn.setEnabled(True)
@Slot()
def _tick(self):
"""Update the elapsed time display."""
self._elapsed_seconds += 1
self._update_display()
def _update_display(self):
"""Update the time display label."""
hours = self._elapsed_seconds // 3600
minutes = (self._elapsed_seconds % 3600) // 60
seconds = self._elapsed_seconds % 60
self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
@Slot()
def _stop_and_log(self):
"""Stop the timer and emit signal to open time log."""
if self._running:
self._running = False
self._timer.stop()
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
self.close()
class PomodoroManager:
"""Manages Pomodoro timers and integrates with time log."""
def __init__(self, db: DBManager, parent_window):
self._db = db
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 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)
)
# 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, rounding up to the nearest 0.25 hour (15 minutes)
quarter_hours = math.ceil(elapsed_seconds / 900)
hours = quarter_hours * 0.25
# 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,
True,
themes=self._parent.themes,
close_after_add=True,
)
# Pre-fill the hours
dlg.hours_spin.setValue(hours)
# Pre-fill the note with task text
dlg.note.setText(task_text)
# 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")
)

917
bouquin/reminders.py Normal file
View file

@ -0,0 +1,917 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDateEdit,
QDialog,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QStyle,
QTableWidget,
QTableWidgetItem,
QTimeEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .settings import load_db_config
import requests
class ReminderType(Enum):
ONCE = strings._("once")
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
class Reminder:
id: Optional[int]
text: str
time_str: str # HH:MM
reminder_type: ReminderType
weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type)
active: bool = True
date_iso: Optional[str] = None # For ONCE type
class ReminderDialog(QDialog):
"""Dialog for creating/editing reminders with recurrence support."""
def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None):
super().__init__(parent)
self._db = db
self._reminder = reminder
self.setWindowTitle(
strings._("set_reminder") if not reminder else strings._("edit_reminder")
)
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
self.form = QFormLayout()
# Reminder text
self.text_edit = QLineEdit()
if reminder:
self.text_edit.setText(reminder.text)
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()
self.time_edit.setDisplayFormat("HH:mm")
if reminder:
parts = reminder.time_str.split(":")
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
else:
# 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"), 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()):
if self.type_combo.itemData(i) == reminder.reminder_type:
self.type_combo.setCurrentIndex(i)
break
self.type_combo.currentIndexChanged.connect(self._on_type_changed)
self.form.addRow("&" + strings._("repeat") + ":", self.type_combo)
# Weekday selector (for weekly reminders)
self.weekday_combo = QComboBox()
days = [
strings._("monday"),
strings._("tuesday"),
strings._("wednesday"),
strings._("thursday"),
strings._("friday"),
strings._("saturday"),
strings._("sunday"),
]
for i, day in enumerate(days):
self.weekday_combo.addItem(day, i)
if reminder and reminder.weekday is not None:
self.weekday_combo.setCurrentIndex(reminder.weekday)
else:
self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1)
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
day_label = self.form.labelForField(self.weekday_combo)
day_label.setVisible(False)
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()
btn_layout.addStretch()
save_btn = QPushButton("&" + strings._("save"))
save_btn.clicked.connect(self.accept)
save_btn.setDefault(True)
btn_layout.addWidget(save_btn)
cancel_btn = QPushButton("&" + strings._("cancel"))
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
self._on_type_changed()
def _on_type_changed(self):
"""Show/hide weekday / nth selectors based on reminder type."""
reminder_type = self.type_combo.currentData()
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."""
reminder_type = self.type_combo.currentData()
time_obj = self.time_edit.time()
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
weekday = None
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:
# 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,
text=self.text_edit.text(),
time_str=time_str,
reminder_type=reminder_type,
weekday=weekday,
active=self._reminder.active if self._reminder else True,
date_iso=date_iso,
)
class UpcomingRemindersWidget(QFrame):
"""Collapsible widget showing upcoming reminders for today and next 7 days."""
reminderTriggered = Signal(str) # Emits reminder text
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self._db = db
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header with toggle button
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("upcoming_reminders"))
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.add_btn = QToolButton()
self.add_btn.setText("")
self.add_btn.setToolTip(strings._("add_reminder"))
self.add_btn.setAutoRaise(True)
self.add_btn.clicked.connect(self._add_reminder)
self.manage_btn = QToolButton()
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip(strings._("manage_reminders"))
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._manage_reminders)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch()
header.addWidget(self.add_btn)
header.addWidget(self.manage_btn)
# Body with reminder list
self.body = QWidget()
body_layout = QVBoxLayout(self.body)
body_layout.setContentsMargins(0, 4, 0, 0)
body_layout.setSpacing(2)
self.reminder_list = QListWidget()
self.reminder_list.setMaximumHeight(200)
self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.reminder_list.itemDoubleClicked.connect(self._edit_reminder)
self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.reminder_list.customContextMenuRequested.connect(
self._show_reminder_context_menu
)
body_layout.addWidget(self.reminder_list)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# Timer to check and fire 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()
# 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()
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, "_tick_timer") and self._tick_timer:
self._tick_timer.stop()
except Exception:
pass # Ignore any cleanup errors
def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list."""
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
self.refresh()
def refresh(self):
"""Reload and display upcoming reminders."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
self.reminder_list.clear()
reminders = self._db.get_all_reminders()
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Get reminders for the next 7 days
upcoming = []
for i in range(8): # Today + 7 days
check_date = today.addDays(i)
for reminder in reminders:
if not reminder.active:
continue
if self._should_fire_on_date(reminder, check_date):
# Parse time
hour, minute = map(int, reminder.time_str.split(":"))
dt = QDateTime(check_date, QTime(hour, minute))
# Skip past reminders
if dt < now:
continue
upcoming.append((dt, reminder))
# Sort by datetime
upcoming.sort(key=lambda x: x[0])
# Display
for dt, reminder in upcoming[:20]: # Show max 20
date_str = dt.date().toString("ddd MMM d")
time_str = dt.time().toString("HH:mm")
item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}")
item.setData(Qt.UserRole, reminder)
self.reminder_list.addItem(item)
if not upcoming:
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."""
rtype = reminder.reminder_type
if rtype == ReminderType.ONCE:
if reminder.date_iso:
return date.toString("yyyy-MM-dd") == reminder.date_iso
return False
if rtype == ReminderType.DAILY:
return True
if rtype == ReminderType.WEEKDAYS:
# Monday=1, Sunday=7
return 1 <= date.dayOfWeek() <= 5
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, 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
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
if not self._should_fire_on_date(reminder, today):
continue
# 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))
# 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())
if reminder_key in self._fired_reminders:
continue
# 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):
"""Open dialog to add a new reminder."""
dlg = ReminderDialog(self._db, self)
if dlg.exec() == QDialog.Accepted:
reminder = dlg.get_reminder()
self._db.save_reminder(reminder)
self.refresh()
@Slot(QListWidgetItem)
def _edit_reminder(self, item: QListWidgetItem):
"""Edit an existing reminder."""
reminder = item.data(Qt.UserRole)
if not reminder:
return
dlg = ReminderDialog(self._db, self, reminder)
if dlg.exec() == QDialog.Accepted:
updated = dlg.get_reminder()
self._db.save_reminder(updated)
self.refresh()
@Slot()
def _show_reminder_context_menu(self, pos):
"""Show context menu for reminder list item(s)."""
selected_items = self.reminder_list.selectedItems()
if not selected_items:
return
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu
menu = QMenu(self)
# Only show Edit if single item selected
if len(selected_items) == 1:
reminder = selected_items[0].data(Qt.UserRole)
if reminder:
edit_action = QAction(strings._("edit"), self)
edit_action.triggered.connect(
lambda: self._edit_reminder(selected_items[0])
)
menu.addAction(edit_action)
# Delete option for any selection
if len(selected_items) == 1:
delete_text = strings._("delete")
else:
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())
menu.addAction(delete_action)
menu.exec(self.reminder_list.mapToGlobal(pos))
def _delete_selected_reminders(self):
"""Delete all selected reminders (handling duplicates)."""
selected_items = self.reminder_list.selectedItems()
if not selected_items:
return
# Collect unique reminder IDs
unique_reminders = {}
for item in selected_items:
reminder = item.data(Qt.UserRole)
if reminder and reminder.id not in unique_reminders:
unique_reminders[reminder.id] = reminder
if not unique_reminders:
return
# Confirmation message
if len(unique_reminders) == 1:
reminder = list(unique_reminders.values())[0]
msg = (
strings._("delete")
+ " "
+ strings._("reminder")
+ f" '{reminder.text}'?"
)
if reminder.reminder_type != ReminderType.ONCE:
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 = (
strings._("delete")
+ f"{len(unique_reminders)} "
+ strings._("reminders")
+ " ?\n\n"
+ strings._("this_will_delete_the_actual_reminders")
)
reply = QMessageBox.question(
self,
strings._("delete_reminders"),
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
for reminder_id in unique_reminders:
self._db.delete_reminder(reminder_id)
self.refresh()
def _delete_reminder(self, reminder):
"""Delete a single reminder after confirmation."""
msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
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,
strings._("delete_reminder"),
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self.refresh()
@Slot()
def _manage_reminders(self):
"""Open dialog to manage all reminders."""
dlg = ManageRemindersDialog(self._db, self)
dlg.exec()
self.refresh()
class ManageRemindersDialog(QDialog):
"""Dialog for managing all reminders."""
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("manage_reminders"))
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels(
[
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(strings._("add_reminder"))
add_btn.clicked.connect(self._add_reminder)
btn_layout.addWidget(add_btn)
btn_layout.addStretch()
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
self._load_reminders()
def _load_reminders(self):
"""Load all reminders into the table."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
reminders = self._db.get_all_reminders()
self.table.setRowCount(len(reminders))
for row, reminder in enumerate(reminders):
# Text
text_item = QTableWidgetItem(reminder.text)
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, 2, time_item)
# Type
base_type_strs = {
ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays",
ReminderType.WEEKLY: "Weekly",
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")
# 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, 3, type_item)
# Active
active_item = QTableWidgetItem("" if reminder.active else "")
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(strings._("edit"))
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
actions_layout.addWidget(edit_btn)
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, 5, actions_widget)
def _add_reminder(self):
"""Add a new reminder."""
dlg = ReminderDialog(self._db, self)
if dlg.exec() == QDialog.Accepted:
reminder = dlg.get_reminder()
self._db.save_reminder(reminder)
self._load_reminders()
def _edit_reminder(self, reminder):
"""Edit an existing reminder."""
dlg = ReminderDialog(self._db, self, reminder)
if dlg.exec() == QDialog.Accepted:
updated = dlg.get_reminder()
self._db.save_reminder(updated)
self._load_reminders()
def _delete_reminder(self, reminder):
"""Delete a reminder."""
reply = QMessageBox.question(
self,
strings._("delete_reminder"),
strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
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

@ -2,30 +2,39 @@ from __future__ import annotations
import datetime
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QDialogButtonBox,
)
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
from . import strings
class SaveDialog(QDialog):
def __init__(
self,
parent=None,
title: str = "Enter a name for this version",
message: str = "Enter a name for this version?",
):
"""
Used for explicitly saving a new version of a page.
"""
super().__init__(parent)
self.setWindowTitle(title)
self.setWindowTitle(strings._("enter_a_name_for_this_version"))
v = QVBoxLayout(self)
v.addWidget(QLabel(message))
v.addWidget(QLabel(strings._("enter_a_name_for_this_version")))
self.note = QLineEdit()
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.note.setText(f"New version I saved at {now}")
text = strings._("new_version_i_saved_at") + f" {now}"
self.note.setText(text)
v.addWidget(self.note)
# make dialog wide enough for the line edit text
fm = QFontMetrics(self.note.font())
text_width = fm.horizontalAdvance(text) + 20
self.note.setMinimumWidth(text_width)
self.adjustSize()
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)

View file

@ -4,20 +4,21 @@ import re
from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QSizePolicy,
QHBoxLayout,
QVBoxLayout,
QWidget,
)
Row = Tuple[str, str]
from . import strings
Row = Tuple[str, str, str, str, str | None]
class Search(QWidget):
@ -31,7 +32,7 @@ class Search(QWidget):
self._db = db
self.search = QLineEdit()
self.search.setPlaceholderText("Search for notes here")
self.search.setPlaceholderText(strings._("search_for_notes_here"))
self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.search.textChanged.connect(self._search)
@ -51,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):
"""
@ -67,11 +86,7 @@ class Search(QWidget):
self.resultDatesChanged.emit([]) # clear highlights
return
try:
rows: Iterable[Row] = self._db.search_entries(q)
except Exception:
# be quiet on DB errors here; caller can surface if desired
rows = []
rows: Iterable[Row] = self._db.search_entries(q)
self._populate_results(q, rows)
@ -83,41 +98,33 @@ 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, left_ell, right_ell = self._make_html_snippet(
content, query, radius=30, maxlen=90
)
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)
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
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)
h.setSpacing(4)
if left_ell:
left = QLabel("")
left.setStyleSheet("color:#888;")
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
preview = QLabel()
preview.setTextFormat(Qt.TextFormat.RichText)
preview.setWordWrap(True)
@ -128,14 +135,9 @@ class Search(QWidget):
else "<span style='color:#888'>(no preview)</span>"
)
h.addWidget(preview, 1)
if right_ell:
right = QLabel("")
right.setStyleSheet("color:#888;")
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
outer.addWidget(row)
# Separator line
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
@ -143,17 +145,30 @@ 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)
# --- Snippet/highlight helpers -----------------------------------------
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
doc = QTextDocument()
doc.setHtml(html_src)
plain = doc.toPlainText()
def _make_html_snippet(self, markdown_src: str, query: str, radius=60, maxlen=180):
# For markdown, we can work directly with the text
# Strip markdown formatting for display
plain = self._strip_markdown(markdown_src)
if not plain:
return "", False, False
@ -180,30 +195,45 @@ class Search(QWidget):
start = max(0, min(idx - radius, max(0, L - maxlen)))
end = min(L, max(idx + mlen + radius, start + maxlen))
# Bold all token matches that fall inside [start, end)
# Extract snippet and highlight matches
snippet = plain[start:end]
# Escape HTML and bold matches
import html as _html
snippet_html = _html.escape(snippet)
if tokens:
lower = plain.lower()
fmt = QTextCharFormat()
fmt.setFontWeight(QFont.Weight.Bold)
for t in tokens:
t_low = t.lower()
pos = start
while True:
k = lower.find(t_low, pos)
if k == -1 or k >= end:
break
c = QTextCursor(doc)
c.setPosition(k)
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
c.mergeCharFormat(fmt)
pos = k + len(t)
# Case-insensitive replacement
pattern = re.compile(re.escape(t), re.IGNORECASE)
snippet_html = pattern.sub(
lambda m: f"<b>{m.group(0)}</b>", snippet_html
)
# Select the window and export as HTML fragment
c = QTextCursor(doc)
c.setPosition(start)
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
fragment_html = (
c.selection().toHtml()
) # preserves original styles + our bolding
return snippet_html
return fragment_html, start > 0, end < L
def _strip_markdown(self, markdown: str) -> str:
"""Strip markdown formatting for plain text display."""
# Remove images
text = re.sub(r"!\[.*?\]\(.*?\)", "[Image]", markdown)
# Remove links but keep text
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
# Remove inline code backticks
text = re.sub(r"`([^`]+)`", r"\1", text)
# Remove bold/italic markers
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"\*([^*]+)\*", r"\1", text)
text = re.sub(r"_([^_]+)_", r"\1", text)
# Remove strikethrough
text = re.sub(r"~~([^~]+)~~", r"\1", text)
# Remove heading markers
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
# Remove list markers
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
# Remove checkbox markers
text = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", text, flags=re.MULTILINE)
# Remove code block fences
text = re.sub(r"```[^\n]*\n", "", text)
return text.strip()

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig
@ -9,31 +10,83 @@ APP_ORG = "Bouquin"
APP_NAME = "Bouquin"
def default_db_path() -> Path:
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
return base / "notebook.db"
def get_settings() -> QSettings:
return QSettings(APP_ORG, APP_NAME)
def _default_db_location() -> Path:
"""Where we put the notebook if nothing has been configured yet."""
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
base.mkdir(parents=True, exist_ok=True)
return base / "notebook.db"
def load_db_config() -> DBConfig:
s = get_settings()
path = Path(s.value("db/path", str(default_db_path())))
# --- DB Path -------------------------------------------------------
# Prefer the new key; fall back to the legacy one.
path_str = s.value("db/default_db", "", type=str)
if not path_str:
legacy = s.value("db/path", "", type=str)
if legacy:
path_str = legacy
# migrate and clean up the old key
s.setValue("db/default_db", legacy)
s.remove("db/path")
path = Path(path_str) if path_str else _default_db_location()
# --- Other settings ------------------------------------------------
key = s.value("db/key", "")
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(
path=path, key=key, idle_minutes=idle, theme=theme, move_todos=move_todos
path=path,
key=key,
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,
)
def save_db_config(cfg: DBConfig) -> None:
s = get_settings()
s.setValue("db/path", str(cfg.path))
s.setValue("db/default_db", str(cfg.path))
s.setValue("db/key", str(cfg.key))
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,60 +2,94 @@ 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,
QWidget,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QFileDialog,
QDialogButtonBox,
QRadioButton,
QSizePolicy,
QSpinBox,
QMessageBox,
QTabWidget,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
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
from .key_prompt import KeyPrompt
class SettingsDialog(QDialog):
def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
super().__init__(parent)
self.setWindowTitle("Settings")
self.setWindowTitle(strings._("settings"))
self._cfg = DBConfig(path=cfg.path, key="")
self._db = db
self.key = ""
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.setMinimumWidth(560)
self.current_settings = load_db_config()
self.setMinimumWidth(600)
self.setSizeGripEnabled(True)
current_settings = load_db_config()
# --- Tabs ----------------------------------------------------------
tabs = QTabWidget()
tabs.setTabPosition(QTabWidget.North)
tabs.setDocumentMode(True)
tabs.setMovable(False)
# Add theme selection
theme_group = QGroupBox("Theme")
tabs.addTab(self._create_appearance_page(cfg), strings._("appearance"))
tabs.addTab(self._create_features_page(), strings._("features"))
tabs.addTab(self._create_security_page(cfg), strings._("security"))
tabs.addTab(self._create_database_page(), strings._("database"))
# --- Buttons -------------------------------------------------------
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save)
bb.rejected.connect(self.reject)
# Root layout
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(8)
root.addWidget(tabs)
root.addWidget(bb, 0, Qt.AlignRight)
# ------------------------------------------------------------------ #
# Pages
# ------------------------------------------------------------------ #
def _create_appearance_page(self, cfg: DBConfig) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
# --- Theme group --------------------------------------------------
theme_group = QGroupBox(strings._("theme"))
theme_layout = QVBoxLayout(theme_group)
self.theme_system = QRadioButton("System")
self.theme_light = QRadioButton("Light")
self.theme_dark = QRadioButton("Dark")
self.theme_system = QRadioButton(strings._("system"))
self.theme_light = QRadioButton(strings._("light"))
self.theme_dark = QRadioButton(strings._("dark"))
# Load current theme from settings
current_theme = current_settings.theme
current_theme = self.current_settings.theme
if current_theme == Theme.DARK.value:
self.theme_dark.setChecked(True)
elif current_theme == Theme.LIGHT.value:
@ -67,63 +101,271 @@ class SettingsDialog(QDialog):
theme_layout.addWidget(self.theme_light)
theme_layout.addWidget(self.theme_dark)
form.addRow(theme_group)
# font size row
font_row = QHBoxLayout()
self.font_heading = QLabel(strings._("font_size"))
self.font_size = QSpinBox()
self.font_size.setRange(1, 24)
self.font_size.setSingleStep(1)
self.font_size.setAccelerated(True)
self.font_size.setValue(getattr(cfg, "font_size", 11))
font_row.addWidget(self.font_heading)
font_row.addWidget(self.font_size)
font_row.addStretch()
theme_layout.addLayout(font_row)
# Add Behaviour
behaviour_group = QGroupBox("Behaviour")
behaviour_layout = QVBoxLayout(behaviour_group)
# explanation
self.font_size_label = QLabel(strings._("font_size_explanation"))
self.font_size_label.setWordWrap(True)
self.font_size_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
pal = self.font_size_label.palette()
self.font_size_label.setForegroundRole(QPalette.PlaceholderText)
self.font_size_label.setPalette(pal)
font_exp_row = QHBoxLayout()
font_exp_row.setContentsMargins(24, 0, 0, 0)
font_exp_row.addWidget(self.font_size_label)
theme_layout.addLayout(font_exp_row)
layout.addWidget(theme_group)
# --- Locale group -------------------------------------------------
locale_group = QGroupBox(strings._("locale"))
locale_layout = QVBoxLayout(locale_group)
self.locale_combobox = QComboBox()
self.locale_combobox.addItems(strings._AVAILABLE)
self.locale_combobox.setCurrentText(self.current_settings.locale)
locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft)
self.locale_label = QLabel(strings._("locale_restart"))
self.locale_label.setWordWrap(True)
self.locale_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
lpal = self.locale_label.palette()
self.locale_label.setForegroundRole(QPalette.PlaceholderText)
self.locale_label.setPalette(lpal)
loc_row = QHBoxLayout()
loc_row.setContentsMargins(24, 0, 0, 0)
loc_row.addWidget(self.locale_label)
locale_layout.addLayout(loc_row)
layout.addWidget(locale_group)
layout.addStretch()
return page
def _create_features_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
features_group = QGroupBox(strings._("features"))
features_layout = QVBoxLayout(features_group)
self.move_todos = QCheckBox(
"Move yesterday's unchecked TODOs to today on startup"
strings._("move_unchecked_todos_to_today_on_startup")
)
self.move_todos.setChecked(current_settings.move_todos)
self.move_todos.setChecked(self.current_settings.move_todos)
self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos)
behaviour_layout.addWidget(self.move_todos)
form.addRow(behaviour_group)
# 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())
self.path_edit = QLineEdit(str(self._cfg.path))
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
browse_btn = QPushButton("Browse…")
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
browse_btn.clicked.connect(self._browse)
path_row = QWidget()
h = QHBoxLayout(path_row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(self.path_edit, 1)
h.addWidget(browse_btn, 0)
h.setStretch(0, 1)
h.setStretch(1, 0)
form.addRow("Database path", path_row)
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)
# Encryption settings
enc_group = QGroupBox("Encryption")
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)
features_layout.addWidget(self.tags)
self.time_log = QCheckBox(strings._("enable_time_log_feature"))
self.time_log.setChecked(self.current_settings.time_log)
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
def _create_security_page(self, cfg: DBConfig) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
# --- Encryption group ---------------------------------------------
enc_group = QGroupBox(strings._("encryption"))
enc = QVBoxLayout(enc_group)
enc.setContentsMargins(12, 8, 12, 12)
enc.setSpacing(6)
# Checkbox to remember key
self.save_key_btn = QCheckBox("Remember key")
self.key = current_settings.key or ""
self.save_key_btn = QCheckBox(strings._("remember_key"))
self.key = self.current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor)
self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
# Explanation for remembering key
self.save_key_label = QLabel(
"If you don't want to be prompted for your encryption key, check this to remember it. "
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
)
self.save_key_label = QLabel(strings._("save_key_warning"))
self.save_key_label.setWordWrap(True)
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
pal = self.save_key_label.palette()
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
self.save_key_label.setForegroundRole(QPalette.PlaceholderText)
self.save_key_label.setPalette(pal)
exp_row = QHBoxLayout()
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
exp_row.setContentsMargins(24, 0, 0, 0)
exp_row.addWidget(self.save_key_label)
enc.addLayout(exp_row)
@ -132,102 +374,77 @@ class SettingsDialog(QDialog):
line.setFrameShadow(QFrame.Sunken)
enc.addWidget(line)
# Change key button
self.rekey_btn = QPushButton("Change encryption key")
self.rekey_btn = QPushButton(strings._("change_encryption_key"))
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.rekey_btn.clicked.connect(self._change_key)
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
form.addRow(enc_group)
layout.addWidget(enc_group)
# Privacy settings
priv_group = QGroupBox("Lock screen when idle")
# --- Idle lock group ----------------------------------------------
priv_group = QGroupBox(strings._("lock_screen_when_idle"))
priv = QVBoxLayout(priv_group)
priv.setContentsMargins(12, 8, 12, 12)
priv.setSpacing(6)
self.idle_spin = QSpinBox()
self.idle_spin.setRange(0, 240)
self.idle_spin.setSingleStep(1)
self.idle_spin.setAccelerated(True)
self.idle_spin.setSuffix(" min")
self.idle_spin.setSpecialValueText("Never")
self.idle_spin.setSpecialValueText(strings._("never"))
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
# Explanation for idle option (autolock)
self.idle_spin_label = QLabel(
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
"Set to 0 (never) to never lock."
)
self.idle_spin_label = QLabel(strings._("autolock_explanation"))
self.idle_spin_label.setWordWrap(True)
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
spal = self.idle_spin_label.palette()
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText)
self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout()
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
spin_row.setContentsMargins(24, 0, 0, 0)
spin_row.addWidget(self.idle_spin_label)
priv.addLayout(spin_row)
form.addRow(priv_group)
layout.addWidget(priv_group)
layout.addStretch()
return page
# Maintenance settings
maint_group = QGroupBox("Database maintenance")
def _create_database_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(12)
maint_group = QGroupBox(strings._("database_maintenance"))
maint = QVBoxLayout(maint_group)
maint.setContentsMargins(12, 8, 12, 12)
maint.setSpacing(6)
self.compact_btn = QPushButton("Compact database")
self.compact_btn = QPushButton(strings._("database_compact"))
self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.compact_btn.clicked.connect(self._compact_btn_clicked)
maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
# Explanation for compating button
self.compact_label = QLabel(
"Compacting runs VACUUM on the database. This can help reduce its size."
)
self.compact_label = QLabel(strings._("database_compact_explanation"))
self.compact_label.setWordWrap(True)
self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
cpal = self.compact_label.palette()
cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
self.compact_label.setForegroundRole(QPalette.PlaceholderText)
self.compact_label.setPalette(cpal)
maint_row = QHBoxLayout()
maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
maint_row.setContentsMargins(24, 0, 0, 0)
maint_row.addWidget(self.compact_label)
maint.addLayout(maint_row)
form.addRow(maint_group)
layout.addWidget(maint_group)
layout.addStretch()
return page
# Buttons
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save)
bb.rejected.connect(self.reject)
# Root layout (adjust margins/spacing a bit)
v = QVBoxLayout(self)
v.setContentsMargins(12, 12, 12, 12)
v.setSpacing(10)
v.addLayout(form)
v.addWidget(bb, 0, Qt.AlignRight)
def _browse(self):
p, _ = QFileDialog.getSaveFileName(
self,
"Choose database file",
self.path_edit.text(),
"DB Files (*.db);;All Files (*)",
)
if p:
self.path_edit.setText(p)
# ------------------------------------------------------------------ #
# Save settings
# ------------------------------------------------------------------ #
def _save(self):
# Save the selected theme into QSettings
if self.theme_dark.isChecked():
selected_theme = Theme.DARK
elif self.theme_light.isChecked():
@ -238,39 +455,131 @@ class SettingsDialog(QDialog):
key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig(
path=Path(self.path_edit.text()),
path=Path(self.current_settings.path),
key=key_to_save,
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)
self.parent().themes.apply(selected_theme)
# 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._("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, title="Change key", message="Enter a new encryption key")
p1 = KeyPrompt(
self,
title=strings._("change_encryption_key"),
message=strings._("enter_a_new_encryption_key"),
)
if p1.exec() != QDialog.Accepted:
return
new_key = p1.key()
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
p2 = KeyPrompt(
self,
title=strings._("change_encryption_key"),
message=strings._("reenter_the_new_key"),
)
if p2.exec() != QDialog.Accepted:
return
if new_key != p2.key():
QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
QMessageBox.warning(
self, strings._("key_mismatch"), strings._("key_mismatch_explanation")
)
return
if not new_key:
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
QMessageBox.warning(
self, strings._("empty_key"), strings._("empty_key_explanation")
)
return
try:
self.key = new_key
self._db.rekey(new_key)
QMessageBox.information(
self, "Key changed", "The notebook was re-encrypted with the new key!"
self, strings._("key_changed"), strings._("key_changed_explanation")
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
QMessageBox.critical(self, strings._("error"), str(e))
@Slot(bool)
def _save_key_btn_clicked(self, checked: bool):
@ -278,7 +587,9 @@ class SettingsDialog(QDialog):
if checked:
if not self.key:
p1 = KeyPrompt(
self, title="Enter your key", message="Enter the encryption key"
self,
title=strings._("unlock_encrypted_notebook_explanation"),
message=strings._("unlock_encrypted_notebook_explanation"),
)
if p1.exec() != QDialog.Accepted:
self.save_key_btn.blockSignals(True)
@ -292,10 +603,10 @@ class SettingsDialog(QDialog):
try:
self._db.compact()
QMessageBox.information(
self, "Compact complete", "Database compacted successfully!"
self, strings._("success"), strings._("database_compacted_successfully")
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
QMessageBox.critical(self, strings._("error"), str(e))
@property
def config(self) -> DBConfig:

View file

@ -0,0 +1,543 @@
from __future__ import annotations
import datetime as _dt
from typing import Dict
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .settings import load_db_config
# ---------- Activity heatmap ----------
class DateHeatmap(QWidget):
"""
Small calendar heatmap for activity by date.
Data is a mapping: datetime.date -> integer value.
"""
date_clicked = Signal(_dt.date)
def __init__(self, parent=None):
super().__init__(parent)
self._data: Dict[_dt.date, int] = {}
self._start: _dt.date | None = None
self._end: _dt.date | None = None
self._max_value: int = 0
self._cell = 12
self._gap = 3
self._margin_left = 30
self._margin_top = 10
self._margin_bottom = 24
self._margin_right = 10
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
def set_data(self, data: Dict[_dt.date, int]) -> None:
"""Replace dataset and recompute layout."""
self._data = {k: int(v) for k, v in (data or {}).items() if v is not None}
if not self._data:
self._start = self._end = None
self._max_value = 0
else:
earliest = min(self._data.keys())
latest = max(self._data.keys())
self._start = earliest - _dt.timedelta(days=earliest.weekday())
self._end = latest
self._max_value = max(self._data.values()) if self._data else 0
self.updateGeometry()
self.update()
# QWidget overrides ---------------------------------------------------
def sizeHint(self) -> QSize:
if not self._start or not self._end:
height = (
self._margin_top + self._margin_bottom + 7 * (self._cell + self._gap)
)
# some default width
width = (
self._margin_left + self._margin_right + 20 * (self._cell + self._gap)
)
return QSize(width, height)
day_count = (self._end - self._start).days + 1
weeks = (day_count + 6) // 7 # ceil
width = (
self._margin_left
+ self._margin_right
+ weeks * (self._cell + self._gap)
+ self._gap
)
height = (
self._margin_top
+ self._margin_bottom
+ 7 * (self._cell + self._gap)
+ self._gap
)
return QSize(width, height)
def minimumSizeHint(self) -> QSize:
sz = self.sizeHint()
return QSize(min(380, sz.width()), sz.height())
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
if not self._start or not self._end:
return
palette = self.palette()
bg_no_data = palette.base().color()
active = palette.highlight().color()
painter.setPen(QPen(Qt.NoPen))
day_count = (self._end - self._start).days + 1
weeks = (day_count + 6) // 7
for week in range(weeks):
for dow in range(7):
idx = week * 7 + dow
date = self._start + _dt.timedelta(days=idx)
if date > self._end:
value = 0
else:
value = self._data.get(date, 0)
x = self._margin_left + week * (self._cell + self._gap)
y = self._margin_top + dow * (self._cell + self._gap)
if value <= 0 or self._max_value <= 0:
color = bg_no_data
else:
ratio = max(0.1, min(1.0, value / float(self._max_value)))
color = QColor(active)
# Lighter for low values, darker for high values
lighten = 150 - int(50 * ratio) # 150 ≈ light, 100 ≈ original
color = color.lighter(lighten)
painter.fillRect(
x,
y,
self._cell,
self._cell,
QBrush(color),
)
painter.setPen(palette.text().color())
fm = painter.fontMetrics()
# --- weekday labels on left -------------------------------------
# Python's weekday(): Monday=0 ... Sunday=6
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
for dow in range(7):
label = weekday_labels[dow]
text_width = fm.horizontalAdvance(label)
# Center text vertically in the cell
y_center = (
self._margin_top + dow * (self._cell + self._gap) + self._cell / 2
)
baseline_y = int(y_center + fm.ascent() / 2 - fm.descent() / 2)
# Right-align text just to the left of the first column
x = self._margin_left - self._gap - 2 - text_width
painter.drawText(x, baseline_y, label)
prev_month = None
for week in range(weeks):
date = self._start + _dt.timedelta(days=week * 7)
if date > self._end: # pragma: no cover
break
if prev_month == date.month:
continue
prev_month = date.month
label = date.strftime("%b")
x_center = (
self._margin_left + week * (self._cell + self._gap) + self._cell / 2
)
y = self._margin_top + 7 * (self._cell + self._gap) + fm.ascent()
text_width = fm.horizontalAdvance(label)
painter.drawText(
int(x_center - text_width / 2),
int(y),
label,
)
painter.end()
def mousePressEvent(self, event):
if event.button() != Qt.LeftButton:
return super().mousePressEvent(event)
# No data = nothing to click
if not self._start or not self._end:
return
# Qt6: position(), older: pos()
pos = event.position() if hasattr(event, "position") else event.pos()
x = pos.x()
y = pos.y()
# Outside grid area (left of weekday labels or above rows)
if x < self._margin_left or y < self._margin_top:
return
cell_span = self._cell + self._gap
col = int((x - self._margin_left) // cell_span) # week index
row = int((y - self._margin_top) // cell_span) # dow (0..6)
# Only 7 rows (Mon-Sun)
if not (0 <= row < 7):
return
# Only as many weeks as we actually have
day_count = (self._end - self._start).days + 1
weeks = (day_count + 6) // 7
if col < 0 or col >= weeks:
return
idx = col * 7 + row
date = self._start + _dt.timedelta(days=idx)
# Skip trailing empty cells beyond the last date
if date > self._end:
return
self.date_clicked.emit(date)
# ---------- Statistics dialog itself ----------
class StatisticsDialog(QDialog):
"""
Shows aggregate statistics and the date heatmap with a metric switcher.
"""
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("statistics"))
self.setMinimumWidth(650)
self.setMinimumHeight(650)
root = QVBoxLayout(self)
(
pages_with_content,
total_revisions,
page_most_revisions,
page_most_revisions_count,
words_by_date,
total_words,
unique_tags,
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()
self.cfg = load_db_config()
# 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)),
)
pages_form.addRow(
strings._("stats_total_revisions"),
QLabel(str(total_revisions)),
)
if page_most_revisions:
pages_form.addRow(
strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
)
else:
pages_form.addRow(
strings._("stats_page_most_revisions"),
QLabel(""),
)
pages_form.addRow(
strings._("stats_total_words"),
QLabel(str(total_words)),
)
root.addWidget(pages_group)
# --- 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)),
)
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)
# Metric selector
combo_row = QHBoxLayout()
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",
)
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()
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setWidget(self._heatmap)
group_layout.addWidget(scroll)
root.addWidget(group)
# Default to "words"
self._apply_metric("words")
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
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)
def _on_metric_changed(self, index: int) -> None:
metric = self.metric_combo.currentData()
if metric:
self._apply_metric(metric)
def _gather_stats(self):
return self._db.gather_stats()

39
bouquin/strings.py Normal file
View file

@ -0,0 +1,39 @@
import json
from importlib.resources import files
# Get list of locales
root = files("bouquin") / "locales"
_AVAILABLE = tuple(
entry.stem
for entry in root.iterdir()
if entry.is_file() and entry.suffix == ".json"
)
_DEFAULT = "en"
strings = {}
translations = {}
def load_strings(current_locale: str) -> None:
global strings, translations
translations = {}
# read in the locales json
for loc in _AVAILABLE:
data = (root / f"{loc}.json").read_text(encoding="utf-8")
translations[loc] = json.loads(data)
if current_locale not in translations:
current_locale = _DEFAULT
base = translations[_DEFAULT]
cur = translations.get(current_locale, {})
strings = {k: (cur.get(k) or base[k]) for k in base}
def translated(k: str) -> str:
return strings.get(k, k)
_ = translated

293
bouquin/tag_browser.py Normal file
View file

@ -0,0 +1,293 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QColorDialog,
QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
)
from sqlcipher3.dbapi2 import IntegrityError
from . import strings
from .db import DBManager
from .settings import load_db_config
class TagBrowserDialog(QDialog):
openDateRequested = Signal(str)
tagsModified = Signal()
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")
)
self.resize(600, 500)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(strings._("tag_browser_instructions"))
instructions.setWordWrap(True)
layout.addWidget(instructions)
self.tree = QTreeWidget()
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)
self.tree.itemClicked.connect(self._on_item_clicked)
self.tree.setSortingEnabled(True)
self.tree.sortByColumn(0, Qt.AscendingOrder)
layout.addWidget(self.tree)
# Tag management buttons
btn_row = QHBoxLayout()
self.add_tag_btn = QPushButton("&" + strings._("add_a_tag"))
self.add_tag_btn.clicked.connect(self._add_a_tag)
btn_row.addWidget(self.add_tag_btn)
self.edit_name_btn = QPushButton("&" + strings._("edit_tag_name"))
self.edit_name_btn.clicked.connect(self._edit_tag_name)
self.edit_name_btn.setEnabled(False)
btn_row.addWidget(self.edit_name_btn)
self.change_color_btn = QPushButton("&" + strings._("change_color"))
self.change_color_btn.clicked.connect(self._change_tag_color)
self.change_color_btn.setEnabled(False)
btn_row.addWidget(self.change_color_btn)
self.delete_btn = QPushButton("&" + strings._("delete_tag"))
self.delete_btn.clicked.connect(self._delete_tag)
self.delete_btn.setEnabled(False)
btn_row.addWidget(self.delete_btn)
btn_row.addStretch(1)
layout.addLayout(btn_row)
# Close button
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self._populate(focus_tag)
def _populate(self, focus_tag: str | None):
# Disable sorting during population for better performance
was_sorting = self.tree.isSortingEnabled()
self.tree.setSortingEnabled(False)
self.tree.clear()
tags = self._db.list_tags()
focus_item = None
for tag_id, name, color in tags:
# Create the tree item
root = QTreeWidgetItem([name, "", ""])
root.setData(
0,
Qt.ItemDataRole.UserRole,
{"type": "tag", "id": tag_id, "name": name, "color": color},
)
# Set background color for the second column to show the tag color
bg_color = QColor(color)
root.setBackground(1, bg_color)
# Calculate luminance and set contrasting text color
# Using relative luminance formula (ITU-R BT.709)
luminance = (
0.2126 * bg_color.red()
+ 0.7152 * bg_color.green()
+ 0.0722 * bg_color.blue()
) / 255.0
text_color = QColor(0, 0, 0) if luminance > 0.5 else QColor(255, 255, 255)
root.setForeground(1, text_color)
root.setText(1, color) # Also show the hex code
root.setTextAlignment(1, Qt.AlignCenter)
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])
child.setData(
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
)
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
if focus_item:
self.tree.expandItem(focus_item)
self.tree.setCurrentItem(focus_item)
# Re-enable sorting after population
self.tree.setSortingEnabled(was_sorting)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Enable/disable buttons based on selection"""
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "tag":
self.edit_name_btn.setEnabled(True)
self.change_color_btn.setEnabled(True)
self.delete_btn.setEnabled(True)
else:
self.edit_name_btn.setEnabled(False)
self.change_color_btn.setEnabled(False)
self.delete_btn.setEnabled(False)
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
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"""
new_name, ok = QInputDialog.getText(
self, strings._("add_a_tag"), strings._("new_tag_name"), text=""
)
if ok and new_name:
color = QColorDialog.getColor(QColor(), self)
if color.isValid():
try:
self._db.add_tag(new_name, color.name())
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _edit_tag_name(self):
"""Edit the name of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
old_name = data["name"]
color = data["color"]
new_name, ok = QInputDialog.getText(
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
)
if ok and new_name and new_name != old_name:
try:
self._db.update_tag(tag_id, new_name, color)
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _change_tag_color(self):
"""Change the color of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
current_color = data["color"]
color = QColorDialog.getColor(QColor(current_color), self)
if color.isValid():
try:
self._db.update_tag(tag_id, name, color.name())
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _delete_tag(self):
"""Delete the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
# Confirm deletion
reply = QMessageBox.question(
self,
strings._("delete_tag"),
strings._("delete_tag_confirm").format(name=name),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_tag(tag_id)
self._populate(None)
self.tagsModified.emit()

259
bouquin/tags_widget.py Normal file
View file

@ -0,0 +1,259 @@
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCompleter,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QStyle,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .flow_layout import FlowLayout
class TagChip(QFrame):
removeRequested = Signal(int) # tag_id
clicked = Signal(str) # tag name
def __init__(
self,
tag_id: int,
name: str,
color: str,
parent: QWidget | None = None,
show_remove: bool = True,
):
super().__init__(parent)
self._id = tag_id
self._name = name
self.setObjectName("TagChip")
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(4)
color_lbl = QLabel()
color_lbl.setFixedSize(10, 10)
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;")
layout.addWidget(color_lbl)
name_lbl = QLabel(name)
layout.addWidget(name_lbl)
if show_remove:
btn = QToolButton()
btn.setText("×")
btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
layout.addWidget(btn)
self.setCursor(Qt.PointingHandCursor)
@property
def tag_id(self) -> int:
return self._id
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self.clicked.emit(self._name)
try:
super().mouseReleaseEvent(ev)
except RuntimeError:
pass
class PageTagsWidget(QFrame):
"""
Collapsible per-page tag editor shown in the left sidebar.
Now displays tag chips even when collapsed.
"""
tagActivated = Signal(str) # tag name
tagAdded = Signal() # emitted when a tag is added to trigger autosave
def __init__(self, db: DBManager, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
self._current_date: Optional[str] = None
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header (toggle + manage button)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("tags"))
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.manage_btn = QToolButton()
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip(strings._("manage_tags"))
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._open_manager)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.manage_btn)
# Body (chips + add line - only visible when expanded)
self.body = QWidget()
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(0, 4, 0, 0)
self.body_layout.setSpacing(4)
# Chips container
self.chip_container = QWidget()
self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4)
self.body_layout.addWidget(self.chip_container)
self.add_edit = QLineEdit()
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
self.add_edit.returnPressed.connect(self._on_add_tag)
# Setup autocomplete
self._setup_autocomplete()
self.body_layout.addWidget(self.add_edit)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# ----- external API ------------------------------------------------
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
# Only reload tags if expanded
if self.toggle_btn.isChecked():
self._reload_tags()
else:
self._clear_chips() # Clear chips when collapsed
self._setup_autocomplete() # Update autocomplete with all available tags
# ----- internals ---------------------------------------------------
def _setup_autocomplete(self) -> None:
"""Setup autocomplete for the tag input with all existing tags"""
all_tags = [name for _, name, _ in self._db.list_tags()]
completer = QCompleter(all_tags, self.add_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.add_edit.setCompleter(completer)
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
if self._current_date:
self._reload_tags()
self.add_edit.setFocus()
def _clear_chips(self) -> None:
while self.chip_layout.count():
item = self.chip_layout.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _reload_tags(self) -> None:
if not self._current_date:
self._clear_chips()
return
self._clear_chips()
tags = self._db.get_tags_for_page(self._current_date)
for tag_id, name, color in tags:
# Always show remove button since chips only visible when expanded
chip = TagChip(tag_id, name, color, self, show_remove=True)
chip.removeRequested.connect(self._remove_tag)
chip.clicked.connect(self._on_chip_clicked)
self.chip_layout.addWidget(chip)
chip.show()
chip.adjustSize()
# Force complete layout recalculation
self.chip_layout.invalidate()
self.chip_layout.activate()
self.chip_container.updateGeometry()
self.updateGeometry()
# Process pending events to ensure layout is applied
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
def _on_add_tag(self) -> None:
if not self._current_date:
return
# If the completer popup is visible and user pressed Enter,
# the completer will handle it - don't process it again
if self.add_edit.completer() and self.add_edit.completer().popup().isVisible():
return
new_tag = self.add_edit.text().strip()
if not new_tag:
return
# Get existing tags for current page
existing = [
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
]
# Check for duplicates (case-insensitive)
if any(tag.lower() == new_tag.lower() for tag in existing):
self.add_edit.clear()
return
existing.append(new_tag)
self._db.set_tags_for_page(self._current_date, existing)
self.add_edit.clear()
self._reload_tags()
self._setup_autocomplete() # Update autocomplete list
# Signal that a tag was added so main window can trigger autosave
self.tagAdded.emit()
def _remove_tag(self, tag_id: int) -> None:
if not self._current_date:
return
tags = self._db.get_tags_for_page(self._current_date)
remaining = [name for (tid, name, _color) in tags if tid != tag_id]
self._db.set_tags_for_page(self._current_date, remaining)
self._reload_tags()
def _open_manager(self) -> None:
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self._db, self)
dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso))
if dlg.exec():
# Reload tags after manager closes to pick up any changes
if self._current_date:
self._reload_tags()
self._setup_autocomplete()
def _on_chip_clicked(self, name: str) -> None:
self.tagActivated.emit(name)

View file

@ -1,9 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from PySide6.QtGui import QPalette, QColor, QGuiApplication
from PySide6.QtWidgets import QApplication
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):
@ -26,6 +29,9 @@ class ThemeManager(QObject):
super().__init__()
self._app = app
self._cfg = cfg
self._current = None
self._calendars: "WeakSet[QCalendarWidget]" = WeakSet()
self._lock_overlays: "WeakSet[QWidget]" = WeakSet()
# Follow OS if supported (Qt 6+)
hints = QGuiApplication.styleHints()
@ -35,6 +41,20 @@ class ThemeManager(QObject):
and self.apply(self._cfg.theme)
)
def _is_system_dark(self) -> bool:
pal = QGuiApplication.palette()
# Heuristic: dark windows/backgrounds mean dark system theme
return pal.color(QPalette.Window).lightness() < 128
def _restyle_registered(self) -> None:
for cal in list(self._calendars):
if cal is not None:
self._apply_calendar_theme(cal)
for overlay in list(self._lock_overlays):
if overlay is not None:
self._apply_lock_overlay_theme(overlay)
def current(self) -> Theme:
return self._cfg.theme
@ -43,29 +63,34 @@ class ThemeManager(QObject):
self.apply(theme)
def apply(self, theme: Theme):
# Resolve "system"
# Resolve "system" into a concrete theme
resolved = theme
if theme == Theme.SYSTEM:
hints = QGuiApplication.styleHints()
scheme = getattr(hints, "colorScheme", None)
if callable(scheme):
scheme = hints.colorScheme()
# 0=Light, 1=Dark in newer Qt; fall back to Light
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT
# Always use Fusion so palette applies consistently cross-platform
self._app.setStyle("Fusion")
if theme == Theme.DARK:
if resolved == Theme.DARK:
pal = self._dark_palette()
self._app.setPalette(pal)
# keep stylesheet empty unless you need widget-specific tweaks
self._app.setStyleSheet("")
else:
pal = self._light_palette()
self._app.setPalette(pal)
self._app.setStyleSheet("")
self.themeChanged.emit(theme)
# Always use Fusion so palette applies consistently cross-platform
QApplication.setStyle("Fusion")
self._app.setPalette(pal)
self._current = resolved
# Re-style any registered widgets
self._restyle_registered()
self.themeChanged.emit(self._current)
def register_calendar(self, cal: QCalendarWidget) -> None:
"""Start theming calendar and keep it in sync with theme changes."""
self._calendars.add(cal)
self._apply_calendar_theme(cal)
def register_lock_overlay(self, overlay: QWidget) -> None:
"""Start theming lock overlay and keep it in sync with theme changes."""
self._lock_overlays.add(overlay)
self._apply_lock_overlay_theme(overlay)
# ----- Palettes -----
def _dark_palette(self) -> QPalette:
@ -76,17 +101,24 @@ class ThemeManager(QObject):
disabled = QColor(127, 127, 127)
focus = QColor(42, 130, 218)
# Base surfaces
pal.setColor(QPalette.Window, window)
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.Base, base)
pal.setColor(QPalette.AlternateBase, window)
# Text
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.ToolTipBase, window)
pal.setColor(QPalette.ToolTipText, text)
pal.setColor(QPalette.Text, text)
pal.setColor(QPalette.PlaceholderText, disabled)
pal.setColor(QPalette.Button, window)
pal.setColor(QPalette.ButtonText, text)
# Buttons/frames
pal.setColor(QPalette.Button, window)
pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
# Links / selection
pal.setColor(QPalette.Highlight, focus)
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
@ -95,11 +127,144 @@ class ThemeManager(QObject):
return pal
def _light_palette(self) -> QPalette:
# Let Qt provide its default light palette, but nudge a couple roles
pal = self._app.style().standardPalette()
pal.setColor(QPalette.Highlight, QColor(0, 120, 215))
pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
pal.setColor(
QPalette.Link, QColor("#1a73e8")
) # Light blue for links in light mode
pal = QPalette()
# Base surfaces
pal.setColor(QPalette.Window, QColor("#ffffff"))
pal.setColor(QPalette.Base, QColor("#ffffff"))
pal.setColor(QPalette.AlternateBase, QColor("#f5f5f5"))
# Text
pal.setColor(QPalette.WindowText, QColor("#000000"))
pal.setColor(QPalette.Text, QColor("#000000"))
pal.setColor(QPalette.ButtonText, QColor("#000000"))
# Buttons/frames
pal.setColor(QPalette.Button, QColor("#f0f0f0"))
pal.setColor(QPalette.Mid, QColor("#9e9e9e"))
# Links / selection
pal.setColor(QPalette.Highlight, QColor("#1a73e8"))
pal.setColor(QPalette.HighlightedText, QColor("#ffffff"))
pal.setColor(QPalette.Link, QColor("#1a73e8"))
pal.setColor(QPalette.LinkVisited, QColor("#6b4ca5"))
return pal
def _apply_calendar_theme(self, cal: QCalendarWidget) -> None:
"""Use orange accents on the calendar in dark mode only."""
app_pal = QApplication.instance().palette()
is_dark = (self.current() == Theme.DARK) or (
self.current() == Theme.SYSTEM and self._is_system_dark()
)
if is_dark:
highlight_css = Theme.ORANGE_ANCHOR.value
highlight = QColor(highlight_css)
black = QColor(0, 0, 0)
# Per-widget palette: selection color inside the date grid
pal = cal.palette()
pal.setColor(QPalette.Highlight, highlight)
pal.setColor(QPalette.HighlightedText, black)
cal.setPalette(pal)
# Stylesheet: nav bar + selected-day background
cal.setStyleSheet(self._calendar_qss(highlight_css))
else:
# Back to app defaults in light/system-light
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:
return f"""
QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
QCalendarWidget QToolButton {{ color: black; }}
QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }}
/* Selected day color in the table view */
QCalendarWidget QTableView:enabled {{
selection-background-color: {highlight_css};
selection-color: black;
}}
/* Keep weekday header readable */
QCalendarWidget QTableView QHeaderView::section {{
background: transparent;
color: palette(windowText);
}}
"""
def _apply_lock_overlay_theme(self, overlay: QWidget) -> None:
"""
Style the LockOverlay (objectName 'LockOverlay') using theme colors.
Dark: opaque black bg, orange accent; Light: translucent scrim, palette-driven colors.
"""
pal = QApplication.instance().palette()
is_dark = (self.current() == Theme.DARK) or (
self.current() == Theme.SYSTEM and self._is_system_dark()
)
if is_dark:
# Use the link color as the accent
accent = pal.color(QPalette.Link)
r, g, b = accent.red(), accent.green(), accent.blue()
accent_hex = accent.name()
qss = f"""
#LockOverlay {{ background-color: rgb(0,0,0); }}
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
else:
qss = """
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
overlay.setStyleSheet(qss)
overlay.update()

1689
bouquin/time_log.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,131 +1,138 @@
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
class ToolBar(QToolBar):
boldRequested = Signal()
italicRequested = Signal()
underlineRequested = Signal()
strikeRequested = Signal()
codeRequested = Signal()
headingRequested = Signal(int)
bulletsRequested = Signal()
numbersRequested = Signal()
checkboxesRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal()
insertImageRequested = Signal()
alarmRequested = Signal()
timerRequested = Signal()
documentsRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
def __init__(self, parent=None):
super().__init__("Format", parent)
self.setObjectName("Format")
super().__init__(strings._("toolbar_format"), parent)
self.setObjectName(strings._("toolbar_format"))
self.setToolButtonStyle(Qt.ToolButtonTextOnly)
self._build_actions()
self._apply_toolbar_styles()
def _build_actions(self):
self.actBold = QAction("B", self)
self.actBold.setToolTip("Bold")
self.actBold.setToolTip(strings._("toolbar_bold"))
self.actBold.setCheckable(True)
self.actBold.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested)
self.actItalic = QAction("I", self)
self.actItalic.setToolTip("Italic")
self.actItalic.setToolTip(strings._("toolbar_italic"))
self.actItalic.setCheckable(True)
self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested)
self.actUnderline = QAction("U", self)
self.actUnderline.setToolTip("Underline")
self.actUnderline.setCheckable(True)
self.actUnderline.setShortcut(QKeySequence.Underline)
self.actUnderline.triggered.connect(self.underlineRequested)
self.actStrike = QAction("S", self)
self.actStrike.setToolTip("Strikethrough")
self.actStrike.setToolTip(strings._("toolbar_strikethrough"))
self.actStrike.setCheckable(True)
self.actStrike.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested)
self.actCode = QAction("</>", self)
self.actCode.setToolTip("Code block")
self.actCode.setToolTip(strings._("toolbar_code_block"))
self.actCode.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested)
# Headings
self.actH1 = QAction("H1", self)
self.actH1.setToolTip("Heading 1")
self.actH1.setToolTip(strings._("toolbar_heading") + " 1")
self.actH1.setCheckable(True)
self.actH1.setShortcut("Ctrl+1")
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
self.actH2 = QAction("H2", self)
self.actH2.setToolTip("Heading 2")
self.actH2.setToolTip(strings._("toolbar_heading") + " 2")
self.actH2.setCheckable(True)
self.actH2.setShortcut("Ctrl+2")
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
self.actH3 = QAction("H3", self)
self.actH3.setToolTip("Heading 3")
self.actH3.setToolTip(strings._("toolbar_heading") + " 3")
self.actH3.setCheckable(True)
self.actH3.setShortcut("Ctrl+3")
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
self.actNormal = QAction("N", self)
self.actNormal.setToolTip("Normal paragraph text")
self.actNormal = QAction("P", self)
self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text"))
self.actNormal.setCheckable(True)
self.actNormal.setShortcut("Ctrl+N")
self.actNormal.setShortcut("Ctrl+.")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
self.actFontSmaller = QAction("P-", self)
self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller"))
self.actFontSmaller.setShortcut("Ctrl+Shift+-")
self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested)
self.actFontLarger = QAction("P+", self)
self.actFontLarger.setToolTip(strings._("toolbar_font_larger"))
self.actFontLarger.setShortcut("Ctrl+Shift+=")
self.actFontLarger.triggered.connect(self.fontSizeLargerRequested)
# Lists
self.actBullets = QAction("", self)
self.actBullets.setToolTip("Bulleted list")
self.actBullets.setToolTip(strings._("toolbar_bulleted_list"))
self.actBullets.setCheckable(True)
self.actBullets.triggered.connect(self.bulletsRequested)
self.actNumbers = QAction("1.", self)
self.actNumbers.setToolTip("Numbered list")
self.actNumbers.setToolTip(strings._("toolbar_numbered_list"))
self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip("Toggle checkboxes")
self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images
self.actInsertImg = QAction("Image", self)
self.actInsertImg.setToolTip("Insert image")
self.actInsertImg = QAction("📸", self)
self.actInsertImg.setToolTip(strings._("insert_images"))
self.actInsertImg.setShortcut("Ctrl+Shift+I")
self.actInsertImg.triggered.connect(self.insertImageRequested)
# Alignment
self.actAlignL = QAction("L", self)
self.actAlignL.setToolTip("Align Left")
self.actAlignL.setCheckable(True)
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
self.actAlignC = QAction("C", self)
self.actAlignC.setToolTip("Align Center")
self.actAlignC.setCheckable(True)
self.actAlignC.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignHCenter)
)
self.actAlignR = QAction("R", self)
self.actAlignR.setToolTip("Align Right")
self.actAlignR.setCheckable(True)
self.actAlignR.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignRight)
)
# History button
self.actHistory = QAction("History", self)
self.actHistory = QAction("", self)
self.actHistory.setToolTip(strings._("history"))
self.actHistory.triggered.connect(self.historyRequested)
# Alarm / reminder
self.actAlarm = QAction("", self)
self.actAlarm.setToolTip(strings._("toolbar_alarm"))
self.actAlarm.triggered.connect(self.alarmRequested)
# Focus timer
self.actTimer = QAction("", self)
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
self.actTimer.setCheckable(True)
self.actTimer.triggered.connect(self.timerRequested)
# Documents
self.actDocuments = QAction("📁", self)
self.actDocuments.setToolTip(strings._("toolbar_documents"))
self.actDocuments.triggered.connect(self.documentsRequested)
# Set exclusive buttons in QActionGroups
self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True)
for a in (
self.actBold,
self.actItalic,
self.actUnderline,
self.actStrike,
self.actH1,
self.actH2,
@ -135,30 +142,31 @@ class ToolBar(QToolBar):
a.setCheckable(True)
a.setActionGroup(self.grpHeadings)
self.grpAlign = QActionGroup(self)
self.grpAlign.setExclusive(True)
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
a.setActionGroup(self.grpAlign)
self.grpLists = QActionGroup(self)
self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
a.setActionGroup(self.grpLists)
# Add actions
self.addActions(
[
self.actBold,
self.actItalic,
self.actUnderline,
self.actStrike,
self.actCode,
self.actH1,
self.actH2,
self.actH3,
self.actNormal,
self.actFontSmaller,
self.actFontLarger,
self.actBullets,
self.actNumbers,
self.actCheckboxes,
self.actInsertImg,
self.actAlignL,
self.actAlignC,
self.actAlignR,
self.actAlarm,
self.actTimer,
self.actDocuments,
self.actHistory,
]
)
@ -166,7 +174,6 @@ class ToolBar(QToolBar):
def _apply_toolbar_styles(self):
self._style_letter_button(self.actBold, "B", bold=True)
self._style_letter_button(self.actItalic, "I", italic=True)
self._style_letter_button(self.actUnderline, "U", underline=True)
self._style_letter_button(self.actStrike, "S", strike=True)
# Monospace look for code; use a fixed font
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
@ -176,19 +183,20 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actH1, "H1")
self._style_letter_button(self.actH2, "H2")
self._style_letter_button(self.actH3, "H3")
self._style_letter_button(self.actNormal, "N")
self._style_letter_button(self.actNormal, "P")
self._style_letter_button(self.actFontSmaller, "P-")
self._style_letter_button(self.actFontLarger, "P+")
# Lists
self._style_letter_button(self.actBullets, "")
self._style_letter_button(self.actNumbers, "1.")
# Alignment
self._style_letter_button(self.actAlignL, "L")
self._style_letter_button(self.actAlignC, "C")
self._style_letter_button(self.actAlignR, "R")
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, "View History")
self._style_letter_button(self.actHistory, "")
def _style_letter_button(
self,

406
bouquin/version_check.py Normal file
View file

@ -0,0 +1,406 @@
from __future__ import annotations
import importlib.metadata
import os
import re
import subprocess # nosec
import tempfile
from importlib.resources import files
from pathlib import Path
import requests
from PySide6.QtCore import QStandardPaths, Qt
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
from . import strings
from .settings import APP_NAME
# Where to fetch the latest version string from
VERSION_URL = "https://mig5.net/bouquin/version.txt"
# Name of the installed distribution according to pyproject.toml
# (used with importlib.metadata.version)
DIST_NAME = "bouquin"
# Base URL where AppImages are hosted
APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download"
# Where we expect to find the bundled public key, relative to the *installed* package.
GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc")
class VersionChecker:
"""
Handles:
* showing the version dialog
* checking for updates
* downloading & verifying a new AppImage
All dialogs use `parent` as their parent widget.
"""
def __init__(self, parent: QWidget | None = None):
self._parent = parent
# ---------- Version helpers ---------- #
def _logo_pixmap(self, logical_size: int = 96) -> QPixmap:
"""
Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp.
"""
svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg"
# Logical size (what Qt layouts see)
dpr = QGuiApplication.primaryScreen().devicePixelRatio()
img_size = int(logical_size * dpr)
image = QImage(img_size, img_size, QImage.Format_ARGB32)
image.fill(Qt.transparent)
renderer = QSvgRenderer(str(svg_path))
painter = QPainter(image)
renderer.render(painter)
painter.end()
pixmap = QPixmap.fromImage(image)
pixmap.setDevicePixelRatio(dpr)
return pixmap
def current_version(self) -> str:
"""
Return the current app version as reported by importlib.metadata
"""
try:
return importlib.metadata.version(DIST_NAME)
except importlib.metadata.PackageNotFoundError:
# Fallback for editable installs / dev trees
return "0.0.0"
@staticmethod
def _parse_version(v: str) -> tuple[int, ...]:
"""
Very small helper to compare simple semantic versions like 1.2.3.
Extracts numeric components and returns them as a tuple.
"""
parts = re.findall(r"\d+", v)
if not parts:
return (0,)
return tuple(int(p) for p in parts)
def _is_newer_version(self, available: str, current: str) -> bool:
"""
True if `available` > `current` according to _parse_version.
"""
return self._parse_version(available) > self._parse_version(current)
# ---------- Public entrypoint for Help → Version ---------- #
def show_version_dialog(self) -> None:
"""
Show the Version dialog with a 'Check for updates' button.
"""
version = self.current_version()
version_formatted = f"{APP_NAME} {version}"
box = QMessageBox(self._parent)
box.setWindowTitle(strings._("version"))
box.setIconPixmap(self._logo_pixmap(96))
box.setText(version_formatted)
check_button = box.addButton(
strings._("check_for_updates"), QMessageBox.ActionRole
)
box.addButton(QMessageBox.Close)
box.exec()
if box.clickedButton() is check_button:
self.check_for_updates()
# ---------- Core update logic ---------- #
def check_for_updates(self) -> None:
"""
Fetch VERSION_URL, compare against the current version, and optionally
download + verify a new AppImage.
"""
current = self.current_version()
try:
resp = requests.get(VERSION_URL, timeout=10)
resp.raise_for_status()
available_raw = resp.text.strip()
except Exception as e:
QMessageBox.warning(
self._parent,
strings._("update"),
strings._("could_not_check_for_updates") + str(e),
)
return
if not available_raw:
QMessageBox.warning(
self._parent,
strings._("update"),
strings._("update_server_returned_an_empty_version_string"),
)
return
if not self._is_newer_version(available_raw, current):
QMessageBox.information(
self._parent,
strings._("update"),
strings._("you_are_running_the_latest_version") + f"({current}).",
)
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)
# ---------- Download + verification helpers ---------- #
def _download_file(
self,
url: str,
dest_path: Path,
timeout: int = 30,
progress: QProgressDialog | None = None,
label: str | None = None,
) -> None:
"""
Stream a URL to a local file, optionally updating a QProgressDialog.
If the user cancels via the dialog, raises RuntimeError.
"""
resp = requests.get(url, timeout=timeout, stream=True)
resp.raise_for_status()
dest_path.parent.mkdir(parents=True, exist_ok=True)
total_bytes: int | None = None
content_length = resp.headers.get("Content-Length")
if content_length is not None:
try:
total_bytes = int(content_length)
except ValueError:
total_bytes = None
if progress is not None:
progress.setLabelText(
label or strings._("downloading") + f" {dest_path.name}..."
)
# Unknown size → busy indicator; known size → real range
if total_bytes is not None and total_bytes > 0:
progress.setRange(0, total_bytes)
else:
progress.setRange(0, 0) # pragma: no cover
progress.setValue(0)
progress.show()
QApplication.processEvents()
downloaded = 0
with dest_path.open("wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
if not chunk:
continue # pragma: no cover
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
if total_bytes is not None and total_bytes > 0:
progress.setValue(downloaded)
else:
# Just bump a little so the dialog looks alive
progress.setValue(progress.value() + 1) # pragma: no cover
QApplication.processEvents()
if progress.wasCanceled():
raise RuntimeError(strings._("download_cancelled"))
if progress is not None and total_bytes is not None and total_bytes > 0:
progress.setValue(total_bytes)
QApplication.processEvents()
def _download_and_verify_appimage(self, version: str) -> None:
"""
Download the AppImage + its GPG signature to the user's Downloads dir,
then verify it with a bundled public key.
"""
# Where to put the file
download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
if not download_dir:
download_dir = os.path.expanduser("~/Downloads")
download_dir = Path(download_dir)
download_dir.mkdir(parents=True, exist_ok=True)
# Construct AppImage filename and URLs
appimage_path = download_dir / "Bouquin.AppImage"
sig_path = Path(str(appimage_path) + ".asc")
appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage"
sig_url = f"{appimage_url}.asc"
# Progress dialog covering both downloads
progress = QProgressDialog(
"Downloading update...",
"Cancel",
0,
100,
self._parent,
)
progress.setWindowTitle(strings._("update"))
progress.setWindowModality(Qt.WindowModal)
progress.setAutoClose(False)
progress.setAutoReset(False)
try:
# AppImage download
self._download_file(
appimage_url,
appimage_path,
progress=progress,
label=strings._("downloading") + " Bouquin.AppImage...",
)
# Signature download (usually tiny, but we still show it)
self._download_file(
sig_url,
sig_path,
progress=progress,
label=strings._("downloading") + " signature...",
)
except RuntimeError:
# User cancelled
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
progress.close()
QMessageBox.information(
self._parent,
strings._("update"),
strings._("download_cancelled"),
)
return
except Exception as e:
# Other error
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
progress.close()
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("failed_to_download_update") + str(e),
)
return
progress.close()
# Load the bundled public key
try:
pkg, *rel = GPG_PUBKEY_RESOURCE
pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes()
except Exception as e: # pragma: no cover
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("could_not_read_bundled_gpg_public_key") + str(e),
)
# On failure, delete the downloaded files for safety
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError: # pragma: no cover
pass
return
# Use a temporary GNUPGHOME so we don't touch the user's main keyring
try:
with tempfile.TemporaryDirectory() as gnupg_home:
pubkey_path = Path(gnupg_home) / "pubkey.asc"
pubkey_path.write_bytes(pubkey_bytes)
# Import the key
subprocess.run(
["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
) # nosec
# Verify the signature
subprocess.run(
[
"gpg",
"--homedir",
gnupg_home,
"--verify",
str(sig_path),
str(appimage_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
) # nosec
except FileNotFoundError:
# gpg not installed / not on PATH
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("could_not_find_gpg_executable"),
)
return
except subprocess.CalledProcessError as e:
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink() # pragma: no cover
except OSError: # pragma: no cover
pass
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("gpg_signature_verification_failed")
+ e.stderr.decode(errors="ignore"),
)
return
# Success
QMessageBox.information(
self._parent,
strings._("update"),
strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
)

259
find_unused_strings.py Executable file
View file

@ -0,0 +1,259 @@
#!/usr/bin/env python3
import argparse
import ast
import json
from pathlib import Path
from typing import Dict, Set
BASE_DIR = Path(__file__).resolve().parent / "bouquin"
LOCALES_DIR = BASE_DIR / "locales"
def load_json_keys(locale: str) -> Set[str]:
"""Load all keys from the given locale JSON file."""
path = LOCALES_DIR / f"{locale}.json"
with path.open(encoding="utf-8") as f:
data = json.load(f)
return set(data.keys())
class KeyParamFinder(ast.NodeVisitor):
"""
First pass:
For each function/method, figure out which parameters are later passed
into _(), translated(), or strings._().
Example: in your _prompt_name, it discovers that title_key and label_key
are translation-key parameters.
"""
def __init__(self) -> None:
# func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]}
self.func_info: Dict[str, dict] = {}
self.current_func_name_stack: list[str] = []
self.current_param_positions_stack: list[Dict[str, int]] = []
self.current_class_stack: list[str] = []
# Track when we're inside a class so we can treat "self" specially
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.current_class_stack.append(node.name)
self.generic_visit(node)
self.current_class_stack.pop()
def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
funcname = node.name
params = [arg.arg for arg in node.args.args]
# If we're inside a class and there is at least one param,
# assume the first one is "self"/"cls" and is implicit at call sites.
is_method = bool(self.current_class_stack) and len(params) > 0
param_positions: Dict[str, int] = {}
for i, name in enumerate(params):
if is_method and i == 0:
# skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...)
continue
call_index = i - 1 if is_method else i
param_positions[name] = call_index
self.current_func_name_stack.append(funcname)
self.current_param_positions_stack.append(param_positions)
self.func_info.setdefault(
funcname,
{
"param_positions": param_positions,
"key_param_positions": set(),
},
)
# If the function name is reused, last definition wins
self.func_info[funcname]["param_positions"] = param_positions
def _exit_function(self) -> None:
self.current_func_name_stack.pop()
self.current_param_positions_stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._enter_function(node)
self.generic_visit(node)
self._exit_function()
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._enter_function(node)
self.generic_visit(node)
self._exit_function()
def visit_Call(self, node: ast.Call) -> None:
# Only care about calls *inside* functions
if not self.current_func_name_stack:
return self.generic_visit(node)
func = node.func
func_name: str | None = None
if isinstance(func, ast.Name):
func_name = func.id
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
# e.g. strings._(...)
func_name = f"{func.value.id}.{func.attr}"
# Is this a translation call?
if func_name in {"_", "translated", "strings._"}:
cur_name = self.current_func_name_stack[-1]
param_positions = self.current_param_positions_stack[-1]
# Positional first arg
if node.args:
first = node.args[0]
if isinstance(first, ast.Name):
pname = first.id
if pname in param_positions:
idx = param_positions[pname]
self.func_info[cur_name]["key_param_positions"].add(idx)
# Keyword args, e.g. strings._(key=title_key)
for kw in node.keywords or []:
if isinstance(kw.value, ast.Name):
pname = kw.value.id
if pname in param_positions:
idx = param_positions[pname]
self.func_info[cur_name]["key_param_positions"].add(idx)
self.generic_visit(node)
class UsedKeyCollector(ast.NodeVisitor):
"""
Second pass:
- Collect string literals passed directly to _()/translated()/strings._()
- Collect string literals passed into parameters that we know are
"translation-key parameters" of wrapper functions/methods.
"""
def __init__(self, func_info: Dict[str, dict]) -> None:
self.func_info = func_info
self.used_keys: Set[str] = set()
def visit_Call(self, node: ast.Call) -> None:
func = node.func
def full_name(f: ast.expr) -> str | None:
if isinstance(f, ast.Name):
return f.id
if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
return f"{f.value.id}.{f.attr}"
return None
func_full = full_name(func)
# 1) Direct translation calls like _("key") or strings._("key")
if func_full in {"_", "translated", "strings._"}:
if node.args:
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
self.used_keys.add(first.value)
for kw in node.keywords or []:
if isinstance(kw.value, ast.Constant) and isinstance(
kw.value.value, str
):
self.used_keys.add(kw.value.value)
# 2) Wrapper calls: functions whose params we know are translation-key params
called_base_name: str | None = None
if isinstance(func, ast.Name):
called_base_name = func.id
elif isinstance(func, ast.Attribute):
called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name"
if called_base_name in self.func_info:
info = self.func_info[called_base_name]
param_positions: Dict[str, int] = info["param_positions"]
key_positions: Set[int] = info["key_param_positions"]
# positional args
for idx, arg in enumerate(node.args):
if (
idx in key_positions
and isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
):
self.used_keys.add(arg.value)
# keyword args
for kw in node.keywords or []:
if kw.arg is None:
continue # **kwargs, ignore
param_name = kw.arg
if param_name in param_positions:
idx = param_positions[param_name]
if idx in key_positions:
val = kw.value
if isinstance(val, ast.Constant) and isinstance(val.value, str):
self.used_keys.add(val.value)
self.generic_visit(node)
def collect_used_keys() -> Set[str]:
"""Parse all .py files and collect all translation keys used."""
trees: list[ast.AST] = []
# Read and parse all Python files in this folder
for path in BASE_DIR.glob("*.py"):
# Optionally skip this script itself
if path.name == Path(__file__).name:
continue
src = path.read_text(encoding="utf-8")
tree = ast.parse(src, filename=str(path))
trees.append(tree)
# First pass: find which parameters are translation-key params
finder = KeyParamFinder()
for tree in trees:
finder.visit(tree)
# Second pass: collect string literals passed to those parameters
collector = UsedKeyCollector(finder.func_info)
for tree in trees:
collector.visit(tree)
return collector.used_keys
def main() -> None:
parser = argparse.ArgumentParser(
description="Find missing or unused strings for a given locale"
)
parser.add_argument(
"--locale",
type=str,
default="en",
help="Locale key e.g en, fr, it",
)
args = parser.parse_args()
json_keys = load_json_keys(args.locale)
used_keys = collect_used_keys()
unused_keys = sorted(json_keys - used_keys)
missing_in_json = sorted(used_keys - json_keys)
print("=== Unused keys in JSON (present in locales but never used in code) ===")
if unused_keys:
for k in unused_keys:
print(" ", k)
else:
print(" (none)")
print("\n=== Keys used in code but missing from JSON ===")
if missing_in_json:
for k in missing_in_json:
print(" ", k)
else:
print(" (none)")
if __name__ == "__main__":
main()

811
poetry.lock generated
View file

@ -1,26 +1,137 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "beautifulsoup4"
version = "4.14.2"
description = "Screen-scraping library"
name = "certifi"
version = "2025.11.12"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7.0"
python-versions = ">=3.7"
files = [
{file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"},
{file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"},
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
]
[package.dependencies]
soupsieve = ">1.2"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
files = [
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
]
[[package]]
name = "colorama"
@ -35,115 +146,103 @@ files = [
[[package]]
name = "coverage"
version = "7.10.7"
version = "7.13.0"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
files = [
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"},
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"},
{file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"},
{file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"},
{file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"},
{file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"},
{file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"},
{file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"},
{file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"},
{file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"},
{file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"},
{file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"},
{file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"},
{file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"},
{file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"},
{file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"},
{file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"},
{file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"},
{file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"},
{file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"},
{file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"},
{file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"},
{file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"},
{file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"},
{file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"},
{file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"},
{file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"},
{file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"},
{file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"},
{file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"},
{file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"},
{file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"},
{file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"},
{file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"},
{file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"},
{file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"},
{file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"},
{file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"},
{file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"},
{file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"},
{file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"},
{file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"},
{file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"},
{file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"},
{file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"},
{file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"},
{file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"},
{file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"},
{file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"},
{file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"},
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
]
[package.dependencies]
@ -152,15 +251,29 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
[package.extras]
toml = ["tomli"]
[[package]]
name = "desktop-entry-lib"
version = "5.0"
description = "A library for working with .desktop files"
optional = false
python-versions = ">=3.10"
files = [
{file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"},
{file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"},
]
[package.extras]
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]
@ -170,30 +283,44 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
test = ["pytest (>=6)"]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
name = "idna"
version = "3.11"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "markdownify"
version = "1.2.0"
description = "Convert HTML to markdown."
name = "markdown"
version = "3.10"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = "*"
python-versions = ">=3.10"
files = [
{file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"},
{file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"},
{file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"},
{file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"},
]
[package.dependencies]
beautifulsoup4 = ">=4.9,<5"
six = ">=1.15,<2"
[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]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "packaging"
@ -236,58 +363,74 @@ files = [
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyside6"
version = "6.10.0"
description = "Python bindings for the Qt cross-platform application and UI framework"
name = "pyproject-appimage"
version = "4.2"
description = "Generate AppImages from your Python projects"
optional = false
python-versions = "<3.14,>=3.9"
python-versions = ">=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 = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"},
{file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"},
]
[package.dependencies]
PySide6_Addons = "6.10.0"
PySide6_Essentials = "6.10.0"
shiboken6 = "6.10.0"
desktop-entry-lib = "*"
requests = "*"
tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6"
version = "6.10.1"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.9"
files = [
{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.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"
@ -369,170 +512,175 @@ dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx_rtd_theme"]
[[package]]
name = "shiboken6"
version = "6.10.0"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.14,>=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 = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "soupsieve"
version = "2.8"
description = "A modern CSS selector implementation for Beautiful Soup."
name = "requests"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
files = [
{file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"},
{file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"},
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "shiboken6"
version = "6.10.1"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.9"
files = [
{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 = "sqlcipher3-wheels"
version = "0.5.5.post0"
version = "0.5.6"
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 = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e16c8caf59e86589fb5f52253420db07121f1f96e2a12e244f6fdcaf8b946530"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:337f2e059114729dd1529ee356c98e2aa06440d6a9772917514a3bda0647c61c"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f6bd900658446e1cdeebda0760adb9a89f55888b460623db88b100845cb51bc2"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dc6fcca569858145cb5ba3c878997d1788973e36f689090178f807b9a44d9ca6"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:eef50cc39554ad1fb82faa33d25c7f3cb11e2f7087b41109bc169db2c942f0c7"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:0fc36fc67f639a0e03cf6f7c6a5d1bc5cdd8005e8e07da3b21c54d4d81ed353b"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:53d0b861668d6847c7cc0dc7b443263b95a5cd211bcc326a457bd3122ebbb5a0"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:10aef293397a4ab25d8346ba5f96181214ab9c6a8836d83320cf23a2ad773a2c"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1105e7edba36a29625a824bff0eca3685c1cf6e391182b85a9a73b4b1604eef3"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5db9b4035e42a27672abbe75120908c74a235a496cd92b4c685fda1e95e9b19c"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f9e3fb5e96c5067a8cfd7b2fa7d939e529e30439058bbc15d0e9adca5e4cff1b"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6f3c1a8a4a2c04225f5159cf7f1c315101a89271afbaef4205c6fc50766c5535"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fc0504a1dbe6d478614ef55eb80d0c02ead24bc91f34b41c07d404452389f42d"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win32.whl", hash = "sha256:05ef2b35f176e3b29092ec9aa03b09f4803feddbabdc2174e7ccc608758f2beb"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:0f6873e4badf64eb8c5771c9e8a726df46ac663bc8051dfefb51fe2a46358b37"},
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_arm64.whl", hash = "sha256:9fd30c1cffa10f63f504a33494564efc0e0a475bbf069487016a9d2462d115e5"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a6c511bacd40ba769368b1abbf97fbefb285f525e6d2a399a704c22ba2aae37f"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa25610cda2b2a1b1cefddbd93488e939cf0059480f2fda5a8704acddd0e8935"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5a5258fb99e99b6fda6f011a0a4094ff99fe2e9b9ac7ce81cf646e0e779829a3"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:459836d52904fa006bf36e2144959bd21577c32947fdd173db50b037108a8620"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:5b36f9949f4d35c72f0626aaac109b17688c1d6a9a6e11de2538b4cfc32cfad0"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:87301b545556a1811780bb6fc6480ab1f2640d1d5b5e5e33ed404559ae383647"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:fcc4705b5b7bd3508d08a6389a45e14591071a3e575c2864c9c1c615df89e0da"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0a231eb677a8246c47e423c710198631850c0a090e8f02a7fb1ad266ba517c56"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71ef871c65ad7c61048acb4f57da29bc0d5e35874183006222c229b5f1f64c73"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3480298c9bc4117207535636fe74b01b4860ecd74a028c73b42f5f0ddaa8661"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d48cf218ed13f17e3037564f08fba7ddf2c260dac7993e3d4ac58ee30483f115"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ff57a80904b9bd55e18774cb59bffacad06e196298381ee576ce683d1c09b032"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50978685717cd9293ff5508c192695a894879f9faed5142d0e8d7b63310f87c2"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win32.whl", hash = "sha256:24207dbb699ca68fc5fc7248385fdf33a92fb1e17a6ea88d3cf2345a18fb29ff"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:40b1f8188a0aa5bbec354a12561b014b43a6a0d0a0d230a8a9378ed7b826b0ec"},
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:107ef02bbd0f2ffb39a564c14ebf3bedfa4569949a0d72ec8e106f754d715b7c"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:59a572b18d1ef8318e9f583a7b3e1a67b4b04ed4b783c3f29fa806635274d12a"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32dfb8903b24db5879b1f922114f650bc6a15df9d071c55eefeb6937e13b2d20"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f5770257736c43cbf910a22f74c1490ef1ecde0432e475904f038e64ffdacb0"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c33f99ddfe08c0f34807046800e510316b8bac2974b3c5fb9ecb1ee25c391ac8"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:97d4c000deeb72c2421f555f3e55a8c161ddfb0499caabf60df2bfde6460a5fc"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:67d9889028b4adfcaecd32e1e60330e1764c209ad12438f0eec2a5145ebf4a2d"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:00cf178b15da486ab43ee2bed41edb1b393c5cfe2a48cae68893a2b31260dbd3"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:95bfa4c5ffdd72d9d8676c913d585b7885a42824824cf1d9e93d3669f01492dd"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:030ab50a8f4153cfe8dd5c98724909b210243af2350b9c79914838905a99518e"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5dc3c3d9deea654f8ea9c1dbc7bc90561331e4da9c7055381fac6498ca7267a3"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cc986e8aa89e5a4a30b4eb8fd841d913a4e22ada99ec42be83f69bde3d86a31"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a41f0d30fa63d8db915566ec6987e68f064d96052cd6492ed8384b3e4807e60b"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fefe8a41e68334c545465813782fd45ef5cfe1082d012d95514c8a78e8015"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win32.whl", hash = "sha256:ac2332f44758794a2fa19c77b824853e2a57ce5c27cc71c61066a52845be22d0"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:6f016ba5a2a531938f332a234865dfc25d3a69abc169c3bf1d5c06c3c3f24601"},
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:101ce0f7403801b6988d1f6c94244900e0f6c5378666e0ffd74b300687a6f9ef"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:94527fa3994c0fa1275c23d9fbb02512aacc675f1e45f566c660f4f9d5376e75"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0920a4b24362522ba83b36a47495d174221361213207191c325749a621fabeca"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5061b07b121ebd76aa697755b1b8f642cc3a27a0f6d392180ab249b35f1c2394"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:79de8511bb1fec62128e1b366cdc0cbd2ad1d725f3e29f9c91e96946a3c67945"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4b92c2f35bb8153cc20bcfc651536f51cc1194403782c542a852497ac789cbe2"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2d55211e3d2addff8a2df7335927d7fe6d75aa9ed12b396a22a5a0bfe2773ed9"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8cb31de5d67799cc2bba92f23adc10281d66c2c16ca6418b94d80500a164aa60"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:123796de3e471db5ed8b4ee4f97ec562ad38347ad678dad71133eade280202e0"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6d34fabacfad4f301a22b5d8466d7ee3481f735bdb327d8756f04c81d3516c4"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:91b02fc765485c5b65f2a3eacfd2e16059253e007d0b5a5f24bba5fcea9032dd"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13db7f23c553ffdd35f6e3b26415bdb9f100dcf89038873965caef769e8f1af5"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4ba79a81cd591d32a3a225e3e9b50a9871324d0e414fb6d0866049d8820e4e46"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f97be07997681ca90fb339d5411fcb957bd7cbe810389404baed207cb366badd"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win32.whl", hash = "sha256:9e56e0a7aa778da3d46323fc1233da5dcede795a6c7fe4c11980fec0ce8c3fe3"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:744845e4aa3cc614590f967aa1d38cc5d549177a2a83ed68c1821b5fb0505f8a"},
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:c92de0b940533ca3a5b43a45d0768e0698b6ca95020b2fd47ec269b6bfc228d1"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a3f558df797aabf51680b3fbce48c4b3df89c36ad7fcaa3886b2ed8057aa2786"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7e216586720663960c82f046c495ef6d828e8e95c8fcf4c767b555fb9b8feead"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e4ef70d3af8ebe6ababe8eff93b8bd4ad288d0a38ab29a2420c91d636fbfe14"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:11e34aac6cb7e29d23e339c5de9e87700ddf09886e104640578b5afb566a2c50"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:79e220312a075546e6be0a6062dda6315857b1478d78f97eb352f1383dde8ce2"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b953af7b57867bcffeeab59681921671615ae4b42fd0a9234ad0be7e0e43dfd4"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:130ac318dbcb3a51a4377b0bf3e450c6c21d508a8b00d2d9d4b3ee6a46ab3595"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5154c8022e58722987522ddce30f19fb69d6f8f6314959100d9f37c3dc5cba5b"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f91d1f5b7b927aa00a8d83724c58875d9d0e47bd81ca40445090ab521b5fa"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e1c140bfa6b0a7e08f414f2a9f8f529f7d8c4cfa8386ce588e6c747c4ccc6615"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:18fc56dfb32c6ce370d929897205027f78275c32446d6b1be712d462789ae8c2"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c03ec5e058fbf3fd94ecd8e0448834e8e7f46418eaec5fe5c7a0982c6e62c13f"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08756c1b25aebabb25a55dfe6f323876caea0c69511e34553807ae1d7ab843dd"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win32.whl", hash = "sha256:bdbc58d224d27c002aed8a6361b43f3651943ecbfac69cd2674bbe681cf83790"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:dcc313f4519922c1ec3406b010d53f700750c1cf5331b9633a3c8b196307e852"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:dc1f0c77cc0395680176913a1d634a4014a1ebf02e7a7b2ac03a180b44241842"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fc30e82d2b8f139ac1ab81a3b3d9a59da8e3ce3b1e753285727480667efd5417"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f11d1d2c41141dd95f7d45f03dbe9f69a6427463e69db50609d83c0cd29980b5"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92beff11fd9683941de7b47b8fc280e834b135ba7966d139b0ce2159b551ebad"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3060403647df7d44844c2808a384e4c4cf4a2a1b65e509a8016aca971c08ad39"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:9380de7e8fc952f376c9dae9ba1cdbb6a24ff5e41fd8f3b3cf39f1e305ed3248"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:9a26be381b0fb1c8d4fcdfd48182c78217ae9458513e4fe51b5045d4f94d41cb"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:c3be08f8d81372a6d084062f969f88be0b942ac449b0ac01825b853c12705421"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c5bd4abbebc15f8a2a9a653500cd1abeb3aac13887fcc83de31ca40fce32e3a2"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4bb3c2c8e9a1e16455b989b2c7598b8053029bcbb519dc22601fa82bc8896f89"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:aac8ca9d2b4e18637e61ea1d8193500a1186f0b113b9224dc74186190f41c8e7"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f237a41c3f08e69f2532aec29a2589097baa73886164537d90c744d3d2eb3b3"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e6e59c3e0301cb04351b1cb12231aaadb40f56f779fb50a7857c6b4ed4c57297"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba2296a608081f4474f4447658a1e032d0b5506153baf68233471afde1463da9"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win32.whl", hash = "sha256:8c8edfbd38a49ebbec2d1d56a000a499da2ac80b00488c156a1e0b8a7b8c10c6"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:21df85bc14d5d86225c1e7466ff65cbcc10f0d1d4f466823b4534c4c0564554c"},
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_arm64.whl", hash = "sha256:64df3e807fb0e6d89c1e90ce7c900bb82b695c474e1a0945a5f92862cac8b63d"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3784b22a29e4a675b456ca6ff1841d61e0eb97a28d0ba23d3d8cb5fe6da88238"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e27881be24f03d8a67a6db763f5671aaa05205de2380b1793b5e20bdabe49fba"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:667b6eec50ed03111676a0f4565be133643c9ad8bc88e6eea1c96b2af590c417"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4eaaa5cf77b125e05908b1200681e2988b1a6a307c7e677967053a1e4b07fba5"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:4ead5b8f2607718548c8571e4a89fe735dd53443a2b5e42d8147eecd11b0d94b"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_ppc64le.whl", hash = "sha256:d82a8a7b478d23368320ad185533d063ec14d11a1d188f07ace513a66bfa9580"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_s390x.whl", hash = "sha256:39d871ee8c13d9b0326b24a02e5af21a7b1c8fb5e6f6f4ec62b935392202ec69"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:5a8737683621c2917a4ee9ff774e597a368c5b3d23f08ae53897d6bd1f8bfc0e"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:08b6922d5020384fa641c8dc416f6f2b143110c86dcf3aae086e7ce15b192eae"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9dcc7f830ec56c090884a83be265c51c0a4fd60bb033b000c69c3bee08d77d8"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:0848f628b1528dd6a19a36679d8cde4b6f1f8d288757ba2e3df5578b79d79e90"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1476bb15586ce27ea5fae7c54469b2be4efe51ca9cefa20871a6c394a18892cc"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:de17d373d9e7807236013950f598bf59b9ed7c375938fdb95378a7114e55ff95"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win32.whl", hash = "sha256:02fa9e7f98a8e9be871219014b9ac015ba630b51615d90a2c06d45547a4b0cf1"},
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win_amd64.whl", hash = "sha256:6b2d7daab225c578aec8109fde99624f281b4ccdc6c53c8cd8feb86d8e7d3cf2"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:abef5e28b4d1ca518291a8ca27af1cf9e4d68dd4a264d83874ec4d0a69589395"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd4c12a5a60cbd533ba4a3b4131d23302283ba597739c7867066b4efefe178db"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b7672837f1b9a6a67e375b743d74371d0428ead79ff367591145d06f3711c96"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:61c33e2697b0d91f3cbe806104e1d5b93961d3ab55ba55ee53bb36efe83c9933"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:2e6eb09782dd719a1bb34af6e5ef25e5713c1f806231b472fcf64eb9288957af"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6469b756ced0293e74806db2f114e5307cd4b05a559e986d3cc0b2eeb1eb8153"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:b6492f9bcb9296ac2179b5c9f7e7f329449b580836c0e8e5cfc2f3fe9af3486c"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2e4968d98917309463f02e4a48abebd95ed3d37968346f2693ed8a08e2fe9794"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:50214729697a1ee9e7603ba62b8ea46d78903ae1332caaa94fbaedde113944b7"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1ec9fd1dd5774d665903b8ba2e3e4f8ed72879dc42f6e9b2815040f0cb2d8ccd"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ced8ab30d205c8b6225b5703885576e629266767b091158731ec76c8c490bef4"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3c7242a267dd802fee273084a5707a95d02df4102afbea133c8f716234c7edcc"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6c239d15085af4b0f3433fa274c1fc37369509b99a7c035a359d5142a0536d"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win32.whl", hash = "sha256:cc29963df04a73d8420a4d023ba016c9013d86378969d8a11fe2148477282936"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_amd64.whl", hash = "sha256:38cc7bb3a371c4a5fe7f4236a409e64f1286796d780833243f9e15ef852f159d"},
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_arm64.whl", hash = "sha256:186e49af3ddb98d260b95d436eaf58f2125712c268c8475627129c1f80a68164"},
{file = "sqlcipher3_wheels-0.5.6.tar.gz", hash = "sha256:1d232c14be44db95a7f3018433cae01ecd18803fa2468fce3cc45ebd5e034942"},
]
[[package]]
@ -597,7 +745,24 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.6.2"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
]
[package.extras]
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 = ["backports-zstd (>=1.0.0)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<3.14"
content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d"
python-versions = ">=3.10,<3.14"
content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df"

View file

@ -1,17 +1,20 @@
[tool.poetry]
name = "bouquin"
version = "0.1.10.1"
version = "0.7.3"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"
license = "GPL-3.0-or-later"
repository = "https://git.mig5.net/mig5/bouquin"
packages = [{ include = "bouquin" }]
include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/NotoSansSymbols2-Regular.ttf", "bouquin/fonts/OFL.txt"]
[tool.poetry.dependencies]
python = ">=3.9,<3.14"
python = ">=3.10,<3.14"
pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0"
markdownify = "^1.2.0"
requests = "^2.32.5"
markdown = "^3.10"
[tool.poetry.scripts]
bouquin = "bouquin.__main__:main"
@ -22,6 +25,20 @@ pytest-qt = "^4.5.0"
pytest-mock = "^3.15.1"
pytest-cov = "^7.0.0"
[tool.poetry.group.dev.dependencies]
pyproject-appimage = "^4.2"
[tool.pyproject-appimage]
script = "bouquin"
output = "Bouquin.AppImage"
icon = "bouquin/icons/bouquin.svg"
rename-icon = "bouquin.png"
desktop-entry = "bouquin.desktop"
[tool.vulture]
paths = ["bouquin", "vulture_ignorelist.py"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

48
release.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
set -eo pipefail
# 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 -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
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
screenshots/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

BIN
screenshots/statistics.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
screenshots/tags.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
screenshots/time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

View file

@ -1,104 +1,122 @@
import os
import sys
from pathlib import Path
import pytest
from PySide6.QtCore import QStandardPaths
from tests.qt_helpers import AutoResponder
# Force Qt *non-native* file dialog so we can type a filename programmatically.
os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0")
# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env
import pytest
from PySide6.QtWidgets import QApplication
# Ensure the nested package directory (repo_root/bouquin) is on sys.path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PKG_PARENT = PROJECT_ROOT / "bouquin"
if str(PKG_PARENT) not in sys.path:
sys.path.insert(0, str(PKG_PARENT))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# Make project importable
from PySide6.QtWidgets import QApplication, QWidget
from bouquin.theme import ThemeManager, ThemeConfig, Theme
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
@pytest.fixture(scope="session")
def app():
app = QApplication.instance()
if app is None:
app = QApplication([])
return app
@pytest.fixture(scope="session", autouse=True)
def enable_qstandardpaths_test_mode():
QStandardPaths.setTestModeEnabled(True)
@pytest.fixture()
def temp_home(tmp_path, monkeypatch):
home = tmp_path / "home"
(home / "Documents").mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
return home
@pytest.fixture()
def clean_settings():
try:
from bouquin.settings import APP_NAME, APP_ORG
from PySide6.QtCore import QSettings
except Exception:
yield
return
s = QSettings(APP_ORG, APP_NAME)
s.clear()
def isolate_qsettings(tmp_path_factory):
cfgdir = tmp_path_factory.mktemp("qt_cfg")
os.environ["XDG_CONFIG_HOME"] = str(cfgdir)
yield
s.clear()
@pytest.fixture(autouse=True)
def auto_accept_common_dialogs(qtbot):
ar = AutoResponder()
ar.start()
try:
yield
finally:
ar.stop()
@pytest.fixture()
def open_window(qtbot, temp_home, clean_settings):
"""Launch the app and immediately satisfy first-run/unlock key prompts."""
from bouquin.main_window import MainWindow
app = QApplication.instance()
themes = ThemeManager(app, ThemeConfig())
themes.apply(Theme.SYSTEM)
win = MainWindow(themes=themes)
qtbot.addWidget(win)
win.show()
qtbot.waitExposed(win)
# Immediately satisfy first-run 'Set key' or 'Unlock' prompts if already visible
AutoResponder().prehandle_key_prompts_if_present()
return win
@pytest.fixture()
def today_iso():
from datetime import date
d = date.today()
return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
@pytest.fixture
def theme_parent_widget(qtbot):
"""A minimal parent that provides .themes.apply(...) like MainWindow."""
def tmp_db_cfg(tmp_path):
from bouquin.db import DBConfig
class _ThemesStub:
def __init__(self):
self.applied = []
default_db = tmp_path / "notebook.db"
key = "test-secret-key"
return DBConfig(
path=default_db,
key=key,
idle_minutes=0,
theme="light",
move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
)
def apply(self, theme):
self.applied.append(theme)
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemesStub()
@pytest.fixture
def fresh_db(tmp_db_cfg):
from bouquin.db import DBManager
parent = _Parent()
qtbot.addWidget(parent)
return parent
db = DBManager(tmp_db_cfg)
ok = db.connect()
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,287 +0,0 @@
import time
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QWidget,
QDialog,
QFileDialog,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QAbstractButton,
QListWidget,
)
# ---------- robust widget finders ----------
def _visible_widgets():
for w in QApplication.topLevelWidgets():
if w.isVisible():
yield w
for c in w.findChildren(QWidget):
if c.isWindow() and c.isVisible():
yield c
def wait_for_widget(cls=None, predicate=lambda w: True, timeout_ms: int = 15000):
deadline = time.time() + timeout_ms / 1000.0
while time.time() < deadline:
for w in _visible_widgets():
if (cls is None or isinstance(w, cls)) and predicate(w):
return w
QTest.qWait(25)
raise TimeoutError(f"Timed out waiting for {cls} matching predicate")
# ---------- generic ui helpers ----------
def click_button_by_text(container: QWidget, contains: str) -> bool:
"""Click any QAbstractButton whose label contains the substring."""
target = contains.lower()
for btn in container.findChildren(QAbstractButton):
text = (btn.text() or "").lower()
if target in text:
from PySide6.QtTest import QTest
if not btn.isEnabled():
QTest.qWait(50) # give UI a tick to enable
QTest.mouseClick(btn, Qt.LeftButton)
return True
return False
def _first_line_edit(dlg: QDialog) -> QLineEdit | None:
edits = dlg.findChildren(QLineEdit)
return edits[0] if edits else None
def fill_first_line_edit_and_accept(dlg: QDialog, text: str | None):
le = _first_line_edit(dlg)
assert le is not None, "Expected a QLineEdit in the dialog"
if text is not None:
le.clear()
QTest.keyClicks(le, text)
# Prefer 'OK'; fallback to Return
ok = None
for btn in dlg.findChildren(QPushButton):
t = btn.text().lower().lstrip("&")
if t == "ok" or btn.isDefault():
ok = btn
break
if ok:
QTest.mouseClick(ok, Qt.LeftButton)
else:
QTest.keyClick(le, Qt.Key_Return)
def accept_all_message_boxes(limit: int = 5) -> bool:
"""
Accept every visible QMessageBox, preferring Yes/Accept/Ok.
Returns True if at least one box was accepted.
"""
accepted_any = False
for _ in range(limit):
accepted_this_round = False
for w in _visible_widgets():
if isinstance(w, QMessageBox) and w.isVisible():
# Prefer "Yes", then any Accept/Apply role, then Ok, then default/first.
btn = (
w.button(QMessageBox.Yes)
or next(
(
b
for b in w.buttons()
if w.buttonRole(b)
in (
QMessageBox.YesRole,
QMessageBox.AcceptRole,
QMessageBox.ApplyRole,
)
),
None,
)
or w.button(QMessageBox.Ok)
or w.defaultButton()
or (w.buttons()[0] if w.buttons() else None)
)
if btn:
QTest.mouseClick(btn, Qt.LeftButton)
accepted_this_round = True
accepted_any = True
if not accepted_this_round:
break
QTest.qWait(30) # give the next box a tick to appear
return accepted_any
def trigger_menu_action(win, text_contains: str) -> QAction:
for act in win.findChildren(QAction):
if text_contains in act.text():
act.trigger()
return act
raise AssertionError(f"Action containing '{text_contains}' not found")
def find_line_edit_by_placeholder(container: QWidget, needle: str) -> QLineEdit | None:
n = needle.lower()
for le in container.findChildren(QLineEdit):
if n in (le.placeholderText() or "").lower():
return le
return None
class AutoResponder:
def __init__(self):
self._seen: set[int] = set()
self._timer = QTimer()
self._timer.setInterval(50)
self._timer.timeout.connect(self._tick)
def start(self):
self._timer.start()
def stop(self):
self._timer.stop()
def prehandle_key_prompts_if_present(self):
for w in _visible_widgets():
if isinstance(w, QDialog) and (
_looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w)
):
fill_first_line_edit_and_accept(w, "ci-secret-key")
def _tick(self):
if accept_all_message_boxes(limit=3):
return
for w in _visible_widgets():
if not isinstance(w, QDialog) or not w.isVisible():
continue
wid = id(w)
# Handle first-run / unlock / save-name prompts
if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w):
fill_first_line_edit_and_accept(w, "ci-secret-key")
self._seen.add(wid)
continue
if _looks_like_save_version_dialog(w):
fill_first_line_edit_and_accept(w, None)
self._seen.add(wid)
continue
if _is_history_dialog(w):
# Don't mark as seen until we've actually clicked the button.
if _click_revert_in_history(w):
accept_all_message_boxes(limit=5)
self._seen.add(wid)
continue
# ---------- dialog classifiers ----------
def _looks_like_set_key_dialog(dlg: QDialog) -> bool:
labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
title = (dlg.windowTitle() or "").lower()
has_line = bool(dlg.findChildren(QLineEdit))
return has_line and (
"set an encryption key" in title
or "create a strong passphrase" in labels
or "encrypts your data" in labels
)
def _looks_like_unlock_dialog(dlg: QDialog) -> bool:
labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
title = (dlg.windowTitle() or "").lower()
has_line = bool(dlg.findChildren(QLineEdit))
return has_line and ("unlock" in labels or "unlock" in title) and "key" in labels
# ---------- version prompt ----------
def _looks_like_save_version_dialog(dlg: QDialog) -> bool:
labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
title = (dlg.windowTitle() or "").lower()
has_line = bool(dlg.findChildren(QLineEdit))
return has_line and (
"enter a name" in labels or "name for this version" in labels or "save" in title
)
# ---------- QFileDialog driver ----------
def drive_qfiledialog_save(path: Path, name_filter: str | None = None):
dlg = wait_for_widget(QFileDialog, timeout_ms=20000)
if name_filter:
try:
dlg.selectNameFilter(name_filter)
except Exception:
pass
# Prefer typing in the filename edit so Save enables on all styles
filename_edit = None
for le in dlg.findChildren(QLineEdit):
if le.echoMode() == QLineEdit.Normal:
filename_edit = le
break
if filename_edit is not None:
filename_edit.clear()
QTest.keyClicks(filename_edit, str(path))
# Return usually triggers Save in non-native dialogs
QTest.keyClick(filename_edit, Qt.Key_Return)
else:
dlg.selectFile(str(path))
QTimer.singleShot(0, dlg.accept)
# Some themes still need an explicit Save click
_ = click_button_by_text(dlg, "save")
def _is_history_dialog(dlg: QDialog) -> bool:
if not isinstance(dlg, QDialog) or not dlg.isVisible():
return False
title = (dlg.windowTitle() or "").lower()
if "history" in title:
return True
return bool(dlg.findChildren(QListWidget))
def _click_revert_in_history(dlg: QDialog) -> bool:
"""
Returns True if we successfully clicked an enabled 'Revert' button.
Ensures a row is actually clicked first so the button enables.
"""
lists = dlg.findChildren(QListWidget)
if not lists:
return False
versions = max(lists, key=lambda lw: lw.count())
if versions.count() < 2:
return False
# Click the older row (index 1); real click so the dialog enables the button.
from PySide6.QtTest import QTest
from PySide6.QtCore import Qt
rect = versions.visualItemRect(versions.item(1))
QTest.mouseClick(versions.viewport(), Qt.LeftButton, pos=rect.center())
QTest.qWait(60)
# Find any enabled button that looks like "revert"
for btn in dlg.findChildren(QAbstractButton):
meta = " ".join(
[(btn.text() or ""), (btn.toolTip() or ""), (btn.objectName() or "")]
).lower()
if "revert" in meta and btn.isEnabled():
QTest.mouseClick(btn, Qt.LeftButton)
return True
return False

View file

@ -0,0 +1,324 @@
import bouquin.bug_report_dialog as bugmod
from bouquin import strings
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):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
max_chars = getattr(dlg, "MAX_CHARS", 5000)
# Make a string longer than the allowed maximum
long_text = "x" * (max_chars + 50)
# Setting the text should trigger textChanged -> _enforce_max_length
dlg.text_edit.setPlainText(long_text)
# Let Qt process the signal/slot if needed
qtbot.wait(10)
current = dlg.text_edit.toPlainText()
assert len(current) == max_chars
assert current == long_text[:max_chars]
def test_bug_report_allows_up_to_max_chars_unchanged(qtbot):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
max_chars = getattr(dlg, "MAX_CHARS", 5000)
exact_text = "y" * max_chars
dlg.text_edit.setPlainText(exact_text)
qtbot.wait(10)
current = dlg.text_edit.toPlainText()
# Should not be trimmed if it's exactly the limit
assert len(current) == max_chars
assert current == exact_text
def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
# Non-empty message so we don't hit the "empty" warning branch
dlg.text_edit.setPlainText("Hello, something broke.")
qtbot.wait(10)
# Make version() deterministic
def fake_version(pkg_name):
assert pkg_name == "bouquin"
return "1.2.3"
monkeypatch.setattr(
bugmod.importlib.metadata, "version", fake_version, raising=True
)
# Capture the POST call and fake a 201 Created response
calls = {}
class DummyResp:
status_code = 201
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return DummyResp()
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
# Capture information / critical message boxes
info_called = {}
crit_called = {}
def fake_info(parent, title, text, *a, **k):
info_called["title"] = title
info_called["text"] = str(text)
return 0
def fake_critical(parent, title, text, *a, **k):
crit_called["title"] = title
crit_called["text"] = str(text)
return 0
monkeypatch.setattr(
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
)
monkeypatch.setattr(
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
# Don't actually close the dialog in the test; just record that accept() was called
accepted = {}
def fake_accept():
accepted["called"] = True
dlg.accept = fake_accept
# Call the send logic directly
dlg._send()
# --- Assertions ---------------------------------------------------------
# POST was called with the expected URL and JSON payload
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
assert calls["json"]["message"] == "Hello, something broke."
assert calls["json"]["version"] == "1.2.3"
# No attachment fields expected any more
# Success path: information dialog shown, critical not shown
assert "title" in info_called
assert "text" in info_called
assert crit_called == {}
# Dialog accepted
assert accepted.get("called") is True
def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted(
qtbot, monkeypatch
):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
dlg.text_edit.setPlainText("Broken again.")
qtbot.wait(10)
# Stub version() again
monkeypatch.setattr(
bugmod.importlib.metadata,
"version",
lambda name: "9.9.9",
raising=True,
)
# Fake a non-201 response (e.g. 500)
calls = {}
class DummyResp:
status_code = 500
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return DummyResp()
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
info_called = {}
crit_called = {}
def fake_info(parent, title, text, *a, **k):
info_called["title"] = title
info_called["text"] = str(text)
return 0
def fake_critical(parent, title, text, *a, **k):
crit_called["title"] = title
crit_called["text"] = str(text)
return 0
monkeypatch.setattr(
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
)
monkeypatch.setattr(
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
accepted = {}
def fake_accept():
accepted["called"] = True
dlg.accept = fake_accept
dlg._send()
# POST still called with JSON payload
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
assert calls["json"]["message"] == "Broken again."
assert calls["json"]["version"] == "9.9.9"
# Failure path: critical dialog shown, information not shown
assert crit_called # non-empty
assert info_called == {}
# Dialog should NOT be accepted on failure
assert accepted.get("called") is not True
def test_bug_report_dialog_text_limit_clamps_cursor(qtbot):
"""Test that cursor position is clamped when text exceeds limit."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Set text that exceeds MAX_CHARS
max_chars = dialog.MAX_CHARS
long_text = "A" * (max_chars + 100)
# Set text and move cursor to end
dialog.text_edit.setPlainText(long_text)
dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End)
# Text should be truncated
assert len(dialog.text_edit.toPlainText()) == max_chars
# Cursor should be clamped to max position
final_cursor = dialog.text_edit.textCursor()
assert final_cursor.position() <= max_chars
def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch):
"""Test that sending empty report shows warning."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Clear any text
dialog.text_edit.clear()
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
# Try to send empty report
dialog._send()
assert warning_shown["shown"]
def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch):
"""Test that sending whitespace-only report shows warning."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Set whitespace only
dialog.text_edit.setPlainText(" \n\n \t\t ")
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._send()
assert warning_shown["shown"]
def test_bug_report_dialog_network_error(qtbot, monkeypatch):
"""Test handling network error during send."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
dialog.text_edit.setPlainText("Test bug report")
# Mock requests.post to raise exception
import requests
def mock_post(*args, **kwargs):
raise requests.exceptions.ConnectionError("Network error")
monkeypatch.setattr(requests, "post", mock_post)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
dialog._send()
assert critical_shown["shown"]
def test_bug_report_dialog_timeout_error(qtbot, monkeypatch):
"""Test handling timeout error during send."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
dialog.text_edit.setPlainText("Test bug report")
# Mock requests.post to raise timeout
import requests
def mock_post(*args, **kwargs):
raise requests.exceptions.Timeout("Request timed out")
monkeypatch.setattr(requests, "post", mock_post)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
dialog._send()
assert critical_shown["shown"]

View file

@ -0,0 +1,325 @@
from bouquin import strings
from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog,
CodeEditorWithLineNumbers,
)
from PySide6.QtCore import QRect, QSize
from PySide6.QtGui import QFont, QPaintEvent
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

View file

@ -0,0 +1,398 @@
from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter
from PySide6.QtGui import QFont, QTextCharFormat
def test_get_language_patterns_python(app):
"""Test getting highlighting patterns for Python."""
patterns = CodeHighlighter.get_language_patterns("python")
assert len(patterns) > 0
# Should have comment pattern
assert any("#" in p[0] for p in patterns)
# Should have string patterns
assert any('"' in p[0] for p in patterns)
# Should have keyword patterns
assert any("keyword" == p[1] for p in patterns)
def test_get_language_patterns_javascript(app):
"""Test getting highlighting patterns for JavaScript."""
patterns = CodeHighlighter.get_language_patterns("javascript")
assert len(patterns) > 0
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have /* */ comment pattern (with escaped asterisks in regex)
assert any(r"/\*" in p[0] for p in patterns)
def test_get_language_patterns_php(app):
"""Test getting highlighting patterns for PHP."""
patterns = CodeHighlighter.get_language_patterns("php")
assert len(patterns) > 0
# Should have # comment pattern
assert any("#" in p[0] for p in patterns)
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have /* */ comment pattern (with escaped asterisks in regex)
assert any(r"/\*" in p[0] for p in patterns)
def test_get_language_patterns_bash(app):
"""Test getting highlighting patterns for Bash."""
patterns = CodeHighlighter.get_language_patterns("bash")
assert len(patterns) > 0
# Should have # comment pattern
assert any("#" in p[0] for p in patterns)
# Should have bash keywords
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
assert len(keyword_patterns) > 0
def test_get_language_patterns_html(app):
"""Test getting highlighting patterns for HTML."""
patterns = CodeHighlighter.get_language_patterns("html")
assert len(patterns) > 0
# Should have tag pattern
assert any("tag" == p[1] for p in patterns)
# Should have HTML comment pattern
assert any("<!--" in p[0] for p in patterns)
def test_get_language_patterns_css(app):
"""Test getting highlighting patterns for CSS."""
patterns = CodeHighlighter.get_language_patterns("css")
assert len(patterns) > 0
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have CSS properties as keywords
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
assert len(keyword_patterns) > 0
def test_get_language_patterns_unknown_language(app):
"""Test getting patterns for an unknown language."""
patterns = CodeHighlighter.get_language_patterns("unknown-lang")
# Should still return basic patterns (strings, numbers)
assert len(patterns) > 0
assert any("string" == p[1] for p in patterns)
assert any("number" == p[1] for p in patterns)
def test_get_language_patterns_case_insensitive(app):
"""Test that language matching is case insensitive."""
patterns_lower = CodeHighlighter.get_language_patterns("python")
patterns_upper = CodeHighlighter.get_language_patterns("PYTHON")
patterns_mixed = CodeHighlighter.get_language_patterns("PyThOn")
assert len(patterns_lower) == len(patterns_upper)
assert len(patterns_lower) == len(patterns_mixed)
def test_get_format_for_type_keyword(app):
"""Test getting format for keyword type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
assert fmt.fontWeight() == QFont.Weight.Bold
assert fmt.foreground().color().blue() > 0 # Should have blue-ish color
def test_get_format_for_type_string(app):
"""Test getting format for string type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("string", base_format)
# Should have orangish color
color = fmt.foreground().color()
assert color.red() > 100
def test_get_format_for_type_comment(app):
"""Test getting format for comment type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("comment", base_format)
assert fmt.fontItalic() is True
# Should have greenish color
color = fmt.foreground().color()
assert color.green() > 0
def test_get_format_for_type_number(app):
"""Test getting format for number type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("number", base_format)
# Should have some color
color = fmt.foreground().color()
assert color.isValid()
def test_get_format_for_type_tag(app):
"""Test getting format for HTML tag type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("tag", base_format)
# Should have cyan-ish color
color = fmt.foreground().color()
assert color.green() > 0
assert color.blue() > 0
def test_get_format_for_type_unknown(app):
"""Test getting format for unknown type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("unknown", base_format)
# Should return a valid format (based on base_format)
assert fmt is not None
def test_code_block_metadata_init(app):
"""Test CodeBlockMetadata initialization."""
metadata = CodeBlockMetadata()
assert len(metadata._block_languages) == 0
def test_code_block_metadata_set_get_language(app):
"""Test setting and getting language for a block."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
metadata.set_language(5, "javascript")
assert metadata.get_language(0) == "python"
assert metadata.get_language(5) == "javascript"
assert metadata.get_language(10) is None
def test_code_block_metadata_set_language_case_normalization(app):
"""Test that language is normalized to lowercase."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "PYTHON")
metadata.set_language(1, "JavaScript")
assert metadata.get_language(0) == "python"
assert metadata.get_language(1) == "javascript"
def test_code_block_metadata_serialize_empty(app):
"""Test serializing empty metadata."""
metadata = CodeBlockMetadata()
result = metadata.serialize()
assert result == ""
def test_code_block_metadata_serialize(app):
"""Test serializing metadata."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
metadata.set_language(3, "javascript")
result = metadata.serialize()
assert "<!-- code-langs:" in result
assert "0:python" in result
assert "3:javascript" in result
assert "-->" in result
def test_code_block_metadata_serialize_sorted(app):
"""Test that serialized metadata is sorted by block number."""
metadata = CodeBlockMetadata()
metadata.set_language(5, "python")
metadata.set_language(2, "javascript")
metadata.set_language(8, "bash")
result = metadata.serialize()
# Find positions in string
pos_2 = result.find("2:")
pos_5 = result.find("5:")
pos_8 = result.find("8:")
# Should be in order
assert pos_2 < pos_5 < pos_8
def test_code_block_metadata_deserialize(app):
"""Test deserializing metadata."""
metadata = CodeBlockMetadata()
text = (
"Some content\n<!-- code-langs: 0:python,3:javascript,5:bash -->\nMore content"
)
metadata.deserialize(text)
assert metadata.get_language(0) == "python"
assert metadata.get_language(3) == "javascript"
assert metadata.get_language(5) == "bash"
def test_code_block_metadata_deserialize_empty(app):
"""Test deserializing from text without metadata."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python") # Set some initial data
text = "Just some regular text with no metadata"
metadata.deserialize(text)
# Should clear existing data
assert len(metadata._block_languages) == 0
def test_code_block_metadata_deserialize_invalid_format(app):
"""Test deserializing with invalid format."""
metadata = CodeBlockMetadata()
text = "<!-- code-langs: invalid,format,here -->"
metadata.deserialize(text)
# Should handle gracefully, resulting in empty or minimal data
# Pairs without ':' should be skipped
assert len(metadata._block_languages) == 0
def test_code_block_metadata_deserialize_invalid_block_number(app):
"""Test deserializing with invalid block number."""
metadata = CodeBlockMetadata()
text = "<!-- code-langs: abc:python,3:javascript -->"
metadata.deserialize(text)
# Should skip invalid block number 'abc'
assert metadata.get_language(3) == "javascript"
assert "abc" not in str(metadata._block_languages)
def test_code_block_metadata_round_trip(app):
"""Test serializing and deserializing preserves data."""
metadata1 = CodeBlockMetadata()
metadata1.set_language(0, "python")
metadata1.set_language(2, "javascript")
metadata1.set_language(7, "bash")
serialized = metadata1.serialize()
metadata2 = CodeBlockMetadata()
metadata2.deserialize(serialized)
assert metadata2.get_language(0) == "python"
assert metadata2.get_language(2) == "javascript"
assert metadata2.get_language(7) == "bash"
def test_python_keywords_present(app):
"""Test that Python keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("python", [])
assert "def" in keywords
assert "class" in keywords
assert "if" in keywords
assert "for" in keywords
assert "import" in keywords
def test_javascript_keywords_present(app):
"""Test that JavaScript keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("javascript", [])
assert "function" in keywords
assert "const" in keywords
assert "let" in keywords
assert "var" in keywords
assert "class" in keywords
def test_php_keywords_present(app):
"""Test that PHP keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("php", [])
assert "function" in keywords
assert "class" in keywords
assert "echo" in keywords
assert "require" in keywords
def test_bash_keywords_present(app):
"""Test that Bash keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("bash", [])
assert "if" in keywords
assert "then" in keywords
assert "fi" in keywords
assert "for" in keywords
def test_html_keywords_present(app):
"""Test that HTML keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("html", [])
assert "div" in keywords
assert "span" in keywords
assert "body" in keywords
assert "html" in keywords
def test_css_keywords_present(app):
"""Test that CSS keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("css", [])
assert "color" in keywords
assert "background" in keywords
assert "margin" in keywords
assert "padding" in keywords
def test_all_patterns_have_string_and_number(app):
"""Test that all languages have string and number patterns."""
languages = ["python", "javascript", "php", "bash", "html", "css"]
for lang in languages:
patterns = CodeHighlighter.get_language_patterns(lang)
pattern_types = [p[1] for p in patterns]
assert "string" in pattern_types, f"{lang} should have string pattern"
assert "number" in pattern_types, f"{lang} should have number pattern"
def test_patterns_have_regex_format(app):
"""Test that patterns are in regex format."""
patterns = CodeHighlighter.get_language_patterns("python")
for pattern, pattern_type in patterns:
# Each pattern should be a string (regex pattern)
assert isinstance(pattern, str)
# Each type should be a string
assert isinstance(pattern_type, str)
def test_code_block_metadata_update_language(app):
"""Test updating language for existing block."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
assert metadata.get_language(0) == "python"
metadata.set_language(0, "javascript")
assert metadata.get_language(0) == "javascript"
def test_get_format_preserves_base_format_properties(app):
"""Test that get_format_for_type preserves base format properties."""
base_format = QTextCharFormat()
base_format.setFontPointSize(14)
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
# Should be based on the base_format
assert isinstance(fmt, QTextCharFormat)

621
tests/test_db.py Normal file
View file

@ -0,0 +1,621 @@
import csv
import datetime as dt
import json
from datetime import date, timedelta
import pytest
from bouquin.db import DBManager
from sqlcipher3 import dbapi2 as sqlite
def _today():
return dt.date.today().isoformat()
def _yesterday():
return (dt.date.today() - dt.timedelta(days=1)).isoformat()
def _tomorrow():
return (dt.date.today() + dt.timedelta(days=1)).isoformat()
def _days_ago(n):
return (date.today() - timedelta(days=n)).isoformat()
def _entry(text, i=0):
return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo"
def test_connect_integrity_and_schema(fresh_db):
d = _today()
fresh_db.save_new_version(d, _entry("hello world"), "initial")
vlist = fresh_db.list_versions(d)
assert vlist
v = fresh_db.get_version(version_id=vlist[0]["id"])
assert v and "created_at" in v
def test_save_and_get_entry_versions(fresh_db):
d = _today()
fresh_db.save_new_version(d, _entry("hello world"), "initial")
txt = fresh_db.get_entry(d)
assert "hello world" in txt
fresh_db.save_new_version(d, _entry("hello again"), "second")
versions = fresh_db.list_versions(d)
assert len(versions) >= 2
assert any(v["is_current"] for v in versions)
first = sorted(versions, key=lambda v: v["version_no"])[0]
fresh_db.revert_to_version(d, version_id=first["id"])
txt2 = fresh_db.get_entry(d)
assert "hello world" in txt2 and "again" not in txt2
def test_dates_with_content_and_search(fresh_db):
fresh_db.save_new_version(_today(), _entry("alpha bravo"), "t1")
fresh_db.save_new_version(_yesterday(), _entry("bravo charlie"), "t2")
fresh_db.save_new_version(_tomorrow(), _entry("delta alpha"), "t3")
dates = set(fresh_db.dates_with_content())
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
hits = list(fresh_db.search_entries("alpha"))
# 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):
for i in range(3):
d = (dt.date.today() - dt.timedelta(days=i)).isoformat()
fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}")
entries = fresh_db.get_all_entries()
assert entries and all(len(t) == 2 for t in entries)
json_path = tmp_path / "export.json"
fresh_db.export_json(entries, str(json_path))
assert json_path.exists() and json.load(open(json_path)) is not None
csv_path = tmp_path / "export.csv"
fresh_db.export_csv(entries, str(csv_path))
assert csv_path.exists() and list(csv.reader(open(csv_path)))
md_path = tmp_path / "export.md"
fresh_db.export_markdown(entries, str(md_path))
md_text = md_path.read_text()
assert md_path.exists() and entries[0][0] in md_text
html_path = tmp_path / "export.html"
fresh_db.export_html(entries, str(html_path), title="My Notebook")
assert html_path.exists() and "<html" in html_path.read_text().lower()
sql_path = tmp_path / "export.sql"
fresh_db.export_sql(str(sql_path))
assert sql_path.exists() and sql_path.read_bytes()
sqlc_path = tmp_path / "export.db"
fresh_db.export_sqlcipher(str(sqlc_path))
assert sqlc_path.exists() and sqlc_path.read_bytes()
def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
fresh_db.save_new_version(_today(), _entry("secure"), "before rekey")
fresh_db.rekey("new-key-123")
fresh_db.close()
tmp_db_cfg.key = "new-key-123"
db2 = DBManager(tmp_db_cfg)
assert db2.connect()
assert "secure" in db2.get_entry(_today())
db2.close()
def test_compact_and_close_dont_crash(fresh_db):
fresh_db.compact()
fresh_db.close()
def test_connect_integrity_failure(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
# simulate cursor() ok, but integrity check raising
called = {"ok": False}
def bad_integrity(self):
called["ok"] = True
raise sqlite.Error("bad cipher")
monkeypatch.setattr(DBManager, "_integrity_ok", bad_integrity, raising=True)
ok = db.connect()
assert not ok and called["ok"]
assert db.conn is None
def test_rekey_reopen_failure(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Monkeypatch connect() on the instance so the reconnect attempt fails
def fail_connect():
return False
monkeypatch.setattr(db, "connect", fail_connect, raising=False)
with pytest.raises(sqlite.Error):
db.rekey("newkey")
def test_revert_wrong_date_raises(fresh_db):
d1, d2 = "2024-01-01", "2024-01-02"
v1_id, _ = fresh_db.save_new_version(d1, "one", "seed")
fresh_db.save_new_version(d2, "two", "seed")
with pytest.raises(ValueError):
fresh_db.revert_to_version(d2, version_id=v1_id)
def test_compact_error_path(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Replace cursor.execute to raise to hit except branch
class BadCur:
def execute(self, *a, **k):
raise RuntimeError("boom")
class BadConn:
def cursor(self):
return BadCur()
db.conn = BadConn()
# Should not raise; just print error
db.compact()
class _Cur:
def __init__(self, rows):
self._rows = rows
def execute(self, *a, **k):
return self
def fetchall(self):
return list(self._rows)
class _Conn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _Cur(self._rows)
def test_integrity_check_raises_with_details(tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Force the integrity check to report problems with text details
db.conn = _Conn([("bad page checksum",), (None,)])
with pytest.raises(sqlite.IntegrityError) as ei:
db._integrity_ok()
# Message should contain the detail string
assert "bad page checksum" in str(ei.value)
def test_integrity_check_raises_without_details(tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Force the integrity check to report problems but without textual details
db.conn = _Conn([(None,), (None,)])
with pytest.raises(sqlite.IntegrityError):
db._integrity_ok()
# ============================================================================
# DB _strip_markdown and _count_words Tests
# ============================================================================
def test_db_strip_markdown_empty_text(fresh_db):
"""Test strip_markdown with empty text."""
result = fresh_db._strip_markdown("")
assert result == ""
def test_db_strip_markdown_none_text(fresh_db):
"""Test strip_markdown with None."""
result = fresh_db._strip_markdown(None)
assert result == ""
def test_db_strip_markdown_fenced_code_blocks(fresh_db):
"""Test stripping fenced code blocks."""
text = """
Some text here
```python
def hello():
print("world")
```
More text after
"""
result = fresh_db._strip_markdown(text)
assert "def hello" not in result
assert "Some text" in result
assert "More text" in result
def test_db_strip_markdown_inline_code(fresh_db):
"""Test stripping inline code."""
text = "Here is some `inline code` in text"
result = fresh_db._strip_markdown(text)
assert "`" not in result
assert "inline code" not in result
assert "Here is some" in result
assert "in text" in result
def test_db_strip_markdown_links(fresh_db):
"""Test converting markdown links to plain text."""
text = "Check out [this link](https://example.com) for more info"
result = fresh_db._strip_markdown(text)
assert "this link" in result
assert "https://example.com" not in result
assert "[" not in result
assert "]" not in result
def test_db_strip_markdown_emphasis_and_headers(fresh_db):
"""Test stripping emphasis markers and headers."""
text = """
# Header 1
## Header 2
**bold text** and *italic text*
> blockquote
_underline_
"""
result = fresh_db._strip_markdown(text)
assert "#" not in result
assert "*" not in result
assert "_" not in result
assert ">" not in result
assert "bold text" in result
assert "italic text" in result
def test_db_strip_markdown_html_tags(fresh_db):
"""Test stripping HTML tags."""
text = "Some <b>bold</b> and <i>italic</i> text with <div>divs</div>"
result = fresh_db._strip_markdown(text)
# The regex replaces tags with spaces, may leave some angle brackets from malformed HTML
# The important thing is that the words are preserved
assert "bold" in result
assert "italic" in result
assert "divs" in result
def test_db_strip_markdown_complex_document(fresh_db):
"""Test stripping complex markdown document."""
text = """
# My Document
This is a paragraph with **bold** and *italic* text.
```javascript
const x = 10;
console.log(x);
```
Here's a [link](https://example.com) and some `code`.
> A blockquote
<p>HTML paragraph</p>
"""
result = fresh_db._strip_markdown(text)
assert "My Document" in result
assert "paragraph" in result
assert "const x" not in result
assert "https://example.com" not in result
assert "<p>" not in result
def test_db_count_words_simple(fresh_db):
"""Test word counting on simple text."""
text = "This is a simple test with seven words"
count = fresh_db._count_words(text)
assert count == 8
def test_db_count_words_empty(fresh_db):
"""Test word counting on empty text."""
count = fresh_db._count_words("")
assert count == 0
def test_db_count_words_with_markdown(fresh_db):
"""Test word counting strips markdown first."""
text = "**Bold** and *italic* and `code` words"
count = fresh_db._count_words(text)
# Should count: Bold, and, italic, and, words (5 words, code is in backticks so stripped)
assert count == 5
def test_db_count_words_with_unicode(fresh_db):
"""Test word counting with unicode characters."""
text = "Hello 世界 café naïve résumé"
count = fresh_db._count_words(text)
# Should count all words including unicode
assert count >= 5
def test_db_count_words_with_numbers(fresh_db):
"""Test word counting includes numbers."""
text = "There are 123 apples and 456 oranges"
count = fresh_db._count_words(text)
assert count == 7
def test_db_count_words_with_punctuation(fresh_db):
"""Test word counting handles punctuation correctly."""
text = "Hello, world! How are you? I'm fine, thanks."
count = fresh_db._count_words(text)
# Hello, world, How, are, you, I, m, fine, thanks = 9 words
assert count == 9
# ============================================================================
# DB gather_stats Tests
# ============================================================================
def test_db_gather_stats_empty_database(fresh_db):
"""Test gather_stats on empty database."""
stats = fresh_db.gather_stats()
assert len(stats) == 22
(
pages_with_content,
total_revisions,
page_most_revisions,
page_most_revisions_count,
words_by_date,
total_words,
unique_tags,
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
assert total_revisions == 0
assert page_most_revisions is None
assert page_most_revisions_count == 0
assert len(words_by_date) == 0
assert total_words == 0
assert unique_tags == 0
assert page_most_tags is None
assert page_most_tags_count == 0
assert len(revisions_by_date) == 0
def test_db_gather_stats_with_content(fresh_db):
"""Test gather_stats with actual content."""
# Add multiple pages with different content
fresh_db.save_new_version("2024-01-01", "Hello world this is a test", "v1")
fresh_db.save_new_version(
"2024-01-01", "Hello world this is version two", "v2"
) # 2nd revision
fresh_db.save_new_version("2024-01-02", "Another page with more words here", "v1")
stats = fresh_db.gather_stats()
(
pages_with_content,
total_revisions,
page_most_revisions,
page_most_revisions_count,
words_by_date,
total_words,
unique_tags,
page_most_tags,
page_most_tags_count,
revisions_by_date,
*_rest,
) = stats
assert pages_with_content == 2
assert total_revisions == 3
assert page_most_revisions == "2024-01-01"
assert page_most_revisions_count == 2
assert total_words > 0
assert len(words_by_date) == 2
def test_db_gather_stats_word_counting(fresh_db):
"""Test that gather_stats counts words correctly."""
# Add page with known word count
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
assert total_words == 5
test_date = date(2024, 1, 1)
assert test_date in words_by_date
assert words_by_date[test_date] == 5
def test_db_gather_stats_with_tags(fresh_db):
"""Test gather_stats with tags."""
# Add tags
fresh_db.add_tag("tag1", "#ff0000")
fresh_db.add_tag("tag2", "#00ff00")
fresh_db.add_tag("tag3", "#0000ff")
# Add pages with tags
fresh_db.save_new_version("2024-01-01", "Page 1", "test")
fresh_db.save_new_version("2024-01-02", "Page 2", "test")
fresh_db.set_tags_for_page(
"2024-01-01", ["tag1", "tag2", "tag3"]
) # Page 1 has 3 tags
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, *_rest = stats
assert unique_tags == 3
assert page_most_tags == "2024-01-01"
assert page_most_tags_count == 3
def test_db_gather_stats_revisions_by_date(fresh_db):
"""Test revisions_by_date tracking."""
# Add multiple revisions on different dates
fresh_db.save_new_version("2024-01-01", "First", "v1")
fresh_db.save_new_version("2024-01-01", "Second", "v2")
fresh_db.save_new_version("2024-01-01", "Third", "v3")
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
assert date(2024, 1, 1) in revisions_by_date
assert revisions_by_date[date(2024, 1, 1)] == 3
assert date(2024, 1, 2) in revisions_by_date
assert revisions_by_date[date(2024, 1, 2)] == 1
def test_db_gather_stats_handles_malformed_dates(fresh_db):
"""Test that gather_stats handles malformed dates gracefully."""
# This is hard to test directly since the DB enforces date format
# But we can test that normal dates work
fresh_db.save_new_version("2024-01-15", "Test", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
# Should have parsed the date correctly
assert date(2024, 1, 15) in revisions_by_date
def test_db_gather_stats_current_version_only(fresh_db):
"""Test that word counts use current version only, not all revisions."""
# Add multiple revisions
fresh_db.save_new_version("2024-01-01", "one two three", "v1")
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
# Should count words from current version (5 words), not old version
assert total_words == 5
assert words_by_date[date(2024, 1, 1)] == 5
def test_db_gather_stats_no_tags(fresh_db):
"""Test gather_stats when there are no tags."""
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, *_rest = stats
assert unique_tags == 0
assert page_most_tags is None
assert page_most_tags_count == 0
def test_db_gather_stats_exception_in_dates_with_content(fresh_db, monkeypatch):
"""Test that gather_stats handles exception in dates_with_content."""
def bad_dates():
raise RuntimeError("Simulated error")
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates)
# Should still return stats without crashing
stats = fresh_db.gather_stats()
pages_with_content = stats[0]
# Should default to 0 when exception occurs
assert pages_with_content == 0
def test_delete_version(fresh_db):
"""Test deleting a specific version by version_id."""
d = date.today().isoformat()
# Create multiple versions
vid1, _ = fresh_db.save_new_version(d, "version 1", "note1")
vid2, _ = fresh_db.save_new_version(d, "version 2", "note2")
vid3, _ = fresh_db.save_new_version(d, "version 3", "note3")
# Verify all versions exist
versions = fresh_db.list_versions(d)
assert len(versions) == 3
# Delete the second version
fresh_db.delete_version(version_id=vid2)
# Verify it's deleted
versions_after = fresh_db.list_versions(d)
assert len(versions_after) == 2
# Make sure the deleted version is not in the list
version_ids = [v["id"] for v in versions_after]
assert vid2 not in version_ids
assert vid1 in version_ids
assert vid3 in version_ids
def test_update_reminder_active(fresh_db):
"""Test updating the active status of a reminder."""
from bouquin.reminders import Reminder, ReminderType
# Create a reminder object
reminder = Reminder(
id=None,
text="Test reminder",
reminder_type=ReminderType.ONCE,
time_str="14:30",
date_iso=date.today().isoformat(),
active=True,
)
# Save it
reminder_id = fresh_db.save_reminder(reminder)
# Verify it's active
reminders = fresh_db.get_all_reminders()
active_reminder = [r for r in reminders if r.id == reminder_id][0]
assert active_reminder.active is True
# Deactivate it
fresh_db.update_reminder_active(reminder_id, False)
# Verify it's inactive
reminders = fresh_db.get_all_reminders()
inactive_reminder = [r for r in reminders if r.id == reminder_id][0]
assert inactive_reminder.active is False
# Reactivate it
fresh_db.update_reminder_active(reminder_id, True)
# Verify it's active again
reminders = fresh_db.get_all_reminders()
reactivated_reminder = [r for r in reminders if r.id == reminder_id][0]
assert reactivated_reminder.active is True

View file

@ -1,137 +0,0 @@
import bouquin.db as dbmod
from bouquin.db import DBConfig, DBManager
class FakeCursor:
def __init__(self, rows=None):
self._rows = rows or []
self.executed = []
def execute(self, sql, params=None):
self.executed.append((sql, tuple(params) if params else None))
return self
def fetchall(self):
return list(self._rows)
def fetchone(self):
return self._rows[0] if self._rows else None
class FakeConn:
def __init__(self, rows=None):
self._rows = rows or []
self.closed = False
self.cursors = []
self.row_factory = None
def cursor(self):
c = FakeCursor(rows=self._rows)
self.cursors.append(c)
return c
def close(self):
self.closed = True
def commit(self):
pass
def __enter__(self):
return self
def __exit__(self, *a):
pass
def test_integrity_ok_ok(monkeypatch, tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
mgr.conn = FakeConn(rows=[])
assert mgr._integrity_ok() is None
def test_integrity_ok_raises(monkeypatch, tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
mgr.conn = FakeConn(rows=[("oops",), (None,)])
try:
mgr._integrity_ok()
except Exception as e:
assert isinstance(e, dbmod.sqlite.IntegrityError)
def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path):
# Use a non-empty key to avoid SQLCipher complaining before our patch runs
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
# Make the integrity check raise so connect() takes the failure path
monkeypatch.setattr(
DBManager,
"_integrity_ok",
lambda self: (_ for _ in ()).throw(RuntimeError("bad")),
)
ok = mgr.connect()
assert ok is False
assert mgr.conn is None
def test_rekey_not_connected_raises(tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
mgr.conn = None
import pytest
with pytest.raises(RuntimeError):
mgr.rekey("new")
def test_rekey_reopen_failure(monkeypatch, tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
mgr.conn = FakeConn(rows=[(None,)])
monkeypatch.setattr(DBManager, "connect", lambda self: False)
import pytest
with pytest.raises(Exception):
mgr.rekey("new")
def test_export_by_extension_and_unknown(tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
entries = [("2025-01-01", "<b>Hi</b>")]
# Test each exporter writes the file
p = tmp_path / "out.json"
mgr.export_json(entries, str(p))
assert p.exists() and p.stat().st_size > 0
p = tmp_path / "out.csv"
mgr.export_csv(entries, str(p))
assert p.exists()
p = tmp_path / "out.txt"
mgr.export_txt(entries, str(p))
assert p.exists()
p = tmp_path / "out.html"
mgr.export_html(entries, str(p))
assert p.exists()
p = tmp_path / "out.md"
mgr.export_markdown(entries, str(p))
assert p.exists()
# Router
import types
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
for ext in [".json", ".csv", ".txt", ".html"]:
path = tmp_path / f"route{ext}"
mgr.export_by_extension(str(path))
assert path.exists()
import pytest
with pytest.raises(ValueError):
mgr.export_by_extension(str(tmp_path / "x.zzz"))
def test_compact_error_prints(monkeypatch, tmp_path, capsys):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
class BadConn:
def cursor(self):
raise RuntimeError("no")
mgr.conn = BadConn()
mgr.compact()
out = capsys.readouterr().out
assert "Error:" in out

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,55 +0,0 @@
from PySide6.QtCore import QUrl, QObject, Slot
from PySide6.QtGui import QDesktopServices
from PySide6.QtTest import QTest
from tests.qt_helpers import trigger_menu_action
def test_launch_write_save_and_navigate(open_window, qtbot, today_iso):
win = open_window
win.editor.setPlainText("Hello Bouquin")
qtbot.waitUntil(lambda: win.editor.toPlainText() == "Hello Bouquin", timeout=15000)
trigger_menu_action(win, "Save a version") # AutoResponder clicks OK
versions = win.db.list_versions(today_iso)
assert versions and versions[0]["is_current"] == 1
selected = win.calendar.selectedDate()
trigger_menu_action(win, "Next Day")
qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected.addDays(1))
trigger_menu_action(win, "Previous Day")
qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected)
win.calendar.setSelectedDate(selected.addDays(3))
trigger_menu_action(win, "Today")
qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected)
def test_help_menu_opens_urls(open_window, qtbot):
opened: list[str] = []
class UrlCatcher(QObject):
@Slot(QUrl)
def handle(self, url: QUrl):
opened.append(url.toString())
catcher = UrlCatcher()
# Qt6/PySide6: setUrlHandler(scheme, receiver, methodName)
QDesktopServices.setUrlHandler("https", catcher, "handle")
QDesktopServices.setUrlHandler("http", catcher, "handle")
try:
win = open_window
trigger_menu_action(win, "Documentation")
trigger_menu_action(win, "Report a bug")
QTest.qWait(150)
assert len(opened) >= 2
finally:
QDesktopServices.unsetUrlHandler("https")
QDesktopServices.unsetUrlHandler("http")
def test_idle_lock_and_unlock(open_window, qtbot):
win = open_window
win._enter_lock()
assert getattr(win, "_locked", False) is True
win._on_unlock_clicked() # AutoResponder types 'ci-secret-key'
qtbot.waitUntil(lambda: getattr(win, "_locked", True) is False, timeout=15000)

View file

@ -1,344 +0,0 @@
from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl
from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from bouquin.editor import Editor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
import re
def _mk_editor() -> Editor:
# pytest-qt ensures a QApplication exists
app = QApplication.instance()
tm = ThemeManager(app, ThemeConfig())
return Editor(tm)
def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
while True:
c2 = QTextCursor(c)
c2.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
if c2.position() == c.position():
break
fmt = c2.charFormat()
if fmt.isImageFormat():
editor.setTextCursor(c2)
return QTextImageFormat(fmt)
c.movePosition(QTextCursor.Right)
return None
def _fmt_at(editor: Editor, pos: int):
c = editor.textCursor()
c.setPosition(pos)
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
return c.charFormat()
def test_space_breaks_link_anchor_and_styling(qtbot):
e = _mk_editor()
e.resize(600, 300)
e.show()
qtbot.waitExposed(e)
# Type a URL, which should be linkified (anchor + underline + blue)
url = "https://mig5.net"
QTest.keyClicks(e, url)
qtbot.waitUntil(lambda: e.toPlainText() == url)
# Sanity: characters within the URL are anchors
for i in range(len(url)):
assert _fmt_at(e, i).isAnchor()
# Hit Space Editor.keyPressEvent() should call _break_anchor_for_next_char()
QTest.keyClick(e, Qt.Key_Space)
# Type some normal text; it must not inherit the link formatting
tail = "this is a test"
QTest.keyClicks(e, tail)
qtbot.waitUntil(lambda: e.toPlainText().endswith(tail))
txt = e.toPlainText()
# Find where our 'tail' starts
start = txt.index(tail)
end = start + len(tail)
# None of the trailing characters should be part of an anchor or visually underlined
for i in range(start, end):
fmt = _fmt_at(e, i)
assert not fmt.isAnchor(), f"char {i} unexpectedly still has an anchor"
assert not fmt.fontUnderline(), f"char {i} unexpectedly still underlined"
# Optional: ensure the HTML only wraps the URL in <a>, not the trailing text
html = e.document().toHtml()
assert re.search(
r'<a [^>]*href="https?://mig5\.net"[^>]*>(?:<span[^>]*>)?https?://mig5\.net(?:</span>)?</a>\s+this is a test',
html,
re.S,
), html
assert "this is a test</a>" not in html
def test_embed_qimage_saved_as_data_url(qtbot):
e = _mk_editor()
e.resize(600, 400)
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
img = QImage(60, 40, QImage.Format_ARGB32)
img.fill(0xFF336699)
e._insert_qimage_at_cursor(img, autoscale=False)
html = e.to_html_with_embedded_images()
assert "data:image/png;base64," in html
def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
# Create a very wide image so autoscale triggers
big = QImage(2000, 800, QImage.Format_ARGB32)
big.fill(0xFF00FF00)
big_path = tmp_path / "big.png"
big.save(str(big_path))
e = _mk_editor()
e.resize(420, 300) # known viewport width
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.insert_images([str(big_path)], autoscale=True)
# Cursor lands after the image + a blank block; helper will select the image char
fmt = _move_cursor_to_first_image(e)
assert fmt is not None
# After autoscale, width should be <= ~92% of viewport
max_w = int(e.viewport().width() * 0.92)
assert fmt.width() <= max_w + 1 # allow off-by-1 from rounding
# Now exercise "fit to editor width"
e._fit_image_to_editor_width()
_tc, fmt2, _orig = e._image_info_at_cursor()
assert fmt2 is not None
assert abs(fmt2.width() - max_w) <= 1
def test_linkify_trims_trailing_punctuation(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.setPlainText("See (https://example.com).")
# Wait until linkification runs (connected to textChanged)
qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
html = e.document().toHtml()
# Anchor should *not* include the closing ')'
assert 'href="https://example.com"' in html
assert 'href="https://example.com)."' not in html
def test_code_block_enter_exits_on_empty_line(qtbot):
from PySide6.QtCore import Qt
from PySide6.QtGui import QTextCursor
from PySide6.QtTest import QTest
from bouquin.editor import Editor
e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.setPlainText("code")
c = e.textCursor()
c.select(QTextCursor.BlockUnderCursor)
e.setTextCursor(c)
e.apply_code()
# Put caret at end of the code block, then Enter to create an empty line *inside* the frame
c = e.textCursor()
c.movePosition(QTextCursor.EndOfBlock)
e.setTextCursor(c)
QTest.keyClick(e, Qt.Key_Return)
# Ensure we are on an empty block *inside* the code frame
qtbot.waitUntil(
lambda: e._find_code_frame(e.textCursor()) is not None
and e.textCursor().block().length() == 1
)
# Second Enter should jump *out* of the frame
QTest.keyClick(e, Qt.Key_Return)
# qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None)
class DummyMenu:
def __init__(self):
self.seps = 0
self.subs = []
self.exec_called = False
def addSeparator(self):
self.seps += 1
def addMenu(self, title):
m = DummyMenu()
self.subs.append((title, m))
return m
def addAction(self, *a, **k):
pass
def exec(self, *a, **k):
self.exec_called = True
def _themes():
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def test_context_menu_adds_image_actions(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Fake an image at cursor
qi = QImage(10, 10, QImage.Format_ARGB32)
qi.fill(0xFF00FF00)
imgfmt = QTextImageFormat()
imgfmt.setName("x")
imgfmt.setWidth(10)
imgfmt.setHeight(10)
tc = e.textCursor()
monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi))
dummy = DummyMenu()
monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy)
class Evt:
def globalPos(self):
return QPoint(0, 0)
e.contextMenuEvent(Evt())
assert dummy.exec_called
assert dummy.seps == 1
assert any(t == "Image size" for t, _ in dummy.subs)
def test_insert_from_mime_image_and_urls(tmp_path, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Build a mime with an image
mime = QMimeData()
img = QImage(6, 6, QImage.Format_ARGB32)
img.fill(0xFF0000FF)
mime.setImageData(img)
e.insertFromMimeData(mime)
html = e.document().toHtml()
assert "<img" in html
# Now with urls: local non-image + local image + remote url
png = tmp_path / "t.png"
img.save(str(png))
txt = tmp_path / "x.txt"
txt.write_text("hi", encoding="utf-8")
mime2 = QMimeData()
mime2.setUrls(
[
QUrl.fromLocalFile(str(txt)),
QUrl.fromLocalFile(str(png)),
QUrl("https://example.com/file"),
]
)
e.insertFromMimeData(mime2)
h2 = e.document().toHtml()
assert 'href="file://' in h2 # local file link inserted
assert "<img" in h2 # image inserted
assert 'href="https://example.com/file"' in h2 # remote url link
def test_mouse_release_ctrl_click_opens(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Anchor under cursor
monkeypatch.setattr(e, "anchorAt", lambda p: "https://example.com")
opened = {}
from PySide6.QtGui import QDesktopServices as DS
monkeypatch.setattr(
DS, "openUrl", lambda url: opened.setdefault("u", url.toString())
)
ev = QMouseEvent(
QMouseEvent.MouseButtonRelease,
QPoint(1, 1),
Qt.LeftButton,
Qt.LeftButton,
Qt.ControlModifier,
)
e.mouseReleaseEvent(ev)
assert opened.get("u") == "https://example.com"
def test_keypress_space_breaks_anchor(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
called = {}
monkeypatch.setattr(
e, "_break_anchor_for_next_char", lambda: called.setdefault("x", True)
)
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Space, Qt.NoModifier, " ")
e.keyPressEvent(ev)
assert called.get("x") is True
def test_enter_leaves_code_frame(qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
e.setPlainText("")
# Insert a code block frame
e.apply_code()
# Place cursor inside the empty code block
c = e.textCursor()
c.movePosition(QTextCursor.End)
e.setTextCursor(c)
# Press Enter; should jump outside the frame and start normal paragraph
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
e.keyPressEvent(ev)
# After enter, the cursor should not be inside a code frame
assert e._find_code_frame(e.textCursor()) is None
def test_space_does_not_bleed_anchor_format(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.setPlainText("https://a.example")
qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
c = e.textCursor()
c.movePosition(QTextCursor.End)
e.setTextCursor(c)
# Press Space; keyPressEvent should break the anchor for the next char
QTest.keyClick(e, Qt.Key_Space)
assert e.currentCharFormat().isAnchor() is False
def test_editor_small_helpers(qtbot):
app = QApplication.instance()
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
e = Editor(themes)
qtbot.addWidget(e)
# _approx returns True when |a-b| <= eps
assert e._approx(1.0, 1.25, eps=0.3) is True
assert e._approx(1.0, 1.6, eps=0.3) is False
# Exercise helpers
_ = e._is_heading_typing()
e._apply_normal_typing()

View file

@ -1,69 +0,0 @@
import importlib
def test___main___exports_main():
entry_mod = importlib.import_module("bouquin.__main__")
main_mod = importlib.import_module("bouquin.main")
assert entry_mod.main is main_mod.main
def test_main_entry_initializes_qt(monkeypatch):
main_mod = importlib.import_module("bouquin.main")
# Fakes to avoid real Qt event loop
class FakeApp:
def __init__(self, argv):
self.argv = argv
self.name = None
self.org = None
def setApplicationName(self, n):
self.name = n
def setOrganizationName(self, n):
self.org = n
def exec(self):
return 0
class FakeWin:
def __init__(self, themes=None):
self.themes = themes
self.shown = False
def show(self):
self.shown = True
class FakeThemes:
def __init__(self, app, cfg):
self._applied = None
self.app = app
self.cfg = cfg
def apply(self, t):
self._applied = t
class FakeSettings:
def __init__(self):
self._map = {"ui/theme": "dark"}
def value(self, k, default=None, type=None):
return self._map.get(k, default)
def fake_get_settings():
return FakeSettings()
monkeypatch.setattr(main_mod, "QApplication", FakeApp)
monkeypatch.setattr(main_mod, "MainWindow", FakeWin)
monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes)
monkeypatch.setattr(main_mod, "get_settings", fake_get_settings)
exits = {}
def fake_exit(code):
exits["code"] = code
monkeypatch.setattr(main_mod.sys, "exit", fake_exit)
main_mod.main()
assert exits.get("code", None) == 0

View file

@ -1,112 +0,0 @@
import csv, json, sqlite3
import pytest
from tests.qt_helpers import trigger_menu_action, accept_all_message_boxes
# Export filters used by the app (format is chosen by this name filter, not by extension)
EXPORT_FILTERS = {
".txt": "Text (*.txt)",
".json": "JSON (*.json)",
".csv": "CSV (*.csv)",
".html": "HTML (*.html)",
".sql": "SQL (*.sql)", # app writes a SQLite DB here
}
BACKUP_FILTER = "SQLCipher (*.db)"
def _write_sample_entries(win, qtbot):
win.editor.setPlainText("alpha <b>bold</b>")
win._save_current(explicit=True)
d = win.calendar.selectedDate().addDays(1)
win.calendar.setSelectedDate(d)
win.editor.setPlainText("beta text")
win._save_current(explicit=True)
@pytest.mark.parametrize(
"ext,verifier",
[
(".txt", lambda p: p.read_text(encoding="utf-8").strip()),
(".json", lambda p: json.loads(p.read_text(encoding="utf-8"))),
(".csv", lambda p: list(csv.reader(p.open("r", encoding="utf-8-sig")))),
(".html", lambda p: p.read_text(encoding="utf-8")),
(".sql", lambda p: p),
],
)
def test_export_all_formats(open_window, qtbot, tmp_path, ext, verifier, monkeypatch):
win = open_window
_write_sample_entries(win, qtbot)
out = tmp_path / f"export_test{ext}"
# 1) Short-circuit the file dialog so it returns our path + the filter we want.
from PySide6.QtWidgets import QFileDialog
def fake_getSaveFileName(*args, **kwargs):
return (str(out), EXPORT_FILTERS[ext])
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
)
# 2) Kick off the export
trigger_menu_action(win, "Export")
# 3) Click through the "unencrypted export" warning
accept_all_message_boxes()
# 4) Wait for the file to appear (export happens synchronously after the stub)
qtbot.waitUntil(out.exists, timeout=5000)
# 5) Dismiss the "Export complete" info box so it can't block later tests
accept_all_message_boxes()
# 6) Assert as before
val = verifier(out)
if ext == ".json":
assert isinstance(val, list) and all(
"date" in d and "content" in d for d in val
)
elif ext == ".csv":
flat = [cell for row in val for cell in row]
assert any("alpha" in c for c in flat) and any("beta" in c for c in flat)
elif ext == ".html":
lower = val.lower()
assert "<html" in lower and ("<article" in lower or "<body" in lower)
elif ext == ".txt":
assert "alpha" in val and "beta" in val
elif ext == ".sql":
con = sqlite3.connect(str(out))
cur = con.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
names = {r[0] for r in cur.fetchall()}
assert {"pages", "versions"} <= names
con.close()
def test_backup_encrypted_database(open_window, qtbot, tmp_path, monkeypatch):
win = open_window
_write_sample_entries(win, qtbot)
from PySide6.QtWidgets import QFileDialog
def fake_getSaveFileName(*args, **kwargs):
return (str(tmp_path / "backup.db"), BACKUP_FILTER)
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
)
trigger_menu_action(win, "Backup")
backup = tmp_path / "backup.db"
qtbot.waitUntil(backup.exists, timeout=5000)
# The backup path is now ready; proceed as before...
sqlcipher3 = pytest.importorskip("sqlcipher3")
con = sqlcipher3.dbapi2.connect(str(backup))
cur = con.cursor()
cur.execute("PRAGMA key = 'ci-secret-key';")
ok = cur.execute("PRAGMA cipher_integrity_check;").fetchall()
assert ok == []
con.close()

170
tests/test_find_bar.py Normal file
View file

@ -0,0 +1,170 @@
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
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ed = MarkdownEditor(themes)
qtbot.addWidget(ed)
ed.show()
return ed
def test_findbar_basic_navigation(qtbot, editor):
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
editor.moveCursor(QTextCursor.Start)
fb = FindBar(editor, parent=editor)
qtbot.addWidget(fb)
fb.show_bar()
fb.edit.setText("alpha")
fb.find_next()
pos1 = editor.textCursor().position()
fb.find_next()
pos2 = editor.textCursor().position()
assert pos2 > pos1
fb.find_prev()
pos3 = editor.textCursor().position()
assert pos3 <= pos2
fb.case.setChecked(True)
fb.refresh()
fb.hide_bar()
def test_show_bar_seeds_selection(qtbot, editor):
editor.from_markdown("alpha beta")
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb = FindBar(editor, parent=editor)
qtbot.addWidget(fb)
fb.show_bar()
assert fb.edit.text().lower() == "alpha"
fb.hide_bar()
def test_show_bar_no_editor(qtbot, app):
fb = FindBar(lambda: None)
qtbot.addWidget(fb)
fb.show_bar() # should early return without crashing and not become visible
assert not fb.isVisible()
def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor):
editor.from_markdown("alpha\n\nbeta")
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
# Select across the paragraph separator U+2029 equivalent select more than one block
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.show_bar()
assert fb.edit.text() == "" # should not seed with multi-paragraph
fb.hide_bar()
def test_find_wraps_and_bumps_caret(qtbot, editor):
editor.from_markdown("alpha alpha alpha")
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.edit.setText("alpha")
# Select the first occurrence so caret bumping path triggers
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb.find_next() # should bump to after current selection then find next
sel = editor.textCursor().selectedText()
assert sel.lower() == "alpha"
# Force wrap to start by moving cursor to end then searching next
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
fb.find_next() # triggers wrap-to-start path
assert editor.textCursor().hasSelection()
def test_update_highlight_clear_when_empty(qtbot, editor):
editor.from_markdown("find me find me")
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.edit.setText("find")
fb._update_highlight()
assert editor.extraSelections() # some highlights present
fb.edit.setText("")
fb._update_highlight() # should clear
assert not editor.extraSelections()
def test_maybe_hide_and_wrap_prev(qtbot, editor):
editor.setPlainText("a a a")
fb = FindBar(editor=editor, shortcut_parent=editor)
qtbot.addWidget(editor)
qtbot.addWidget(fb)
editor.show()
fb.show()
fb.edit.setText("a")
fb._update_highlight()
assert fb.isVisible()
fb._maybe_hide()
assert not fb.isVisible()
fb.show_bar()
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
editor.setTextCursor(c)
fb.find_prev()
def _make_fb(editor, qtbot):
"""Create a FindBar with a live parent kept until teardown."""
parent = QWidget()
qtbot.addWidget(parent)
fb = FindBar(editor=editor, parent=parent)
qtbot.addWidget(fb)
parent.show()
fb.show()
return fb, parent
def test_find_next_early_returns_no_editor(qtbot):
# No editor: should early return and not crash
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
fb.find_next()
def test_find_next_early_returns_empty_text(qtbot):
ed = QTextEdit()
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
fb.edit.setText("") # empty -> early return
fb.find_next()
def test_find_prev_early_returns_empty_text(qtbot):
ed = QTextEdit()
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
fb.edit.setText("") # empty -> early return
fb.find_prev()
def test_update_highlight_early_returns_no_editor(qtbot):
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
fb.edit.setText("abc")
fb._update_highlight() # should return without error

View file

@ -0,0 +1,310 @@
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):
d = "2001-01-01"
fresh_db.save_new_version(d, "v1", "first")
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
dlg.list.setCurrentRow(1)
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
assert fresh_db.get_entry(d) == "v1"
def test_history_dialog_no_selection_clears(qtbot, fresh_db):
d = "2001-01-01"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Clear selection (no current item) and call slot
dlg.list.setCurrentItem(None)
dlg._on_select()
assert dlg.preview.toPlainText() == ""
assert dlg.diff.toPlainText() == ""
assert not dlg.btn_revert.isEnabled()
def test_history_dialog_revert_same_version_noop(qtbot, fresh_db):
d = "2001-01-01"
# Only one version; that's the current
vid, _ = fresh_db.save_new_version(d, "seed", "note")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Pick the only item (current)
dlg.list.setCurrentRow(0)
# Clicking revert should simply return (no change)
before = fresh_db.get_entry(d)
dlg._revert()
after = fresh_db.get_entry(d)
assert before == after
def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
d = "2001-01-02"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select the row
dlg.list.setCurrentRow(0)
# Monkeypatch db to raise inside revert_to_version to hit except path
def boom(date_iso, version_id):
raise RuntimeError("nope")
dlg._db.revert_to_version = boom
# Auto-accept any QMessageBox that appears
def _pump():
for m in QMessageBox.instances():
m.accept()
t = QTimer()
t.setInterval(10)
t.timeout.connect(_pump)
t.start()
try:
dlg._revert()
finally:
t.stop()
def test_revert_returns_when_no_item_selected(qtbot, fresh_db):
d = "2000-01-01"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# No selection at all -> early return
dlg.list.clearSelection()
dlg._revert() # should not raise
def test_revert_returns_when_current_selected(qtbot, fresh_db):
d = "2000-01-02"
fresh_db.save_new_version(d, "v1", "first")
# Create a second version so there is a 'current'
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select the current item -> early return
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) == dlg._current_id:
dlg.list.setCurrentItem(item)
break
dlg._revert() # no-op
def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
"""
Trigger the exception path in _revert() and auto-accept the modal
QMessageBox that HistoryDialog pops so the test doesn't hang.
"""
d = "2000-01-03"
fresh_db.save_new_version(d, "v1", "first")
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select a non-current item
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Make revert raise to hit the except/critical message path.
def boom(*_a, **_k):
raise RuntimeError("nope")
monkeypatch.setattr(dlg._db, "revert_to_version", boom)
# Prepare a small helper that keeps trying to close an active modal box,
# but gives up after a bounded number of attempts.
def make_closer(max_tries=50, interval_ms=10):
tries = {"n": 0}
def closer():
tries["n"] += 1
w = QApplication.activeModalWidget()
if isinstance(w, QMessageBox):
# Prefer clicking the OK button if present; otherwise accept().
ok = w.button(QMessageBox.Ok)
if ok is not None:
ok.click()
else:
w.accept()
elif tries["n"] < max_tries:
QTimer.singleShot(interval_ms, closer)
return closer
# Schedule auto-close right before we trigger the modal dialog.
QTimer.singleShot(0, make_closer())
# Should show the critical box, which our timer will accept; _revert returns.
dlg._revert()
def test_delete_version_from_history(qtbot, fresh_db):
"""Test deleting a version through the history dialog."""
d = "2001-01-01"
# Create multiple versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
vid3, _ = fresh_db.save_new_version(d, "v3", "third")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Verify we have 3 versions
assert dlg.list.count() == 3
# Select the first version (oldest, not current)
dlg.list.setCurrentRow(2) # Last row is oldest version
# Call _delete
dlg._delete()
# Verify the version was deleted
assert dlg.list.count() == 2
# Verify from DB
versions = fresh_db.list_versions(d)
assert len(versions) == 2
def test_delete_current_version_returns_early(qtbot, fresh_db):
"""Test that deleting the current version returns early without deleting."""
d = "2001-01-02"
# Create versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Find and select the current version
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) == dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Try to delete - should return early
dlg._delete()
# Verify nothing was deleted
versions = fresh_db.list_versions(d)
assert len(versions) == 2
def test_delete_version_with_error(qtbot, fresh_db, monkeypatch):
"""Test that delete version error shows a message box."""
d = "2001-01-03"
# Create versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select a non-current version
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Make delete_version raise an error
def boom(*args, **kwargs):
raise RuntimeError("Delete failed")
monkeypatch.setattr(dlg._db, "delete_version", boom)
# Set up auto-closer for message box
def make_closer(max_tries=50, interval_ms=10):
tries = {"n": 0}
def closer():
tries["n"] += 1
w = QApplication.activeModalWidget()
if isinstance(w, QMessageBox):
ok = w.button(QMessageBox.Ok)
if ok is not None:
ok.click()
else:
w.accept()
elif tries["n"] < max_tries:
QTimer.singleShot(interval_ms, closer)
return closer
QTimer.singleShot(0, make_closer())
# Call delete - should show error message
dlg._delete()
def test_delete_multiple_versions(qtbot, fresh_db):
"""Test deleting multiple versions at once."""
d = "2001-01-04"
# Create multiple versions
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
vid3, _ = fresh_db.save_new_version(d, "v3", "third")
vid4, _ = fresh_db.save_new_version(d, "v4", "fourth")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select multiple non-current items
selected_count = 0
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
item.setSelected(True)
selected_count += 1
if selected_count >= 2: # Select 2 items
break
# Delete them
dlg._delete()
# Verify versions were deleted (should have current + 1 remaining)
versions = fresh_db.list_versions(d)
assert len(versions) == 2 # Current + 1 that wasn't deleted

View file

@ -1,66 +0,0 @@
from PySide6.QtWidgets import QListWidgetItem
from PySide6.QtCore import Qt
from bouquin.history_dialog import HistoryDialog
class FakeDB:
def __init__(self):
self.fail_revert = False
def list_versions(self, date_iso):
# Simulate two versions; mark second as current
return [
{
"id": 1,
"version_no": 1,
"created_at": "2025-01-01T10:00:00Z",
"note": None,
"is_current": False,
"content": "<p>a</p>",
},
{
"id": 2,
"version_no": 2,
"created_at": "2025-01-02T10:00:00Z",
"note": None,
"is_current": True,
"content": "<p>b</p>",
},
]
def get_version(self, version_id):
if version_id == 2:
return {"content": "<p>b</p>"}
return {"content": "<p>a</p>"}
def revert_to_version(self, date, version_id=None, version_no=None):
if self.fail_revert:
raise RuntimeError("boom")
def test_on_select_no_item(qtbot):
dlg = HistoryDialog(FakeDB(), "2025-01-01")
qtbot.addWidget(dlg)
dlg.list.clear()
dlg._on_select()
def test_revert_failure_shows_critical(qtbot, monkeypatch):
from PySide6.QtWidgets import QMessageBox
fake = FakeDB()
fake.fail_revert = True
dlg = HistoryDialog(fake, "2025-01-01")
qtbot.addWidget(dlg)
item = QListWidgetItem("v1")
item.setData(Qt.UserRole, 1) # different from current 2
dlg.list.addItem(item)
dlg.list.setCurrentItem(item)
msgs = {}
def fake_crit(parent, title, text):
msgs["t"] = (title, text)
monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit))
dlg._revert()
assert "Revert failed" in msgs["t"][0]

1346
tests/test_invoices.py Normal file

File diff suppressed because it is too large Load diff

204
tests/test_key_prompt.py Normal file
View file

@ -0,0 +1,204 @@
from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFileDialog, QLineEdit
def test_key_prompt_roundtrip(qtbot):
kp = KeyPrompt()
qtbot.addWidget(kp)
kp.show()
kp.key_entry.setText("swordfish")
assert kp.key() == "swordfish"
def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch):
"""Test KeyPrompt with DB path selection - covers lines 57-67"""
test_db = tmp_path / "test.db"
test_db.touch()
# Create prompt with show_db_change=True
prompt = KeyPrompt(show_db_change=True)
qtbot.addWidget(prompt)
# Mock the file dialog to return a file
def mock_get_open_filename(*args, **kwargs):
return str(test_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
# Simulate clicking the browse button
# Find the browse button by looking through the widget's children
browse_btn = None
for child in prompt.findChildren(object):
if hasattr(child, "clicked") and hasattr(child, "text"):
if (
"select" in str(child.text()).lower()
or "browse" in str(child.text()).lower()
):
browse_btn = child
break
if browse_btn:
browse_btn.click()
qtbot.wait(50)
# Verify the path was set
assert prompt.path_edit is not None
assert str(test_db) in prompt.path_edit.text()
def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch):
"""Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition"""
# Create prompt with show_db_change=True
prompt = KeyPrompt(show_db_change=True)
qtbot.addWidget(prompt)
# Mock the file dialog to return empty string (user cancelled)
def mock_get_open_filename(*args, **kwargs):
return "", ""
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
# Store original path text
original_text = prompt.path_edit.text() if prompt.path_edit else ""
# Simulate clicking the browse button
browse_btn = None
for child in prompt.findChildren(object):
if hasattr(child, "clicked") and hasattr(child, "text"):
if (
"select" in str(child.text()).lower()
or "browse" in str(child.text()).lower()
):
browse_btn = child
break
if browse_btn:
browse_btn.click()
qtbot.wait(50)
# Path should not have changed since no file was selected
if prompt.path_edit:
assert prompt.path_edit.text() == original_text
def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
"""Test KeyPrompt with existing DB path provided"""
test_db = tmp_path / "existing.db"
test_db.touch()
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
qtbot.addWidget(prompt)
# Verify the path is pre-filled
assert prompt.path_edit is not None
assert str(test_db) in prompt.path_edit.text()
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"""
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
qtbot.addWidget(prompt)
# Path edit should exist but be empty
assert prompt.path_edit is not None
assert prompt.path_edit.text() == ""
def test_key_prompt_accept_with_valid_key(qtbot, app):
"""Test accepting prompt with valid key"""
prompt = KeyPrompt()
qtbot.addWidget(prompt)
# Enter a key
prompt.key_entry.setText("test-key-123")
# Accept
QTimer.singleShot(0, prompt.accept)
qtbot.wait(50)
assert prompt.key_entry.text() == "test-key-123"
def test_key_prompt_without_db_change(qtbot, app):
"""Test KeyPrompt without show_db_change"""
prompt = KeyPrompt(show_db_change=False)
qtbot.addWidget(prompt)
# Path edit should not exist
assert prompt.path_edit is None
def test_key_prompt_password_visibility(qtbot, app):
"""Test password entry mode"""
prompt = KeyPrompt()
qtbot.addWidget(prompt)
# Initially should be password mode
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
# Enter some text
prompt.key_entry.setText("secret")
# The text should be obscured
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
def test_key_prompt_key_method(qtbot, app):
"""Test the key() method returns entered text"""
prompt = KeyPrompt()
qtbot.addWidget(prompt)
prompt.key_entry.setText("my-secret-key")
assert prompt.key() == "my-secret-key"
def test_key_prompt_db_path_method(qtbot, app, tmp_path):
"""Test the db_path() method returns selected path"""
test_db = tmp_path / "test.db"
test_db.touch()
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
qtbot.addWidget(prompt)
# Should return the db_path
assert prompt.db_path() == test_db
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
"""Test browsing when initial_db_path is set"""
initial_db = tmp_path / "initial.db"
initial_db.touch()
new_db = tmp_path / "new.db"
new_db.touch()
prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db)
qtbot.addWidget(prompt)
# Mock the file dialog to return a different file
def mock_get_open_filename(*args, **kwargs):
# Verify that start_dir was passed correctly
return str(new_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
# Find and click browse button
browse_btn = None
for child in prompt.findChildren(object):
if hasattr(child, "clicked") and hasattr(child, "text"):
if (
"select" in str(child.text()).lower()
or "browse" in str(child.text()).lower()
):
browse_btn = child
break
if browse_btn:
browse_btn.click()
qtbot.wait(50)
# Verify new path was set
assert str(new_db) in prompt.path_edit.text()
assert prompt.db_path() == new_db

View file

@ -0,0 +1,17 @@
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
def test_lock_overlay_reacts_to_theme(app, qtbot):
host = QWidget()
qtbot.addWidget(host)
host.show()
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ol = LockOverlay(host, on_unlock=lambda: None, themes=themes)
qtbot.addWidget(ol)
ol.show()
ev = QEvent(QEvent.Type.PaletteChange)
ol.changeEvent(ev)

98
tests/test_main.py Normal file
View file

@ -0,0 +1,98 @@
import importlib
import runpy
import pytest
def test_main_module_has_main():
m = importlib.import_module("bouquin.main")
assert hasattr(m, "main")
def test_dunder_main_imports_main():
m = importlib.import_module("bouquin.__main__")
assert hasattr(m, "main")
def test_dunder_main_calls_main(monkeypatch):
called = {"ok": False}
def fake_main():
called["ok"] = True
# Replace real main with a stub to avoid launching Qt event loop
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
# Ensure that when __main__ imports from .main it gets our stub
import bouquin.main as real_main
monkeypatch.setattr(real_main, "main", fake_main, raising=True)
# Execute the module as a script
runpy.run_module("bouquin.__main__", run_name="__main__")
assert called["ok"]
def test_main_creates_and_shows(monkeypatch):
# Create a fake QApplication with the minimal API
class FakeApp:
def __init__(self, argv):
self.ok = True
def setApplicationName(self, *_):
pass
def setOrganizationName(self, *_):
pass
def setWindowIcon(self, *_):
pass
def exec(self):
return 0
class FakeWin:
def __init__(self, themes=None):
self.shown = False
def show(self):
self.shown = True
class FakeSettings:
def value(self, k, default=None):
return "light" if k == "ui/theme" else default
# Patch imports inside bouquin.main
import bouquin.main as m
monkeypatch.setattr(m, "QApplication", FakeApp, raising=True)
monkeypatch.setattr(m, "MainWindow", FakeWin, raising=True)
# Theme classes
class FakeTM:
def __init__(self, app, cfg):
pass
def apply(self, theme):
pass
class FakeTheme:
def __init__(self, s):
pass
class FakeCfg:
def __init__(self, theme):
self.theme = theme
monkeypatch.setattr(m, "ThemeManager", FakeTM, raising=True)
monkeypatch.setattr(m, "Theme", FakeTheme, raising=True)
monkeypatch.setattr(m, "ThemeConfig", FakeCfg, raising=True)
# get_settings() used inside main()
def fake_get_settings():
return FakeSettings()
monkeypatch.setattr(m, "get_settings", fake_get_settings, raising=True)
# Run
with pytest.raises(SystemExit) as e:
m.main()
assert e.value.code == 0

2430
tests/test_main_window.py Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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,113 +0,0 @@
from PySide6.QtWidgets import QApplication, QMessageBox
from bouquin.main_window import MainWindow
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.db import DBConfig
def _themes_light():
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def _themes_dark():
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=Theme.DARK))
class FakeDBErr:
def __init__(self, cfg):
pass
def connect(self):
raise Exception("file is not a database")
class FakeDBOk:
def __init__(self, cfg):
pass
def connect(self):
return True
def save_new_version(self, date, text, note):
raise RuntimeError("nope")
def get_entry(self, date):
return "<p>hi</p>"
def get_entries_days(self):
return []
def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path):
# Config with a key so __init__ calls _try_connect immediately
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr)
msgs = {}
monkeypatch.setattr(
QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m))
)
w = MainWindow(_themes_light()) # auto-calls _try_connect
qtbot.addWidget(w)
assert "incorrect" in msgs.get("m", "").lower()
def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path):
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
w = MainWindow(_themes_dark())
qtbot.addWidget(w)
w._apply_link_css()
css = w.editor.document().defaultStyleSheet()
assert "a {" in css
def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path):
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
w = MainWindow(_themes_light())
qtbot.addWidget(w)
called = {}
class FakeSettings:
def value(self, key, default=None, type=None):
if key == "main/geometry":
return None
if key == "main/windowState":
return None
if key == "main/maximized":
return False
return default
w.settings = FakeSettings()
monkeypatch.setattr(
w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True)
)
w._restore_window_position()
assert called.get("x") is True
def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path):
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
w = MainWindow(_themes_light())
qtbot.addWidget(w)
captured = {}
monkeypatch.setattr(
w.editor, "insert_images", lambda paths: captured.setdefault("p", paths)
)
# Simulate file dialog returning paths
monkeypatch.setattr(
"bouquin.main_window.QFileDialog.getOpenFileNames",
staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")),
)
w._on_insert_image()
assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"]

View file

@ -0,0 +1,408 @@
from unittest.mock import Mock, patch
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):
"""Test PomodoroTimer initialization."""
task_text = "Write unit tests"
timer = PomodoroTimer(task_text)
qtbot.addWidget(timer)
assert timer._task_text == task_text
assert timer._elapsed_seconds == 0
assert timer._running is False
assert timer.time_label.text() == "00:00:00"
assert timer.stop_btn.isEnabled() is False
def test_pomodoro_timer_start(qtbot, app):
"""Test starting the timer."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
timer._toggle_timer()
assert timer._running is True
assert timer.stop_btn.isEnabled() is True
def test_pomodoro_timer_pause(qtbot, app):
"""Test pausing the timer."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start the timer
timer._toggle_timer()
assert timer._running is True
# Pause the timer
timer._toggle_timer()
assert timer._running is False
def test_pomodoro_timer_resume(qtbot, app):
"""Test resuming the timer after pause."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start, pause, then resume
timer._toggle_timer() # Start
timer._toggle_timer() # Pause
timer._toggle_timer() # Resume
assert timer._running is True
def test_pomodoro_timer_tick(qtbot, app):
"""Test timer tick increments elapsed time."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
initial_time = timer._elapsed_seconds
timer._tick()
assert timer._elapsed_seconds == initial_time + 1
def test_pomodoro_timer_display_update(qtbot, app):
"""Test display updates with various elapsed times."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Test 0 seconds
timer._elapsed_seconds = 0
timer._update_display()
assert timer.time_label.text() == "00:00:00"
# Test 65 seconds (1 min 5 sec)
timer._elapsed_seconds = 65
timer._update_display()
assert timer.time_label.text() == "00:01:05"
# Test 3665 seconds (1 hour 1 min 5 sec)
timer._elapsed_seconds = 3665
timer._update_display()
assert timer.time_label.text() == "01:01:05"
# Test 3600 seconds (1 hour exactly)
timer._elapsed_seconds = 3600
timer._update_display()
assert timer.time_label.text() == "01:00:00"
def test_pomodoro_timer_stop_and_log_running(qtbot, app):
"""Test stopping the timer while it's running."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start the timer
timer._toggle_timer()
timer._elapsed_seconds = 100
# Connect a mock to the signal
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
timer._stop_and_log()
assert timer._running is False
assert len(signal_received) == 1
assert signal_received[0][0] == 100 # elapsed seconds
assert signal_received[0][1] == "Test task"
def test_pomodoro_timer_stop_and_log_paused(qtbot, app):
"""Test stopping the timer when it's paused."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
timer._elapsed_seconds = 50
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
timer._stop_and_log()
assert len(signal_received) == 1
assert signal_received[0][0] == 50
def test_pomodoro_timer_multiple_ticks(qtbot, app):
"""Test multiple timer ticks."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
for i in range(10):
timer._tick()
assert timer._elapsed_seconds == 10
assert "00:00:10" in timer.time_label.text()
def test_pomodoro_timer_modal_state(qtbot, app):
"""Test that timer is non-modal."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
assert timer.isModal() is False
def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
assert manager._db is fresh_db
assert manager._parent is parent
assert manager._active_timer is None
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
"""Test starting a timer through the manager."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
line_text = "Important task"
date_iso = "2024-01-15"
manager.start_timer_for_line(line_text, date_iso)
assert manager._active_timer is not None
assert manager._active_timer._task_text == line_text
# 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/replaces the previous one."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
# Start first timer
manager.start_timer_for_line("Task 1", "2024-01-15")
first_timer = manager._active_timer
qtbot.addWidget(first_timer)
first_timer.show()
# Start second timer
manager.start_timer_for_line("Task 2", "2024-01-16")
second_timer = manager._active_timer
qtbot.addWidget(second_timer)
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
):
"""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 showing it
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager._on_timer_stopped(10, "Quick task", "2024-01-15")
# Should set minimum of 0.25 hours
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
assert hours_set >= 0.25
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
"""Elapsed time should be rounded up to the nearest 0.25 hours."""
parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
# 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]
assert hours_set > 0
# Should be a multiple of 0.25
assert hours_set * 4 == int(hours_set * 4)
def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch
):
"""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()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
task_text = "Write documentation"
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager._on_timer_stopped(3600, task_text, "2024-01-15")
mock_dialog.note.setText.assert_called_once_with(task_text)
def test_pomodoro_manager_timer_stopped_signal_connection(
qtbot, app, fresh_db, monkeypatch
):
"""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
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager.start_timer_for_line("Task", "2024-01-15")
timer = manager._active_timer
qtbot.addWidget(timer)
# 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()
assert mock_dialog.exec.called
def test_pomodoro_timer_accepts_parent(qtbot, app):
"""Test that timer accepts a parent widget."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
timer = PomodoroTimer("Task", parent)
qtbot.addWidget(timer)
assert timer.parent() is parent
def test_pomodoro_manager_no_active_timer_initially(app, fresh_db):
"""Test that manager starts with no active timer."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
assert manager._active_timer is None
def test_pomodoro_timer_start_stop_cycle(qtbot, app):
"""Test a complete start-stop cycle."""
timer = PomodoroTimer("Complete cycle")
qtbot.addWidget(timer)
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
# Start
timer._toggle_timer()
assert timer._running is True
# Simulate some ticks
for _ in range(5):
timer._tick()
# Stop
timer._stop_and_log()
assert timer._running is False
assert len(signal_received) == 1
assert signal_received[0][0] == 5
def test_pomodoro_timer_long_elapsed_time(qtbot, app):
"""Test display with very long elapsed time."""
timer = PomodoroTimer("Long task")
qtbot.addWidget(timer)
# Set to 2 hours, 34 minutes, 56 seconds
timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56
timer._update_display()
assert timer.time_label.text() == "02:34:56"

937
tests/test_reminders.py Normal file
View file

@ -0,0 +1,937 @@
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import pytest
from bouquin.reminders import (
ManageRemindersDialog,
Reminder,
ReminderDialog,
ReminderType,
UpcomingRemindersWidget,
)
from PySide6.QtCore import QDate, QDateTime, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
@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):
"""Test ReminderType enum values."""
assert ReminderType.ONCE is not None
assert ReminderType.DAILY is not None
assert ReminderType.WEEKDAYS is not None
assert ReminderType.WEEKLY is not None
def test_reminder_dataclass_creation(app):
"""Test creating a Reminder instance."""
reminder = Reminder(
id=1,
text="Test reminder",
time_str="10:30",
reminder_type=ReminderType.DAILY,
weekday=None,
active=True,
date_iso=None,
)
assert reminder.id == 1
assert reminder.text == "Test reminder"
assert reminder.time_str == "10:30"
assert reminder.reminder_type == ReminderType.DAILY
assert reminder.active is True
def test_reminder_dialog_init_new(qtbot, app, fresh_db):
"""Test ReminderDialog initialization for new reminder."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog._db is fresh_db
assert dialog._reminder is None
assert dialog.text_edit.text() == ""
def test_reminder_dialog_init_existing(qtbot, app, fresh_db):
"""Test ReminderDialog initialization with existing reminder."""
reminder = Reminder(
id=1,
text="Existing reminder",
time_str="14:30",
reminder_type=ReminderType.WEEKLY,
weekday=2,
active=True,
)
dialog = ReminderDialog(fresh_db, reminder=reminder)
qtbot.addWidget(dialog)
assert dialog.text_edit.text() == "Existing reminder"
assert dialog.time_edit.time().hour() == 14
assert dialog.time_edit.time().minute() == 30
def test_reminder_dialog_type_changed(qtbot, app, fresh_db):
"""Test that weekday combo visibility changes with type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show() # Show the dialog so child widgets can be visible
# Find weekly type in combo
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
dialog.type_combo.setCurrentIndex(i)
break
qtbot.wait(10) # Wait for Qt event processing
assert dialog.weekday_combo.isVisible() is True
# Switch to daily
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.DAILY:
dialog.type_combo.setCurrentIndex(i)
break
qtbot.wait(10) # Wait for Qt event processing
assert dialog.weekday_combo.isVisible() is False
def test_reminder_dialog_get_reminder_once(qtbot, app, fresh_db):
"""Test getting reminder with ONCE type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Test task")
dialog.time_edit.setTime(QTime(10, 30))
# Set to ONCE type
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.ONCE:
dialog.type_combo.setCurrentIndex(i)
break
reminder = dialog.get_reminder()
assert reminder.text == "Test task"
assert reminder.time_str == "10:30"
assert reminder.reminder_type == ReminderType.ONCE
assert reminder.date_iso is not None
def test_reminder_dialog_get_reminder_weekly(qtbot, app, fresh_db):
"""Test getting reminder with WEEKLY type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Weekly meeting")
dialog.time_edit.setTime(QTime(15, 0))
# Set to WEEKLY type
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
dialog.type_combo.setCurrentIndex(i)
break
dialog.weekday_combo.setCurrentIndex(1) # Tuesday
reminder = dialog.get_reminder()
assert reminder.text == "Weekly meeting"
assert reminder.reminder_type == ReminderType.WEEKLY
assert reminder.weekday == 1
def test_upcoming_reminders_widget_init(qtbot, app, fresh_db):
"""Test UpcomingRemindersWidget initialization."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
assert widget._db is fresh_db
assert widget.body.isVisible() is False
def test_upcoming_reminders_widget_toggle(qtbot, app, fresh_db):
"""Test toggling reminder list visibility."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.show() # Show the widget so child widgets can be visible
# Initially hidden
assert widget.body.isVisible() is False
# Click toggle
widget.toggle_btn.click()
qtbot.wait(10) # Wait for Qt event processing
assert widget.body.isVisible() is True
def test_upcoming_reminders_widget_should_fire_on_date_once(qtbot, app, fresh_db):
"""Test should_fire_on_date for ONCE type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.ONCE,
date_iso="2024-01-15",
)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is False
def test_upcoming_reminders_widget_should_fire_on_date_daily(qtbot, app, fresh_db):
"""Test should_fire_on_date for DAILY type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
# Should fire every day
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is True
def test_upcoming_reminders_widget_should_fire_on_date_weekdays(qtbot, app, fresh_db):
"""Test should_fire_on_date for WEEKDAYS type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.WEEKDAYS,
)
# Monday (dayOfWeek = 1)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
# Friday (dayOfWeek = 5)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 19)) is True
# Saturday (dayOfWeek = 6)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 20)) is False
# Sunday (dayOfWeek = 7)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 21)) is False
def test_upcoming_reminders_widget_should_fire_on_date_weekly(qtbot, app, fresh_db):
"""Test should_fire_on_date for WEEKLY type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# Fire on Wednesday (weekday = 2)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.WEEKLY,
weekday=2,
)
# Wednesday (dayOfWeek = 3, so weekday = 2)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 17)) is True
# Thursday (dayOfWeek = 4, so weekday = 3)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 18)) is False
def test_upcoming_reminders_widget_refresh_no_db(qtbot, app):
"""Test refresh with no database connection."""
widget = UpcomingRemindersWidget(None)
qtbot.addWidget(widget)
# Should not crash
widget.refresh()
def test_upcoming_reminders_widget_refresh_with_reminders(qtbot, app, fresh_db):
"""Test refresh displays reminders."""
# Add a reminder to the database
reminder = Reminder(
id=None,
text="Test reminder",
time_str="23:59", # Late time so it's in the future
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should have at least one item (or "No upcoming reminders")
assert widget.reminder_list.count() > 0
def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
"""Test adding a reminder through the widget."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
new_reminder = Reminder(
id=None,
text="New reminder",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
# 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
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):
"""Test editing a reminder through the widget."""
# Add a reminder first
reminder = Reminder(
id=None,
text="Original",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Get the list item
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
# 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):
"""Test deleting a single selected reminder."""
# Add a reminder
reminder = Reminder(
id=None,
text="To delete",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
if widget.reminder_list.count() > 0:
widget.reminder_list.setCurrentRow(0)
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
widget._delete_selected_reminders()
def test_upcoming_reminders_widget_delete_selected_multiple(qtbot, app, fresh_db):
"""Test deleting multiple selected reminders."""
# Add multiple reminders
for i in range(3):
reminder = Reminder(
id=None,
text=f"Reminder {i}",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Select all items
for i in range(widget.reminder_list.count()):
widget.reminder_list.item(i).setSelected(True)
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
widget._delete_selected_reminders()
def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
"""Test check_reminders with no database."""
widget = UpcomingRemindersWidget(None)
qtbot.addWidget(widget)
# Should not crash
widget._check_reminders()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog._db is fresh_db
assert dialog.table is not None
def test_manage_reminders_dialog_load_reminders(qtbot, app, fresh_db):
"""Test loading reminders into the table."""
# Add some reminders
for i in range(3):
reminder = Reminder(
id=None,
text=f"Reminder {i}",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.table.rowCount() == 3
def test_manage_reminders_dialog_load_reminders_no_db(qtbot, app):
"""Test loading reminders with no database."""
dialog = ManageRemindersDialog(None)
qtbot.addWidget(dialog)
# Should not crash
dialog._load_reminders()
def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
"""Test adding a reminder through the manage dialog."""
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
new_reminder = Reminder(
id=None,
text="New",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
# 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
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):
"""Test editing a reminder through the manage dialog."""
reminder = Reminder(
id=None,
text="Original",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
# 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):
"""Test deleting a reminder through the manage dialog."""
reminder = Reminder(
id=None,
text="To delete",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
saved_reminders = fresh_db.get_all_reminders()
reminder_to_delete = saved_reminders[0]
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
dialog._delete_reminder(reminder_to_delete)
# Table should have one fewer row
assert dialog.table.rowCount() == initial_count - 1
def test_manage_reminders_dialog_delete_reminder_declined(qtbot, app, fresh_db):
"""Test declining to delete a reminder."""
reminder = Reminder(
id=None,
text="Keep me",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
saved_reminders = fresh_db.get_all_reminders()
reminder_to_keep = saved_reminders[0]
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
dialog._delete_reminder(reminder_to_keep)
# Table should have same number of rows
assert dialog.table.rowCount() == initial_count
def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
"""Test that weekly reminders display the day name."""
reminder = Reminder(
id=None,
text="Weekly",
time_str="10:00",
reminder_type=ReminderType.WEEKLY,
weekday=2, # Wednesday
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
# Check that the type column shows the day
type_item = dialog.table.item(0, 3)
assert "Wed" in type_item.text()
def test_reminder_dialog_accept(qtbot, app, fresh_db):
"""Test accepting the reminder dialog."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Test")
dialog.accept()
def test_reminder_dialog_reject(qtbot, app, fresh_db):
"""Test rejecting the reminder dialog."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.reject()
def test_upcoming_reminders_widget_signal_emitted(qtbot, app, fresh_db):
"""Test that reminderTriggered signal is emitted."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
signal_received = []
widget.reminderTriggered.connect(lambda text: signal_received.append(text))
# Manually emit for testing
widget.reminderTriggered.emit("Test reminder")
assert len(signal_received) == 1
assert signal_received[0] == "Test reminder"
def test_upcoming_reminders_widget_no_upcoming_message(qtbot, app, fresh_db):
"""Test that 'No upcoming reminders' message is shown when appropriate."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should show message when no reminders
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
if "No upcoming" in item.text():
assert True
def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
"""Test clicking the manage button."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# 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()
def test_reminder_dialog_time_format(qtbot, app, fresh_db):
"""Test that time is formatted correctly."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.time_edit.setTime(QTime(9, 5))
reminder = dialog.get_reminder()
assert reminder.time_str == "09:05"
def test_upcoming_reminders_widget_past_reminders_filtered(qtbot, app, fresh_db):
"""Test that past reminders are not shown in upcoming list."""
# Create a reminder that's in the past
reminder = Reminder(
id=None,
text="Past reminder",
time_str="00:01", # Very early morning
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# Current time should be past 00:01
from PySide6.QtCore import QTime
if QTime.currentTime().hour() > 0:
widget.refresh()
# The past reminder for today should be filtered out
# but tomorrow's occurrence should be shown
def test_reminder_with_inactive_status(qtbot, app, fresh_db):
"""Test that inactive reminders are not displayed."""
reminder = Reminder(
id=None,
text="Inactive",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=False,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should not show inactive reminder
for i in range(widget.reminder_list.count()):
item = widget.reminder_list.item(i)
assert "Inactive" not in item.text() or "No upcoming" in item.text()
def test_reminder_triggers_and_deactivates(qtbot, fresh_db):
"""Test that ONCE reminders deactivate after firing."""
# Add a ONCE reminder for right now
now = QTime.currentTime()
hour = now.hour()
minute = now.minute()
reminder = Reminder(
id=None,
text="Test once reminder",
reminder_type=ReminderType.ONCE,
time_str=f"{hour:02d}:{minute:02d}",
date_iso=date.today().isoformat(),
active=True,
)
reminder_id = fresh_db.save_reminder(reminder)
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Set up signal spy
triggered_texts = []
reminders_widget.reminderTriggered.connect(
lambda text: triggered_texts.append(text)
)
# Trigger the check
reminders_widget._check_reminders()
# Verify reminder was triggered
assert len(triggered_texts) > 0
assert "Test once reminder" in triggered_texts
# Verify reminder was deactivated
reminders = fresh_db.get_all_reminders()
deactivated = [r for r in reminders if r.id == reminder_id][0]
assert deactivated.active is False
def test_reminder_not_active_skipped(qtbot, fresh_db):
"""Test that inactive reminders are not triggered."""
now = QTime.currentTime()
hour = now.hour()
minute = now.minute()
reminder = Reminder(
id=None,
text="Inactive reminder",
reminder_type=ReminderType.ONCE,
time_str=f"{hour:02d}:{minute:02d}",
date_iso=date.today().isoformat(),
active=False, # Not active
)
fresh_db.save_reminder(reminder)
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Set up signal spy
triggered_texts = []
reminders_widget.reminderTriggered.connect(
lambda text: triggered_texts.append(text)
)
# Trigger the check
reminders_widget._check_reminders()
# Should not trigger inactive reminder
assert len(triggered_texts) == 0
def test_reminder_not_today_skipped(qtbot, fresh_db):
"""Test that reminders not scheduled for today are skipped."""
now = QTime.currentTime()
hour = now.hour()
minute = now.minute()
# Schedule for tomorrow
tomorrow = date.today() + timedelta(days=1)
reminder = Reminder(
id=None,
text="Tomorrow's reminder",
reminder_type=ReminderType.ONCE,
time_str=f"{hour:02d}:{minute:02d}",
date_iso=tomorrow.isoformat(),
active=True,
)
fresh_db.save_reminder(reminder)
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Set up signal spy
triggered_texts = []
reminders_widget.reminderTriggered.connect(
lambda text: triggered_texts.append(text)
)
# Trigger the check
reminders_widget._check_reminders()
# Should not trigger tomorrow's reminder
assert len(triggered_texts) == 0
def test_reminder_context_menu_no_selection(qtbot, fresh_db):
"""Test context menu with no selection returns early."""
reminders_widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(reminders_widget)
# Clear selection
reminders_widget.reminder_list.clearSelection()
# Show context menu - should return early
reminders_widget._show_reminder_context_menu(reminders_widget.reminder_list.pos())
def test_edit_reminder_dialog(qtbot, fresh_db):
"""Test editing a reminder through the dialog."""
reminder = Reminder(
id=None,
text="Original text",
reminder_type=ReminderType.DAILY,
time_str="14:30",
date_iso=None,
active=True,
)
fresh_db.save_reminder(reminder)
widget = QWidget()
# Create edit dialog
reminder_obj = fresh_db.get_all_reminders()[0]
dlg = ReminderDialog(fresh_db, widget, reminder=reminder_obj)
qtbot.addWidget(dlg)
# 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

@ -0,0 +1,8 @@
from bouquin.save_dialog import SaveDialog
def test_save_dialog_note_text(qtbot):
dlg = SaveDialog()
qtbot.addWidget(dlg)
dlg.show()
assert dlg.note_text()

103
tests/test_search.py Normal file
View file

@ -0,0 +1,103 @@
from bouquin.search import Search
from PySide6.QtWidgets import QListWidgetItem
def test_search_widget_populates_results(qtbot, fresh_db):
fresh_db.save_new_version("2024-01-01", "alpha bravo", "seed")
fresh_db.save_new_version("2024-01-02", "bravo charlie", "seed")
fresh_db.save_new_version("2024-01-03", "delta alpha bravo", "seed")
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
emitted = []
s.resultDatesChanged.connect(lambda ds: emitted.append(tuple(ds)))
s.search.setText("alpha")
qtbot.wait(50)
assert s.results.count() >= 2
assert emitted and {"2024-01-01", "2024-01-03"}.issubset(set(emitted[-1]))
s.search.setText("")
qtbot.wait(50)
assert s.results.isHidden()
def test_open_selected_with_data(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
seen = []
s.openDateRequested.connect(lambda d: seen.append(d))
it = QListWidgetItem("dummy")
from PySide6.QtCore import Qt
it.setData(
Qt.ItemDataRole.UserRole,
{"kind": "page", "date": "1999-12-31"},
)
s.results.addItem(it)
s._open_selected(it)
assert seen == ["1999-12-31"]
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
s = Search(fresh_db)
long = (
"This is **bold** text with alpha in the middle and some more trailing content."
)
frag = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
assert "alpha" in frag
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
def test_open_selected_ignores_no_data(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
seen = []
s.openDateRequested.connect(lambda d: seen.append(d))
it = QListWidgetItem("dummy")
# No UserRole data set -> should not emit
s._open_selected(it)
assert not seen
def test_make_html_snippet_variants(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
src = " ".join(["word"] * 200)
frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
assert frag
# Case: multiple tokens highlighted
src = "Alpha bravo charlie delta echo"
frag = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
assert "<b>delta</b>" in frag
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
s.search.setText("alpha")
frag, left, right = s._make_html_snippet("", "alpha", radius=10, maxlen=40)
assert frag == "" and not left and not right
def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
long = "X" * 40 + "alpha" + "Y" * 40
rows = [("page", "2000-01-01", "2000-01-01", long, None)]
s._populate_results("alpha", rows)
assert s.results.count() >= 1

View file

@ -1,110 +0,0 @@
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QListWidget, QWidget, QAbstractButton
from tests.qt_helpers import (
trigger_menu_action,
wait_for_widget,
find_line_edit_by_placeholder,
)
def test_search_and_open_date(open_window, qtbot):
win = open_window
win.editor.setPlainText("lorem ipsum target")
win._save_current(explicit=True)
base = win.calendar.selectedDate()
d2 = base.addDays(7)
win.calendar.setSelectedDate(d2)
win.editor.setPlainText("target appears here, too")
win._save_current(explicit=True)
search_box = find_line_edit_by_placeholder(win, "search")
assert search_box is not None, "Search input not found"
search_box.setText("target")
QTest.qWait(150)
results = getattr(getattr(win, "search", None), "results", None)
if isinstance(results, QListWidget) and results.count() > 0:
# Click until we land on d2
landed = False
for i in range(results.count()):
item = results.item(i)
rect = results.visualItemRect(item)
QTest.mouseDClick(results.viewport(), Qt.LeftButton, pos=rect.center())
qtbot.wait(120)
if win.calendar.selectedDate() == d2:
landed = True
break
assert landed, "Search results did not navigate to the expected date"
else:
assert "target" in win.editor.toPlainText().lower()
def test_history_dialog_revert(open_window, qtbot):
win = open_window
# Create two versions on the current day
win.editor.setPlainText("v1 text")
win._save_current(explicit=True)
win.editor.setPlainText("v2 text")
win._save_current(explicit=True)
# Open the History UI (label varies)
try:
trigger_menu_action(win, "View History")
except AssertionError:
trigger_menu_action(win, "History")
# Find ANY top-level window that looks like the History dialog
def _is_history(w: QWidget):
if not w.isWindow() or not w.isVisible():
return False
title = (w.windowTitle() or "").lower()
return "history" in title or bool(w.findChildren(QListWidget))
hist = wait_for_widget(QWidget, predicate=_is_history, timeout_ms=15000)
# Wait for population and pick the list with the most items
chosen = None
for _ in range(120): # up to ~3s
lists = hist.findChildren(QListWidget)
if lists:
chosen = max(lists, key=lambda lw: lw.count())
if chosen.count() >= 2:
break
QTest.qWait(25)
assert (
chosen is not None and chosen.count() >= 2
), "History list never populated with 2+ versions"
# Click the older version row so the Revert button enables
idx = 1 # row 0 is most-recent "v2 text", row 1 is "v1 text"
rect = chosen.visualItemRect(chosen.item(idx))
QTest.mouseClick(chosen.viewport(), Qt.LeftButton, pos=rect.center())
QTest.qWait(100)
# Find any enabled button whose text/tooltip/objectName contains 'revert'
revert_btn = None
for _ in range(120): # wait until it enables
for btn in hist.findChildren(QAbstractButton):
meta = " ".join(
[btn.text() or "", btn.toolTip() or "", btn.objectName() or ""]
).lower()
if "revert" in meta:
revert_btn = btn
break
if revert_btn and revert_btn.isEnabled():
break
QTest.qWait(25)
assert (
revert_btn is not None and revert_btn.isEnabled()
), "Revert button not found/enabled"
QTest.mouseClick(revert_btn, Qt.LeftButton)
# AutoResponder will accept confirm/success boxes
QTest.qWait(150)
assert "v1 text" in win.editor.toPlainText()

View file

@ -1,57 +0,0 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QListWidgetItem
# The widget class is named `Search` in bouquin.search
from bouquin.search import Search as SearchWidget
class FakeDB:
def __init__(self, rows):
self.rows = rows
def search_entries(self, q):
return list(self.rows)
def test_search_empty_clears_and_hides(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
w.show()
qtbot.waitExposed(w)
dates = []
w.resultDatesChanged.connect(lambda ds: dates.extend(ds))
w._search(" ")
assert w.results.isHidden()
assert dates == []
def test_populate_empty_hides(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
w._populate_results("x", [])
assert w.results.isHidden()
def test_open_selected_emits_when_present(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
got = {}
w.openDateRequested.connect(lambda d: got.setdefault("d", d))
it = QListWidgetItem("x")
it.setData(Qt.ItemDataRole.UserRole, "")
w._open_selected(it)
assert "d" not in got
it.setData(Qt.ItemDataRole.UserRole, "2025-01-02")
w._open_selected(it)
assert got["d"] == "2025-01-02"
def test_make_html_snippet_edge_cases(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
# Empty HTML -> empty fragment, no ellipses
frag, l, r = w._make_html_snippet("", "hello")
assert frag == "" and not l and not r
# Small doc around token -> should not show ellipses
frag, l, r = w._make_html_snippet("<p>Hello world</p>", "world")
assert "<b>world</b>" in frag or "world" in frag

67
tests/test_settings.py Normal file
View file

@ -0,0 +1,67 @@
from bouquin.db import DBConfig
from bouquin.settings import get_settings, load_db_config, save_db_config
def _clear_db_settings():
s = get_settings()
for k in [
"db/default_db",
"db/path", # legacy key
"db/key",
"ui/idle_minutes",
"ui/theme",
"ui/move_todos",
"ui/tags",
"ui/time_log",
"ui/reminders",
"ui/locale",
"ui/font_size",
]:
s.remove(k)
def test_load_and_save_db_config_roundtrip(app, tmp_path):
_clear_db_settings()
cfg = DBConfig(
path=tmp_path / "notes.db",
key="abc123",
idle_minutes=7,
theme="dark",
move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
)
save_db_config(cfg)
loaded = load_db_config()
assert loaded.path == cfg.path
assert loaded.key == cfg.key
assert loaded.idle_minutes == cfg.idle_minutes
assert loaded.theme == cfg.theme
assert loaded.move_todos == cfg.move_todos
assert loaded.tags == cfg.tags
assert loaded.time_log == cfg.time_log
assert loaded.reminders == cfg.reminders
assert loaded.locale == cfg.locale
assert loaded.font_size == cfg.font_size
def test_load_db_config_migrates_legacy_db_path(app, tmp_path):
_clear_db_settings()
s = get_settings()
legacy_path = tmp_path / "legacy.db"
s.setValue("db/path", str(legacy_path))
cfg = load_db_config()
# Uses the legacy value…
assert cfg.path == legacy_path
# …but also migrates to the new key and clears the old one.
assert s.value("db/default_db", "", type=str) == str(legacy_path)
assert s.value("db/path", "", type=str) == ""

View file

@ -1,296 +1,412 @@
from pathlib import Path
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget
from bouquin.db import DBConfig
import bouquin.settings_dialog as sd
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
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget
class _ThemeSpy:
def __init__(self):
self.calls = []
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
app = QApplication.instance()
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
def apply(self, t):
self.calls.append(t)
dlg.idle_spin.setValue(3)
dlg.theme_light.setChecked(True)
dlg.move_todos.setChecked(True)
dlg.tags.setChecked(False)
dlg.time_log.setChecked(False)
dlg.reminders.setChecked(False)
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
def _auto_accept_msgbox():
for w in QApplication.topLevelWidgets():
if isinstance(w, QMessageBox):
w.accept()
QTimer.singleShot(0, _auto_accept_msgbox)
dlg._compact_btn_clicked()
qtbot.wait(50)
dlg._save()
cfg = dlg.config
assert cfg.idle_minutes == 3
assert cfg.move_todos is True
assert cfg.tags is False
assert cfg.time_log is False
assert cfg.reminders is False
assert cfg.theme in ("light", "dark", "system")
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemeSpy()
def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
# Ensure a clean starting state (suite may leave settings toggled on)
dlg.save_key_btn.setChecked(False)
dlg.key = ""
# Robust popup pump so we never miss late dialogs
def _pump():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.key_entry.setText("supersecret")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump)
timer.start()
try:
dlg.save_key_btn.setChecked(True)
qtbot.waitUntil(lambda: dlg.key == "supersecret", timeout=1000)
assert dlg.save_key_btn.isChecked()
dlg.save_key_btn.setChecked(False)
qtbot.waitUntil(lambda: dlg.key == "", timeout=1000)
assert dlg.key == ""
finally:
timer.stop()
class FakeDB:
def __init__(self):
self.rekey_called_with = None
self.compact_called = False
self.fail_compact = False
def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
cfg = DBConfig(
path=tmp_path / "iso.db",
key="oldkey",
idle_minutes=0,
theme="light",
move_todos=True,
)
db = DBManager(cfg)
assert db.connect()
db.save_new_version("2000-01-01", "seed", "seed")
def rekey(self, key: str):
self.rekey_called_with = key
def compact(self):
if self.fail_compact:
raise RuntimeError("boom")
self.compact_called = True
class AcceptingPrompt:
def __init__(self, parent=None, title="", message=""):
self._key = ""
self._accepted = True
def set_key(self, k: str):
self._key = k
return self
def exec(self):
return QDialog.Accepted if self._accepted else QDialog.Rejected
def key(self):
return self._key
class RejectingPrompt(AcceptingPrompt):
def __init__(self, *a, **k):
super().__init__()
self._accepted = False
def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
db = FakeDB()
cfg = DBConfig(path=tmp_path / "db.sqlite", key="", idle_minutes=15)
saved = {}
def fake_save(cfg2):
saved["cfg"] = cfg2
monkeypatch.setattr("bouquin.settings_dialog.save_db_config", fake_save)
# Drive the "remember key" checkbox via the prompt (no pre-set key)
p = AcceptingPrompt().set_key("sekrit")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
# Provide a lightweight parent that mimics MainWindows `themes` API
class _ThemeSpy:
def __init__(self):
self.calls = []
def apply(self, theme):
self.calls.append(theme)
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemeSpy()
parent = _Parent()
qtbot.addWidget(parent)
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
# Change fields
new_path = tmp_path / "new.sqlite"
dlg.path_edit.setText(str(new_path))
dlg.idle_spin.setValue(0)
keys = ["one", "two"]
# User toggles "Remember key" -> stores prompted key
dlg.save_key_btn.setChecked(True)
def _pump_popups():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.key_entry.setText(keys.pop(0) if keys else "zzz")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
dlg._save()
out = saved["cfg"]
assert out.path == new_path
assert out.idle_minutes == 0
assert out.key == "sekrit"
assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump_popups)
timer.start()
try:
dlg._change_key()
finally:
timer.stop()
db.close()
db2 = DBManager(cfg)
assert db2.connect()
db2.close()
def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot):
# When toggled on with no key yet, it prompts; cancelling should revert the check
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt)
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
assert dlg.key == ""
dlg.save_key_btn.click() # toggles True -> triggers prompt which rejects
assert dlg.save_key_btn.isChecked() is False
assert dlg.key == ""
def test_save_key_checkbox_accepts_and_stores_key(monkeypatch, qtbot):
# Toggling on with an accepting prompt should store the typed key
p = AcceptingPrompt().set_key("remember-me")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg.save_key_btn.click()
assert dlg.save_key_btn.isChecked() is True
assert dlg.key == "remember-me"
def test_change_key_success(monkeypatch, qtbot):
# Two prompts returning the same non-empty key -> rekey() and info message
p1 = AcceptingPrompt().set_key("newkey")
p2 = AcceptingPrompt().set_key("newkey")
seq = [p1, p2]
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
shown = {"info": 0}
monkeypatch.setattr(
QMessageBox,
"information",
lambda *a, **k: shown.__setitem__("info", shown["info"] + 1),
def test_change_key_success(qtbot, tmp_path, app):
cfg = DBConfig(
path=tmp_path / "iso2.db",
key="oldkey",
idle_minutes=0,
theme="light",
move_todos=True,
)
db = DBManager(cfg)
assert db.connect()
db.save_new_version("2001-01-01", "seed", "seed")
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._change_key()
keys = ["newkey", "newkey"]
assert db.rekey_called_with == "newkey"
assert shown["info"] >= 1
assert dlg.key == "newkey"
def _pump():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.key_entry.setText(keys.pop(0) if keys else "newkey")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump)
timer.start()
try:
dlg._change_key()
finally:
timer.stop()
qtbot.wait(50)
db.close()
cfg.key = "newkey"
db2 = DBManager(cfg)
assert db2.connect()
assert "seed" in db2.get_entry("2001-01-01")
db2.close()
def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot):
p1 = AcceptingPrompt().set_key("a")
p2 = AcceptingPrompt().set_key("b")
seq = [p1, p2]
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
called = {"warn": 0}
monkeypatch.setattr(
QMessageBox,
"warning",
lambda *a, **k: called.__setitem__("warn", called["warn"] + 1),
)
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
# Parent with ThemeManager (dialog uses parent().themes.set(...))
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._change_key()
# Monkeypatch db.compact to raise
def boom():
raise RuntimeError("nope")
assert db.rekey_called_with is None
assert called["warn"] >= 1
dlg._db.compact = boom # type: ignore
called = {"critical": False, "title": None, "text": None}
def test_change_key_empty_shows_warning(monkeypatch, qtbot):
p1 = AcceptingPrompt().set_key("")
p2 = AcceptingPrompt().set_key("")
seq = [p1, p2]
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
class DummyMB:
@staticmethod
def information(*args, **kwargs):
return 0
called = {"warn": 0}
monkeypatch.setattr(
QMessageBox,
"warning",
lambda *a, **k: called.__setitem__("warn", called["warn"] + 1),
)
@staticmethod
def critical(parent, title, text, *rest):
called["critical"] = True
called["title"] = title
called["text"] = str(text)
return 0
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._change_key()
assert db.rekey_called_with is None
assert called["warn"] >= 1
def test_browse_sets_path(monkeypatch, qtbot, tmp_path):
def fake_get_save_file_name(*a, **k):
return (str(tmp_path / "picked.sqlite"), "")
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name)
)
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._browse()
assert dlg.path_edit.text().endswith("picked.sqlite")
def test_compact_success_and_failure(monkeypatch, qtbot):
shown = {"info": 0, "crit": 0}
monkeypatch.setattr(
QMessageBox,
"information",
lambda *a, **k: shown.__setitem__("info", shown["info"] + 1),
)
monkeypatch.setattr(
QMessageBox,
"critical",
lambda *a, **k: shown.__setitem__("crit", shown["crit"] + 1),
)
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
# Swap QMessageBox used inside the dialog module so signature mismatch can't occur
monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True)
# Invoke
dlg._compact_btn_clicked()
assert db.compact_called is True
assert shown["info"] >= 1
# Failure path
db2 = FakeDB()
db2.fail_compact = True
dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2)
qtbot.addWidget(dlg2)
dlg2.show()
qtbot.waitExposed(dlg2)
dlg2._compact_btn_clicked()
assert shown["crit"] >= 1
assert called["critical"]
assert called["title"]
assert called["text"]
def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot):
p = AcceptingPrompt().set_key("already")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
class _Host(QWidget):
def __init__(self, themes):
super().__init__()
self.themes = themes
dlg = SettingsDialog(DBConfig(Path("x"), key="already"), FakeDB())
def _make_host_and_dialog(tmp_db_cfg, fresh_db):
# Create a real ThemeManager so we don't have to fake anything here
from PySide6.QtWidgets import QApplication
themes = ThemeManager(QApplication.instance(), ThemeConfig(theme=Theme.SYSTEM))
host = _Host(themes)
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=host)
return host, dlg
def _clear_qsettings_theme_to_system():
"""Make the radio-button default deterministic across the full suite."""
s = get_settings()
s.clear()
s.setValue("ui/theme", "system")
def test_default_theme_radio_is_system_checked(qtbot, tmp_db_cfg, fresh_db):
# Ensure no stray theme value from previous tests
_clear_qsettings_theme_to_system()
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(host)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg.save_key_btn.setChecked(True)
# We should reach here with the original key preserved.
assert dlg.key == "already"
# With fresh settings (system), the 'system' radio should be selected
assert dlg.theme_system.isChecked()
def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path):
parent = _Parent()
qtbot.addWidget(parent)
cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5)
dlg = SettingsDialog(cfg, FakeDB(), parent=parent)
def test_save_selects_system_when_no_explicit_choice(
qtbot, tmp_db_cfg, fresh_db, monkeypatch
):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
dlg.save_key_btn.setChecked(False)
# Trigger save
# Ensure neither dark nor light is checked so SYSTEM path is taken
dlg.theme_dark.setChecked(False)
dlg.theme_light.setChecked(False)
# This should not raise
dlg._save()
assert dlg.config.key == "" # cleared
assert parent.themes.calls # applied some theme
def test_save_with_dark_selected(qtbot, tmp_db_cfg, fresh_db):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
dlg.theme_dark.setChecked(True)
dlg._save()
def test_change_key_cancel_first_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return ""
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
dlg._change_key() # returns early
def test_change_key_cancel_second_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return "abc"
class P2:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return "abc"
# First call yields P1, second yields P2
seq = [P1, P2]
def _factory(*a, **k):
cls = seq.pop(0)
return cls(*a, **k)
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
dlg._change_key() # returns early
def test_change_key_empty_and_exception_paths(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Timer that auto-accepts any modal QMessageBox so we don't hang.
def _pump_boxes():
# Try both the active modal and the general top-level enumeration
m = QApplication.activeModalWidget()
if isinstance(m, QMessageBox):
m.accept()
for w in QApplication.topLevelWidgets():
if isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump_boxes)
timer.start()
try:
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return ""
class P2:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return ""
seq = [P1, P2, P1, P2]
def _factory(*a, **k):
cls = seq.pop(0)
return cls(*a, **k)
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
# First run triggers empty-key warning path and return (auto-closed)
dlg._change_key()
# Now make rekey() raise to hit the except block (critical dialog)
def boom(*a, **k):
raise RuntimeError("nope")
dlg._db.rekey = boom
# Return a non-empty matching key twice
class P3:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return "secret"
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: P3())
dlg._change_key()
finally:
timer.stop()
def test_save_key_btn_clicked_cancel_flow(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Make sure we start with no key saved so it will prompt
dlg.key = ""
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return ""
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
dlg.save_key_btn.setChecked(True) # toggles and calls handler
# Handler should have undone the checkbox back to False
assert not dlg.save_key_btn.isChecked()

View file

@ -1,28 +0,0 @@
from bouquin.db import DBConfig
import bouquin.settings as settings
class FakeSettings:
def __init__(self):
self.store = {}
def value(self, key, default=None, type=None):
return self.store.get(key, default)
def setValue(self, key, value):
self.store[key] = value
def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path):
fake = FakeSettings()
monkeypatch.setattr(settings, "get_settings", lambda: fake)
cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark")
settings.save_db_config(cfg)
# Now read back into a new DBConfig
cfg2 = settings.load_db_config()
assert cfg2.path == cfg.path
assert cfg2.key == "k"
assert cfg2.idle_minutes == "7"
assert cfg2.theme == "dark"

View file

@ -0,0 +1,660 @@
import datetime as _dt
from datetime import date, datetime, timedelta
from bouquin import strings
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:
"""Minimal stub that returns a fixed stats payload."""
def __init__(self):
d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2)
self.stats = (
2, # pages_with_content
5, # total_revisions
"2024-01-02", # page_most_revisions
3, # page_most_revisions_count
{d1: 10, d2: 20}, # words_by_date
30, # total_words
4, # unique_tags
"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):
self.called = True
return self.stats
def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
# Make sure we have a known language for label texts
strings.load_strings("en")
db = FakeStatsDB()
dlg = StatisticsDialog(db)
qtbot.addWidget(dlg)
dlg.show()
# Stats were actually requested from the DB
assert db.called
# Window title comes from translations
assert dlg.windowTitle() == strings._("statistics")
# Grab all label texts for simple content checks
label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)}
# Page with most revisions / tags are rendered as "DATE (COUNT)"
assert "2024-01-02 (3)" in label_texts
assert "2024-01-02 (2)" in label_texts
# Heatmap is created and uses "words" by default
words_by_date = db.stats[4]
revisions_by_date = db.stats[9]
assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date
# Switching the metric to "revisions" should swap the dataset
dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions
qtbot.wait(10)
assert dlg._heatmap._data == revisions_by_date
class EmptyStatsDB:
"""Stub that returns a 'no data yet' stats payload."""
def __init__(self):
self.called = False
def gather_stats(self):
self.called = True
return (
0, # pages_with_content
0, # total_revisions
None, # page_most_revisions
0, # page_most_revisions_count
{}, # words_by_date
0, # total_words
0, # unique_tags
None, # page_most_tags
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
)
def test_statistics_dialog_no_data_shows_placeholder(qtbot):
strings.load_strings("en")
db = EmptyStatsDB()
dlg = StatisticsDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert db.called
label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)]
assert strings._("stats_no_data") in label_texts
# When there's no data, the heatmap and metric combo shouldn't exist
assert not hasattr(dlg, "metric_combo")
assert not hasattr(dlg, "_heatmap")
def _date(year, month, day):
return date(year, month, day)
# ============================================================================
# DateHeatmapTests - Missing Coverage
# ============================================================================
def test_activity_heatmap_empty_data(qtbot):
"""Test heatmap with empty data dict."""
strings.load_strings("en")
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set empty data
heatmap.set_data({})
# Should handle empty data gracefully
assert heatmap._start is None
assert heatmap._end is None
assert heatmap._max_value == 0
# Size hint should return default dimensions
size = heatmap.sizeHint()
assert size.width() > 0
assert size.height() > 0
# Paint should not crash
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_none_data(qtbot):
"""Test heatmap with None data."""
strings.load_strings("en")
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set None data
heatmap.set_data(None)
assert heatmap._start is None
assert heatmap._end is None
# Paint event should return early
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_click_when_no_data(qtbot):
"""Test clicking heatmap when there's no data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data({})
# Simulate click - should not crash or emit signal
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click in the middle of widget
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# Should not have clicked any date
assert len(clicked_dates) == 0
def test_activity_heatmap_click_outside_grid(qtbot):
"""Test clicking outside the grid area."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set some data
data = {
date(2024, 1, 1): 5,
date(2024, 1, 2): 10,
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click in top-left margin (before grid starts)
pos = QPoint(5, 5)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_click_beyond_end_date(qtbot):
"""Test clicking on trailing empty cells beyond the last date."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set data that doesn't fill a complete week
data = {
date(2024, 1, 1): 5, # Monday
date(2024, 1, 2): 10, # Tuesday
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Try clicking far to the right (beyond end date)
# This is tricky to target precisely, but we can simulate
pos = QPoint(1000, 50) # Far right
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# Should either not click or only click valid dates
# If it did click, it should be a valid date within range
if clicked_dates:
assert clicked_dates[0] <= date(2024, 1, 2)
def test_activity_heatmap_click_invalid_row(qtbot):
"""Test clicking below the 7 weekday rows."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {
date(2024, 1, 1): 5,
date(2024, 1, 8): 10,
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click below the grid (row 8 or higher)
pos = QPoint(100, 500) # Very low Y
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_right_click_ignored(qtbot):
"""Test that right-click is ignored."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {date(2024, 1, 1): 5}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Right click should be ignored
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.RightButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_month_label_rendering(qtbot):
"""Test heatmap spanning multiple months renders month labels."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Data spanning multiple months
data = {
date(2024, 1, 1): 5,
date(2024, 1, 15): 10,
date(2024, 2, 1): 8,
date(2024, 2, 15): 12,
date(2024, 3, 1): 6,
}
heatmap.set_data(data)
# Should calculate proper size
size = heatmap.sizeHint()
assert size.width() > 0
assert size.height() > 0
# Paint should work without crashing
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_same_month_continues(qtbot):
"""Test that month labels skip weeks in the same month."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Multiple dates in same month
data = {}
for day in range(1, 29): # January 1-28
data[date(2024, 1, day)] = day
heatmap.set_data(data)
# Should render without issues
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_data_with_zero_values(qtbot):
"""Test heatmap with zero values in data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {
date(2024, 1, 1): 0,
date(2024, 1, 2): 5,
date(2024, 1, 3): 0,
}
heatmap.set_data(data)
assert heatmap._max_value == 5
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_single_day(qtbot):
"""Test heatmap with just one day of data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {date(2024, 1, 15): 10}
heatmap.set_data(data)
# Should handle single day
assert heatmap._start is not None
assert heatmap._end is not None
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click should work
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# ============================================================================
# StatisticsDialog Tests
# ============================================================================
def test_statistics_dialog_with_empty_database(qtbot, fresh_db):
"""Test statistics dialog with an empty database."""
strings.load_strings("en")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should handle empty database gracefully
assert dialog.isVisible()
# Heatmap should be empty
heatmap = dialog.findChild(DateHeatmap)
if heatmap:
# No crash when displaying empty heatmap
qtbot.wait(10)
def test_statistics_dialog_with_data(qtbot, fresh_db):
"""Test statistics dialog with actual data."""
strings.load_strings("en")
# Add some content
fresh_db.save_new_version("2024-01-01", "Hello world", "test")
fresh_db.save_new_version("2024-01-02", "More content here", "test")
fresh_db.save_new_version("2024-01-03", "Even more text", "test")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should display statistics
assert dialog.isVisible()
qtbot.wait(10)
def test_statistics_dialog_gather_stats_exception_handling(
qtbot, fresh_db, monkeypatch
):
"""Test that gather_stats handles exceptions gracefully."""
strings.load_strings("en")
# Make dates_with_content raise an exception
def bad_dates_with_content():
raise RuntimeError("Simulated DB error")
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content)
# Should still create dialog without crashing
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should handle error gracefully
assert dialog.isVisible()
def test_statistics_dialog_with_sparse_data(qtbot, tmp_db_cfg, fresh_db):
"""Test statistics dialog with sparse data"""
# Add some entries on non-consecutive days
dates = ["2024-01-01", "2024-01-05", "2024-01-10", "2024-01-20"]
for _date in dates:
content = "Word " * 100 # 100 words
fresh_db.save_new_version(_date, content, "note")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Should create without crashing
assert dialog is not None
def test_statistics_dialog_with_empty_data(qtbot, tmp_db_cfg, fresh_db):
"""Test statistics dialog with no data"""
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Should handle empty data gracefully
assert dialog is not None
def test_statistics_dialog_date_range_selection(qtbot, tmp_db_cfg, fresh_db):
"""Test changing metric in statistics dialog"""
# Add some test data
for i in range(10):
date = QDate.currentDate().addDays(-i).toString("yyyy-MM-dd")
fresh_db.save_new_version(date, f"Content for day {i}", "note")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Change metric to revisions
idx = dialog.metric_combo.findData("revisions")
if idx >= 0:
dialog.metric_combo.setCurrentIndex(idx)
qtbot.wait(50)
# Change back to words
idx = dialog.metric_combo.findData("words")
if idx >= 0:
dialog.metric_combo.setCurrentIndex(idx)
qtbot.wait(50)
def test_heatmap_with_varying_word_counts(qtbot):
"""Test heatmap color scaling with varying word counts"""
today = datetime.now().date()
start = today - timedelta(days=30)
entries = {}
# Create entries with varying word counts
for i in range(31):
date = start + timedelta(days=i)
entries[date] = i * 50 # Increasing word counts
heatmap = DateHeatmap()
heatmap.set_data(entries)
qtbot.addWidget(heatmap)
heatmap.show()
# Should paint without errors
assert heatmap.isVisible()
def test_heatmap_single_day(qtbot):
"""Test heatmap with single day of data"""
today = datetime.now().date()
entries = {today: 500}
heatmap = DateHeatmap()
heatmap.set_data(entries)
qtbot.addWidget(heatmap)
heatmap.show()
assert heatmap.isVisible()
def test_statistics_dialog_metric_changes(qtbot, tmp_db_cfg, fresh_db):
"""Test various metric selections"""
# Add data spanning multiple months
base_date = QDate.currentDate().addDays(-90)
for i in range(90):
date = base_date.addDays(i).toString("yyyy-MM-dd")
fresh_db.save_new_version(date, f"Day {i} content with many words", "note")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
# Test each metric option
for i in range(dialog.metric_combo.count()):
dialog.metric_combo.setCurrentIndex(i)
qtbot.wait(50)
def test_heatmap_date_beyond_end(qtbot, fresh_db):
"""Test clicking on a date beyond the end date in heatmap."""
# Create entries spanning a range
today = date.today()
start = today - timedelta(days=30)
data = {}
for i in range(20):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set data
heatmap.set_data(data)
# Try to click beyond the end date - should return early
# Calculate a position that would be beyond the end
if heatmap._start and heatmap._end:
cell_span = heatmap._cell + heatmap._gap
weeks = ((heatmap._end - heatmap._start).days + 6) // 7
# Click beyond the last week
x = heatmap._margin_left + (weeks + 1) * cell_span + 5
y = heatmap._margin_top + 3 * cell_span + 5
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
def test_heatmap_click_outside_grid(qtbot, fresh_db):
"""Test clicking outside the heatmap grid area."""
today = date.today()
start = today - timedelta(days=7)
data = {}
for i in range(7):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data(data)
# Click in the margin (outside grid)
x = heatmap._margin_left - 10 # Before the grid
y = heatmap._margin_top - 10 # Above the grid
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
# Should not crash, just return early
def test_heatmap_click_invalid_row(qtbot, fresh_db):
"""Test clicking on an invalid row (>= 7)."""
today = date.today()
start = today - timedelta(days=7)
data = {}
for i in range(7):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data(data)
# Click below row 6 (day of week > Sunday)
cell_span = heatmap._cell + heatmap._gap
x = heatmap._margin_left + 5
y = heatmap._margin_top + 7 * cell_span + 5 # Row 7, which is invalid
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
# Should return early, not crash
def test_heatmap_month_label_continuation(qtbot, fresh_db):
"""Test that month labels don't repeat when continuing in same month."""
# Create a date range that spans multiple weeks within the same month
today = date.today()
# Use a date that's guaranteed to be mid-month
start = date(today.year, today.month, 1)
data = {}
for i in range(21):
d = start + timedelta(days=i)
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
data[d] = 1
w = QWidget()
qtbot.addWidget(w)
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data(data)
# Force a repaint to execute paintEvent
heatmap.repaint()
# The month continuation logic should prevent duplicate labels
# We can't easily test the visual output, but we ensure no crash

17
tests/test_strings.py Normal file
View file

@ -0,0 +1,17 @@
from bouquin import strings
def test_load_strings_uses_system_locale_and_fallback():
# pass a bogus locale to trigger fallback-to-default
strings.load_strings("zz")
assert strings._("next") # key exists in base translations
def test_load_strings_french():
strings.load_strings("fr")
assert strings._("today") == "Aujourd'hui" # translation exists in French
def test_load_strings_italian():
strings.load_strings("it")
assert strings._("today") == "Oggi" # translation exists in Italian

194
tests/test_tabs.py Normal file
View file

@ -0,0 +1,194 @@
import types
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):
# point to the temp encrypted DB
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# first tab is today's date
date1 = w.calendar.selectedDate()
initial_count = w.tab_widget.count()
# opening the same date should NOT create a new tab
w._open_date_in_tab(date1)
assert w.tab_widget.count() == initial_count
assert w.tab_widget.currentWidget().current_date == date1
# opening a different date should create exactly one new tab
date2 = date1.addDays(1)
w._open_date_in_tab(date2)
assert w.tab_widget.count() == initial_count + 1
assert w.tab_widget.currentWidget().current_date == date2
# jumping back to date1 just focuses the existing tab
w._open_date_in_tab(date1)
assert w.tab_widget.count() == initial_count + 1
assert w.tab_widget.currentWidget().current_date == date1
def test_toolbar_signals_dispatch_once_per_click(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
tb = w.toolBar
# Spy on the first tab's editor
calls1 = {
"bold": 0,
"italic": 0,
"strike": 0,
"code": 0,
"heading": 0,
"bullets": 0,
"numbers": 0,
"checkboxes": 0,
}
def mk(key):
def _spy(self, *a, **k):
calls1[key] += 1
return _spy
w.editor.apply_weight = types.MethodType(mk("bold"), w.editor)
w.editor.apply_italic = types.MethodType(mk("italic"), w.editor)
w.editor.apply_strikethrough = types.MethodType(mk("strike"), w.editor)
w.editor.apply_code = types.MethodType(mk("code"), w.editor)
w.editor.apply_heading = types.MethodType(mk("heading"), w.editor)
w.editor.toggle_bullets = types.MethodType(mk("bullets"), w.editor)
w.editor.toggle_numbers = types.MethodType(mk("numbers"), w.editor)
w.editor.toggle_checkboxes = types.MethodType(mk("checkboxes"), w.editor)
# Click all the things once
tb.boldRequested.emit()
tb.italicRequested.emit()
tb.strikeRequested.emit()
tb.codeRequested.emit()
tb.headingRequested.emit(24)
tb.bulletsRequested.emit()
tb.numbersRequested.emit()
tb.checkboxesRequested.emit()
assert all(v == 1 for v in calls1.values()) # fired once each
# Switch to a new tab and make sure clicks go ONLY to the active editor
date2 = w.calendar.selectedDate().addDays(1)
w._open_date_in_tab(date2)
calls2 = {"bold": 0}
w.editor.apply_weight = types.MethodType(
lambda self: calls2.__setitem__("bold", calls2["bold"] + 1), w.editor
)
tb.boldRequested.emit()
assert calls1["bold"] == 1
assert calls2["bold"] == 1
w._open_date_in_tab(date2.addDays(-1)) # back to first tab
tb.boldRequested.emit()
assert calls1["bold"] == 2
assert calls2["bold"] == 1
def test_history_and_insert_image_not_duplicated(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# History dialog opens exactly once
opened = {"count": 0}
def fake_exec(self):
opened["count"] += 1
return 0 # Rejected
monkeypatch.setattr(HistoryDialog, "exec", fake_exec, raising=False)
w.toolBar.historyRequested.emit()
assert opened["count"] == 1
# Insert image: simulate user selecting one file, and ensure it's inserted once
dummy = tmp_path / "x.png"
dummy.write_bytes(b"\x89PNG\r\n\x1a\n")
inserted = {"count": 0}
def fake_insert(self, p):
inserted["count"] += 1
w.editor.insert_image_from_path = types.MethodType(fake_insert, w.editor)
monkeypatch.setattr(
QFileDialog,
"getOpenFileNames",
lambda *a, **k: ([str(dummy)], "Images (*.png)"),
raising=False,
)
w.toolBar.insertImageRequested.emit()
assert inserted["count"] == 1
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
w.editor.from_markdown("**bold**\n- [ ] task\n~~strike~~")
assert w.editor.highlighter is not None
assert w.editor.highlighter.document() is w.editor.document()
def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Tab 1 content
w.editor.from_markdown("alpha bravo charlie")
w.findBar.show_bar()
w.findBar.edit.setText("bravo")
w.findBar.find_next()
assert w.editor.textCursor().selectedText() == "bravo"
# Tab 2 content (contains the query too)
date2 = w.calendar.selectedDate().addDays(1)
w._open_date_in_tab(date2)
w.editor.from_markdown("x bravo y bravo z")
w.editor.moveCursor(QTextCursor.Start)
w.findBar.find_next()
assert w.editor.textCursor().selectedText() == "bravo"

2316
tests/test_tags.py Normal file

File diff suppressed because it is too large Load diff

51
tests/test_theme.py Normal file
View file

@ -0,0 +1,51 @@
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
def test_theme_manager_apply_light_and_dark(app):
cfg = ThemeConfig(theme=Theme.LIGHT)
mgr = ThemeManager(app, cfg)
mgr.apply(Theme.LIGHT)
assert isinstance(app.palette(), QPalette)
mgr.set(Theme.DARK)
assert isinstance(app.palette(), QPalette)
def test_theme_manager_system_roundtrip(app, qtbot):
cfg = ThemeConfig(theme=Theme.SYSTEM)
mgr = ThemeManager(app, cfg)
mgr.apply(cfg.theme)
def _make_themes(theme):
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=theme))
def test_register_and_restyle_calendar_and_overlay(qtbot):
themes = _make_themes(Theme.DARK)
cal = QCalendarWidget()
ov = QWidget()
ov.setObjectName("LockOverlay")
qtbot.addWidget(cal)
qtbot.addWidget(ov)
themes.register_calendar(cal)
themes.register_lock_overlay(ov)
# Force a restyle pass (covers the "is not None" branches)
themes._restyle_registered()
def test_apply_dark_styles_cover_css_paths(qtbot):
themes = _make_themes(Theme.DARK)
cal = QCalendarWidget()
ov = QWidget()
ov.setObjectName("LockOverlay")
qtbot.addWidget(cal)
qtbot.addWidget(ov)
themes.register_calendar(cal) # drives _apply_calendar_theme (dark path)
themes.register_lock_overlay(ov) # drives _apply_lock_overlay_theme (dark path)

View file

@ -1,19 +0,0 @@
from bouquin.theme import Theme
def test_apply_link_css_dark_theme(open_window, qtbot):
win = open_window
# Switch to dark and apply link CSS
win.themes.set(Theme.DARK)
win._apply_link_css()
css = win.editor.document().defaultStyleSheet()
assert "#FFA500" in css and "a:visited" in css
def test_apply_link_css_light_theme(open_window, qtbot):
win = open_window
# Switch to light and apply link CSS
win.themes.set(Theme.LIGHT)
win._apply_link_css()
css = win.editor.document().defaultStyleSheet()
assert css == "" or "a {" not in css

Some files were not shown because too many files have changed in this diff Show more