Compare commits

...
Sign in to create a new pull request.

146 commits

Author SHA1 Message Date
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
Lint / test (push) Successful in 28s
CI / test (push) Failing after 4m18s
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
Trivy / test (push) Successful in 20s
Lint / test (push) Successful in 30s
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
107 changed files with 25324 additions and 4859 deletions

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

@ -0,0 +1,37 @@
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

View file

@ -0,0 +1,27 @@
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/

View file

@ -0,0 +1,26 @@
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 .

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ __pycache__
.pytest_cache .pytest_cache
dist dist
.coverage .coverage
*.db

View file

@ -1,3 +1,159 @@
# 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 # 0.1.12.1
* Fix newline after URL keeps URL style formatting * Fix newline after URL keeps URL style formatting

View file

@ -1,9 +1,16 @@
# Bouquin # 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 ## 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 It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
for SQLite3. This means that the underlying database for the notebook is encrypted at rest. for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@ -11,42 +18,78 @@ 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 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. 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 * Data is encrypted at rest
* Encryption key is prompted for and never stored, unless user chooses to via Settings * 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, revert or delete revisions
* All changes are version controlled, with ability to view/diff versions and revert * Automatic rendering of basic Markdown syntax
* Text is HTML with basic styling * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
* Images are supported * Images are supported
* Search * Search all pages, or find text on current page
* Add and manage tags
* Automatic periodic saving (or explicitly save) * 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) * Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password) * 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) * 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' * 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
## How to install ## 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).
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
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 ### From PyPi/pip
* `pip install bouquin` * `pip install bouquin`
### From AppImage
* Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it.
### From source ### From source
* Clone this repo or download the tarball from the releases page * Clone this repo or download the tarball from the releases page

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,121 @@
from __future__ import annotations
import importlib.metadata
import requests
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QTextEdit,
QDialogButtonBox,
QMessageBox,
)
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})",
)

367
bouquin/code_highlighter.py Normal file
View file

@ -0,0 +1,367 @@
from __future__ import annotations
import re
from typing import Optional, Dict
from PySide6.QtGui import QColor, QTextCharFormat, QFont
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",
"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",
"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) + " -->"
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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -17,13 +17,14 @@ from PySide6.QtWidgets import (
QTextEdit, QTextEdit,
) )
from . import strings
class FindBar(QWidget): class FindBar(QWidget):
"""Widget for finding text in the Editor""" """Widget for finding text in the Editor"""
closed = ( # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
Signal() closed = Signal()
) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
def __init__( def __init__(
self, self,
@ -31,23 +32,28 @@ class FindBar(QWidget):
shortcut_parent: QWidget | None = None, shortcut_parent: QWidget | None = None,
parent: QWidget | None = None, parent: QWidget | None = None,
): ):
super().__init__(parent)
self.editor = editor
# UI 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 = QHBoxLayout(self)
layout.setContentsMargins(6, 0, 6, 0) layout.setContentsMargins(6, 0, 6, 0)
layout.addWidget(QLabel("Find:")) layout.addWidget(QLabel(strings._("find")))
self.edit = QLineEdit(self) self.edit = QLineEdit(self)
self.edit.setPlaceholderText("Type to search…") self.edit.setPlaceholderText(strings._("find_bar_type_to_search"))
layout.addWidget(self.edit) layout.addWidget(self.edit)
self.case = QCheckBox("Match case", self) self.case = QCheckBox(strings._("find_bar_match_case"), self)
layout.addWidget(self.case) layout.addWidget(self.case)
self.prevBtn = QPushButton("Prev", self) self.prevBtn = QPushButton(strings._("previous"), self)
self.nextBtn = QPushButton("Next", self) self.nextBtn = QPushButton(strings._("next"), self)
self.closeBtn = QPushButton("", self) self.closeBtn = QPushButton("", self)
self.closeBtn.setFlat(True) self.closeBtn.setFlat(True)
layout.addWidget(self.prevBtn) layout.addWidget(self.prevBtn)
@ -56,11 +62,15 @@ class FindBar(QWidget):
self.setVisible(False) self.setVisible(False)
# Shortcut escape key to close findBar # Shortcut (press Esc to hide bar)
sp = shortcut_parent if shortcut_parent is not None else (parent or self) sp = (
self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide) 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 # Signals (connect ONCE)
self.edit.returnPressed.connect(self.find_next) self.edit.returnPressed.connect(self.find_next)
self.edit.textChanged.connect(self._update_highlight) self.edit.textChanged.connect(self._update_highlight)
self.case.toggled.connect(self._update_highlight) self.case.toggled.connect(self._update_highlight)
@ -68,10 +78,17 @@ class FindBar(QWidget):
self.prevBtn.clicked.connect(self.find_prev) self.prevBtn.clicked.connect(self.find_prev)
self.closeBtn.clicked.connect(self.hide_bar) self.closeBtn.clicked.connect(self.hide_bar)
@property
def editor(self) -> QTextEdit | None:
"""Get the current editor"""
return self._editor_getter()
# ----- Public API ----- # ----- Public API -----
def show_bar(self): def show_bar(self):
"""Show the bar, seed with current selection if sensible, focus the line edit.""" """Show the bar, seed with current selection if sensible, focus the line edit."""
if not self.editor:
return
tc = self.editor.textCursor() tc = self.editor.textCursor()
sel = tc.selectedText().strip() sel = tc.selectedText().strip()
if sel and "\u2029" not in sel: # ignore multi-paragraph selections if sel and "\u2029" not in sel: # ignore multi-paragraph selections
@ -155,6 +172,8 @@ class FindBar(QWidget):
self._update_highlight() self._update_highlight()
def _update_highlight(self): def _update_highlight(self):
if not self.editor:
return
txt = self.edit.text() txt = self.edit.text()
if not txt: if not txt:
self._clear_highlight() self._clear_highlight()
@ -183,4 +202,5 @@ class FindBar(QWidget):
self.editor.setExtraSelections(selections) self.editor.setExtraSelections(selections)
def _clear_highlight(self): def _clear_highlight(self):
if self.editor:
self.editor.setExtraSelections([]) 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

Binary file not shown.

Binary file not shown.

93
bouquin/fonts/OFL.txt Normal file
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.

View file

@ -13,35 +13,42 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
QTextBrowser, QTextBrowser,
QTabWidget, QTabWidget,
QAbstractItemView,
) )
from . import strings
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) def _markdown_to_text(s: str) -> str:
s = STYLE_SCRIPT_RE.sub("", s) """Convert markdown to plain text for diff comparison."""
s = COMMENT_RE.sub("", s) # Remove images
s = BR_RE.sub("\n", s) s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
s = BLOCK_END_RE.sub("\n", s) # Remove inline code formatting
s = TAG_RE.sub("", s) s = re.sub(r"`([^`]+)`", r"\1", s)
s = _html.unescape(s) # Remove bold/italic markers
s = MULTINL_RE.sub("\n\n", s) 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() 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).""" """Return HTML with colored unified diff (+ green, - red, context gray)."""
a = _html_to_text(old_html).splitlines() a = _markdown_to_text(old_md).splitlines()
b = _html_to_text(new_html).splitlines() b = _markdown_to_text(new_md).splitlines()
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="") ud = difflib.unified_diff(
a, b, fromfile=strings._("current"), tofile=strings._("selected"), lineterm=""
)
lines = [] lines = []
for line in ud: for line in ud:
if line.startswith("+") and not line.startswith("+++"): if line.startswith("+") and not line.startswith("+++"):
@ -65,7 +72,7 @@ class HistoryDialog(QDialog):
def __init__(self, db, date_iso: str, parent=None): def __init__(self, db, date_iso: str, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(f"History{date_iso}") self.setWindowTitle(f"{strings._('history')}{date_iso}")
self._db = db self._db = db
self._date = date_iso self._date = date_iso
self._versions = [] # list[dict] from DB self._versions = [] # list[dict] from DB
@ -76,6 +83,7 @@ class HistoryDialog(QDialog):
# Top: list of versions # Top: list of versions
top = QHBoxLayout() top = QHBoxLayout()
self.list = QListWidget() self.list = QListWidget()
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.list.setMinimumSize(500, 650) self.list.setMinimumSize(500, 650)
self.list.currentItemChanged.connect(self._on_select) self.list.currentItemChanged.connect(self._on_select)
top.addWidget(self.list, 1) top.addWidget(self.list, 1)
@ -86,8 +94,8 @@ class HistoryDialog(QDialog):
self.preview.setOpenExternalLinks(True) self.preview.setOpenExternalLinks(True)
self.diff = QTextBrowser() self.diff = QTextBrowser()
self.diff.setOpenExternalLinks(False) self.diff.setOpenExternalLinks(False)
self.tabs.addTab(self.preview, "Preview") self.tabs.addTab(self.preview, strings._("history_dialog_preview"))
self.tabs.addTab(self.diff, "Diff") self.tabs.addTab(self.diff, strings._("history_dialog_diff"))
self.tabs.setMinimumSize(500, 650) self.tabs.setMinimumSize(500, 650)
top.addWidget(self.tabs, 2) top.addWidget(self.tabs, 2)
@ -96,39 +104,38 @@ class HistoryDialog(QDialog):
# Buttons # Buttons
row = QHBoxLayout() row = QHBoxLayout()
row.addStretch(1) 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_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) self.btn_close.clicked.connect(self.reject)
row.addWidget(self.btn_revert) row.addWidget(self.btn_revert)
row.addWidget(self.btn_delete)
row.addWidget(self.btn_close) row.addWidget(self.btn_close)
root.addLayout(row) root.addLayout(row)
self._load_versions() self._load_versions()
# --- Data/UX helpers --- # --- 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")
def _load_versions(self): def _load_versions(self):
self._versions = self._db.list_versions( # [{id,version_no,created_at,note,is_current}]
self._date self._versions = self._db.list_versions(self._date)
) # [{id,version_no,created_at,note,is_current}]
self._current_id = next( self._current_id = next(
(v["id"] for v in self._versions if v["is_current"]), None (v["id"] for v in self._versions if v["is_current"]), None
) )
self.list.clear() self.list.clear()
for v in self._versions: 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"): if v.get("note"):
label += f" · {v['note']}" label += f" · {v['note']}"
if v["is_current"]: if v["is_current"]:
label += " **(current)**" label += " **(" + strings._("current") + ")**"
it = QListWidgetItem(label) it = QListWidgetItem(label)
it.setData(Qt.UserRole, v["id"]) it.setData(Qt.UserRole, v["id"])
self.list.addItem(it) self.list.addItem(it)
@ -143,27 +150,28 @@ class HistoryDialog(QDialog):
@Slot() @Slot()
def _on_select(self): def _on_select(self):
selected_items = self.list.selectedItems()
item = self.list.currentItem() item = self.list.currentItem()
if not item: if not item or len(selected_items) > 1:
self.preview.clear() self.preview.clear()
self.diff.clear() self.diff.clear()
self.btn_revert.setEnabled(False) self.btn_revert.setEnabled(False)
return return
sel_id = item.data(Qt.UserRole) sel_id = item.data(Qt.UserRole)
# Preview selected as HTML
sel = self._db.get_version(version_id=sel_id) sel = self._db.get_version(version_id=sel_id)
self.preview.setHtml(sel["content"]) self.preview.setMarkdown(sel["content"])
# Diff vs current (textual diff) # Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id) cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"])) self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
# Enable revert only if selecting a non-current version
# Enable revert and delete buttons only if selecting a non-current version
self.btn_revert.setEnabled(sel_id != self._current_id) self.btn_revert.setEnabled(sel_id != self._current_id)
self.btn_delete.setEnabled(sel_id != self._current_id)
@Slot() @Slot()
def _revert(self): def _revert(self):
item = self.list.currentItem() item = self.list.currentItem()
if not item:
return
sel_id = item.data(Qt.UserRole) sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id: if sel_id == self._current_id:
return return
@ -171,6 +179,24 @@ class HistoryDialog(QDialog):
try: try:
self._db.revert_to_version(self._date, version_id=sel_id) self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Revert failed", str(e)) QMessageBox.critical(
self, strings._("history_dialog_revert_failed"), str(e)
)
return return
self.accept() 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

View file

@ -1,47 +1,107 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QDialogButtonBox, QDialogButtonBox,
QFileDialog,
) )
from . import strings
class KeyPrompt(QDialog): class KeyPrompt(QDialog):
def __init__( def __init__(
self, self,
parent=None, parent=None,
title: str = "Enter key", title: str = strings._("key_prompt_enter_key"),
message: str = "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. Prompt the user for the key required to decrypt the database.
Used when opening the app, unlocking the idle locked screen, Used when opening the app, unlocking the idle locked screen,
or when rekeying. 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) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)
self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.addWidget(QLabel(message)) v.addWidget(QLabel(message))
self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.Password) # DB chooser
v.addWidget(self.edit) self.path_edit: QLineEdit | None = None
toggle = QPushButton("Show") 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.setCheckable(True)
toggle.toggled.connect( toggle.toggled.connect(
lambda c: self.edit.setEchoMode( lambda c: self.key_entry.setEchoMode(
QLineEdit.Normal if c else QLineEdit.Password QLineEdit.Normal if c else QLineEdit.Password
) )
) )
v.addWidget(toggle) v.addWidget(toggle)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept) bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject) bb.rejected.connect(self.reject)
v.addWidget(bb) v.addWidget(bb)
self.key_entry.setFocus()
self.resize(500, self.sizeHint().height())
def key(self) -> str: 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-----

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

@ -0,0 +1,293 @@
{
"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",
"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",
"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",
"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_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_no_data": "No statistics available yet.",
"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",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
"date_range": "Date range",
"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",
"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",
"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",
"time_log_date_label": "Time log date: {date}",
"time_log_for": "Time log 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",
"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",
"once": "once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"set_reminder": "Set reminder",
"edit_reminder": "Edit reminder",
"reminder": "Reminder",
"time": "Time",
"once_today": "Once (today)",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
"repeat": "Repeat",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"day": "Day"
}

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_today": "Une fois (aujourd'hui)",
"every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines",
"repeat": "Répéter",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche",
"day": "Jour"
}

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,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt, QEvent from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from . import strings
from .theme import ThemeManager
class LockOverlay(QWidget): 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. Widget that 'locks' the screen after a configured idle time.
""" """
@ -16,18 +18,16 @@ class LockOverlay(QWidget):
self.setFocusPolicy(Qt.StrongFocus) self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect()) self.setGeometry(parent.rect())
self._styling = False # <-- reentrancy guard
self._last_dark: bool | None = None
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.addStretch(1) lay.addStretch(1)
msg = QLabel("Locked due to inactivity", self) msg = QLabel(strings._("lock_overlay_locked"), self)
msg.setObjectName("lockLabel") msg.setObjectName("lockLabel")
msg.setAlignment(Qt.AlignCenter) msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton("Unlock", self) self._btn = QPushButton(strings._("lock_overlay_unlock"), self)
self._btn.setObjectName("unlockButton") self._btn.setObjectName("unlockButton")
self._btn.setShortcut("Ctrl+Shift+U")
self._btn.setFixedWidth(200) self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor) self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True) self._btn.setAutoDefault(True)
@ -38,91 +38,9 @@ class LockOverlay(QWidget):
lay.addWidget(self._btn, 0, Qt.AlignCenter) lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1) lay.addStretch(1)
self._apply_overlay_style() themes.register_lock_overlay(self)
self.hide() self.hide()
def _is_dark(self, pal: QPalette) -> bool:
"""
Detect if dark mode is in use.
"""
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); }}
#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 (user changed theme)
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
self._apply_overlay_style()
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show): if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect()) self.setGeometry(obj.rect())

View file

@ -1,17 +1,25 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from .settings import APP_NAME, APP_ORG, get_settings from .settings import APP_NAME, APP_ORG, get_settings
from .main_window import MainWindow from .main_window import MainWindow
from .theme import Theme, ThemeConfig, ThemeManager from .theme import Theme, ThemeConfig, ThemeManager
from . import strings
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG) 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() s = get_settings()
theme_str = s.value("ui/theme", "system") theme_str = s.value("ui/theme", "system")
@ -19,6 +27,7 @@ def main():
themes = ThemeManager(app, cfg) themes = ThemeManager(app, cfg)
themes.apply(cfg.theme) themes.apply(cfg.theme)
strings.load_strings(s.value("ui/locale", "en"))
win = MainWindow(themes=themes) win = MainWindow(themes=themes)
win.show() win.show()
sys.exit(app.exec()) sys.exit(app.exec())

File diff suppressed because it is too large Load diff

1311
bouquin/markdown_editor.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,344 @@
from __future__ import annotations
import re
from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
QFontMetrics,
QGuiApplication,
QPalette,
QSyntaxHighlighter,
QTextCharFormat,
QTextDocument,
)
from .theme import ThemeManager, Theme
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
bg = pal.color(QPalette.AlternateBase)
fg = pal.color(QPalette.Text)
else:
# Light mode: keep the existing light gray
bg = QColor(245, 245, 245)
fg = QColor( # pragma: no cover
0, 0, 0
) # avoiding using QPalette.Text as it can be white on macOS
self.code_block_format.setBackground(bg)
self.code_block_format.setForeground(fg)
# 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)
# 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
bg = pal.color(QPalette.Base)
hidden = QColor(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)
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format)

149
bouquin/pomodoro_timer.py Normal file
View file

@ -0,0 +1,149 @@
from __future__ import annotations
import math
from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QWidget,
)
from . import strings
from .db import DBManager
from .time_log import TimeLogDialog
class PomodoroTimer(QDialog):
"""A simple timer dialog for tracking work time on a specific task."""
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
def __init__(self, task_text: str, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
self.setModal(False)
self.setMinimumWidth(300)
self._task_text = task_text
self._elapsed_seconds = 0
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(24)
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.accept()
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."""
# Stop any existing timer
if self._active_timer and self._active_timer.isVisible():
self._active_timer.close()
# Create new timer
self._active_timer = PomodoroTimer(line_text, self._parent)
self._active_timer.timerStopped.connect(
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
)
self._active_timer.show()
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
"""Handle timer stop - open time log dialog with pre-filled data."""
# Convert seconds to decimal hours, rounded up
hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour
# Ensure minimum of 0.25 hours
if hours < 0.25:
hours = 0.25
# Open time log dialog
dlg = TimeLogDialog(self._db, date_iso, self._parent)
# 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()

639
bouquin/reminders.py Normal file
View file

@ -0,0 +1,639 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLineEdit,
QComboBox,
QTimeEdit,
QPushButton,
QFrame,
QWidget,
QToolButton,
QListWidget,
QListWidgetItem,
QStyle,
QSizePolicy,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
)
from . import strings
from .db import DBManager
class ReminderType(Enum):
ONCE = strings._("once")
DAILY = strings._("daily")
WEEKDAYS = strings._("weekdays") # Mon-Fri
WEEKLY = strings._("weekly") # specific day of week
@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)
form = QFormLayout()
# Reminder text
self.text_edit = QLineEdit()
if reminder:
self.text_edit.setText(reminder.text)
form.addRow("&" + strings._("reminder") + ":", self.text_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:
self.time_edit.setTime(QTime.currentTime())
form.addRow("&" + strings._("time") + ":", self.time_edit)
# Recurrence type
self.type_combo = QComboBox()
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
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)
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(QDate.currentDate().dayOfWeek() - 1)
form.addRow("&" + strings._("day") + ":", self.weekday_combo)
layout.addLayout(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 selector based on reminder type."""
reminder_type = self.type_combo.currentData()
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
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 == ReminderType.WEEKLY:
weekday = self.weekday_combo.currentData()
date_iso = None
if reminder_type == ReminderType.ONCE:
# Right now this just means "today at the chosen time".
date_iso = QDate.currentDate().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("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("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("Manage All 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
# Start by syncing to the next minute boundary
self._check_timer = QTimer(self)
self._check_timer.timeout.connect(self._check_reminders)
# Calculate milliseconds until next minute (HH:MM:00)
now = QDateTime.currentDateTime()
current_second = now.time().second()
current_msec = now.time().msec()
# Milliseconds until next minute
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
# Start with a single-shot to sync to the minute
self._sync_timer = QTimer(self)
self._sync_timer.setSingleShot(True)
self._sync_timer.timeout.connect(self._start_regular_timer)
self._sync_timer.start(ms_until_next_minute)
# Also check immediately in case there are pending reminders
QTimer.singleShot(1000, self._check_reminders)
def __del__(self):
"""Cleanup timers when widget is destroyed."""
try:
if hasattr(self, "_check_timer") and self._check_timer:
self._check_timer.stop()
if hasattr(self, "_sync_timer") and self._sync_timer:
self._sync_timer.stop()
except:
pass # Ignore any cleanup errors
def _start_regular_timer(self):
"""Start the regular check timer after initial sync."""
# Now we're at a minute boundary, check and start regular timer
self._check_reminders()
self._check_timer.start(60000) # Check every minute
def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list."""
self.body.setVisible(checked)
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("No upcoming reminders")
item.setFlags(Qt.NoItemFlags)
self.reminder_list.addItem(item)
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
"""Check if a reminder should fire on a given date."""
if reminder.reminder_type == ReminderType.ONCE:
if reminder.date_iso:
return date.toString("yyyy-MM-dd") == reminder.date_iso
return False
elif reminder.reminder_type == ReminderType.DAILY:
return True
elif reminder.reminder_type == ReminderType.WEEKDAYS:
# Monday=1, Sunday=7
return 1 <= date.dayOfWeek() <= 5
elif reminder.reminder_type == ReminderType.WEEKLY:
# Qt: Monday=1, reminder: Monday=0
return date.dayOfWeek() - 1 == reminder.weekday
return False
def _check_reminders(self):
"""Check if any reminders should fire now."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
reminders = self._db.get_all_reminders()
for reminder in reminders:
if not reminder.active:
continue
if not self._should_fire_on_date(reminder, today):
continue
# Parse time
hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0))
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
seconds_diff = current_minute.secsTo(target)
if -120 <= seconds_diff <= 0:
# Check if we haven't already fired this one
if not hasattr(self, "_fired_reminders"):
self._fired_reminders = {}
reminder_key = (reminder.id, target.toString())
# Only fire once per reminder per target time
if reminder_key not in self._fired_reminders:
self._fired_reminders[reminder_key] = current_minute
self.reminderTriggered.emit(reminder.text)
# 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.QtWidgets import QMenu
from PySide6.QtGui import QAction
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("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 = "Delete"
else:
delete_text = f"Delete {len(selected_items)} 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 = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
else:
msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
reply = QMessageBox.question(
self,
"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 = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
reply = QMessageBox.question(
self,
"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("Manage Reminders")
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
["Text", "Time", "Type", "Active", "Actions"]
)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
layout.addWidget(self.table)
# Buttons
btn_layout = QHBoxLayout()
add_btn = QPushButton("Add Reminder")
add_btn.clicked.connect(self._add_reminder)
btn_layout.addWidget(add_btn)
btn_layout.addStretch()
close_btn = QPushButton("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)
# Time
time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 1, time_item)
# Type
type_str = {
ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays",
ReminderType.WEEKLY: "Weekly",
}.get(reminder.reminder_type, "Unknown")
if (
reminder.reminder_type == ReminderType.WEEKLY
and reminder.weekday is not None
):
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
type_str += f" ({days[reminder.weekday]})"
type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 2, type_item)
# Active
active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 3, active_item)
# Actions
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(2, 2, 2, 2)
edit_btn = QPushButton("Edit")
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
actions_layout.addWidget(edit_btn)
delete_btn = QPushButton("Delete")
delete_btn.clicked.connect(
lambda checked, r=reminder: self._delete_reminder(r)
)
actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 4, actions_widget)
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,
"Delete Reminder",
f"Delete reminder '{reminder.text}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self._load_reminders()

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import datetime import datetime
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
@ -10,25 +11,36 @@ from PySide6.QtWidgets import (
QDialogButtonBox, QDialogButtonBox,
) )
from . import strings
class SaveDialog(QDialog): class SaveDialog(QDialog):
def __init__( def __init__(
self, self,
parent=None, 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. Used for explicitly saving a new version of a page.
""" """
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title)
self.setWindowTitle(strings._("enter_a_name_for_this_version"))
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.addWidget(QLabel(message)) v.addWidget(QLabel(strings._("enter_a_name_for_this_version")))
self.note = QLineEdit() self.note = QLineEdit()
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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) 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 = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept) bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject) bb.rejected.connect(self.reject)

View file

@ -4,7 +4,6 @@ import re
from typing import Iterable, Tuple from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QLabel, QLabel,
@ -17,6 +16,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from . import strings
Row = Tuple[str, str] Row = Tuple[str, str]
@ -31,7 +32,7 @@ class Search(QWidget):
self._db = db self._db = db
self.search = QLineEdit() 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.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.search.textChanged.connect(self._search) self.search.textChanged.connect(self._search)
@ -67,10 +68,7 @@ class Search(QWidget):
self.resultDatesChanged.emit([]) # clear highlights self.resultDatesChanged.emit([]) # clear highlights
return return
try:
rows: Iterable[Row] = self._db.search_entries(q) rows: Iterable[Row] = self._db.search_entries(q)
except Exception:
rows = []
self._populate_results(q, rows) self._populate_results(q, rows)
@ -87,10 +85,7 @@ class Search(QWidget):
for date_str, content in rows: for date_str, content in rows:
# Build an HTML fragment around the match and whether to show ellipses # Build an HTML fragment around the match and whether to show ellipses
frag_html, left_ell, right_ell = self._make_html_snippet( frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90)
content, query, radius=30, maxlen=90
)
# ---- Per-item widget: date on top, preview row below (with ellipses) ---- # ---- Per-item widget: date on top, preview row below (with ellipses) ----
container = QWidget() container = QWidget()
outer = QVBoxLayout(container) outer = QVBoxLayout(container)
@ -112,11 +107,6 @@ class Search(QWidget):
h.setContentsMargins(0, 0, 0, 0) h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(4) h.setSpacing(4)
if left_ell:
left = QLabel("")
left.setStyleSheet("color:#888;")
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
preview = QLabel() preview = QLabel()
preview.setTextFormat(Qt.TextFormat.RichText) preview.setTextFormat(Qt.TextFormat.RichText)
preview.setWordWrap(True) preview.setWordWrap(True)
@ -128,11 +118,6 @@ class Search(QWidget):
) )
h.addWidget(preview, 1) h.addWidget(preview, 1)
if right_ell:
right = QLabel("")
right.setStyleSheet("color:#888;")
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
outer.addWidget(row) outer.addWidget(row)
line = QFrame() line = QFrame()
@ -149,10 +134,10 @@ class Search(QWidget):
self.results.setItemWidget(item, container) self.results.setItemWidget(item, container)
# --- Snippet/highlight helpers ----------------------------------------- # --- Snippet/highlight helpers -----------------------------------------
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180): def _make_html_snippet(self, markdown_src: str, query: str, radius=60, maxlen=180):
doc = QTextDocument() # For markdown, we can work directly with the text
doc.setHtml(html_src) # Strip markdown formatting for display
plain = doc.toPlainText() plain = self._strip_markdown(markdown_src)
if not plain: if not plain:
return "", False, False return "", False, False
@ -179,30 +164,45 @@ class Search(QWidget):
start = max(0, min(idx - radius, max(0, L - maxlen))) start = max(0, min(idx - radius, max(0, L - maxlen)))
end = min(L, max(idx + mlen + radius, start + 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: if tokens:
lower = plain.lower()
fmt = QTextCharFormat()
fmt.setFontWeight(QFont.Weight.Bold)
for t in tokens: for t in tokens:
t_low = t.lower() # Case-insensitive replacement
pos = start pattern = re.compile(re.escape(t), re.IGNORECASE)
while True: snippet_html = pattern.sub(
k = lower.find(t_low, pos) lambda m: f"<b>{m.group(0)}</b>", snippet_html
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)
# Select the window and export as HTML fragment return snippet_html
c = QTextCursor(doc)
c.setPosition(start)
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
fragment_html = (
c.selection().toHtml()
) # preserves original styles + our bolding
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

@ -9,31 +9,66 @@ APP_ORG = "Bouquin"
APP_NAME = "Bouquin" APP_NAME = "Bouquin"
def default_db_path() -> Path:
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
return base / "notebook.db"
def get_settings() -> QSettings: def get_settings() -> QSettings:
return QSettings(APP_ORG, APP_NAME) 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: def load_db_config() -> DBConfig:
s = get_settings() 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", "") key = s.value("db/key", "")
idle = s.value("ui/idle_minutes", 15, type=int) idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str) theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool) move_todos = s.value("ui/move_todos", 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)
locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int)
return DBConfig( 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,
tags=tags,
time_log=time_log,
reminders=reminders,
locale=locale,
font_size=font_size,
) )
def save_db_config(cfg: DBConfig) -> None: def save_db_config(cfg: DBConfig) -> None:
s = get_settings() 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("db/key", str(cfg.key))
s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme)) s.setValue("ui/theme", str(cfg.theme))
s.setValue("ui/move_todos", str(cfg.move_todos)) s.setValue("ui/move_todos", str(cfg.move_todos))
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/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -4,22 +4,21 @@ from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox,
QDialog, QDialog,
QFormLayout,
QFrame, QFrame,
QGroupBox, QGroupBox,
QLabel, QLabel,
QHBoxLayout, QHBoxLayout,
QVBoxLayout, QVBoxLayout,
QWidget,
QLineEdit,
QPushButton, QPushButton,
QFileDialog,
QDialogButtonBox, QDialogButtonBox,
QRadioButton, QRadioButton,
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
QMessageBox, QMessageBox,
QWidget,
QTabWidget,
) )
from PySide6.QtCore import Qt, Slot from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette from PySide6.QtGui import QPalette
@ -30,32 +29,64 @@ from .settings import load_db_config, save_db_config
from .theme import Theme from .theme import Theme
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
from . import strings
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
def __init__(self, cfg: DBConfig, db: DBManager, parent=None): def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Settings") self.setWindowTitle(strings._("settings"))
self._cfg = DBConfig(path=cfg.path, key="") self._cfg = DBConfig(path=cfg.path, key="")
self._db = db self._db = db
self.key = "" self.key = ""
form = QFormLayout() self.current_settings = load_db_config()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.setMinimumWidth(560) self.setMinimumWidth(480)
self.setSizeGripEnabled(True) self.setSizeGripEnabled(True)
current_settings = load_db_config() # --- Tabs ----------------------------------------------------------
tabs = QTabWidget()
tabs.setTabPosition(QTabWidget.North)
tabs.setDocumentMode(True)
tabs.setMovable(False)
# Add theme selection tabs.addTab(self._create_appearance_page(cfg), strings._("appearance"))
theme_group = QGroupBox("Theme") 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) theme_layout = QVBoxLayout(theme_group)
self.theme_system = QRadioButton("System") self.theme_system = QRadioButton(strings._("system"))
self.theme_light = QRadioButton("Light") self.theme_light = QRadioButton(strings._("light"))
self.theme_dark = QRadioButton("Dark") self.theme_dark = QRadioButton(strings._("dark"))
# Load current theme from settings current_theme = self.current_settings.theme
current_theme = current_settings.theme
if current_theme == Theme.DARK.value: if current_theme == Theme.DARK.value:
self.theme_dark.setChecked(True) self.theme_dark.setChecked(True)
elif current_theme == Theme.LIGHT.value: elif current_theme == Theme.LIGHT.value:
@ -67,63 +98,119 @@ class SettingsDialog(QDialog):
theme_layout.addWidget(self.theme_light) theme_layout.addWidget(self.theme_light)
theme_layout.addWidget(self.theme_dark) 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 # explanation
behaviour_group = QGroupBox("Behaviour") self.font_size_label = QLabel(strings._("font_size_explanation"))
behaviour_layout = QVBoxLayout(behaviour_group) 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( 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) self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos)
behaviour_layout.addWidget(self.move_todos) self.tags = QCheckBox(strings._("enable_tags_feature"))
form.addRow(behaviour_group) self.tags.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.tags)
self.path_edit = QLineEdit(str(self._cfg.path)) self.time_log = QCheckBox(strings._("enable_time_log_feature"))
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.time_log.setChecked(self.current_settings.time_log)
browse_btn = QPushButton("Browse…") self.time_log.setCursor(Qt.PointingHandCursor)
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) features_layout.addWidget(self.time_log)
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)
# Encryption settings self.reminders = QCheckBox(strings._("enable_reminders_feature"))
enc_group = QGroupBox("Encryption") self.reminders.setChecked(self.current_settings.reminders)
self.reminders.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.reminders)
layout.addWidget(features_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 = QVBoxLayout(enc_group)
enc.setContentsMargins(12, 8, 12, 12)
enc.setSpacing(6)
# Checkbox to remember key self.save_key_btn = QCheckBox(strings._("remember_key"))
self.save_key_btn = QCheckBox("Remember key") self.key = self.current_settings.key or ""
self.key = current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key)) self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor) self.save_key_btn.setCursor(Qt.PointingHandCursor)
self.save_key_btn.toggled.connect(self._save_key_btn_clicked) self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft) enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
# Explanation for remembering key self.save_key_label = QLabel(strings._("save_key_warning"))
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.setWordWrap(True) self.save_key_label.setWordWrap(True)
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
pal = self.save_key_label.palette() 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) self.save_key_label.setPalette(pal)
exp_row = QHBoxLayout() 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) exp_row.addWidget(self.save_key_label)
enc.addLayout(exp_row) enc.addLayout(exp_row)
@ -132,102 +219,77 @@ class SettingsDialog(QDialog):
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Sunken)
enc.addWidget(line) enc.addWidget(line)
# Change key button self.rekey_btn = QPushButton(strings._("change_encryption_key"))
self.rekey_btn = QPushButton("Change encryption key")
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.rekey_btn.clicked.connect(self._change_key) self.rekey_btn.clicked.connect(self._change_key)
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft) enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
form.addRow(enc_group) layout.addWidget(enc_group)
# Privacy settings # --- Idle lock group ----------------------------------------------
priv_group = QGroupBox("Lock screen when idle") priv_group = QGroupBox(strings._("lock_screen_when_idle"))
priv = QVBoxLayout(priv_group) priv = QVBoxLayout(priv_group)
priv.setContentsMargins(12, 8, 12, 12)
priv.setSpacing(6)
self.idle_spin = QSpinBox() self.idle_spin = QSpinBox()
self.idle_spin.setRange(0, 240) self.idle_spin.setRange(0, 240)
self.idle_spin.setSingleStep(1) self.idle_spin.setSingleStep(1)
self.idle_spin.setAccelerated(True) self.idle_spin.setAccelerated(True)
self.idle_spin.setSuffix(" min") 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)) self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft) priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
# Explanation for idle option (autolock)
self.idle_spin_label = QLabel( self.idle_spin_label = QLabel(strings._("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. "
"Set to 0 (never) to never lock."
)
self.idle_spin_label.setWordWrap(True) self.idle_spin_label.setWordWrap(True)
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
spal = self.idle_spin_label.palette() 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) self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout() 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) spin_row.addWidget(self.idle_spin_label)
priv.addLayout(spin_row) priv.addLayout(spin_row)
form.addRow(priv_group) layout.addWidget(priv_group)
layout.addStretch()
return page
# Maintenance settings def _create_database_page(self) -> QWidget:
maint_group = QGroupBox("Database maintenance") 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 = 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.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.compact_btn.clicked.connect(self._compact_btn_clicked) self.compact_btn.clicked.connect(self._compact_btn_clicked)
maint.addWidget(self.compact_btn, 0, Qt.AlignLeft) maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
# Explanation for compating button self.compact_label = QLabel(strings._("database_compact_explanation"))
self.compact_label = QLabel(
"Compacting runs VACUUM on the database. This can help reduce its size."
)
self.compact_label.setWordWrap(True) self.compact_label.setWordWrap(True)
self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
cpal = self.compact_label.palette() 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) self.compact_label.setPalette(cpal)
maint_row = QHBoxLayout() 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_row.addWidget(self.compact_label)
maint.addLayout(maint_row) maint.addLayout(maint_row)
form.addRow(maint_group) layout.addWidget(maint_group)
layout.addStretch()
return page
# Buttons # ------------------------------------------------------------------ #
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) # Save settings
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)
def _save(self): def _save(self):
# Save the selected theme into QSettings
if self.theme_dark.isChecked(): if self.theme_dark.isChecked():
selected_theme = Theme.DARK selected_theme = Theme.DARK
elif self.theme_light.isChecked(): elif self.theme_light.isChecked():
@ -238,11 +300,16 @@ class SettingsDialog(QDialog):
key_to_save = self.key if self.save_key_btn.isChecked() else "" key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig( self._cfg = DBConfig(
path=Path(self.path_edit.text()), path=Path(self.current_settings.path),
key=key_to_save, key=key_to_save,
idle_minutes=self.idle_spin.value(), idle_minutes=self.idle_spin.value(),
theme=selected_theme.value, theme=selected_theme.value,
move_todos=self.move_todos.isChecked(), move_todos=self.move_todos.isChecked(),
tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(),
) )
save_db_config(self._cfg) save_db_config(self._cfg)
@ -250,27 +317,39 @@ class SettingsDialog(QDialog):
self.accept() self.accept()
def _change_key(self): 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: if p1.exec() != QDialog.Accepted:
return return
new_key = p1.key() 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: if p2.exec() != QDialog.Accepted:
return return
if new_key != p2.key(): 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 return
if not new_key: if not new_key:
QMessageBox.warning(self, "Empty key", "Key cannot be empty.") QMessageBox.warning(
self, strings._("empty_key"), strings._("empty_key_explanation")
)
return return
try: try:
self.key = new_key self.key = new_key
self._db.rekey(new_key) self._db.rekey(new_key)
QMessageBox.information( 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: except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") QMessageBox.critical(self, strings._("error"), str(e))
@Slot(bool) @Slot(bool)
def _save_key_btn_clicked(self, checked: bool): def _save_key_btn_clicked(self, checked: bool):
@ -278,7 +357,9 @@ class SettingsDialog(QDialog):
if checked: if checked:
if not self.key: if not self.key:
p1 = KeyPrompt( 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: if p1.exec() != QDialog.Accepted:
self.save_key_btn.blockSignals(True) self.save_key_btn.blockSignals(True)
@ -292,10 +373,10 @@ class SettingsDialog(QDialog):
try: try:
self._db.compact() self._db.compact()
QMessageBox.information( QMessageBox.information(
self, "Compact complete", "Database compacted successfully!" self, strings._("success"), strings._("database_compacted_successfully")
) )
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}") QMessageBox.critical(self, strings._("error"), str(e))
@property @property
def config(self) -> DBConfig: def config(self) -> DBConfig:

View file

@ -0,0 +1,356 @@
from __future__ import annotations
import datetime as _dt
from typing import Dict
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QFormLayout,
QLabel,
QGroupBox,
QHBoxLayout,
QComboBox,
QScrollArea,
QWidget,
QSizePolicy,
)
from . import strings
from .db import DBManager
# ---------- 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, same as your rows.
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 (MonSun)
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(600)
self.setMinimumHeight(400)
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,
) = self._gather_stats()
# --- Numeric summary at the top ----------------------------------
form = QFormLayout()
root.addLayout(form)
form.addRow(
strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)),
)
form.addRow(
strings._("stats_total_revisions"),
QLabel(str(total_revisions)),
)
if page_most_revisions:
form.addRow(
strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
)
else:
form.addRow(strings._("stats_page_most_revisions"), QLabel(""))
form.addRow(
strings._("stats_total_words"),
QLabel(str(total_words)),
)
# Unique tag names
form.addRow(
strings._("stats_unique_tags"),
QLabel(str(unique_tags)),
)
if page_most_tags:
form.addRow(
strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
)
else:
form.addRow(strings._("stats_page_most_tags"), QLabel(""))
# --- Heatmap with switcher ---------------------------------------
if words_by_date or revisions_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")
combo_row.addWidget(self.metric_combo)
combo_row.addStretch(1)
group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap()
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
scroll = QScrollArea()
scroll.setWidgetResizable(True)
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")))
# ---------- internal helpers ----------
def _apply_metric(self, metric: str) -> None:
if metric == "revisions":
self._heatmap.set_data(self._revisions_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 @@
from importlib.resources import files
import json
# 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

253
bouquin/tag_browser.py Normal file
View file

@ -0,0 +1,253 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
QLabel,
QColorDialog,
QMessageBox,
QInputDialog,
)
from .db import DBManager
from . import strings
from sqlcipher3.dbapi2 import IntegrityError
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.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()
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
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 = 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)
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):
if data.get("type") == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
self.accept()
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 (
QFrame,
QHBoxLayout,
QVBoxLayout,
QWidget,
QToolButton,
QLabel,
QLineEdit,
QSizePolicy,
QStyle,
QCompleter,
)
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

@ -2,8 +2,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from PySide6.QtGui import QPalette, QColor, QGuiApplication from PySide6.QtGui import QPalette, QColor, QGuiApplication
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from weakref import WeakSet
class Theme(Enum): class Theme(Enum):
@ -26,6 +27,9 @@ class ThemeManager(QObject):
super().__init__() super().__init__()
self._app = app self._app = app
self._cfg = cfg self._cfg = cfg
self._current = None
self._calendars: "WeakSet[QCalendarWidget]" = WeakSet()
self._lock_overlays: "WeakSet[QWidget]" = WeakSet()
# Follow OS if supported (Qt 6+) # Follow OS if supported (Qt 6+)
hints = QGuiApplication.styleHints() hints = QGuiApplication.styleHints()
@ -35,6 +39,20 @@ class ThemeManager(QObject):
and self.apply(self._cfg.theme) 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: def current(self) -> Theme:
return self._cfg.theme return self._cfg.theme
@ -43,28 +61,34 @@ class ThemeManager(QObject):
self.apply(theme) self.apply(theme)
def apply(self, theme: Theme): def apply(self, theme: Theme):
# Resolve "system" # Resolve "system" into a concrete theme
resolved = theme
if theme == Theme.SYSTEM: if theme == Theme.SYSTEM:
hints = QGuiApplication.styleHints() resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT
scheme = getattr(hints, "colorScheme", None)
if callable(scheme):
scheme = hints.colorScheme()
# 0=Light, 1=Dark; fall back to Light
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
# Always use Fusion so palette applies consistently cross-platform if resolved == Theme.DARK:
self._app.setStyle("Fusion")
if theme == Theme.DARK:
pal = self._dark_palette() pal = self._dark_palette()
self._app.setPalette(pal)
self._app.setStyleSheet("")
else: else:
pal = self._light_palette() 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 ----- # ----- Palettes -----
def _dark_palette(self) -> QPalette: def _dark_palette(self) -> QPalette:
@ -75,17 +99,24 @@ class ThemeManager(QObject):
disabled = QColor(127, 127, 127) disabled = QColor(127, 127, 127)
focus = QColor(42, 130, 218) focus = QColor(42, 130, 218)
# Base surfaces
pal.setColor(QPalette.Window, window) pal.setColor(QPalette.Window, window)
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.Base, base) pal.setColor(QPalette.Base, base)
pal.setColor(QPalette.AlternateBase, window) pal.setColor(QPalette.AlternateBase, window)
# Text
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.ToolTipBase, window) pal.setColor(QPalette.ToolTipBase, window)
pal.setColor(QPalette.ToolTipText, text) pal.setColor(QPalette.ToolTipText, text)
pal.setColor(QPalette.Text, text) pal.setColor(QPalette.Text, text)
pal.setColor(QPalette.PlaceholderText, disabled) pal.setColor(QPalette.PlaceholderText, disabled)
pal.setColor(QPalette.Button, window)
pal.setColor(QPalette.ButtonText, text) pal.setColor(QPalette.ButtonText, text)
# Buttons/frames
pal.setColor(QPalette.Button, window)
pal.setColor(QPalette.BrightText, QColor(255, 84, 84)) pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
# Links / selection
pal.setColor(QPalette.Highlight, focus) pal.setColor(QPalette.Highlight, focus)
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0)) pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value)) pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
@ -94,11 +125,136 @@ class ThemeManager(QObject):
return pal return pal
def _light_palette(self) -> QPalette: def _light_palette(self) -> QPalette:
# Let Qt provide its default light palette, but nudge a couple roles pal = QPalette()
pal = self._app.style().standardPalette()
pal.setColor(QPalette.Highlight, QColor(0, 120, 215)) # Base surfaces
pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) pal.setColor(QPalette.Window, QColor("#ffffff"))
pal.setColor( pal.setColor(QPalette.Base, QColor("#ffffff"))
QPalette.Link, QColor("#1a73e8") pal.setColor(QPalette.AlternateBase, QColor("#f5f5f5"))
) # Light blue for links in light mode
# 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 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("")
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()

1217
bouquin/time_log.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,128 +4,128 @@ from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
from PySide6.QtWidgets import QToolBar from PySide6.QtWidgets import QToolBar
from . import strings
class ToolBar(QToolBar): class ToolBar(QToolBar):
boldRequested = Signal() boldRequested = Signal()
italicRequested = Signal() italicRequested = Signal()
underlineRequested = Signal()
strikeRequested = Signal() strikeRequested = Signal()
codeRequested = Signal() codeRequested = Signal()
headingRequested = Signal(int) headingRequested = Signal(int)
bulletsRequested = Signal() bulletsRequested = Signal()
numbersRequested = Signal() numbersRequested = Signal()
checkboxesRequested = Signal() checkboxesRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal() historyRequested = Signal()
insertImageRequested = Signal() insertImageRequested = Signal()
alarmRequested = Signal()
timerRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__("Format", parent) super().__init__(strings._("toolbar_format"), parent)
self.setObjectName("Format") self.setObjectName(strings._("toolbar_format"))
self.setToolButtonStyle(Qt.ToolButtonTextOnly) self.setToolButtonStyle(Qt.ToolButtonTextOnly)
self._build_actions() self._build_actions()
self._apply_toolbar_styles() self._apply_toolbar_styles()
def _build_actions(self): def _build_actions(self):
self.actBold = QAction("B", self) self.actBold = QAction("B", self)
self.actBold.setToolTip("Bold") self.actBold.setToolTip(strings._("toolbar_bold"))
self.actBold.setCheckable(True) self.actBold.setCheckable(True)
self.actBold.setShortcut(QKeySequence.Bold) self.actBold.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested) self.actBold.triggered.connect(self.boldRequested)
self.actItalic = QAction("I", self) self.actItalic = QAction("I", self)
self.actItalic.setToolTip("Italic") self.actItalic.setToolTip(strings._("toolbar_italic"))
self.actItalic.setCheckable(True) self.actItalic.setCheckable(True)
self.actItalic.setShortcut(QKeySequence.Italic) self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested) 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 = QAction("S", self)
self.actStrike.setToolTip("Strikethrough") self.actStrike.setToolTip(strings._("toolbar_strikethrough"))
self.actStrike.setCheckable(True) self.actStrike.setCheckable(True)
self.actStrike.setShortcut("Ctrl+-") self.actStrike.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested) self.actStrike.triggered.connect(self.strikeRequested)
self.actCode = QAction("</>", self) self.actCode = QAction("</>", self)
self.actCode.setToolTip("Code block") self.actCode.setToolTip(strings._("toolbar_code_block"))
self.actCode.setShortcut("Ctrl+`") self.actCode.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested) self.actCode.triggered.connect(self.codeRequested)
# Headings # Headings
self.actH1 = QAction("H1", self) self.actH1 = QAction("H1", self)
self.actH1.setToolTip("Heading 1") self.actH1.setToolTip(strings._("toolbar_heading") + " 1")
self.actH1.setCheckable(True) self.actH1.setCheckable(True)
self.actH1.setShortcut("Ctrl+1") self.actH1.setShortcut("Ctrl+1")
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24)) self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
self.actH2 = QAction("H2", self) self.actH2 = QAction("H2", self)
self.actH2.setToolTip("Heading 2") self.actH2.setToolTip(strings._("toolbar_heading") + " 2")
self.actH2.setCheckable(True) self.actH2.setCheckable(True)
self.actH2.setShortcut("Ctrl+2") self.actH2.setShortcut("Ctrl+2")
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18)) self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
self.actH3 = QAction("H3", self) self.actH3 = QAction("H3", self)
self.actH3.setToolTip("Heading 3") self.actH3.setToolTip(strings._("toolbar_heading") + " 3")
self.actH3.setCheckable(True) self.actH3.setCheckable(True)
self.actH3.setShortcut("Ctrl+3") self.actH3.setShortcut("Ctrl+3")
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
self.actNormal = QAction("N", self) self.actNormal = QAction("P", self)
self.actNormal.setToolTip("Normal paragraph text") self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text"))
self.actNormal.setCheckable(True) self.actNormal.setCheckable(True)
self.actNormal.setShortcut("Ctrl+N") self.actNormal.setShortcut("Ctrl+.")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0)) 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 # Lists
self.actBullets = QAction("", self) self.actBullets = QAction("", self)
self.actBullets.setToolTip("Bulleted list") self.actBullets.setToolTip(strings._("toolbar_bulleted_list"))
self.actBullets.setCheckable(True) self.actBullets.setCheckable(True)
self.actBullets.triggered.connect(self.bulletsRequested) self.actBullets.triggered.connect(self.bulletsRequested)
self.actNumbers = QAction("1.", self) self.actNumbers = QAction("1.", self)
self.actNumbers.setToolTip("Numbered list") self.actNumbers.setToolTip(strings._("toolbar_numbered_list"))
self.actNumbers.setCheckable(True) self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested) self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self) self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip("Toggle checkboxes") self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
self.actCheckboxes.triggered.connect(self.checkboxesRequested) self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images # Images
self.actInsertImg = QAction("Image", self) self.actInsertImg = QAction("📸", self)
self.actInsertImg.setToolTip("Insert image") self.actInsertImg.setToolTip(strings._("insert_images"))
self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.setShortcut("Ctrl+Shift+I")
self.actInsertImg.triggered.connect(self.insertImageRequested) 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 # History button
self.actHistory = QAction("History", self) self.actHistory = QAction("🔁", self)
self.actHistory.setToolTip(strings._("history"))
self.actHistory.triggered.connect(self.historyRequested) 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.triggered.connect(self.timerRequested)
# Set exclusive buttons in QActionGroups # Set exclusive buttons in QActionGroups
self.grpHeadings = QActionGroup(self) self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True) self.grpHeadings.setExclusive(True)
for a in ( for a in (
self.actBold, self.actBold,
self.actItalic, self.actItalic,
self.actUnderline,
self.actStrike, self.actStrike,
self.actH1, self.actH1,
self.actH2, self.actH2,
@ -135,11 +135,6 @@ class ToolBar(QToolBar):
a.setCheckable(True) a.setCheckable(True)
a.setActionGroup(self.grpHeadings) 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 = QActionGroup(self)
self.grpLists.setExclusive(True) self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes): for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
@ -150,20 +145,20 @@ class ToolBar(QToolBar):
[ [
self.actBold, self.actBold,
self.actItalic, self.actItalic,
self.actUnderline,
self.actStrike, self.actStrike,
self.actCode, self.actCode,
self.actH1, self.actH1,
self.actH2, self.actH2,
self.actH3, self.actH3,
self.actNormal, self.actNormal,
self.actFontSmaller,
self.actFontLarger,
self.actBullets, self.actBullets,
self.actNumbers, self.actNumbers,
self.actCheckboxes, self.actCheckboxes,
self.actInsertImg, self.actInsertImg,
self.actAlignL, self.actAlarm,
self.actAlignC, self.actTimer,
self.actAlignR,
self.actHistory, self.actHistory,
] ]
) )
@ -171,7 +166,6 @@ class ToolBar(QToolBar):
def _apply_toolbar_styles(self): def _apply_toolbar_styles(self):
self._style_letter_button(self.actBold, "B", bold=True) self._style_letter_button(self.actBold, "B", bold=True)
self._style_letter_button(self.actItalic, "I", italic=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) self._style_letter_button(self.actStrike, "S", strike=True)
# Monospace look for code; use a fixed font # Monospace look for code; use a fixed font
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
@ -181,19 +175,19 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actH1, "H1") self._style_letter_button(self.actH1, "H1")
self._style_letter_button(self.actH2, "H2") self._style_letter_button(self.actH2, "H2")
self._style_letter_button(self.actH3, "H3") 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 # Lists
self._style_letter_button(self.actBullets, "") self._style_letter_button(self.actBullets, "")
self._style_letter_button(self.actNumbers, "1.") self._style_letter_button(self.actNumbers, "1.")
self._style_letter_button(self.actCheckboxes, "")
# Alignment self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actAlignL, "L") self._style_letter_button(self.actTimer, "")
self._style_letter_button(self.actAlignC, "C")
self._style_letter_button(self.actAlignR, "R")
# History # History
self._style_letter_button(self.actHistory, "View History") self._style_letter_button(self.actHistory, "🔁")
def _style_letter_button( def _style_letter_button(
self, self,

412
bouquin/version_check.py Normal file
View file

@ -0,0 +1,412 @@
from __future__ import annotations
import importlib.metadata
import os
import re
import subprocess # nosec
import tempfile
from pathlib import Path
import requests
from importlib.resources import files
from PySide6.QtCore import QStandardPaths, Qt
from PySide6.QtWidgets import (
QApplication,
QMessageBox,
QWidget,
QProgressDialog,
)
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
from PySide6.QtSvg import QSvgRenderer
from .settings import APP_NAME
from . import strings
# 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()

479
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. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "beautifulsoup4" name = "certifi"
version = "4.14.2" version = "2025.11.12"
description = "Screen-scraping library" description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7.0" python-versions = ">=3.7"
files = [ files = [
{file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"}, {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
] ]
[package.dependencies] [[package]]
soupsieve = ">1.2" name = "charset-normalizer"
typing-extensions = ">=4.0.0" version = "3.4.4"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
[package.extras] optional = false
cchardet = ["cchardet"] python-versions = ">=3.7"
chardet = ["chardet"] files = [
charset-normalizer = ["charset-normalizer"] {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
html5lib = ["html5lib"] {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
lxml = ["lxml"] {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]] [[package]]
name = "colorama" name = "colorama"
@ -35,115 +146,103 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.10.7" version = "7.12.0"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.10"
files = [ files = [
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
{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.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
{file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
{file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
{file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
{file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
{file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
{file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
{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.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
{file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
{file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
{file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
{file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
{file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
{file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
{file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
{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.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
{file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
{file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
{file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
{file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
{file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
{file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
{file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
{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.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
{file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
{file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
{file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
{file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
{file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
{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.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
{file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
{file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
{file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
{file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
{file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
{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.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
{file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
{file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
{file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
{file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
{file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
{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.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
{file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
{file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
{file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
{file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
{file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
{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"},
] ]
[package.dependencies] [package.dependencies]
@ -152,6 +251,20 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
[package.extras] [package.extras]
toml = ["tomli"] 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]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
version = "1.3.0" version = "1.3.0"
@ -170,30 +283,44 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
test = ["pytest (>=6)"] test = ["pytest (>=6)"]
[[package]] [[package]]
name = "iniconfig" name = "idna"
version = "2.1.0" version = "3.11"
description = "brain-dead simple config-ini parsing" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, {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]] [[package]]
name = "markdownify" name = "markdown"
version = "1.2.0" version = "3.10"
description = "Convert HTML to markdown." description = "Python implementation of John Gruber's Markdown."
optional = false optional = false
python-versions = "*" python-versions = ">=3.10"
files = [ files = [
{file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"}, {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"},
{file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"}, {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"},
] ]
[package.dependencies] [package.extras]
beautifulsoup4 = ">=4.9,<5" docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
six = ">=1.15,<2" testing = ["coverage", "pyyaml"]
[[package]] [[package]]
name = "packaging" name = "packaging"
@ -235,6 +362,22 @@ files = [
[package.extras] [package.extras]
windows-terminal = ["colorama (>=0.4.6)"] windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyproject-appimage"
version = "4.2"
description = "Generate AppImages from your Python projects"
optional = false
python-versions = ">=3.9"
files = [
{file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"},
{file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"},
]
[package.dependencies]
desktop-entry-lib = "*"
requests = "*"
tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]] [[package]]
name = "pyside6" name = "pyside6"
version = "6.10.0" version = "6.10.0"
@ -368,6 +511,27 @@ typing_extensions = "*"
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx_rtd_theme"] doc = ["sphinx", "sphinx_rtd_theme"]
[[package]]
name = "requests"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
files = [
{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]] [[package]]
name = "shiboken6" name = "shiboken6"
version = "6.10.0" version = "6.10.0"
@ -382,28 +546,6 @@ files = [
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, {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."
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"},
]
[[package]] [[package]]
name = "sqlcipher3-wheels" name = "sqlcipher3-wheels"
version = "0.5.5.post0" version = "0.5.5.post0"
@ -597,7 +739,24 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
[[package]]
name = "urllib3"
version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.9,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d" content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df"

View file

@ -1,17 +1,20 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.1.12.1" version = "0.5.2"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://git.mig5.net/mig5/bouquin" 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] [tool.poetry.dependencies]
python = ">=3.9,<3.14" python = ">=3.10,<3.14"
pyside6 = ">=6.8.1,<7.0.0" pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0" sqlcipher3-wheels = "^0.5.5.post0"
markdownify = "^1.2.0" requests = "^2.32.5"
markdown = "^3.10"
[tool.poetry.scripts] [tool.poetry.scripts]
bouquin = "bouquin.__main__:main" bouquin = "bouquin.__main__:main"
@ -22,6 +25,20 @@ pytest-qt = "^4.5.0"
pytest-mock = "^3.15.1" pytest-mock = "^3.15.1"
pytest-cov = "^7.0.0" 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] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View file

@ -1,10 +1,19 @@
#!/bin/bash #!/bin/bash
set -e set -eo pipefail
rm -rf dist rm -rf dist
# Publish to Pypi
poetry build poetry build
poetry publish poetry publish
# Make AppImage
sudo apt-get 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 for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
echo "Don't forget to update version string on remote server."

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: 184 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,133 +1,60 @@
import os import os
import sys import sys
from pathlib import Path 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. import pytest
os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0") from PySide6.QtWidgets import QApplication
# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env
# 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") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# Make project importable @pytest.fixture(scope="session")
from PySide6.QtWidgets import QApplication, QWidget def app():
from bouquin.theme import ThemeManager, ThemeConfig, Theme app = QApplication.instance()
if app is None:
PROJECT_ROOT = Path(__file__).resolve().parents[1] app = QApplication([])
if str(PROJECT_ROOT) not in sys.path: return app
sys.path.insert(0, str(PROJECT_ROOT))
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def enable_qstandardpaths_test_mode(): def isolate_qsettings(tmp_path_factory):
QStandardPaths.setTestModeEnabled(True) cfgdir = tmp_path_factory.mktemp("qt_cfg")
os.environ["XDG_CONFIG_HOME"] = str(cfgdir)
@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 yield
return
s = QSettings(APP_ORG, APP_NAME)
s.clear()
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 @pytest.fixture
def theme_parent_widget(qtbot): def tmp_db_cfg(tmp_path):
"""A minimal parent that provides .themes.apply(...) like MainWindow."""
class _ThemesStub:
def __init__(self):
self.applied = []
def apply(self, theme):
self.applied.append(theme)
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemesStub()
parent = _Parent()
qtbot.addWidget(parent)
return parent
@pytest.fixture(scope="session")
def qapp():
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
# do not quit; pytest might still need it
# app.quit()
@pytest.fixture
def temp_db_path(tmp_path):
return tmp_path / "notebook.db"
@pytest.fixture
def cfg(temp_db_path):
# Use the real DBConfig from the app (SQLCipher-backed)
from bouquin.db import DBConfig from bouquin.db import DBConfig
default_db = tmp_path / "notebook.db"
key = "test-secret-key"
return DBConfig( return DBConfig(
path=Path(temp_db_path), path=default_db,
key="testkey", key=key,
idle_minutes=0, idle_minutes=0,
theme="system", theme="light",
move_todos=True, move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
) )
@pytest.fixture
def fresh_db(tmp_db_cfg):
from bouquin.db import DBManager
db = DBManager(tmp_db_cfg)
ok = db.connect()
assert ok, "DB connect() should succeed"
yield db
db.close()

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.bug_report_dialog import BugReportDialog
from bouquin import strings
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QTextCursor
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,398 @@
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
from PySide6.QtGui import QTextCharFormat, QFont
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)

604
tests/test_db.py Normal file
View file

@ -0,0 +1,604 @@
import pytest
import json, csv
import datetime as dt
from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager
from datetime import date, timedelta
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"))
assert any(d == _today() for d, _ in hits)
assert any(d == _tomorrow() for d, _ in hits)
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) == 10
(
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,
) = 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,
) = 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, _, _, _, _ = 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, _ = 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 = 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 = 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, _, _, _, _ = 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, _ = 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,117 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
from bouquin.db import DBManager, DBConfig
# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs
from sqlcipher3 import dbapi2 as sqlite
def connect_raw_sqlcipher(db_path: Path, key: str):
conn = sqlite.connect(str(db_path))
conn.row_factory = sqlite.Row
cur = conn.cursor()
cur.execute(f"PRAGMA key = '{key}';")
cur.execute("PRAGMA foreign_keys = ON;")
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
return conn
def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path):
# Prepare a "legacy" DB that has only entries(date, content) and no pages/versions
db_path = cfg.path
conn = connect_raw_sqlcipher(db_path, cfg.key)
cur = conn.cursor()
cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);")
cur.execute(
"INSERT INTO entries(date, content) VALUES(?, ?);",
("2025-01-02", "<p>Hello</p>"),
)
conn.commit()
conn.close()
# Now use the real DBManager, which will run _ensure_schema and migrate
mgr = DBManager(cfg)
assert mgr.connect() is True
# After migration, legacy table should be gone and content reachable via get_entry
text = mgr.get_entry("2025-01-02")
assert "Hello" in text
cur = mgr.conn.cursor()
# entries table should be dropped
with pytest.raises(sqlite.OperationalError):
cur.execute("SELECT count(*) FROM entries;").fetchone()
# pages & versions exist and head points to v1
rows = cur.execute(
"SELECT current_version_id FROM pages WHERE date='2025-01-02'"
).fetchone()
assert rows is not None and rows["current_version_id"] is not None
vers = mgr.list_versions("2025-01-02")
assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1
def test_save_new_version_requires_connection_raises(cfg: DBConfig):
mgr = DBManager(cfg)
with pytest.raises(RuntimeError):
mgr.save_new_version("2025-01-03", "<p>x</p>")
def _bootstrap_db(cfg: DBConfig) -> DBManager:
mgr = DBManager(cfg)
assert mgr.connect() is True
return mgr
def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig):
mgr = _bootstrap_db(cfg)
# Create two versions for the same date
ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "<p>v1</p>", note="init")
ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
assert ver1_no == 1 and ver2_no == 2
# Revert using version_id
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
cur = mgr.conn.cursor()
head2 = cur.execute(
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
).fetchone()[0]
assert head2 == ver2_id
# Error: version_id belongs to a different date
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
with pytest.raises(ValueError):
mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id)
def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path):
mgr = _bootstrap_db(cfg)
# Seed a couple of entries
mgr.save_new_version("2025-01-06", "<p>A</p>")
mgr.save_new_version("2025-01-07", "<p>B</p>")
# Prepare output files
out = tmp_path
exts = [
".json",
".csv",
".txt",
".html",
".sql",
] # exclude .md due to different signature
for ext in exts:
path = out / f"export{ext}"
mgr.export_by_extension(str(path))
assert path.exists() and path.stat().st_size > 0
# Markdown export uses a different signature (entries + path)
entries = mgr.get_all_entries()
md_path = out / "export.md"
mgr.export_markdown(entries, str(md_path))
assert md_path.exists() and md_path.stat().st_size > 0
# Run VACUUM path
mgr.compact() # should not raise

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", ".md"]:
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

@ -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,339 +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):
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._nearest_code_frame(e.textCursor(), tolerant=False) is not None
and e.textCursor().block().length() == 1
)
# Second Enter should jump *out* of the frame
QTest.keyClick(e, Qt.Key_Return)
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._nearest_code_frame(e.textCursor(), tolerant=False) 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,103 +0,0 @@
import base64
import pytest
from PySide6.QtCore import Qt, QMimeData, QByteArray
from PySide6.QtGui import QImage, QTextCursor
from PySide6.QtWidgets import QApplication
from PySide6.QtTest import QTest
from bouquin.editor import Editor
from bouquin.theme import ThemeManager, ThemeConfig
@pytest.fixture(scope="module")
def app():
a = QApplication.instance()
if a is None:
a = QApplication([])
return a
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig())
e = Editor(themes)
qtbot.addWidget(e)
e.show()
return e
def test_todo_prefix_converts_to_checkbox_on_space(editor):
editor.clear()
editor.setPlainText("TODO")
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
QTest.keyClick(editor, Qt.Key_Space)
# Now the line should start with the checkbox glyph and a space
assert editor.toPlainText().startswith("")
def test_enter_inside_empty_code_frame_jumps_out(editor):
editor.clear()
editor.setPlainText("") # single empty block
# Apply code block to current line
editor.apply_code()
# Cursor is inside the code frame. Press Enter on empty block should jump out.
QTest.keyClick(editor, Qt.Key_Return)
# We expect two blocks: one code block (with a newline inserted) and then a normal block
txt = editor.toPlainText()
assert "\n" in txt # a normal paragraph created after exiting the frame
def test_insertFromMimeData_with_data_image(editor):
# Build an in-memory PNG and embed as data URL inside HTML
img = QImage(8, 8, QImage.Format_ARGB32)
img.fill(0xFF00FF00) # green
ba = QByteArray()
from PySide6.QtCore import QBuffer, QIODevice
buf = QBuffer(ba)
buf.open(QIODevice.WriteOnly)
img.save(buf, "PNG")
data_b64 = base64.b64encode(bytes(ba)).decode("ascii")
html = f'<img src="data:image/png;base64,{data_b64}"/>'
md = QMimeData()
md.setHtml(html)
editor.insertFromMimeData(md)
# HTML export with embedded images should contain a data: URL
h = editor.to_html_with_embedded_images()
assert "data:image/png;base64," in h
def test_toggle_checkboxes_selection(editor):
editor.clear()
editor.setPlainText("item 1\nitem 2")
# Select both lines
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
# Toggle on -> inserts ☐
editor.toggle_checkboxes()
assert editor.toPlainText().startswith("")
# Toggle again -> remove ☐
editor.toggle_checkboxes()
assert not editor.toPlainText().startswith("")
def test_heading_then_enter_reverts_to_normal(editor):
editor.clear()
editor.setPlainText("A heading")
# Apply H2 via apply_heading(size=18)
editor.apply_heading(18)
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
# Press Enter -> new block should be Normal (not bold/large)
QTest.keyClick(editor, Qt.Key_Return)
# The new block exists
txt = editor.toPlainText()
assert "\n" in txt

View file

@ -1,75 +0,0 @@
from PySide6.QtCore import QUrl
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
from bouquin.theme import ThemeManager
from bouquin.editor import Editor
def _mk_editor(qapp, cfg):
themes = ThemeManager(qapp, cfg)
ed = Editor(themes)
ed.resize(400, 300)
return ed
def test_image_scale_and_reset(qapp, cfg):
ed = _mk_editor(qapp, cfg)
# Register an image resource and insert it at the cursor
img = QImage(20, 10, QImage.Format_ARGB32)
img.fill(QColor(200, 0, 0))
url = QUrl("test://img")
from PySide6.QtGui import QTextDocument
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
fmt = QTextImageFormat()
fmt.setName(url.toString())
# No explicit width -> code should use original width
tc = ed.textCursor()
tc.insertImage(fmt)
# Place cursor at start (on the image) and scale
tc = ed.textCursor()
tc.movePosition(QTextCursor.Start)
ed.setTextCursor(tc)
ed._scale_image_at_cursor(1.5) # increases width
ed._reset_image_size() # restores to original width
# Ensure resulting HTML contains an <img> tag
html = ed.toHtml()
assert "<img" in html
def test_apply_image_size_fallbacks(qapp, cfg):
ed = _mk_editor(qapp, cfg)
# Create a dummy image format with no width/height -> fallback branch inside _apply_image_size
fmt = QTextImageFormat()
fmt.setName("") # no resource available
tc = ed.textCursor()
# Insert a single character to have a valid cursor
tc.insertText("x")
tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1)
ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise
def test_to_html_with_embedded_images_and_link_tint(qapp, cfg):
ed = _mk_editor(qapp, cfg)
# Insert an anchor + image and ensure HTML embedding + retint pass runs
img = QImage(8, 8, QImage.Format_ARGB32)
img.fill(QColor(0, 200, 0))
url = QUrl("test://img2")
from PySide6.QtGui import QTextDocument
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
# Compose HTML with a link and an image referencing our resource
ed.setHtml(
f'<p><a href="http://example.com">link</a></p><p><img src="{url.toString()}"></p>'
)
html = ed.to_html_with_embedded_images()
# Embedded data URL should appear for the image
assert "data:image" in html
# The link should still be present (retinted internally) without crashing
assert "example.com" in html

View file

@ -1,136 +0,0 @@
from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot
from PySide6.QtGui import QImage, QMouseEvent, QTextCursor
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from bouquin.editor import Editor
from bouquin.theme import ThemeManager, ThemeConfig
def _mk_editor() -> Editor:
app = QApplication.instance()
tm = ThemeManager(app, ThemeConfig())
e = Editor(tm)
e.resize(700, 400)
e.show()
return e
def _point_for_char(e: Editor, pos: int):
c = e.textCursor()
c.setPosition(pos)
r = e.cursorRect(c)
return r.center()
def test_trim_url_and_linkify_and_ctrl_mouse(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
assert e._trim_url_end("https://ex.com)") == "https://ex.com"
assert e._trim_url_end("www.mysite.org]") == "www.mysite.org"
url = "https://example.org/path"
QTest.keyClicks(e, url)
qtbot.waitUntil(lambda: url in e.toPlainText())
p = _point_for_char(e, 0)
move = QMouseEvent(
QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier
)
e.mouseMoveEvent(move)
assert e.viewport().cursor().shape() == Qt.PointingHandCursor
opened = {}
class Catcher(QObject):
@Slot(QUrl)
def handle(self, u: QUrl):
opened["u"] = u.toString()
from PySide6.QtGui import QDesktopServices
catcher = Catcher()
QDesktopServices.setUrlHandler("https", catcher, "handle")
try:
rel = QMouseEvent(
QEvent.MouseButtonRelease,
p,
Qt.LeftButton,
Qt.LeftButton,
Qt.ControlModifier,
)
e.mouseReleaseEvent(rel)
got_signal = []
e.linkActivated.connect(lambda href: got_signal.append(href))
e.mouseReleaseEvent(rel)
assert opened or got_signal
finally:
QDesktopServices.unsetUrlHandler("https")
def test_insert_images_and_image_helpers(qtbot, tmp_path):
e = _mk_editor()
qtbot.addWidget(e)
# No image under cursor yet (412 guard)
tc, fmt, orig = e._image_info_at_cursor()
assert tc is None and fmt is None and orig is None
# Insert a real image file (574584 path)
img_path = tmp_path / "tiny.png"
img = QImage(4, 4, QImage.Format_ARGB32)
img.fill(0xFF336699)
assert img.save(str(img_path), "PNG")
e.insert_images([str(img_path)], autoscale=False)
assert "<img" in e.toHtml()
# Guards when not on an image (453, 464)
e._scale_image_at_cursor(1.1)
e._fit_image_to_editor_width()
def test_checkbox_click_and_enter_continuation(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.setPlainText("☐ task one")
# Need it visible for mouse coords
e.resize(600, 300)
e.show()
qtbot.waitExposed(e)
# Click on the checkbox glyph to toggle (605614)
start_point = _point_for_char(e, 0)
press = QMouseEvent(
QEvent.MouseButtonPress,
start_point,
Qt.LeftButton,
Qt.LeftButton,
Qt.NoModifier,
)
e.mousePressEvent(press)
assert e.toPlainText().startswith("")
# Press Enter at end -> new line with fresh checkbox (680684)
c = e.textCursor()
c.movePosition(QTextCursor.End)
e.setTextCursor(c)
QTest.keyClick(e, Qt.Key_Return)
lines = e.toPlainText().splitlines()
assert len(lines) >= 2 and lines[1].startswith("")
def test_heading_and_lists_toggle_remove(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.setPlainText("para")
# "Normal" path is size=0 (904…)
e.apply_heading(0)
# bullets twice -> second call removes (945946)
e.toggle_bullets()
e.toggle_bullets()
# numbers twice -> second call removes (955956)
e.toggle_numbers()
e.toggle_numbers()

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()

View file

@ -1,100 +1,171 @@
from PySide6.QtCore import Qt import pytest
from PySide6.QtGui import QKeySequence, QTextCursor
from PySide6.QtTest import QTest
from tests.qt_helpers import trigger_menu_action from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.find_bar import FindBar
def _cursor_info(editor): @pytest.fixture
"""Return (start, end, selectedText) for the current selection.""" def editor(app, qtbot):
tc: QTextCursor = editor.textCursor() themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
start = min(tc.anchor(), tc.position()) ed = MarkdownEditor(themes)
end = max(tc.anchor(), tc.position()) qtbot.addWidget(ed)
return start, end, tc.selectedText() ed.show()
return ed
def test_find_actions_and_shortcuts(open_window, qtbot): def test_findbar_basic_navigation(qtbot, editor):
win = open_window editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
editor.moveCursor(QTextCursor.Start)
# Actions should be present under Navigate and advertise canonical shortcuts fb = FindBar(editor, parent=editor)
act_find = trigger_menu_action(win, "Find on page") qtbot.addWidget(fb)
assert act_find.shortcut().matches(QKeySequence.Find) == QKeySequence.ExactMatch fb.show_bar()
fb.edit.setText("alpha")
fb.find_next()
pos1 = editor.textCursor().position()
fb.find_next()
pos2 = editor.textCursor().position()
assert pos2 > pos1
act_next = trigger_menu_action(win, "Find Next") fb.find_prev()
assert act_next.shortcut().matches(QKeySequence.FindNext) == QKeySequence.ExactMatch pos3 = editor.textCursor().position()
assert pos3 <= pos2
act_prev = trigger_menu_action(win, "Find Previous") fb.case.setChecked(True)
assert ( fb.refresh()
act_prev.shortcut().matches(QKeySequence.FindPrevious) fb.hide_bar()
== QKeySequence.ExactMatch
)
# "Find on page" should open the bar and focus the input
act_find.trigger()
qtbot.waitUntil(lambda: win.findBar.isVisible())
qtbot.waitUntil(lambda: win.findBar.edit.hasFocus())
def test_find_navigate_case_sensitive_and_close_focus(open_window, qtbot): def test_show_bar_seeds_selection(qtbot, editor):
win = open_window
# Mixed-case content with three matches editor.from_markdown("alpha beta")
text = "alpha … ALPHA … alpha" c = editor.textCursor()
win.editor.setPlainText(text) c.movePosition(QTextCursor.Start)
qtbot.waitUntil(lambda: win.editor.toPlainText() == text) c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
# Open the find bar from the menu fb = FindBar(editor, parent=editor)
trigger_menu_action(win, "Find on page").trigger() qtbot.addWidget(fb)
qtbot.waitUntil(lambda: win.findBar.isVisible()) fb.show_bar()
win.findBar.edit.clear() assert fb.edit.text().lower() == "alpha"
QTest.keyClicks(win.findBar.edit, "alpha") fb.hide_bar()
# 1) First hit (case-insensitive default)
QTest.keyClick(win.findBar.edit, Qt.Key_Return)
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s0, e0, sel0 = _cursor_info(win.editor)
assert sel0.lower() == "alpha"
# 2) Next → uppercase ALPHA (case-insensitive) def test_show_bar_no_editor(qtbot, app):
trigger_menu_action(win, "Find Next").trigger() fb = FindBar(lambda: None)
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) qtbot.addWidget(fb)
s1, e1, sel1 = _cursor_info(win.editor) fb.show_bar() # should early return without crashing and not become visible
assert sel1.upper() == "ALPHA" assert not fb.isVisible()
# 3) Next → the *other* lowercase "alpha"
trigger_menu_action(win, "Find Next").trigger()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s2, e2, sel2 = _cursor_info(win.editor)
assert sel2.lower() == "alpha"
# Ensure we didn't wrap back to the very first "alpha"
assert s2 != s0
# 4) Case-sensitive: skip ALPHA and only hit lowercase def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor):
win.findBar.case.setChecked(True) editor.from_markdown("alpha\n\nbeta")
# Put the caret at start to make the next search deterministic c = editor.textCursor()
tc = win.editor.textCursor() c.movePosition(QTextCursor.Start)
tc.setPosition(0) # Select across the paragraph separator U+2029 equivalent select more than one block
win.editor.setTextCursor(tc) 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()
win.findBar.find_next()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor)
assert sel_cs1 == "alpha"
win.findBar.find_next() def test_find_wraps_and_bumps_caret(qtbot, editor):
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) editor.from_markdown("alpha alpha alpha")
s_cs2, e_cs2, sel_cs2 = _cursor_info(win.editor) fb = FindBar(lambda: editor, parent=editor)
assert sel_cs2 == "alpha" qtbot.addWidget(fb)
assert s_cs2 != s_cs1 # it's the other lowercase match fb.edit.setText("alpha")
# 5) Previous goes back to the earlier lowercase match # Select the first occurrence so caret bumping path triggers
win.findBar.find_prev() c = editor.textCursor()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) c.movePosition(QTextCursor.Start)
s_prev, e_prev, sel_prev = _cursor_info(win.editor) c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
assert sel_prev == "alpha" editor.setTextCursor(c)
assert s_prev == s_cs1
# 6) Close returns focus to editor fb.find_next() # should bump to after current selection then find next
win.findBar.closeBtn.click() sel = editor.textCursor().selectedText()
qtbot.waitUntil(lambda: not win.findBar.isVisible()) assert sel.lower() == "alpha"
qtbot.waitUntil(lambda: win.editor.hasFocus())
# 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,311 @@
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
from PySide6.QtCore import Qt, QTimer
from bouquin.history_dialog import HistoryDialog
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,43 +0,0 @@
import pytest
from PySide6.QtWidgets import QApplication, QListWidgetItem
from PySide6.QtCore import Qt
from bouquin.db import DBConfig, DBManager
from bouquin.history_dialog import HistoryDialog
@pytest.fixture(scope="module")
def app():
a = QApplication.instance()
if a is None:
a = QApplication([])
return a
@pytest.fixture
def db(tmp_path):
cfg = DBConfig(path=tmp_path / "h.db", key="k")
db = DBManager(cfg)
assert db.connect()
# Seed two versions for a date
db.save_new_version("2025-02-10", "<p>v1</p>", note="v1", set_current=True)
db.save_new_version("2025-02-10", "<p>v2</p>", note="v2", set_current=True)
return db
def test_revert_early_returns(app, db, qtbot):
dlg = HistoryDialog(db, date_iso="2025-02-10")
qtbot.addWidget(dlg)
# (1) No current item -> returns immediately
dlg.list.setCurrentItem(None)
dlg._revert() # should not crash and should not accept
# (2) Selecting the current item -> still returns early
# Build an item with the *current* id as payload
cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"])
it = QListWidgetItem("current")
it.setData(Qt.UserRole, cur_id)
dlg.list.addItem(it)
dlg.list.setCurrentItem(it)
dlg._revert() # should return early (no accept called)

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]

205
tests/test_key_prompt.py Normal file
View file

@ -0,0 +1,205 @@
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 - covers line 57"""
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 - covers line 57 with non-None path"""
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 (line 57)
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 PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_lock_overlay_reacts_to_theme(app, qtbot):
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)

97
tests/test_main.py Normal file
View file

@ -0,0 +1,97 @@
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

View file

@ -1,14 +0,0 @@
import runpy
import types
import sys
def test_dunder_main_executes_without_launching_qt(monkeypatch):
# Replace bouquin.main with a stub that records invocation and returns immediately
calls = {"called": False}
mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True))
monkeypatch.setitem(sys.modules, "bouquin.main", mod)
# Running the module as __main__ should call mod.main() but not start a Qt loop
runpy.run_module("bouquin.__main__", run_name="__main__")
assert calls["called"] is True

2448
tests/test_main_window.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,90 +0,0 @@
from PySide6.QtCore import QDate
from bouquin.theme import ThemeManager
from bouquin.main_window import MainWindow
from bouquin.settings import save_db_config
from bouquin.db import DBManager
def _bootstrap_window(qapp, cfg):
# Ensure DB exists and key is valid in settings
mgr = DBManager(cfg)
assert mgr.connect() is True
save_db_config(cfg)
themes = ThemeManager(qapp, cfg)
win = MainWindow(themes)
# Force an initial selected date
win.calendar.setSelectedDate(QDate.currentDate())
return win
def test_move_todos_copies_unchecked(qapp, cfg, tmp_path):
cfg.move_todos = True
win = _bootstrap_window(qapp, cfg)
# Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
html = (
"<p><span>☐</span> Unchecked 1</p>"
"<p><span>☑</span> Checked 1</p>"
"<p><span>☐</span> Unchecked 2</p>"
)
win.db.save_new_version(y, html)
# Ensure today starts blank
today_iso = QDate.currentDate().toString("yyyy-MM-dd")
win.editor.setHtml("<p></p>")
_html = win.editor.toHtml()
win.db.save_new_version(today_iso, _html)
# Invoke the move-todos logic
win._load_yesterday_todos()
# Verify today's entry now contains only the unchecked items
txt = win.db.get_entry(today_iso)
assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt
def test_adjust_and_save_paths(qapp, cfg):
win = _bootstrap_window(qapp, cfg)
# Move date selection and jump to today
before = win.calendar.selectedDate()
win._adjust_day(-1)
assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString(
"yyyy-MM-dd"
)
win._adjust_today()
assert win.calendar.selectedDate() == QDate.currentDate()
# Save path exercises success feedback + dirty flag reset
win.editor.setHtml("<p>content</p>")
win._dirty = True
win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True)
assert win._dirty is False
def test_restore_window_position(qapp, cfg, tmp_path):
win = _bootstrap_window(qapp, cfg)
# Save geometry/state into settings and restore it (covers maximize singleShot branch too)
geom = win.saveGeometry()
state = win.saveState()
s = win.settings
s.setValue("ui/geometry", geom)
s.setValue("ui/window_state", state)
s.sync()
win._restore_window_position() # should restore without error
def test_idle_lock_unlock_flow(qapp, cfg):
win = _bootstrap_window(qapp, cfg)
# Enter lock
win._enter_lock()
assert getattr(win, "_locked", False) is True
# Disabling idle minutes should unlock and hide overlay
win._apply_idle_minutes(0)
assert getattr(win, "_locked", False) is False

File diff suppressed because it is too large Load diff

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,354 @@
from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
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_timer_window_title(qtbot, app):
"""Test timer window title."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Window title should contain some reference to timer/pomodoro
assert len(timer.windowTitle()) > 0
def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization."""
parent = Mock()
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."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
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
qtbot.addWidget(manager._active_timer)
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
"""Test that starting a new timer closes the previous one."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
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"
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped with very short time logs minimum hours."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid actually 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):
"""Test that elapsed time is properly rounded to decimal hours."""
parent = Mock()
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):
# Test with 1800 seconds (30 minutes)
manager._on_timer_stopped(1800, "Task", "2024-01-15")
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
# Should round up and be a multiple of 0.25
assert hours_set > 0
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped pre-fills the note in time log dialog."""
parent = Mock()
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
):
"""Test that timer stopped signal is properly connected."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
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 stopped
timer._elapsed_seconds = 1000
timer._stop_and_log()
# TimeLogDialog should have been created
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"

801
tests/test_reminders.py Normal file
View file

@ -0,0 +1,801 @@
from unittest.mock import patch
from bouquin.reminders import (
Reminder,
ReminderType,
ReminderDialog,
UpcomingRemindersWidget,
ManageRemindersDialog,
)
from PySide6.QtCore import QDate, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
from datetime import date, timedelta
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)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New reminder",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
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)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
mock_get.return_value = updated
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_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
"""Test starting the regular check timer."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget._start_regular_timer()
# Timer should be running
assert widget._check_timer.isActive()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db)
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()
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
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)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
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, 2)
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)
with patch.object(ManageRemindersDialog, "exec"):
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"

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()

100
tests/test_search.py Normal file
View file

@ -0,0 +1,100 @@
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, "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 = [("2000-01-01", long)]
s._populate_results("alpha", rows)
assert s.results.count() >= 1

View file

@ -1,15 +0,0 @@
from bouquin.search import Search as SearchWidget
class DummyDB:
def search_entries(self, q):
return []
def test_make_html_snippet_no_match_triggers_start_window(qtbot):
w = SearchWidget(db=DummyDB())
qtbot.addWidget(w)
html = "<p>" + ("x" * 300) + "</p>" # long text, no token present
frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80)
assert frag != ""
assert left is False and right is True

View file

@ -1,70 +0,0 @@
from PySide6.QtWidgets import QApplication
import pytest
from bouquin.db import DBConfig, DBManager
from bouquin.search import Search
@pytest.fixture(scope="module")
def app():
# Ensure a single QApplication exists
a = QApplication.instance()
if a is None:
a = QApplication([])
yield a
@pytest.fixture
def fresh_db(tmp_path):
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
db = DBManager(cfg)
assert db.connect() is True
# Seed a couple of entries
db.save_new_version("2025-01-01", "<p>Hello world first day</p>")
db.save_new_version(
"2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
)
db.save_new_version(
"2025-01-03",
"<p>Long content begins "
+ ("x" * 200)
+ " middle token here "
+ ("y" * 200)
+ " ends.</p>",
)
return db
def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot):
# Close the DB to provoke an exception inside Search._search
fresh_db.close()
w = Search(fresh_db)
w.show()
qtbot.addWidget(w)
# Typing should not raise; exception path returns empty results
w._search("anything")
assert w.results.isHidden() # remains hidden because there are no rows
def test_make_html_snippet_ellipses_both_sides(app, fresh_db):
w = Search(fresh_db)
# Choose a query so that the first match sits well inside a long string,
# forcing both left and right ellipses.
html = fresh_db.get_entry("2025-01-03")
snippet, left_ell, right_ell = w._make_html_snippet(html, "middle")
assert snippet # non-empty
assert left_ell is True
assert right_ell is True
def test_search_results_middle(app, fresh_db, qtbot):
w = Search(fresh_db)
w.show()
qtbot.addWidget(w)
# Choose a query so that the first match sits well inside a long string,
# forcing both left and right ellipses.
assert fresh_db.connect()
w._search("middle")
assert w.results.isVisible()

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

View file

@ -1,37 +0,0 @@
import pytest
from bouquin.search import Search
@pytest.fixture
def search_widget(qapp):
# We don't need a real DB for snippet generation pass None
return Search(db=None)
def test_make_html_snippet_empty(search_widget: Search):
html = ""
frag, has_prev, has_next = search_widget._make_html_snippet(
html, "", radius=10, maxlen=20
)
assert frag == "" and has_prev is False and has_next is False
def test_make_html_snippet_phrase_preferred(search_widget: Search):
html = "<p>Alpha beta gamma delta</p>"
frag, has_prev, has_next = search_widget._make_html_snippet(
html, "beta gamma", radius=1, maxlen=10
)
# We expect a window that includes the phrase and has previous text
assert "beta" in frag and "gamma" in frag
assert has_prev is True
def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search):
html = "<p>One two three four five six seven eight nine ten eleven twelve</p>"
# Use tokens such that the phrase doesn't exist, but individual tokens do
frag, has_prev, has_next = search_widget._make_html_snippet(
html, "eleven two", radius=3, maxlen=20
)
assert "two" in frag
# The snippet should be a slice within the text (has more following content)
assert has_next is True

71
tests/test_settings.py Normal file
View file

@ -0,0 +1,71 @@
from bouquin.settings import (
get_settings,
load_db_config,
save_db_config,
)
from bouquin.db import DBConfig
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 bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget import bouquin.settings_dialog as sd
from bouquin.db import DBConfig
from bouquin.settings_dialog import SettingsDialog from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
class _ThemeSpy: def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
def __init__(self): # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
self.calls = [] 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 set(self, t): dlg.idle_spin.setValue(3)
self.calls.append(t) 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 test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
def __init__(self): parent = QWidget()
super().__init__() parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
self.themes = _ThemeSpy()
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 test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
def __init__(self): cfg = DBConfig(
self.rekey_called_with = None path=tmp_path / "iso.db",
self.compact_called = False key="oldkey",
self.fail_compact = False 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): parent = QWidget()
self.rekey_called_with = key parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
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 set(self, theme):
self.calls.append(theme)
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemeSpy()
parent = _Parent()
qtbot.addWidget(parent)
dlg = SettingsDialog(cfg, db, parent=parent) dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.show() dlg.show()
qtbot.waitExposed(dlg)
# Change fields keys = ["one", "two"]
new_path = tmp_path / "new.sqlite"
dlg.path_edit.setText(str(new_path))
dlg.idle_spin.setValue(0)
# User toggles "Remember key" -> stores prompted key def _pump_popups():
dlg.save_key_btn.setChecked(True) for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
dlg._save() w.key_entry.setText(keys.pop(0) if keys else "zzz")
w.accept()
out = saved["cfg"] elif isinstance(w, QMessageBox):
assert out.path == new_path w.accept()
assert out.idle_minutes == 0
assert out.key == "sekrit"
assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM
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),
)
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump_popups)
timer.start()
try:
dlg._change_key() dlg._change_key()
finally:
assert db.rekey_called_with == "newkey" timer.stop()
assert shown["info"] >= 1 db.close()
assert dlg.key == "newkey" db2 = DBManager(cfg)
assert db2.connect()
db2.close()
def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot): def test_change_key_success(qtbot, tmp_path, app):
p1 = AcceptingPrompt().set_key("a") cfg = DBConfig(
p2 = AcceptingPrompt().set_key("b") path=tmp_path / "iso2.db",
seq = [p1, p2] key="oldkey",
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) idle_minutes=0,
theme="light",
called = {"warn": 0} move_todos=True,
monkeypatch.setattr(
QMessageBox,
"warning",
lambda *a, **k: called.__setitem__("warn", called["warn"] + 1),
) )
db = DBManager(cfg)
assert db.connect()
db.save_new_version("2001-01-01", "seed", "seed")
db = FakeDB() parent = QWidget()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.show() dlg.show()
qtbot.waitExposed(dlg)
keys = ["newkey", "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() dlg._change_key()
finally:
timer.stop()
qtbot.wait(50)
assert db.rekey_called_with is None db.close()
assert called["warn"] >= 1 cfg.key = "newkey"
db2 = DBManager(cfg)
assert db2.connect()
assert "seed" in db2.get_entry("2001-01-01")
db2.close()
def test_change_key_empty_shows_warning(monkeypatch, qtbot): def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
p1 = AcceptingPrompt().set_key("") # Parent with ThemeManager (dialog uses parent().themes.set(...))
p2 = AcceptingPrompt().set_key("") parent = QWidget()
seq = [p1, p2] parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
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)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.show() 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 dlg._db.compact = boom # type: ignore
assert called["warn"] >= 1
called = {"critical": False, "title": None, "text": None}
def test_browse_sets_path(monkeypatch, qtbot, tmp_path): class DummyMB:
def fake_get_save_file_name(*a, **k): @staticmethod
return (str(tmp_path / "picked.sqlite"), "") def information(*args, **kwargs):
return 0
monkeypatch.setattr( @staticmethod
QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name) def critical(parent, title, text, *rest):
) called["critical"] = True
called["title"] = title
called["text"] = str(text)
return 0
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) # Swap QMessageBox used inside the dialog module so signature mismatch can't occur
qtbot.addWidget(dlg) monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True)
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)
# Invoke
dlg._compact_btn_clicked() dlg._compact_btn_clicked()
assert db.compact_called is True
assert shown["info"] >= 1
# Failure path assert called["critical"]
db2 = FakeDB() assert called["title"]
db2.fail_compact = True assert called["text"]
dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2)
qtbot.addWidget(dlg2)
dlg2.show()
qtbot.waitExposed(dlg2)
dlg2._compact_btn_clicked()
assert shown["crit"] >= 1
def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot): class _Host(QWidget):
p = AcceptingPrompt().set_key("already") def __init__(self, themes):
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) 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) qtbot.addWidget(dlg)
dlg.show() # With fresh settings (system), the 'system' radio should be selected
qtbot.waitExposed(dlg) assert dlg.theme_system.isChecked()
dlg.save_key_btn.setChecked(True)
# We should reach here with the original key preserved.
assert dlg.key == "already"
def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path): def test_save_selects_system_when_no_explicit_choice(
parent = _Parent() qtbot, tmp_db_cfg, fresh_db, monkeypatch
qtbot.addWidget(parent) ):
cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5) host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
dlg = SettingsDialog(cfg, FakeDB(), parent=parent)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.save_key_btn.setChecked(False) # Ensure neither dark nor light is checked so SYSTEM path is taken
# Trigger save dlg.theme_dark.setChecked(False)
dlg.theme_light.setChecked(False)
# This should not raise
dlg._save() 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,111 +0,0 @@
import pytest
from PySide6.QtWidgets import QApplication, QDialog, QWidget
from bouquin.db import DBConfig, DBManager
from bouquin.settings_dialog import SettingsDialog
from bouquin.settings import APP_NAME, APP_ORG
from bouquin.key_prompt import KeyPrompt
from bouquin.theme import Theme, ThemeManager, ThemeConfig
@pytest.fixture(scope="module")
def app():
a = QApplication.instance()
if a is None:
a = QApplication([])
a.setApplicationName(APP_NAME)
a.setOrganizationName(APP_ORG)
return a
@pytest.fixture
def db(tmp_path):
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
m = DBManager(cfg)
assert m.connect()
return m
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
# Dark preselection
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
dlg.theme_dark.setChecked(True)
dlg._save()
assert dlg.config.theme == Theme.DARK.value
# Light preselection
parent2 = _ParentWithThemes(app)
qtbot.addWidget(parent2)
dlg2 = SettingsDialog(db.cfg, db, parent=parent2)
qtbot.addWidget(dlg2)
dlg2.theme_light.setChecked(True)
dlg2._save()
assert dlg2.config.theme == Theme.LIGHT.value
def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
# First prompt cancelled -> early return
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
dlg._change_key() # should just return without altering key
assert dlg.key == ""
# First OK, second cancelled -> early return at the second branch
state = {"calls": 0}
def _exec(self):
state["calls"] += 1
return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected
monkeypatch.setattr(KeyPrompt, "exec", _exec)
# Also monkeypatch to control key() values
monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret")
dlg._change_key()
# Because the second prompt was rejected, key should remain unchanged
assert dlg.key == ""
def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot):
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
qtbot.addWidget(dlg)
# Simulate user checking the box, but cancelling the prompt -> code unchecks it again
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
dlg.save_key_btn.setChecked(True)
# The slot toggled should run and revert it to unchecked
assert dlg.save_key_btn.isChecked() is False
def test_change_key_exception_path(app, db, monkeypatch, qtbot):
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
# Accept both prompts and supply a key
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted)
monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom")
# Force DB rekey to raise to exercise the except-branch
monkeypatch.setattr(
db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail"))
)
# Should not raise; error is handled internally
dlg._change_key()
class _ParentWithThemes(QWidget):
def __init__(self, app):
super().__init__()
self.themes = ThemeManager(app, ThemeConfig())

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,636 @@
import datetime as _dt
from datetime import datetime, timedelta, date
from bouquin import strings
from PySide6.QtCore import Qt, QPoint, QDate
from PySide6.QtWidgets import QLabel, QWidget
from PySide6.QtTest import QTest
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
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
)
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[-1]
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,
{}, # words_by_date
0, # total_words
0, # unique_tags
None, # page_most_tags
0,
{}, # revisions_by_date
)
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 (line 175) 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

195
tests/test_tabs.py Normal file
View file

@ -0,0 +1,195 @@
import types
from PySide6.QtWidgets import QFileDialog
from PySide6.QtGui import QTextCursor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from bouquin.main_window import MainWindow
from bouquin.history_dialog import HistoryDialog
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"

2319
tests/test_tags.py Normal file

File diff suppressed because it is too large Load diff

52
tests/test_theme.py Normal file
View file

@ -0,0 +1,52 @@
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from bouquin.theme import Theme, ThemeConfig, ThemeManager
def test_theme_manager_apply_light_and_dark(app):
cfg = ThemeConfig(theme=Theme.LIGHT)
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