Compare commits
170 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886b809bd3 | |||
| e6010969cb | |||
| 492633df9f | |||
| dcb62d34af | |||
| 13b1ad7373 | |||
| 7abd99fe24 | |||
| 2112de39b8 | |||
| 206670454f | |||
| 3106d408ab | |||
| d809244cf8 | |||
| 28446340f8 | |||
| c1c95ca0ca | |||
| 7a75d33bb0 | |||
| 57614cefa1 | |||
| fb873edcb5 | |||
| 0862ce7fd6 | |||
| 61b3e5b45a | |||
| 81878c63d9 | |||
| e5c7ccb1da | |||
| 9b2260f6a7 | |||
| aeb3d863e2 | |||
| f5c52eaf3b | |||
| 778d988ebd | |||
| 2464147a59 | |||
| 0795de8572 | |||
| 0ec3ff273d | |||
| 304650dd54 | |||
| 9dc0a620be | |||
| 1e12cae78e | |||
| 498765c782 | |||
| 28c0dd761f | |||
| 9ded9b4a10 | |||
| 3d0f4a7787 | |||
| b06f213522 | |||
| 8823a304cf | |||
| f8909d7fcb | |||
| 779049e467 | |||
| bae91f56e6 | |||
| 3900920b7e | |||
| 3d088fd8d9 | |||
| 55aa8be2c2 | |||
| bffa615c13 | |||
| 605444b149 | |||
| 0b76f0b490 | |||
| 25f0c28582 | |||
| 422411f12e | |||
| 23b6ce62a3 | |||
| 535a380616 | |||
| 4d3593e960 | |||
| 7d58acfc7d | |||
| 22b4cf4da7 | |||
| 078f56a39b | |||
| f2bf337049 | |||
| a27b1d702a | |||
| 3aed9badc2 | |||
| 3b3087cc37 | |||
| 95b7d828b5 | |||
| 32aa1780cf | |||
| 7ed45c919c | |||
| f20ac56624 | |||
| cbbd19ceda | |||
| a4e44643a6 | |||
| 9ab70c76f8 | |||
| 9db4cda8cc | |||
| dc1046632c | |||
| 57f11abb99 | |||
| 7a207df0f3 | |||
| f6fa0aa997 | |||
| 1a56fa80ca | |||
| e160827708 | |||
| 4029d7604e | |||
| a56d6512d3 | |||
| f3ddd2a83c | |||
| a3c74a218f | |||
| 1d94c04551 | |||
| 9a82831e87 | |||
| 81cf878ffd | |||
| 73aa536a32 | |||
| 219b358569 | |||
| 5a54d809ed | |||
| 917e51aa0b | |||
| 576dc435ef | |||
| fdc72a1146 | |||
| 9435800910 | |||
| cb78d9f783 | |||
| 46aed33cf7 | |||
| 808b878658 | |||
| 52c1836c9c | |||
| 10fcd28e23 | |||
| aafff413fe | |||
| c0a9c5be2b | |||
| 770fd0e9ee | |||
| d070907e6c | |||
| 989680845b | |||
| 17560af249 | |||
| 41227e181f | |||
| c0206bd626 | |||
| e0169db52a | |||
| 26737fbfb2 | |||
| 648031786a | |||
| 5b42e921a5 | |||
| 164aa1eb83 | |||
| 632cdf5176 | |||
| 32bf2e1187 | |||
| 47a380ad38 | |||
| 807a21e9af | |||
| 4ec8c64994 | |||
| 054ad88991 | |||
| 78a06e8a73 | |||
| 05879131b4 | |||
| a0153a370b | |||
| 5bf6d4c4d6 | |||
| ab0a9400c9 | |||
| 0960a8587e | |||
| 777d27ecb9 | |||
| 511adb1c34 | |||
| f8c4abd899 | |||
| 75432ca35d | |||
| de23c75478 | |||
| 44e357dd66 | |||
| 40c218674f | |||
| 6820e3b3ab | |||
| 6becae6eac | |||
| 2a78027afc | |||
| 3711df0206 | |||
| ca3c839c7d | |||
| e8db5bcf7d | |||
| 4f8d916346 | |||
| 4fb5be96b1 | |||
| e7ef615053 | |||
| 4adccc3d95 | |||
| 151d053d95 | |||
| 0e5d622a4e | |||
| 0923fb4395 | |||
| 3e91f158c3 | |||
| f9ee150a23 | |||
| cff5f864e4 | |||
| 243980e006 | |||
| 511e7ae7b8 | |||
| 01963ed6a7 | |||
| a7d2c5500e | |||
| 0bc5a37605 | |||
| 6bc6fe4b83 | |||
| 119d326eea | |||
| f41ec9a5a9 | |||
| fc05231268 | |||
| 985541a1d8 | |||
| 0b3249c7ef | |||
| 4db40e6b4b | |||
| 85e2a93199 | |||
| ef10e0aab7 | |||
| 55b78833ac | |||
| 83f25405db | |||
| 63cf561bfe | |||
| a375be629c | |||
| 01997aee90 | |||
| 90d871246b | |||
| 931d08d9d7 | |||
| a4d47edba5 | |||
| c2b2eee022 | |||
| 34349c6133 | |||
| eedf48dc6a | |||
| 6bc5b66d3f | |||
| 8c7226964a | |||
| 31a547e2a0 | |||
| 741d2cc79e | |||
| 22901d0e51 | |||
| 97e723ce34 | |||
| 7ef79c495b | |||
| b1ba599e99 |
83 changed files with 26249 additions and 1123 deletions
|
|
@ -35,3 +35,16 @@ jobs:
|
|||
run: |
|
||||
./tests.sh
|
||||
|
||||
# Notify if any previous step in this job failed
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||
REPOSITORY: ${{ forgejo.repository }}
|
||||
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||
SERVER_URL: ${{ forgejo.server_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
black pyflakes3 vulture
|
||||
black pyflakes3 vulture python3-bandit
|
||||
|
||||
- name: Run linters
|
||||
run: |
|
||||
|
|
@ -24,3 +24,18 @@ jobs:
|
|||
pyflakes3 bouquin/*
|
||||
pyflakes3 tests/*
|
||||
vulture
|
||||
bandit -s B110 -r bouquin/
|
||||
|
||||
# Notify if any previous step in this job failed
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||
REPOSITORY: ${{ forgejo.repository }}
|
||||
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||
SERVER_URL: ${{ forgejo.server_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
|
|
|
|||
|
|
@ -24,3 +24,17 @@ jobs:
|
|||
- name: Run trivy
|
||||
run: |
|
||||
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
|
||||
|
||||
# Notify if any previous step in this job failed
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||
REPOSITORY: ${{ forgejo.repository }}
|
||||
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||
SERVER_URL: ${{ forgejo.server_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -4,3 +4,7 @@ __pycache__
|
|||
.pytest_cache
|
||||
dist
|
||||
.coverage
|
||||
*.db
|
||||
*.pdf
|
||||
*.csv
|
||||
*.html
|
||||
|
|
|
|||
26
.pre-commit-config.yaml
Normal file
26
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
repos:
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.3.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ["--select=F"]
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 25.11.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.2
|
||||
hooks:
|
||||
- id: bandit
|
||||
files: ^bouquin/
|
||||
args: ["-s", "B110"]
|
||||
171
CHANGELOG.md
171
CHANGELOG.md
|
|
@ -1,3 +1,174 @@
|
|||
# 0.7.3
|
||||
|
||||
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
|
||||
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
|
||||
|
||||
# 0.7.2
|
||||
|
||||
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
|
||||
|
||||
# 0.7.1
|
||||
|
||||
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
|
||||
* Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
|
||||
* Invoicing should not be enabled by default
|
||||
* Fix Reminders to fire right on the minute after adding them during runtime
|
||||
* It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
|
||||
* Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
|
||||
|
||||
# 0.7.0
|
||||
|
||||
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
|
||||
* Add 'Last week' to Time Report dialog range option
|
||||
* Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
|
||||
|
||||
# 0.6.4
|
||||
|
||||
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
|
||||
* Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
|
||||
* Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
|
||||
|
||||
# 0.6.3
|
||||
|
||||
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
|
||||
* Allow 'All Projects' for timesheet reports.
|
||||
* Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
|
||||
* Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
|
||||
|
||||
# 0.6.2
|
||||
|
||||
* Ensure that adding a document whilst on an older date page, uses that date as its upload date
|
||||
* Add 'Created at' to time log table.
|
||||
* Show total hours for the day in the time log table (not just in the widget in sidebar)
|
||||
* Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
|
||||
* Indent tabs by 4 spaces in code block editor dialog
|
||||
|
||||
# 0.6.1
|
||||
|
||||
* Consolidate some code related to opening documents using the Documents feature.
|
||||
* Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time.
|
||||
* More code coverage
|
||||
|
||||
# 0.6.0
|
||||
|
||||
* Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature.
|
||||
* Close time log dialog if opened via the + button from sidebar widget
|
||||
* Only show tags in Statistics widget if tags are enabled
|
||||
* Fix rounding up/down in Pomodoro timer to the closest 15 min interval
|
||||
|
||||
# 0.5.5
|
||||
|
||||
* Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option)
|
||||
* Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text.
|
||||
* Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager)
|
||||
* Ensure time log reports have an extension
|
||||
|
||||
# 0.5.4
|
||||
|
||||
* Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it)
|
||||
|
||||
# 0.5.3
|
||||
|
||||
* Prevent triple-click select from selecting the list item (e.g checkbox, bullet)
|
||||
* Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues.
|
||||
* Change History icon (again)
|
||||
* Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it)
|
||||
* Prevent double-click of checkbox leading to selecting/highlighting it
|
||||
* Slightly fade the text of a checkbox line if the checkbox is checked.
|
||||
* Fix weekend date colours being incorrect on theme change while app is running
|
||||
* Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops
|
||||
* Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
|
||||
|
||||
# 0.5.2
|
||||
|
||||
* Update icon again to remove background
|
||||
* Adjust History icon and reorder toolbar items
|
||||
* Try to address checkbox/bullet size issues (again)
|
||||
* Fix HTML export of markdown (with newlines, tables and other styling preserved)
|
||||
* Remove table tool
|
||||
|
||||
# 0.5.1
|
||||
|
||||
* Try to address Noto Sans font issue that works for both numbers and checkbox/bullets.
|
||||
* Update icon
|
||||
* Update French translations
|
||||
* Improve size of flashing reminder dialog
|
||||
|
||||
# 0.5
|
||||
|
||||
* More Italian translations, thank you @mdaleo404
|
||||
* Set locked status on window title when locked
|
||||
* Don't exit on incorrect key, let it be tried again
|
||||
* Make reminders be its own dataset rather than tied to current string.
|
||||
* Add support for repeated reminders
|
||||
* Make reminders be a feature that can be turned on and off
|
||||
* Add syntax highlighting for code blocks (right-click to set it)
|
||||
* Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
|
||||
* Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
|
||||
|
||||
# 0.4.5
|
||||
|
||||
* Make it possible to delete revisions
|
||||
* Make it possible to force-lock the screen even if idle timer hasn't tripped
|
||||
* Add shortcuts for lock and unlock of screen
|
||||
* Other misc bug fixes
|
||||
|
||||
# 0.4.4.1
|
||||
|
||||
* Adjust some widget heights/settings text wrap
|
||||
* Adjust shortcuts
|
||||
* History unicode symbol
|
||||
* Icon in version dialog
|
||||
|
||||
# 0.4.4
|
||||
|
||||
* Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
|
||||
* Moving unchecked TODOs now skips placing them on weekends.
|
||||
* Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
|
||||
* Check for new version / download new AppImage via the Help -> Version screen.
|
||||
* Remove extra newline after headings
|
||||
|
||||
# 0.4.3
|
||||
|
||||
* Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora
|
||||
|
||||
# 0.4.2
|
||||
|
||||
* Improve Statistics widget height
|
||||
* Improve SaveDialog widget width
|
||||
* Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
|
||||
* Make it possible to change regular text size
|
||||
* Refactored Settings dialog to use tabs to reduce its size
|
||||
|
||||
# 0.4.1
|
||||
|
||||
* Allow time log entries to be edited directly in their table cells
|
||||
* Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline)
|
||||
* Add 'Close tab' nav item and shortcut
|
||||
|
||||
# 0.4
|
||||
|
||||
* Remove screenshot tool
|
||||
* Improve width of bug report dialog
|
||||
* Improve size of checkboxes
|
||||
* Convert bullet - to actual unicode bullets
|
||||
* Add alarm option to set reminders
|
||||
* Add time logging and reporting
|
||||
|
||||
# 0.3.2
|
||||
|
||||
* Add weekday letters on left axis of Statistics page
|
||||
* Allow clicking on a date in the Statistics heatmap and have it open that page
|
||||
* Add the ability to choose the database path at startup
|
||||
* Add in-app bug report functionality
|
||||
|
||||
# 0.3.1
|
||||
|
||||
* Make it possible to add a tag from the Tag Browser
|
||||
* Add a statistics dialog with heatmap
|
||||
* Remove export to .txt (just use .md)
|
||||
* Restore link styling and clickability
|
||||
|
||||
# 0.3
|
||||
|
||||
* Introduce Tags
|
||||
|
|
|
|||
65
README.md
65
README.md
|
|
@ -1,9 +1,16 @@
|
|||
# Bouquin
|
||||
|
||||
<div align="center">
|
||||
<img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/bouquin/icons/bouquin.svg" alt="Bouquin logo" width="240" />
|
||||
</div>
|
||||
|
||||
## Introduction
|
||||
|
||||
Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
|
||||
Bouquin ("Book-ahn") is a notebook and planner application written in Python, Qt and SQLCipher.
|
||||
|
||||
It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
|
||||
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
|
||||
also how long we spent on them.
|
||||
|
||||
It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
|
||||
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
|
||||
|
|
@ -11,40 +18,66 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
|
|||
To increase security, the SQLCipher key is requested when the app is opened, and is not written
|
||||
to disk unless the user configures it to be in the settings.
|
||||
|
||||
There is deliberately no network connectivity or syncing intended.
|
||||
There is deliberately no network connectivity or syncing intended, other than the option to send a bug
|
||||
report from within the app, or optionally to check for new versions to upgrade to.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||
### 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>
|
||||
|
||||
## Features
|
||||
### 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>
|
||||
|
||||
### Tags
|
||||
<div align="center">
|
||||
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/tags.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/tags.png" alt="Screenshot of Bouquin Tag Manager screen" width="500" style="margin: 0 10px;" /></a>
|
||||
</div>
|
||||
|
||||
### Time Logging
|
||||
<div align="center">
|
||||
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/time.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/time.png" alt="Screenshot of Bouquin Time Log screens" width="500" style="margin: 0 10px;" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
### Statistics
|
||||
<div align="center">
|
||||
<a href="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/statistics.png"><img src="https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/statistics.png" alt="Bouquin statistics" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
## Some of the features
|
||||
|
||||
* Data is encrypted at rest
|
||||
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
||||
* Every 'page' is linked to the calendar day
|
||||
* All changes are version controlled, with ability to view/diff versions and revert
|
||||
* Text is Markdown with basic styling
|
||||
* All changes are version controlled, with ability to view/diff versions, revert or delete revisions
|
||||
* Automatic rendering of basic Markdown syntax
|
||||
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
||||
* Images are supported
|
||||
* Search all pages, or find text on page (Ctrl+F)
|
||||
* Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
|
||||
* Search all pages, or find text on current page
|
||||
* Add and manage tags
|
||||
* Automatic periodic saving (or explicitly save)
|
||||
* Transparent integrity checking of the database when it opens
|
||||
* Automatic locking of the app after a period of inactivity (default 15 min)
|
||||
* Rekey the database (change the password)
|
||||
* Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
|
||||
* Export the database to json, html, csv, markdown or .sql (for sqlite3)
|
||||
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
||||
* Dark and light themes
|
||||
* Dark and light theme support
|
||||
* Automatically generate checkboxes when typing 'TODO'
|
||||
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
|
||||
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday.
|
||||
* English, French and Italian locales provided
|
||||
* Ability to set reminder alarms (which will be flashed as the reminder)
|
||||
* Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
|
||||
* Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
|
||||
|
||||
|
||||
## How to install
|
||||
|
||||
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
|
||||
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||
|
||||
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||
|
||||
|
|
|
|||
6
bouquin.desktop
Normal file
6
bouquin.desktop
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Bouquin
|
||||
Exec=Bouquin.AppImage
|
||||
Icon=bouquin
|
||||
Categories=Office
|
||||
119
bouquin/bug_report_dialog.py
Normal file
119
bouquin/bug_report_dialog.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
import requests
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
BUG_REPORT_HOST = "https://nr.mig5.net"
|
||||
ROUTE = "forms/bouquin/bugs"
|
||||
|
||||
|
||||
class BugReportDialog(QDialog):
|
||||
"""
|
||||
Dialog to collect a bug report
|
||||
"""
|
||||
|
||||
MAX_CHARS = 5000
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(strings._("report_a_bug"))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QLabel(strings._("bug_report_explanation"))
|
||||
header.setWordWrap(True)
|
||||
layout.addWidget(header)
|
||||
|
||||
self.text_edit = QTextEdit()
|
||||
self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
self.text_edit.textChanged.connect(self._enforce_max_length)
|
||||
|
||||
# Buttons: Cancel / Send
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
|
||||
button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
|
||||
button_box.accepted.connect(self._send)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
self.setMinimumWidth(560)
|
||||
|
||||
self.text_edit.setFocus()
|
||||
|
||||
# ------------Helpers ------------ #
|
||||
|
||||
def _enforce_max_length(self):
|
||||
text = self.text_edit.toPlainText()
|
||||
if len(text) <= self.MAX_CHARS:
|
||||
return
|
||||
|
||||
# Remember cursor position
|
||||
cursor = self.text_edit.textCursor()
|
||||
pos = cursor.position()
|
||||
|
||||
# Trim and restore without re-entering this slot
|
||||
self.text_edit.blockSignals(True)
|
||||
self.text_edit.setPlainText(text[: self.MAX_CHARS])
|
||||
self.text_edit.blockSignals(False)
|
||||
|
||||
cursor.setPosition(pos)
|
||||
self.text_edit.setTextCursor(cursor)
|
||||
|
||||
def _send(self):
|
||||
text = self.text_edit.toPlainText().strip()
|
||||
if not text:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_empty"),
|
||||
)
|
||||
return
|
||||
|
||||
# Get current app version
|
||||
version = importlib.metadata.version("bouquin")
|
||||
|
||||
payload: dict[str, str] = {
|
||||
"message": text,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
# POST as JSON
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{BUG_REPORT_HOST}/{ROUTE}",
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_send_failed") + f"\n{e}",
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status_code == 201:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_sent_ok"),
|
||||
)
|
||||
self.accept()
|
||||
else:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
|
||||
)
|
||||
208
bouquin/code_block_editor_dialog.py
Normal file
208
bouquin/code_block_editor_dialog.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QRect, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
class _LineNumberArea(QWidget):
|
||||
def __init__(self, editor: "CodeEditorWithLineNumbers"):
|
||||
super().__init__(editor)
|
||||
self._editor = editor
|
||||
|
||||
def sizeHint(self) -> QSize: # type: ignore[override]
|
||||
return QSize(self._editor.line_number_area_width(), 0)
|
||||
|
||||
def paintEvent(self, event): # type: ignore[override]
|
||||
self._editor.line_number_area_paint_event(event)
|
||||
|
||||
|
||||
class CodeEditorWithLineNumbers(QPlainTextEdit):
|
||||
"""QPlainTextEdit with a non-selectable line-number gutter on the left."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._line_number_area = _LineNumberArea(self)
|
||||
|
||||
self.blockCountChanged.connect(self._update_line_number_area_width)
|
||||
self.updateRequest.connect(self._update_line_number_area)
|
||||
self.cursorPositionChanged.connect(self._line_number_area.update)
|
||||
|
||||
self._update_line_number_area_width()
|
||||
self._update_tab_stop_width()
|
||||
|
||||
# ---- layout / sizing -------------------------------------------------
|
||||
|
||||
def setFont(self, font: QFont) -> None: # type: ignore[override]
|
||||
"""Ensure tab width stays at 4 spaces when the font changes."""
|
||||
super().setFont(font)
|
||||
self._update_tab_stop_width()
|
||||
|
||||
def _update_tab_stop_width(self) -> None:
|
||||
"""Set tab width to 4 spaces."""
|
||||
metrics = QFontMetrics(self.font())
|
||||
# Tab width = width of 4 space characters
|
||||
self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
|
||||
|
||||
def line_number_area_width(self) -> int:
|
||||
# Enough digits for large-ish code blocks.
|
||||
digits = max(2, len(str(max(1, self.blockCount()))))
|
||||
fm = QFontMetrics(self._line_number_font())
|
||||
return fm.horizontalAdvance("9" * digits) + 8
|
||||
|
||||
def _line_number_font(self) -> QFont:
|
||||
"""Font to use for line numbers (slightly smaller than main text)."""
|
||||
font = self.font()
|
||||
if font.pointSize() > 0:
|
||||
font.setPointSize(font.pointSize() - 1)
|
||||
else:
|
||||
# fallback for pixel-sized fonts
|
||||
font.setPointSizeF(font.pointSizeF() * 0.9)
|
||||
return font
|
||||
|
||||
def _update_line_number_area_width(self) -> None:
|
||||
margin = self.line_number_area_width()
|
||||
self.setViewportMargins(margin, 0, 0, 0)
|
||||
|
||||
def resizeEvent(self, event): # type: ignore[override]
|
||||
super().resizeEvent(event)
|
||||
cr = self.contentsRect()
|
||||
self._line_number_area.setGeometry(
|
||||
QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
|
||||
)
|
||||
|
||||
def _update_line_number_area(self, rect, dy) -> None:
|
||||
if dy:
|
||||
self._line_number_area.scroll(0, dy)
|
||||
else:
|
||||
self._line_number_area.update(
|
||||
0, rect.y(), self._line_number_area.width(), rect.height()
|
||||
)
|
||||
|
||||
if rect.contains(self.viewport().rect()):
|
||||
self._update_line_number_area_width()
|
||||
|
||||
# ---- painting --------------------------------------------------------
|
||||
|
||||
def line_number_area_paint_event(self, event) -> None:
|
||||
painter = QPainter(self._line_number_area)
|
||||
painter.fillRect(event.rect(), self.palette().base())
|
||||
|
||||
# Use a slightly smaller font for numbers
|
||||
painter.setFont(self._line_number_font())
|
||||
|
||||
# Faded colour: same blend used for completed-task text in
|
||||
# MarkdownHighlighter (text colour towards background).
|
||||
pal = self.palette()
|
||||
text_fg = pal.color(QPalette.Text)
|
||||
text_bg = pal.color(QPalette.Base)
|
||||
t = 0.55 # same factor as completed_task_format
|
||||
faded = QColor(
|
||||
int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
|
||||
int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
|
||||
int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
|
||||
)
|
||||
painter.setPen(faded)
|
||||
|
||||
block = self.firstVisibleBlock()
|
||||
block_number = block.blockNumber()
|
||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
fm = self.fontMetrics()
|
||||
line_height = fm.height()
|
||||
right_margin = self._line_number_area.width() - 4
|
||||
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
number = str(block_number + 1)
|
||||
painter.setPen(self.palette().text().color())
|
||||
painter.drawText(
|
||||
0,
|
||||
int(top),
|
||||
right_margin,
|
||||
line_height,
|
||||
Qt.AlignRight | Qt.AlignVCenter,
|
||||
number,
|
||||
)
|
||||
|
||||
block = block.next()
|
||||
top = bottom
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
block_number += 1
|
||||
|
||||
|
||||
class CodeBlockEditorDialog(QDialog):
|
||||
def __init__(
|
||||
self, code: str, language: str | None, parent=None, allow_delete: bool = False
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(strings._("edit_code_block"))
|
||||
|
||||
self.setMinimumSize(650, 650)
|
||||
self._code_edit = CodeEditorWithLineNumbers(self)
|
||||
self._code_edit.setPlainText(code)
|
||||
|
||||
# Track whether the user clicked "Delete"
|
||||
self._delete_requested = False
|
||||
|
||||
# Language selector (optional)
|
||||
self._lang_combo = QComboBox(self)
|
||||
languages = [
|
||||
"",
|
||||
"bash",
|
||||
"css",
|
||||
"html",
|
||||
"javascript",
|
||||
"php",
|
||||
"python",
|
||||
]
|
||||
self._lang_combo.addItems(languages)
|
||||
if language and language in languages:
|
||||
self._lang_combo.setCurrentText(language)
|
||||
|
||||
# Buttons
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
parent=self,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
if allow_delete:
|
||||
delete_btn = buttons.addButton(
|
||||
strings._("delete_code_block"),
|
||||
QDialogButtonBox.ButtonRole.DestructiveRole,
|
||||
)
|
||||
delete_btn.clicked.connect(self._on_delete_clicked)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(strings._("locale") + ":", self))
|
||||
layout.addWidget(self._lang_combo)
|
||||
layout.addWidget(self._code_edit)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _on_delete_clicked(self) -> None:
|
||||
"""Mark this dialog as 'delete requested' and close as Accepted."""
|
||||
self._delete_requested = True
|
||||
self.accept()
|
||||
|
||||
def was_deleted(self) -> bool:
|
||||
"""Return True if the user chose to delete the code block."""
|
||||
return self._delete_requested
|
||||
|
||||
def code(self) -> str:
|
||||
return self._code_edit.toPlainText()
|
||||
|
||||
def language(self) -> str | None:
|
||||
text = self._lang_combo.currentText().strip()
|
||||
return text or None
|
||||
373
bouquin/code_highlighter.py
Normal file
373
bouquin/code_highlighter.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtGui import QColor, QFont, QTextCharFormat
|
||||
|
||||
|
||||
class CodeHighlighter:
|
||||
"""Syntax highlighter for different programming languages."""
|
||||
|
||||
# Language keywords
|
||||
KEYWORDS = {
|
||||
"python": [
|
||||
"False",
|
||||
"None",
|
||||
"True",
|
||||
"and",
|
||||
"as",
|
||||
"assert",
|
||||
"async",
|
||||
"await",
|
||||
"break",
|
||||
"class",
|
||||
"continue",
|
||||
"def",
|
||||
"del",
|
||||
"elif",
|
||||
"else",
|
||||
"except",
|
||||
"finally",
|
||||
"for",
|
||||
"from",
|
||||
"global",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"is",
|
||||
"lambda",
|
||||
"nonlocal",
|
||||
"not",
|
||||
"or",
|
||||
"pass",
|
||||
"pprint",
|
||||
"print",
|
||||
"raise",
|
||||
"return",
|
||||
"try",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
],
|
||||
"javascript": [
|
||||
"abstract",
|
||||
"arguments",
|
||||
"await",
|
||||
"boolean",
|
||||
"break",
|
||||
"byte",
|
||||
"case",
|
||||
"catch",
|
||||
"char",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"double",
|
||||
"else",
|
||||
"enum",
|
||||
"eval",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"final",
|
||||
"finally",
|
||||
"float",
|
||||
"for",
|
||||
"function",
|
||||
"goto",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"int",
|
||||
"interface",
|
||||
"let",
|
||||
"long",
|
||||
"native",
|
||||
"new",
|
||||
"null",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"short",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"synchronized",
|
||||
"this",
|
||||
"throw",
|
||||
"throws",
|
||||
"transient",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"volatile",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
],
|
||||
"php": [
|
||||
"abstract",
|
||||
"and",
|
||||
"array",
|
||||
"as",
|
||||
"break",
|
||||
"callable",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"clone",
|
||||
"const",
|
||||
"continue",
|
||||
"declare",
|
||||
"default",
|
||||
"die",
|
||||
"do",
|
||||
"echo",
|
||||
"else",
|
||||
"elseif",
|
||||
"empty",
|
||||
"enddeclare",
|
||||
"endfor",
|
||||
"endforeach",
|
||||
"endif",
|
||||
"endswitch",
|
||||
"endwhile",
|
||||
"eval",
|
||||
"exit",
|
||||
"extends",
|
||||
"final",
|
||||
"for",
|
||||
"foreach",
|
||||
"function",
|
||||
"global",
|
||||
"goto",
|
||||
"if",
|
||||
"implements",
|
||||
"include",
|
||||
"include_once",
|
||||
"instanceof",
|
||||
"insteadof",
|
||||
"interface",
|
||||
"isset",
|
||||
"list",
|
||||
"namespace",
|
||||
"new",
|
||||
"or",
|
||||
"print",
|
||||
"print_r",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"require",
|
||||
"require_once",
|
||||
"return",
|
||||
"static",
|
||||
"syslog",
|
||||
"switch",
|
||||
"throw",
|
||||
"trait",
|
||||
"try",
|
||||
"unset",
|
||||
"use",
|
||||
"var",
|
||||
"var_dump",
|
||||
"while",
|
||||
"xor",
|
||||
"yield",
|
||||
],
|
||||
"bash": [
|
||||
"if",
|
||||
"then",
|
||||
"echo",
|
||||
"else",
|
||||
"elif",
|
||||
"fi",
|
||||
"case",
|
||||
"esac",
|
||||
"for",
|
||||
"select",
|
||||
"while",
|
||||
"until",
|
||||
"do",
|
||||
"done",
|
||||
"in",
|
||||
"function",
|
||||
"time",
|
||||
"coproc",
|
||||
],
|
||||
"html": [
|
||||
"DOCTYPE",
|
||||
"html",
|
||||
"head",
|
||||
"title",
|
||||
"meta",
|
||||
"link",
|
||||
"style",
|
||||
"script",
|
||||
"body",
|
||||
"div",
|
||||
"span",
|
||||
"p",
|
||||
"a",
|
||||
"img",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"table",
|
||||
"tr",
|
||||
"td",
|
||||
"th",
|
||||
"form",
|
||||
"input",
|
||||
"button",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"br",
|
||||
"hr",
|
||||
],
|
||||
"css": [
|
||||
"color",
|
||||
"background",
|
||||
"background-color",
|
||||
"border",
|
||||
"margin",
|
||||
"padding",
|
||||
"width",
|
||||
"height",
|
||||
"font",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"left",
|
||||
"right",
|
||||
"bottom",
|
||||
"float",
|
||||
"clear",
|
||||
"overflow",
|
||||
"z-index",
|
||||
"opacity",
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_language_patterns(language: str) -> list:
|
||||
"""Get highlighting patterns for a language."""
|
||||
patterns = []
|
||||
|
||||
keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
|
||||
|
||||
if language.lower() in ["python", "bash", "php"]:
|
||||
# Comments (#)
|
||||
patterns.append((r"#.*$", "comment"))
|
||||
|
||||
if language.lower() in ["javascript", "php", "css"]:
|
||||
# Comments (//)
|
||||
patterns.append((r"//.*$", "comment"))
|
||||
# Multi-line comments (/* */)
|
||||
patterns.append((r"/\*.*?\*/", "comment"))
|
||||
|
||||
if language.lower() in ["html", "xml"]:
|
||||
# HTML/XML tags
|
||||
patterns.append((r"<[^>]+>", "tag"))
|
||||
# HTML comments
|
||||
patterns.append((r"<!--.*?-->", "comment"))
|
||||
|
||||
# Numbers
|
||||
patterns.append((r"\b\d+\.?\d*\b", "number"))
|
||||
|
||||
# Keywords
|
||||
for keyword in keywords:
|
||||
patterns.append((r"\b" + keyword + r"\b", "keyword"))
|
||||
|
||||
# Do strings last so they override any of the above (e.g reserved keywords in strings)
|
||||
|
||||
# Strings (double quotes)
|
||||
patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
|
||||
|
||||
# Strings (single quotes)
|
||||
patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
|
||||
|
||||
return patterns
|
||||
|
||||
@staticmethod
|
||||
def get_format_for_type(
|
||||
format_type: str, base_format: QTextCharFormat
|
||||
) -> QTextCharFormat:
|
||||
"""Get text format for a specific syntax type."""
|
||||
fmt = QTextCharFormat(base_format)
|
||||
|
||||
if format_type == "keyword":
|
||||
fmt.setForeground(QColor(86, 156, 214)) # Blue
|
||||
fmt.setFontWeight(QFont.Weight.Bold)
|
||||
elif format_type == "string":
|
||||
fmt.setForeground(QColor(206, 145, 120)) # Orange
|
||||
elif format_type == "comment":
|
||||
fmt.setForeground(QColor(106, 153, 85)) # Green
|
||||
fmt.setFontItalic(True)
|
||||
elif format_type == "number":
|
||||
fmt.setForeground(QColor(181, 206, 168)) # Light green
|
||||
elif format_type == "tag":
|
||||
fmt.setForeground(QColor(78, 201, 176)) # Cyan
|
||||
|
||||
return fmt
|
||||
|
||||
|
||||
class CodeBlockMetadata:
|
||||
"""Stores metadata about code blocks (language, etc.) for a document."""
|
||||
|
||||
def __init__(self):
|
||||
self._block_languages: Dict[int, str] = {} # block_number -> language
|
||||
|
||||
def set_language(self, block_number: int, language: str):
|
||||
"""Set the language for a code block."""
|
||||
self._block_languages[block_number] = language.lower()
|
||||
|
||||
def get_language(self, block_number: int) -> Optional[str]:
|
||||
"""Get the language for a code block."""
|
||||
return self._block_languages.get(block_number)
|
||||
|
||||
def serialize(self) -> str:
|
||||
"""Serialize metadata to a string."""
|
||||
# Store as JSON-like format in a comment at the end
|
||||
if not self._block_languages:
|
||||
return ""
|
||||
|
||||
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
|
||||
return "<!-- code-langs: " + ",".join(items) + " -->\n"
|
||||
|
||||
def deserialize(self, text: str):
|
||||
"""Deserialize metadata from text."""
|
||||
self._block_languages.clear()
|
||||
|
||||
# Look for metadata comment at the end
|
||||
match = re.search(r"<!-- code-langs: ([^>]+) -->", text)
|
||||
if match:
|
||||
pairs = match.group(1).split(",")
|
||||
for pair in pairs:
|
||||
if ":" in pair:
|
||||
block_num, lang = pair.split(":", 1)
|
||||
try:
|
||||
self._block_languages[int(block_num)] = lang
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def clear_language(self, block_number: int):
|
||||
"""Remove any stored language for a given block, if present."""
|
||||
self._block_languages.pop(block_number, None)
|
||||
1882
bouquin/db.py
1882
bouquin/db.py
File diff suppressed because it is too large
Load diff
64
bouquin/document_utils.py
Normal file
64
bouquin/document_utils.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Utility functions for document operations.
|
||||
|
||||
This module provides shared functionality for document handling across
|
||||
different widgets (TodaysDocumentsWidget, DocumentsDialog, SearchResultsDialog,
|
||||
and TagBrowserDialog).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
from . import strings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db import DBManager
|
||||
|
||||
|
||||
def open_document_from_db(
|
||||
db: DBManager, doc_id: int, file_name: str, parent_widget: Optional[QWidget] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Open a document by fetching it from the database and opening with system default app.
|
||||
"""
|
||||
# Fetch document data from database
|
||||
try:
|
||||
data = db.document_data(doc_id)
|
||||
except Exception as e:
|
||||
# Show error dialog if parent widget is provided
|
||||
if parent_widget:
|
||||
QMessageBox.warning(
|
||||
parent_widget,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_open_failed").format(error=str(e)),
|
||||
)
|
||||
return False
|
||||
|
||||
# Extract file extension
|
||||
suffix = Path(file_name).suffix or ""
|
||||
|
||||
# Create temporary file with same extension
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
prefix="bouquin_doc_",
|
||||
suffix=suffix,
|
||||
delete=False,
|
||||
)
|
||||
|
||||
# Write data to temp file
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
# Open with system default application
|
||||
success = QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
|
||||
|
||||
return success
|
||||
601
bouquin/documents.py
Normal file
601
bouquin/documents.py
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStyle,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager, DocumentRow
|
||||
from .settings import load_db_config
|
||||
from .time_log import TimeCodeManagerDialog
|
||||
|
||||
|
||||
class TodaysDocumentsWidget(QFrame):
|
||||
"""
|
||||
Collapsible sidebar widget showing today's documents.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db: DBManager, date_iso: str, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self._current_date = date_iso
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
# Header (toggle + open-documents button)
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText(strings._("todays_documents"))
|
||||
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
self.toggle_btn.setArrowType(Qt.RightArrow)
|
||||
self.toggle_btn.clicked.connect(self._on_toggle)
|
||||
|
||||
self.open_btn = QToolButton()
|
||||
self.open_btn.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||
)
|
||||
self.open_btn.setToolTip(strings._("project_documents_title"))
|
||||
self.open_btn.setAutoRaise(True)
|
||||
self.open_btn.clicked.connect(self._open_documents_dialog)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.addWidget(self.toggle_btn)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self.open_btn)
|
||||
|
||||
# Body: list of today's documents
|
||||
self.body = QWidget()
|
||||
body_layout = QVBoxLayout(self.body)
|
||||
body_layout.setContentsMargins(0, 4, 0, 0)
|
||||
body_layout.setSpacing(2)
|
||||
|
||||
self.list = QListWidget()
|
||||
self.list.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.list.setMaximumHeight(160)
|
||||
self.list.itemDoubleClicked.connect(self._open_selected_document)
|
||||
body_layout.addWidget(self.list)
|
||||
|
||||
self.body.setVisible(False)
|
||||
|
||||
main = QVBoxLayout(self)
|
||||
main.setContentsMargins(0, 0, 0, 0)
|
||||
main.addLayout(header)
|
||||
main.addWidget(self.body)
|
||||
|
||||
# Initial fill
|
||||
self.reload()
|
||||
|
||||
# ----- public API ---------------------------------------------------
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Refresh the list of today's documents."""
|
||||
self.list.clear()
|
||||
|
||||
rows = self._db.todays_documents(self._current_date)
|
||||
if not rows:
|
||||
item = QListWidgetItem(strings._("todays_documents_none"))
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
|
||||
self.list.addItem(item)
|
||||
return
|
||||
|
||||
for doc_id, file_name, project_name in rows:
|
||||
label = file_name
|
||||
extra_parts = []
|
||||
if project_name:
|
||||
extra_parts.append(project_name)
|
||||
if extra_parts:
|
||||
label = f"{file_name} - " + " · ".join(extra_parts)
|
||||
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"doc_id": doc_id, "file_name": file_name},
|
||||
)
|
||||
self.list.addItem(item)
|
||||
|
||||
# ----- internals ----------------------------------------------------
|
||||
|
||||
def set_current_date(self, date_iso: str) -> None:
|
||||
self._current_date = date_iso
|
||||
self.reload()
|
||||
|
||||
def _on_toggle(self, checked: bool) -> None:
|
||||
self.body.setVisible(checked)
|
||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||
if checked:
|
||||
self.reload()
|
||||
|
||||
def _open_selected_document(self, item: QListWidgetItem) -> None:
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
doc_id = data.get("doc_id")
|
||||
file_name = data.get("file_name") or ""
|
||||
if doc_id is None or not file_name:
|
||||
return
|
||||
self._open_document(int(doc_id), file_name)
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""Open a document from the list."""
|
||||
from .document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
def _open_documents_dialog(self) -> None:
|
||||
"""Open the full DocumentsDialog."""
|
||||
dlg = DocumentsDialog(self._db, self, current_date=self._current_date)
|
||||
dlg.exec()
|
||||
# Refresh after any changes
|
||||
self.reload()
|
||||
|
||||
|
||||
class DocumentsDialog(QDialog):
|
||||
"""
|
||||
Per-project document manager.
|
||||
|
||||
- Choose a project
|
||||
- See list of attached documents
|
||||
- Add (from file), open (via temp file), delete
|
||||
- Inline-edit description
|
||||
- Inline-edit tags (comma-separated), using the global tags table
|
||||
"""
|
||||
|
||||
FILE_COL = 0
|
||||
TAGS_COL = 1
|
||||
DESC_COL = 2
|
||||
ADDED_COL = 3
|
||||
SIZE_COL = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: DBManager,
|
||||
parent: QWidget | None = None,
|
||||
initial_project_id: Optional[int] = None,
|
||||
current_date: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self.cfg = load_db_config()
|
||||
self._reloading_docs = False
|
||||
self._search_text: str = ""
|
||||
self._current_date = current_date # Store the current date for document uploads
|
||||
|
||||
self.setWindowTitle(strings._("project_documents_title"))
|
||||
self.resize(900, 450)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# --- Project selector -------------------------------------------------
|
||||
form = QFormLayout()
|
||||
proj_row = QHBoxLayout()
|
||||
self.project_combo = QComboBox()
|
||||
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
|
||||
self.manage_projects_btn.clicked.connect(self._manage_projects)
|
||||
proj_row.addWidget(self.project_combo, 1)
|
||||
proj_row.addWidget(self.manage_projects_btn)
|
||||
form.addRow(strings._("project"), proj_row)
|
||||
|
||||
# --- Search box (all projects) ----------------------------------------
|
||||
self.search_edit = QLineEdit()
|
||||
self.search_edit.setClearButtonEnabled(True)
|
||||
self.search_edit.setPlaceholderText(strings._("documents_search_placeholder"))
|
||||
self.search_edit.textChanged.connect(self._on_search_text_changed)
|
||||
form.addRow(strings._("documents_search_label"), self.search_edit)
|
||||
|
||||
root.addLayout(form)
|
||||
|
||||
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
|
||||
|
||||
# --- Table of documents ----------------------------------------------
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[
|
||||
strings._("documents_col_file"), # FILE_COL
|
||||
strings._("documents_col_tags"), # TAGS_COL
|
||||
strings._("documents_col_description"), # DESC_COL
|
||||
strings._("documents_col_added"), # ADDED_COL
|
||||
strings._("documents_col_size"), # SIZE_COL
|
||||
]
|
||||
)
|
||||
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(self.FILE_COL, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(self.TAGS_COL, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(self.DESC_COL, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(self.ADDED_COL, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(self.SIZE_COL, QHeaderView.ResizeToContents)
|
||||
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
# Editable: tags + description
|
||||
self.table.setEditTriggers(
|
||||
QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked
|
||||
)
|
||||
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
self.table.itemDoubleClicked.connect(self._on_open_clicked)
|
||||
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
# --- Buttons ---------------------------------------------------------
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch(1)
|
||||
|
||||
self.add_btn = QPushButton(strings._("documents_add"))
|
||||
self.add_btn.clicked.connect(self._on_add_clicked)
|
||||
btn_row.addWidget(self.add_btn)
|
||||
|
||||
self.open_btn = QPushButton(strings._("documents_open"))
|
||||
self.open_btn.clicked.connect(self._on_open_clicked)
|
||||
btn_row.addWidget(self.open_btn)
|
||||
|
||||
self.delete_btn = QPushButton(strings._("documents_delete"))
|
||||
self.delete_btn.clicked.connect(self._on_delete_clicked)
|
||||
btn_row.addWidget(self.delete_btn)
|
||||
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
|
||||
root.addLayout(btn_row)
|
||||
|
||||
# Separator at bottom (purely cosmetic)
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
root.addWidget(line)
|
||||
|
||||
# Init data
|
||||
self._reload_projects()
|
||||
self._select_initial_project(initial_project_id)
|
||||
self._reload_documents()
|
||||
|
||||
# --- Helpers -------------------------------------------------------------
|
||||
|
||||
def _reload_projects(self) -> None:
|
||||
self.project_combo.blockSignals(True)
|
||||
try:
|
||||
self.project_combo.clear()
|
||||
for proj_id, name in self._db.list_projects():
|
||||
self.project_combo.addItem(name, proj_id)
|
||||
finally:
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def _select_initial_project(self, project_id: Optional[int]) -> None:
|
||||
if project_id is None:
|
||||
if self.project_combo.count() > 0:
|
||||
self.project_combo.setCurrentIndex(0)
|
||||
return
|
||||
|
||||
idx = self.project_combo.findData(project_id)
|
||||
if idx >= 0:
|
||||
self.project_combo.setCurrentIndex(idx)
|
||||
elif self.project_combo.count() > 0:
|
||||
self.project_combo.setCurrentIndex(0)
|
||||
|
||||
def _current_project(self) -> Optional[int]:
|
||||
idx = self.project_combo.currentIndex()
|
||||
if idx < 0:
|
||||
return None
|
||||
proj_id = self.project_combo.itemData(idx)
|
||||
return int(proj_id) if proj_id is not None else None
|
||||
|
||||
def _manage_projects(self) -> None:
|
||||
dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self)
|
||||
dlg.exec()
|
||||
self._reload_projects()
|
||||
self._reload_documents()
|
||||
|
||||
def _on_search_text_changed(self, text: str) -> None:
|
||||
"""Update the in-memory search text and reload the table."""
|
||||
self._search_text = text
|
||||
self._reload_documents()
|
||||
|
||||
def _reload_documents(self) -> None:
|
||||
|
||||
search = (self._search_text or "").strip()
|
||||
|
||||
self._reloading_docs = True
|
||||
try:
|
||||
self.table.setRowCount(0)
|
||||
|
||||
if search:
|
||||
# Global search across all projects
|
||||
rows: list[DocumentRow] = self._db.search_documents(search)
|
||||
|
||||
else:
|
||||
proj_id = self._current_project()
|
||||
if proj_id is None:
|
||||
return
|
||||
|
||||
rows = self._db.documents_for_project(proj_id)
|
||||
|
||||
self.table.setRowCount(len(rows))
|
||||
|
||||
for row_idx, r in enumerate(rows):
|
||||
(
|
||||
doc_id,
|
||||
_project_id,
|
||||
project_name,
|
||||
file_name,
|
||||
description,
|
||||
size_bytes,
|
||||
uploaded_at,
|
||||
) = r
|
||||
|
||||
# Col 0: File
|
||||
file_item = QTableWidgetItem(file_name)
|
||||
file_item.setData(Qt.ItemDataRole.UserRole, doc_id)
|
||||
file_item.setFlags(file_item.flags() & ~Qt.ItemIsEditable)
|
||||
self.table.setItem(row_idx, self.FILE_COL, file_item)
|
||||
|
||||
# Col 1: Tags (comma-separated)
|
||||
tags = self._db.get_tags_for_document(doc_id)
|
||||
tag_names = [name for (_tid, name, _color) in tags]
|
||||
tags_text = ", ".join(tag_names)
|
||||
tags_item = QTableWidgetItem(tags_text)
|
||||
|
||||
# If there is at least one tag, colour the cell using the first tag's colour
|
||||
if tags:
|
||||
first_color = tags[0][2]
|
||||
if first_color:
|
||||
col = QColor(first_color)
|
||||
tags_item.setBackground(col)
|
||||
# Choose a readable text color
|
||||
if col.lightness() < 128:
|
||||
tags_item.setForeground(QColor("#ffffff"))
|
||||
else:
|
||||
tags_item.setForeground(QColor("#000000"))
|
||||
|
||||
self.table.setItem(row_idx, self.TAGS_COL, tags_item)
|
||||
if not self.cfg.tags:
|
||||
self.table.hideColumn(self.TAGS_COL)
|
||||
|
||||
# Col 2: Description (editable)
|
||||
desc_item = QTableWidgetItem(description or "")
|
||||
self.table.setItem(row_idx, self.DESC_COL, desc_item)
|
||||
|
||||
# Col 3: Added at (editable)
|
||||
added_label = uploaded_at
|
||||
added_item = QTableWidgetItem(added_label)
|
||||
self.table.setItem(row_idx, self.ADDED_COL, added_item)
|
||||
|
||||
# Col 4: Size (not editable)
|
||||
size_item = QTableWidgetItem(self._format_size(size_bytes))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemIsEditable)
|
||||
self.table.setItem(row_idx, self.SIZE_COL, size_item)
|
||||
finally:
|
||||
self._reloading_docs = False
|
||||
|
||||
# --- Signals -------------------------------------------------------------
|
||||
|
||||
def _on_project_changed(self, idx: int) -> None:
|
||||
_ = idx
|
||||
self._reload_documents()
|
||||
|
||||
def _on_add_clicked(self) -> None:
|
||||
proj_id = self._current_project()
|
||||
if proj_id is None:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_no_project_selected"),
|
||||
)
|
||||
return
|
||||
|
||||
paths, _ = QFileDialog.getOpenFileNames(
|
||||
self,
|
||||
strings._("documents_add"),
|
||||
"",
|
||||
strings._("documents_file_filter_all"),
|
||||
)
|
||||
if not paths:
|
||||
return
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
self._db.add_document_from_path(
|
||||
proj_id, path, uploaded_at=self._current_date
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_add_failed").format(error=str(e)),
|
||||
)
|
||||
|
||||
self._reload_documents()
|
||||
|
||||
def _selected_doc_meta(self) -> tuple[Optional[int], Optional[str]]:
|
||||
row = self.table.currentRow()
|
||||
if row < 0:
|
||||
return None, None
|
||||
|
||||
file_item = self.table.item(row, self.FILE_COL)
|
||||
if file_item is None:
|
||||
return None, None
|
||||
|
||||
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
|
||||
file_name = file_item.text()
|
||||
return (int(doc_id) if doc_id is not None else None, file_name)
|
||||
|
||||
def _on_open_clicked(self, *args) -> None:
|
||||
doc_id, file_name = self._selected_doc_meta()
|
||||
if doc_id is None or not file_name:
|
||||
return
|
||||
self._open_document(doc_id, file_name)
|
||||
|
||||
def _on_delete_clicked(self) -> None:
|
||||
doc_id, _file_name = self._selected_doc_meta()
|
||||
if doc_id is None:
|
||||
return
|
||||
|
||||
resp = QMessageBox.question(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_confirm_delete"),
|
||||
)
|
||||
if resp != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self._db.delete_document(doc_id)
|
||||
self._reload_documents()
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
"""
|
||||
Handle inline edits to Description, Tags, and Added date.
|
||||
"""
|
||||
if self._reloading_docs or item is None:
|
||||
return
|
||||
|
||||
row = item.row()
|
||||
col = item.column()
|
||||
|
||||
file_item = self.table.item(row, self.FILE_COL)
|
||||
if file_item is None:
|
||||
return
|
||||
|
||||
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
|
||||
if doc_id is None:
|
||||
return
|
||||
|
||||
doc_id = int(doc_id)
|
||||
|
||||
# Description column
|
||||
if col == self.DESC_COL:
|
||||
desc = item.text().strip() or None
|
||||
self._db.update_document_description(doc_id, desc)
|
||||
return
|
||||
|
||||
# Tags column
|
||||
if col == self.TAGS_COL:
|
||||
raw = item.text()
|
||||
# split on commas, strip, drop empties
|
||||
names = [p.strip() for p in raw.split(",") if p.strip()]
|
||||
self._db.set_tags_for_document(doc_id, names)
|
||||
|
||||
# Re-normalise text to the canonical tag names stored in DB
|
||||
tags = self._db.get_tags_for_document(doc_id)
|
||||
tag_names = [name for (_tid, name, _color) in tags]
|
||||
tags_text = ", ".join(tag_names)
|
||||
|
||||
self._reloading_docs = True
|
||||
try:
|
||||
item.setText(tags_text)
|
||||
# Reset / apply background based on first tag colour
|
||||
if tags:
|
||||
first_color = tags[0][2]
|
||||
if first_color:
|
||||
col = QColor(first_color)
|
||||
item.setBackground(col)
|
||||
if col.lightness() < 128:
|
||||
item.setForeground(QColor("#ffffff"))
|
||||
else:
|
||||
item.setForeground(QColor("#000000"))
|
||||
else:
|
||||
# No tags: clear background / foreground to defaults
|
||||
item.setBackground(QColor())
|
||||
item.setForeground(QColor())
|
||||
finally:
|
||||
self._reloading_docs = False
|
||||
return
|
||||
|
||||
# Added date column
|
||||
if col == self.ADDED_COL:
|
||||
date_str = item.text().strip()
|
||||
|
||||
# Validate date format (YYYY-MM-DD)
|
||||
if not self._validate_date_format(date_str):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
(
|
||||
strings._("documents_invalid_date_format")
|
||||
if hasattr(strings, "_")
|
||||
and callable(getattr(strings, "_"))
|
||||
and "documents_invalid_date_format" in dir(strings)
|
||||
else f"Invalid date format. Please use YYYY-MM-DD format.\nExample: {date_str[:4]}-01-15"
|
||||
),
|
||||
)
|
||||
# Reload to reset the cell to its original value
|
||||
self._reload_documents()
|
||||
return
|
||||
|
||||
# Update the database
|
||||
self._db.update_document_uploaded_at(doc_id, date_str)
|
||||
return
|
||||
|
||||
# --- utils -------------------------------------------------------------
|
||||
|
||||
def _validate_date_format(self, date_str: str) -> bool:
|
||||
"""
|
||||
Validate that a date string is in YYYY-MM-DD format.
|
||||
|
||||
Returns True if valid, False otherwise.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# Check basic format with regex
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
|
||||
return False
|
||||
|
||||
# Validate it's a real date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""
|
||||
Fetch BLOB from DB, write to a temporary file, and open with default app.
|
||||
"""
|
||||
from .document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
@staticmethod
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Human-readable file size.
|
||||
"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
kb = size_bytes / 1024.0
|
||||
if kb < 1024:
|
||||
return f"{kb:.1f} KB"
|
||||
mb = kb / 1024.0
|
||||
if mb < 1024:
|
||||
return f"{mb:.1f} MB"
|
||||
gb = mb / 1024.0
|
||||
return f"{gb:.1f} GB"
|
||||
|
|
@ -1,20 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import (
|
||||
QShortcut,
|
||||
QTextCursor,
|
||||
QTextCharFormat,
|
||||
QTextDocument,
|
||||
)
|
||||
from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
|
|
|||
187
bouquin/fonts/DejaVu.license
Normal file
187
bouquin/fonts/DejaVu.license
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||
|
||||
|
||||
Bitstream Vera Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||
a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Arev Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the fonts accompanying this license ("Fonts") and
|
||||
associated documentation files (the "Font Software"), to reproduce
|
||||
and distribute the modifications to the Bitstream Vera Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be included in all copies of one or more of the Font Software
|
||||
typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in
|
||||
particular the designs of glyphs or characters in the Fonts may be
|
||||
modified and additional glyphs or characters may be added to the
|
||||
Fonts, only if the fonts are renamed to names not containing either
|
||||
the words "Tavmjong Bah" or the word "Arev".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts
|
||||
or Font Software that has been modified and is distributed under the
|
||||
"Tavmjong Bah Arev" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy of one or more of the Font Software typefaces may be sold by
|
||||
itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||
be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in this Font Software without prior written authorization
|
||||
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||
. fr.
|
||||
|
||||
TeX Gyre DJV Math
|
||||
-----------------
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
|
||||
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
|
||||
(on behalf of TeX users groups) are in public domain.
|
||||
|
||||
Letters imported from Euler Fraktur from AMSfonts are (c) American
|
||||
Mathematical Society (see below).
|
||||
Bitstream Vera Fonts Copyright
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
|
||||
is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license (“Fonts”) and associated
|
||||
documentation
|
||||
files (the “Font Software”), to reproduce and distribute the Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute,
|
||||
and/or sell copies of the Font Software, and to permit persons to whom
|
||||
the Font Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be
|
||||
included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional
|
||||
glyphs or characters may be added to the Fonts, only if the fonts are
|
||||
renamed
|
||||
to names not containing either the words “Bitstream” or the word “Vera”.
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or
|
||||
Font Software
|
||||
that has been modified and is distributed under the “Bitstream Vera”
|
||||
names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy
|
||||
of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
|
||||
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
|
||||
ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
|
||||
INABILITY TO USE
|
||||
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Except as contained in this notice, the names of GNOME, the GNOME
|
||||
Foundation,
|
||||
and Bitstream Inc., shall not be used in advertising or otherwise to promote
|
||||
the sale, use or other dealings in this Font Software without prior written
|
||||
authorization from the GNOME Foundation or Bitstream Inc., respectively.
|
||||
For further information, contact: fonts at gnome dot org.
|
||||
|
||||
AMSFonts (v. 2.2) copyright
|
||||
|
||||
The PostScript Type 1 implementation of the AMSFonts produced by and
|
||||
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
|
||||
available for general use. This has been accomplished through the
|
||||
cooperation
|
||||
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
|
||||
Members of this consortium include:
|
||||
|
||||
Elsevier Science IBM Corporation Society for Industrial and Applied
|
||||
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
|
||||
|
||||
In order to assure the authenticity of these fonts, copyright will be
|
||||
held by
|
||||
the American Mathematical Society. This is not meant to restrict in any way
|
||||
the legitimate use of the fonts, such as (but not limited to) electronic
|
||||
distribution of documents containing these fonts, inclusion of these fonts
|
||||
into other public domain or commercial font collections or computer
|
||||
applications, use of the outline data to create derivative fonts and/or
|
||||
faces, etc. However, the AMS does require that the AMS copyright notice be
|
||||
removed from any derivative versions of the fonts which have been altered in
|
||||
any way. In addition, to ensure the fidelity of TeX documents using Computer
|
||||
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
|
||||
has requested that any alterations which yield different font metrics be
|
||||
given a different name.
|
||||
|
||||
$Id$
|
||||
BIN
bouquin/fonts/DejaVuSans.ttf
Normal file
BIN
bouquin/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
93
bouquin/fonts/Noto.license
Normal file
93
bouquin/fonts/Noto.license
Normal 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.
|
||||
BIN
bouquin/fonts/NotoSansSymbols2-Regular.ttf
Normal file
BIN
bouquin/fonts/NotoSansSymbols2-Regular.ttf
Normal file
Binary file not shown.
|
|
@ -1,21 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import difflib, re, html as _html
|
||||
import difflib
|
||||
import html as _html
|
||||
import re
|
||||
from datetime import datetime
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
|
||||
from PySide6.QtCore import QDate, Qt, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCalendarWidget,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QMessageBox,
|
||||
QTextBrowser,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
||||
def _markdown_to_text(s: str) -> str:
|
||||
|
|
@ -69,19 +77,33 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
|||
class HistoryDialog(QDialog):
|
||||
"""Show versions for a date, preview, diff, and allow revert."""
|
||||
|
||||
def __init__(self, db, date_iso: str, parent=None):
|
||||
def __init__(
|
||||
self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
|
||||
self._db = db
|
||||
self._date = date_iso
|
||||
self._themes = themes
|
||||
self._versions = [] # list[dict] from DB
|
||||
self._current_id = None # id of current
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# --- Top: date label + change-date button
|
||||
date_row = QHBoxLayout()
|
||||
self.date_label = QLabel(strings._("date_label").format(date=date_iso))
|
||||
date_row.addWidget(self.date_label)
|
||||
date_row.addStretch(1)
|
||||
self.change_date_btn = QPushButton(strings._("change_date"))
|
||||
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
|
||||
date_row.addWidget(self.change_date_btn)
|
||||
root.addLayout(date_row)
|
||||
|
||||
# Top: list of versions
|
||||
top = QHBoxLayout()
|
||||
self.list = QListWidget()
|
||||
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.list.setMinimumSize(500, 650)
|
||||
self.list.currentItemChanged.connect(self._on_select)
|
||||
top.addWidget(self.list, 1)
|
||||
|
|
@ -104,14 +126,64 @@ class HistoryDialog(QDialog):
|
|||
row.addStretch(1)
|
||||
self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected"))
|
||||
self.btn_revert.clicked.connect(self._revert)
|
||||
self.btn_delete = QPushButton(strings._("history_dialog_delete"))
|
||||
self.btn_delete.clicked.connect(self._delete)
|
||||
self.btn_close = QPushButton(strings._("close"))
|
||||
self.btn_close.clicked.connect(self.reject)
|
||||
row.addWidget(self.btn_revert)
|
||||
row.addWidget(self.btn_delete)
|
||||
row.addWidget(self.btn_close)
|
||||
root.addLayout(row)
|
||||
|
||||
self._load_versions()
|
||||
|
||||
@Slot()
|
||||
def _on_change_date_clicked(self) -> None:
|
||||
"""Let the user choose a different date and reload entries."""
|
||||
|
||||
# Start from current dialog date; fall back to today if invalid
|
||||
current_qdate = QDate.fromString(self._date, Qt.ISODate)
|
||||
if not current_qdate.isValid():
|
||||
current_qdate = QDate.currentDate()
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle(strings._("select_date_title"))
|
||||
|
||||
layout = QVBoxLayout(dlg)
|
||||
|
||||
calendar = QCalendarWidget(dlg)
|
||||
calendar.setSelectedDate(current_qdate)
|
||||
layout.addWidget(calendar)
|
||||
# Apply the same theming as the main sidebar calendar
|
||||
if self._themes is not None:
|
||||
self._themes.register_calendar(calendar)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
|
||||
)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return
|
||||
|
||||
new_qdate = calendar.selectedDate()
|
||||
new_iso = new_qdate.toString(Qt.ISODate)
|
||||
if new_iso == self._date:
|
||||
# No change
|
||||
return
|
||||
|
||||
# Update state
|
||||
self._date = new_iso
|
||||
|
||||
# Update window title and header label
|
||||
self.setWindowTitle(strings._("for").format(date=new_iso))
|
||||
self.date_label.setText(strings._("date_label").format(date=new_iso))
|
||||
|
||||
# Reload entries for the newly selected date
|
||||
self._load_versions()
|
||||
|
||||
# --- Data/UX helpers ---
|
||||
def _load_versions(self):
|
||||
# [{id,version_no,created_at,note,is_current}]
|
||||
|
|
@ -145,20 +217,24 @@ class HistoryDialog(QDialog):
|
|||
|
||||
@Slot()
|
||||
def _on_select(self):
|
||||
selected_items = self.list.selectedItems()
|
||||
item = self.list.currentItem()
|
||||
if not item:
|
||||
if not item or len(selected_items) > 1:
|
||||
self.preview.clear()
|
||||
self.diff.clear()
|
||||
self.btn_revert.setEnabled(False)
|
||||
return
|
||||
|
||||
sel_id = item.data(Qt.UserRole)
|
||||
sel = self._db.get_version(version_id=sel_id)
|
||||
self.preview.setMarkdown(sel["content"])
|
||||
# Diff vs current (textual diff)
|
||||
cur = self._db.get_version(version_id=self._current_id)
|
||||
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
||||
# Enable revert only if selecting a non-current version
|
||||
|
||||
# Enable revert and delete buttons only if selecting a non-current version
|
||||
self.btn_revert.setEnabled(sel_id != self._current_id)
|
||||
self.btn_delete.setEnabled(sel_id != self._current_id)
|
||||
|
||||
@Slot()
|
||||
def _revert(self):
|
||||
|
|
@ -175,3 +251,19 @@ class HistoryDialog(QDialog):
|
|||
)
|
||||
return
|
||||
self.accept()
|
||||
|
||||
@Slot()
|
||||
def _delete(self):
|
||||
selected_items = self.list.selectedItems()
|
||||
for item in selected_items:
|
||||
sel_id = item.data(Qt.UserRole)
|
||||
if sel_id == self._current_id:
|
||||
return
|
||||
try:
|
||||
self._db.delete_version(version_id=sel_id)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self, strings._("history_dialog_delete_failed"), str(e)
|
||||
)
|
||||
return
|
||||
return self._load_versions()
|
||||
|
|
|
|||
53
bouquin/icons/bouquin.svg
Normal file
53
bouquin/icons/bouquin.svg
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Book cover -->
|
||||
<rect
|
||||
x="116"
|
||||
y="76"
|
||||
width="280"
|
||||
height="360"
|
||||
rx="48"
|
||||
ry="48"
|
||||
fill="#FACC15"
|
||||
/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect
|
||||
x="116"
|
||||
y="76"
|
||||
width="64"
|
||||
height="360"
|
||||
rx="40"
|
||||
ry="40"
|
||||
fill="#F59E0B"
|
||||
/>
|
||||
|
||||
<!-- Folded page corner (top-right triangle) -->
|
||||
<path
|
||||
d="M396 76 L356 76 L396 116 Z"
|
||||
fill="#FEF9C3"
|
||||
/>
|
||||
|
||||
<!-- Keyhole: circular top -->
|
||||
<circle
|
||||
cx="256"
|
||||
cy="256"
|
||||
r="34"
|
||||
fill="#0F172A"
|
||||
/>
|
||||
|
||||
<!-- Keyhole: stem -->
|
||||
<rect
|
||||
x="238"
|
||||
y="290"
|
||||
width="36"
|
||||
height="90"
|
||||
rx="12"
|
||||
ry="12"
|
||||
fill="#0F172A"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 733 B |
1445
bouquin/invoices.py
Normal file
1445
bouquin/invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QDialogButtonBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
|
@ -18,32 +22,86 @@ class KeyPrompt(QDialog):
|
|||
parent=None,
|
||||
title: str = strings._("key_prompt_enter_key"),
|
||||
message: str = strings._("key_prompt_enter_key"),
|
||||
initial_db_path: str | Path | None = None,
|
||||
show_db_change: bool = False,
|
||||
):
|
||||
"""
|
||||
Prompt the user for the key required to decrypt the database.
|
||||
|
||||
Used when opening the app, unlocking the idle locked screen,
|
||||
or when rekeying.
|
||||
|
||||
If show_db_change is true, also show a QFileDialog allowing to
|
||||
select a database file, else the default from settings is used.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
|
||||
self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
|
||||
v.addWidget(QLabel(message))
|
||||
self.edit = QLineEdit()
|
||||
self.edit.setEchoMode(QLineEdit.Password)
|
||||
v.addWidget(self.edit)
|
||||
|
||||
# DB chooser
|
||||
self.path_edit: QLineEdit | None = None
|
||||
if show_db_change:
|
||||
path_row = QHBoxLayout()
|
||||
self.path_edit = QLineEdit()
|
||||
if self._db_path is not None:
|
||||
self.path_edit.setText(str(self._db_path))
|
||||
|
||||
browse_btn = QPushButton(strings._("select_notebook"))
|
||||
|
||||
def _browse():
|
||||
start_dir = str(self._db_path or "")
|
||||
fname, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
strings._("select_notebook"),
|
||||
start_dir,
|
||||
"SQLCipher DB (*.db);;All files (*)",
|
||||
)
|
||||
if fname:
|
||||
self._db_path = Path(fname)
|
||||
if self.path_edit is not None:
|
||||
self.path_edit.setText(fname)
|
||||
|
||||
browse_btn.clicked.connect(_browse)
|
||||
|
||||
path_row.addWidget(self.path_edit, 1)
|
||||
path_row.addWidget(browse_btn)
|
||||
v.addLayout(path_row)
|
||||
|
||||
# Key entry
|
||||
self.key_entry = QLineEdit()
|
||||
self.key_entry.setEchoMode(QLineEdit.Password)
|
||||
v.addWidget(self.key_entry)
|
||||
|
||||
toggle = QPushButton(strings._("show"))
|
||||
toggle.setCheckable(True)
|
||||
toggle.toggled.connect(
|
||||
lambda c: self.edit.setEchoMode(
|
||||
lambda c: self.key_entry.setEchoMode(
|
||||
QLineEdit.Normal if c else QLineEdit.Password
|
||||
)
|
||||
)
|
||||
v.addWidget(toggle)
|
||||
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self.accept)
|
||||
bb.rejected.connect(self.reject)
|
||||
v.addWidget(bb)
|
||||
|
||||
self.key_entry.setFocus()
|
||||
self.resize(500, self.sizeHint().height())
|
||||
|
||||
def key(self) -> str:
|
||||
return self.edit.text()
|
||||
return self.key_entry.text()
|
||||
|
||||
def db_path(self) -> Path | None:
|
||||
"""Return the chosen DB path (or None if unchanged/not shown)."""
|
||||
p = self._db_path
|
||||
if self.path_edit is not None:
|
||||
text = self.path_edit.text().strip()
|
||||
if text:
|
||||
p = Path(text)
|
||||
return p
|
||||
|
|
|
|||
109
bouquin/keys/mig5.asc
Normal file
109
bouquin/keys/mig5.asc
Normal 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-----
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
"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_path": "Database path",
|
||||
"database_maintenance": "Database maintenance",
|
||||
"database_compact": "Compact the database",
|
||||
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
|
||||
|
|
@ -23,28 +22,27 @@
|
|||
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"close": "Close",
|
||||
"close": "&Close",
|
||||
"find": "Find",
|
||||
"file": "File",
|
||||
"locale": "Locale",
|
||||
"locale": "Language",
|
||||
"locale_restart": "Please restart the application to load the new language.",
|
||||
"settings": "Settings",
|
||||
"theme": "Theme",
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"behaviour": "Behaviour",
|
||||
"never": "Never",
|
||||
"browse": "Browse",
|
||||
"close_tab": "Close tab",
|
||||
"previous": "Previous",
|
||||
"previous_day": "Previous day",
|
||||
"next": "Next",
|
||||
"next_day": "Next day",
|
||||
"today": "Today",
|
||||
"show": "Show",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"history": "History",
|
||||
"view_history": "View History",
|
||||
"export": "Export",
|
||||
"export_accessible_flag": "&Export",
|
||||
"export_entries": "Export entries",
|
||||
"export_complete": "Export complete",
|
||||
|
|
@ -53,6 +51,8 @@
|
|||
"backup_complete": "Backup complete",
|
||||
"backup_failed": "Backup failed",
|
||||
"quit": "Quit",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"help": "Help",
|
||||
"saved": "Saved",
|
||||
"saved_to": "Saved to",
|
||||
|
|
@ -60,6 +60,20 @@
|
|||
"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",
|
||||
|
|
@ -70,11 +84,14 @@
|
|||
"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_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_due_to_inactivity": "Locked due to inactivity",
|
||||
"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",
|
||||
|
|
@ -85,7 +102,8 @@
|
|||
"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_yesterdays_unchecked_todos_to_today_on_startup": "Move yesterday's unchecked TODOs to today on startup",
|
||||
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
|
||||
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
|
||||
"insert_images": "Insert images",
|
||||
"images": "Images",
|
||||
"reopen_failed": "Re-open failed",
|
||||
|
|
@ -97,15 +115,23 @@
|
|||
"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.",
|
||||
"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",
|
||||
|
|
@ -117,20 +143,290 @@
|
|||
"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.",
|
||||
"tag_name": "Tag name",
|
||||
"tag_color_hex": "Hex colour",
|
||||
"color_hex": "Colour",
|
||||
"date": "Date",
|
||||
"pick_color": "Pick colour",
|
||||
"invalid_color_title": "Invalid colour",
|
||||
"invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"ok": "OK",
|
||||
"page_or_document": "Page / Document",
|
||||
"add_a_tag": "Add a tag",
|
||||
"edit_tag_name": "Edit tag name",
|
||||
"new_tag_name": "New tag name:",
|
||||
"change_color": "Change colour",
|
||||
"delete_tag": "Delete tag",
|
||||
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
|
||||
"tag_already_exists_with_that_name": "A tag already exists with that name"
|
||||
"tag_already_exists_with_that_name": "A tag already exists with that name",
|
||||
"statistics": "Statistics",
|
||||
"main_window_statistics_accessible_flag": "Stat&istics",
|
||||
"stats_group_pages": "Pages",
|
||||
"stats_group_tags": "Tags",
|
||||
"stats_group_documents": "Documents",
|
||||
"stats_group_time_logging": "Time logging",
|
||||
"stats_group_reminders": "Reminders",
|
||||
"stats_pages_with_content": "Pages with content (current version)",
|
||||
"stats_total_revisions": "Total revisions",
|
||||
"stats_page_most_revisions": "Page with most revisions",
|
||||
"stats_total_words": "Total words (current versions)",
|
||||
"stats_unique_tags": "Unique tags",
|
||||
"stats_page_most_tags": "Page with most tags",
|
||||
"stats_activity_heatmap": "Activity heatmap",
|
||||
"stats_heatmap_metric": "Colour by",
|
||||
"stats_metric_words": "Words",
|
||||
"stats_metric_revisions": "Revisions",
|
||||
"stats_metric_documents": "Documents",
|
||||
"stats_total_documents": "Total documents",
|
||||
"stats_date_most_documents": "Date with most documents",
|
||||
"stats_no_data": "No statistics available yet.",
|
||||
"stats_time_total_hours": "Total hours logged",
|
||||
"stats_time_day_most_hours": "Day with most hours logged",
|
||||
"stats_time_project_most_hours": "Project with most hours logged",
|
||||
"stats_time_activity_most_hours": "Activity with most hours logged",
|
||||
"stats_total_reminders": "Total reminders",
|
||||
"stats_date_most_reminders": "Day with most reminders",
|
||||
"stats_metric_hours": "Hours",
|
||||
"stats_metric_reminders": "Reminders",
|
||||
"select_notebook": "Select notebook",
|
||||
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
|
||||
"bug_report_placeholder": "Type your bug report here",
|
||||
"bug_report_empty": "Please enter some details about the bug before sending.",
|
||||
"bug_report_send_failed": "Could not send bug report.",
|
||||
"bug_report_sent_ok": "Bug report sent. Thank you!",
|
||||
"send": "Send",
|
||||
"reminder": "Reminder",
|
||||
"set_reminder": "Set reminder prompt",
|
||||
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
|
||||
"invalid_time_title": "Invalid time",
|
||||
"invalid_time_message": "Please enter a time in the format HH:MM",
|
||||
"dismiss": "Dismiss",
|
||||
"toolbar_alarm": "Set reminder alarm",
|
||||
"activities": "Activities",
|
||||
"activity": "Activity",
|
||||
"note": "Note",
|
||||
"activity_delete_error_message": "A problem occurred deleting the activity",
|
||||
"activity_delete_error_title": "Problem deleting activity",
|
||||
"activity_rename_error_message": "A problem occurred renaming the activity",
|
||||
"activity_rename_error_title": "Problem renaming activity",
|
||||
"activity_required_message": "An activity name is required",
|
||||
"activity_required_title": "Activity name required",
|
||||
"add_activity": "Add activity",
|
||||
"add_project": "Add project",
|
||||
"add_time_entry": "Add time entry",
|
||||
"time_period": "Time period",
|
||||
"dont_group": "Don't group",
|
||||
"by_activity": "by activity",
|
||||
"by_day": "by day",
|
||||
"by_month": "by month",
|
||||
"by_week": "by week",
|
||||
"date_range": "Date range",
|
||||
"custom_range": "Custom",
|
||||
"last_week": "Last week",
|
||||
"this_week": "This week",
|
||||
"this_month": "This month",
|
||||
"this_year": "This year",
|
||||
"all_projects": "All projects",
|
||||
"delete_activity": "Delete activity",
|
||||
"delete_activity_confirm": "Are you sure you want to delete this activity?",
|
||||
"delete_activity_title": "Delete activity - are you sure?",
|
||||
"delete_project": "Delete project",
|
||||
"delete_project_confirm": "Are you sure you want to delete this project?",
|
||||
"delete_project_title": "Delete project - are you sure?",
|
||||
"delete_time_entry": "Delete time entry",
|
||||
"group_by": "Group by",
|
||||
"hours": "Hours",
|
||||
"created_at": "Created at",
|
||||
"invalid_activity_message": "The activity is invalid",
|
||||
"invalid_activity_title": "Invalid activity",
|
||||
"invalid_project_message": "The project is invalid",
|
||||
"invalid_project_title": "Invalid project",
|
||||
"manage_activities": "Manage activities",
|
||||
"manage_projects": "Manage projects",
|
||||
"manage_projects_activities": "Manage project activities",
|
||||
"open_time_log": "Open time log",
|
||||
"project": "Project",
|
||||
"project_delete_error_message": "A problem occurred deleting the project",
|
||||
"project_delete_error_title": "Problem deleting project",
|
||||
"project_rename_error_message": "A problem occurred renaming the project",
|
||||
"project_rename_error_title": "Problem renaming project",
|
||||
"project_required_message": "A project is required",
|
||||
"project_required_title": "Project required",
|
||||
"projects": "Projects",
|
||||
"rename_activity": "Rename activity",
|
||||
"rename_project": "Rename project",
|
||||
"reporting": "Reporting",
|
||||
"reporting_and_invoicing": "Reporting and Invoicing",
|
||||
"run_report": "Run report",
|
||||
"add_activity_title": "Add activity",
|
||||
"add_activity_label": "Add an activity",
|
||||
"rename_activity_label": "Rename activity",
|
||||
"add_project_title": "Add project",
|
||||
"add_project_label": "Add a project",
|
||||
"rename_activity_title": "Rename this activity",
|
||||
"rename_project_label": "Rename project",
|
||||
"rename_project_title": "Rename this project",
|
||||
"select_activity_message": "Select an activity",
|
||||
"select_activity_title": "Select activity",
|
||||
"select_project_message": "Select a project",
|
||||
"select_project_title": "Select project",
|
||||
"time_log": "Time log",
|
||||
"time_log_collapsed_hint": "Time log",
|
||||
"date_label": "Date: {date}",
|
||||
"change_date": "Change date",
|
||||
"select_date_title": "Select date",
|
||||
"for": "For {date}",
|
||||
"time_log_no_date": "Time log",
|
||||
"time_log_no_entries": "No time entries yet",
|
||||
"time_log_report": "Time log report",
|
||||
"time_log_report_title": "Time log for {project}",
|
||||
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
|
||||
"time_log_total_hours": "Total time spent",
|
||||
"time_log_with_total": "Time log ({hours:.2f}h)",
|
||||
"time_log_total_hours": "Total for day: {hours:.2f}h",
|
||||
"update_time_entry": "Update time entry",
|
||||
"time_report_total": "Total: {hours:.2f} hours",
|
||||
"no_report_title": "No report",
|
||||
"no_report_message": "Please run a report before exporting.",
|
||||
"total": "Total",
|
||||
"export_csv": "Export CSV",
|
||||
"export_csv_error_title": "Export failed",
|
||||
"export_csv_error_message": "Could not write CSV file:\n{error}",
|
||||
"export_pdf": "Export PDF",
|
||||
"export_pdf_error_title": "PDF export failed",
|
||||
"export_pdf_error_message": "Could not write PDF file:\n{error}",
|
||||
"enable_tags_feature": "Enable Tags",
|
||||
"enable_time_log_feature": "Enable Time Logging",
|
||||
"enable_reminders_feature": "Enable Reminders",
|
||||
"reminders_webhook_section_title": "Send Reminders to a webhook",
|
||||
"reminders_webhook_url_label":"Webhook URL",
|
||||
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
|
||||
"enable_documents_feature": "Enable storing of documents",
|
||||
"pomodoro_time_log_default_text": "Focus session",
|
||||
"toolbar_pomodoro_timer": "Time-logging timer",
|
||||
"set_code_language": "Set code language",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"stop_and_log": "Stop and log",
|
||||
"manage_reminders": "Manage Reminders",
|
||||
"upcoming_reminders": "Upcoming Reminders",
|
||||
"no_upcoming_reminders": "No upcoming reminders",
|
||||
"once": "once",
|
||||
"daily": "daily",
|
||||
"weekdays": "weekdays",
|
||||
"weekly": "weekly",
|
||||
"add_reminder": "Add Reminder",
|
||||
"set_reminder": "Set Reminder",
|
||||
"edit_reminder": "Edit Reminder",
|
||||
"delete_reminder": "Delete Reminder",
|
||||
"delete_reminders": "Delete Reminders",
|
||||
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
||||
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
||||
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
||||
"reminder": "Reminder",
|
||||
"reminders": "Reminders",
|
||||
"time": "Time",
|
||||
"once": "Once",
|
||||
"every_day": "Every day",
|
||||
"every_weekday": "Every weekday (Mon-Fri)",
|
||||
"every_week": "Every week",
|
||||
"every_fortnight": "Every 2 weeks",
|
||||
"every_month": "Every month (same date)",
|
||||
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
|
||||
"week_in_month": "Week in month",
|
||||
"fortnightly": "Fortnightly",
|
||||
"monthly_same_date": "Monthly (same date)",
|
||||
"monthly_nth_weekday": "Monthly (nth weekday)",
|
||||
"repeat": "Repeat",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"monday_short": "Mon",
|
||||
"tuesday_short": "Tue",
|
||||
"wednesday_short": "Wed",
|
||||
"thursday_short": "Thu",
|
||||
"friday_short": "Fri",
|
||||
"saturday_short": "Sat",
|
||||
"sunday_short": "Sun",
|
||||
"day": "Day",
|
||||
"text": "Text",
|
||||
"type": "Type",
|
||||
"active": "Active",
|
||||
"actions": "Actions",
|
||||
"edit_code_block": "Edit code block",
|
||||
"delete_code_block": "Delete code block",
|
||||
"search_result_heading_document": "Document",
|
||||
"toolbar_documents": "Documents Manager",
|
||||
"project_documents_title": "Project documents",
|
||||
"documents_col_file": "File",
|
||||
"documents_col_description": "Description",
|
||||
"documents_col_added": "Added",
|
||||
"documents_col_path": "Path",
|
||||
"documents_col_tags": "Tags",
|
||||
"documents_col_size": "Size",
|
||||
"documents_add": "&Add",
|
||||
"documents_add_document": "Add a document",
|
||||
"documents_open": "&Open",
|
||||
"documents_delete": "&Delete",
|
||||
"documents_no_project_selected": "Please choose a project first.",
|
||||
"documents_file_filter_all": "All files (*)",
|
||||
"documents_add_failed": "Could not add document: {error}",
|
||||
"documents_open_failed": "Could not open document: {error}",
|
||||
"documents_missing_file": "The file does not exist:\n{path}",
|
||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||
"documents_search_label": "Search",
|
||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||
"todays_documents": "Documents from this day",
|
||||
"todays_documents_none": "No documents yet.",
|
||||
"manage_invoices": "Manage Invoices",
|
||||
"create_invoice": "Create Invoice",
|
||||
"invoice_amount": "Amount",
|
||||
"invoice_apply_tax": "Apply Tax",
|
||||
"invoice_client_address": "Client Address",
|
||||
"invoice_client_company": "Client Company",
|
||||
"invoice_client_email": "Client E-mail",
|
||||
"invoice_client_name": "Client Contact",
|
||||
"invoice_currency": "Currency",
|
||||
"invoice_dialog_title": "Create Invoice",
|
||||
"invoice_due_date": "Due Date",
|
||||
"invoice_hourly_rate": "Hourly Rate",
|
||||
"invoice_hours": "Hours",
|
||||
"invoice_issue_date": "Issue Date",
|
||||
"invoice_mode_detailed": "Detailed mode",
|
||||
"invoice_mode_summary": "Summary mode",
|
||||
"invoice_number": "Invoice Number",
|
||||
"invoice_save_and_export": "Save and export",
|
||||
"invoice_save_pdf_title": "Save PDF",
|
||||
"invoice_subtotal": "Subtotal",
|
||||
"invoice_summary_default_desc": "Consultant services for the month of",
|
||||
"invoice_summary_desc": "Summary description",
|
||||
"invoice_summary_hours": "Summary hours",
|
||||
"invoice_tax": "Tax details",
|
||||
"invoice_tax_label": "Tax type",
|
||||
"invoice_tax_rate": "Tax rate",
|
||||
"invoice_tax_total": "Tax total",
|
||||
"invoice_total": "Total",
|
||||
"invoice_paid_at": "Paid on",
|
||||
"invoice_payment_note": "Payment notes",
|
||||
"invoice_project_required_title": "Project required",
|
||||
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
|
||||
"invoice_need_report_title": "Report required",
|
||||
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
|
||||
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
|
||||
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
|
||||
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
|
||||
"invoice_company_profile": "Business Profile",
|
||||
"invoice_company_name": "Business Name",
|
||||
"invoice_company_address": "Address",
|
||||
"invoice_company_phone": "Phone",
|
||||
"invoice_company_email": "E-mail",
|
||||
"invoice_company_tax_id": "Tax number",
|
||||
"invoice_company_payment_details": "Payment details",
|
||||
"invoice_company_logo": "Logo",
|
||||
"invoice_company_logo_choose": "Choose logo",
|
||||
"invoice_company_logo_set": "Logo has been set",
|
||||
"invoice_company_logo_not_set": "Logo not set",
|
||||
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"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 modification de la clé",
|
||||
"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 peut-être incorrecte",
|
||||
"db_key_incorrect": "La clé est probablement incorrecte",
|
||||
"db_database_error": "Erreur de base de données",
|
||||
"database_path": "Chemin de la 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.",
|
||||
|
|
@ -17,9 +16,9 @@
|
|||
"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": "Clé est vide",
|
||||
"empty_key": "La clé est vide",
|
||||
"empty_key_explanation": "La clé ne peut pas être vide.",
|
||||
"key_changed": "Clé modifiée",
|
||||
"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",
|
||||
|
|
@ -27,15 +26,14 @@
|
|||
"find": "Rechercher",
|
||||
"file": "Fichier",
|
||||
"locale": "Langue",
|
||||
"locale_restart": "Veuillez redémarrer l’application pour appliquer la nouvelle 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",
|
||||
"behaviour": "Comportement",
|
||||
"never": "Jamais",
|
||||
"browse": "Parcourir",
|
||||
"close_tab": "Fermer l'onglet",
|
||||
"previous": "Précédent",
|
||||
"previous_day": "Jour précédent",
|
||||
"next": "Suivant",
|
||||
|
|
@ -43,69 +41,94 @@
|
|||
"today": "Aujourd'hui",
|
||||
"show": "Afficher",
|
||||
"history": "Historique",
|
||||
"view_history": "Afficher l'historique",
|
||||
"export": "Exporter",
|
||||
"export_accessible_flag": "E&xporter",
|
||||
"export_entries": "Exporter les entrées",
|
||||
"export_complete": "Exportation terminée",
|
||||
"export_failed": "Échec de l’exportation",
|
||||
"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",
|
||||
"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 suivant",
|
||||
"find_previous": "Rechercher précédent",
|
||||
"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_due_to_inactivity": "Verrouillé pour cause d’inactivité",
|
||||
"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": "Saisissez votre clé pour déverrouiller le bouquin",
|
||||
"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_yesterdays_unchecked_todos_to_today_on_startup": "Au démarrage, déplacer les TODO non cochés d’hier vers aujourd’hui",
|
||||
"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’export de la base de données ne sera pas chiffré !\nÊtes-vous sûr de vouloir continuer ?'nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu’Export.",
|
||||
"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 à",
|
||||
"save_key_warning": "Si vous ne voulez pas que l’on vous demande votre clé de chiffrement, cochez ceci 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 ; vous devrez alors ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
|
||||
"search_for_notes_here": "Recherchez des notes",
|
||||
"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 normale",
|
||||
"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",
|
||||
|
|
@ -114,23 +137,154 @@
|
|||
"tags": "Étiquettes",
|
||||
"tag": "Étiquette",
|
||||
"manage_tags": "Gérer les étiquettes",
|
||||
"add_tag_placeholder": "Ajouter une étiquette et appuyez sur Entrée",
|
||||
"tag_browser_title": "Navigateur de é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.",
|
||||
"tag_name": "Nom de l'étiquette",
|
||||
"tag_color_hex": "Couleur hexadécimale",
|
||||
"color_hex": "Couleur",
|
||||
"date": "Date",
|
||||
"pick_color": "Choisir la couleur",
|
||||
"invalid_color_title": "Couleur invalide",
|
||||
"invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.",
|
||||
"add": "Ajouter",
|
||||
"remove": "Supprimer",
|
||||
"ok": "OK",
|
||||
"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à"
|
||||
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
|
||||
"statistics": "Statistiques",
|
||||
"main_window_statistics_accessible_flag": "Stat&istiques",
|
||||
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
|
||||
"stats_total_revisions": "Nombre total de révisions",
|
||||
"stats_page_most_revisions": "Page avec le plus de révisions",
|
||||
"stats_total_words": "Nombre total de mots (versions actuelles)",
|
||||
"stats_unique_tags": "Étiquettes uniques",
|
||||
"stats_page_most_tags": "Page avec le plus d'étiquettes",
|
||||
"stats_activity_heatmap": "Carte de chaleur d'activité",
|
||||
"stats_heatmap_metric": "Colorer selon",
|
||||
"stats_metric_words": "Mots",
|
||||
"stats_metric_revisions": "Révisions",
|
||||
"stats_no_data": "Aucune statistique disponible pour le moment.",
|
||||
"select_notebook": "Sélectionner un bouquin",
|
||||
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
|
||||
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
|
||||
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
|
||||
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
|
||||
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
|
||||
"send": "Envoyer",
|
||||
"reminder": "Rappel",
|
||||
"set_reminder": "Définir le rappel",
|
||||
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
|
||||
"invalid_time_title": "Heure invalide",
|
||||
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
|
||||
"dismiss": "Ignorer",
|
||||
"toolbar_alarm": "Régler l'alarme de rappel",
|
||||
"activities": "Activités",
|
||||
"activity": "Activité",
|
||||
"note": "Note",
|
||||
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
|
||||
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
|
||||
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
|
||||
"activity_rename_error_title": "Problème lors du renommage de l'activité",
|
||||
"activity_required_message": "Un nom d'activité est requis",
|
||||
"activity_required_title": "Nom d'activité requis",
|
||||
"add_activity": "Ajouter une activité",
|
||||
"add_project": "Ajouter un projet",
|
||||
"add_time_entry": "Ajouter une entrée de temps",
|
||||
"time_period": "Période",
|
||||
"by_day": "par jour",
|
||||
"by_month": "par mois",
|
||||
"by_week": "par semaine",
|
||||
"date_range": "Plage de dates",
|
||||
"delete_activity": "Supprimer l'activité",
|
||||
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
|
||||
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
|
||||
"delete_project": "Supprimer le projet",
|
||||
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
|
||||
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
|
||||
"delete_time_entry": "Supprimer l'entrée de temps",
|
||||
"group_by": "Grouper par",
|
||||
"hours": "Heures",
|
||||
"invalid_activity_message": "L'activité est invalide",
|
||||
"invalid_activity_title": "Activité invalide",
|
||||
"invalid_project_message": "Le projet est invalide",
|
||||
"invalid_project_title": "Projet invalide",
|
||||
"manage_activities": "Gérer les activités",
|
||||
"manage_projects": "Gérer les projets",
|
||||
"manage_projects_activities": "Gérer les activités du projet",
|
||||
"open_time_log": "Ouvrir le journal de temps",
|
||||
"project": "Projet",
|
||||
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
|
||||
"project_delete_error_title": "Problème lors de la suppression du projet",
|
||||
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
|
||||
"project_rename_error_title": "Problème lors du renommage du projet",
|
||||
"project_required_message": "Un projet est requis",
|
||||
"project_required_title": "Projet requis",
|
||||
"projects": "Projets",
|
||||
"rename_activity": "Renommer l'activité",
|
||||
"rename_project": "Renommer le projet",
|
||||
"run_report": "Exécuter le rapport",
|
||||
"add_activity_title": "Ajouter une activité",
|
||||
"add_activity_label": "Ajouter une activité",
|
||||
"rename_activity_label": "Renommer l'activité",
|
||||
"add_project_title": "Ajouter un projet",
|
||||
"add_project_label": "Ajouter un projet",
|
||||
"rename_activity_title": "Renommer cette activité",
|
||||
"rename_project_label": "Renommer le projet",
|
||||
"rename_project_title": "Renommer ce projet",
|
||||
"select_activity_message": "Sélectionner une activité",
|
||||
"select_activity_title": "Sélectionner une activité",
|
||||
"select_project_message": "Sélectionner un projet",
|
||||
"select_project_title": "Sélectionner un projet",
|
||||
"time_log": "Journal de temps",
|
||||
"time_log_collapsed_hint": "Journal de temps",
|
||||
"time_log_date_label": "Date du journal de temps : {date}",
|
||||
"time_log_for": "Journal de temps pour {date}",
|
||||
"time_log_no_date": "Journal de temps",
|
||||
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
|
||||
"time_log_report": "Rapport de temps",
|
||||
"time_log_report_title": "Journal de temps pour {project}",
|
||||
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
|
||||
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
|
||||
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
|
||||
"update_time_entry": "Mettre à jour l'entrée de temps",
|
||||
"time_report_total": "Total : {hours:.2f} heures",
|
||||
"no_report_title": "Aucun rapport",
|
||||
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
|
||||
"total": "Total",
|
||||
"export_csv": "Exporter en CSV",
|
||||
"export_csv_error_title": "Échec de l'exportation",
|
||||
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
|
||||
"export_pdf": "Exporter en PDF",
|
||||
"export_pdf_error_title": "Échec de l'exportation PDF",
|
||||
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
|
||||
"enable_tags_feature": "Activer les étiquettes",
|
||||
"enable_time_log_feature": "Activer le journal de temps",
|
||||
"enable_reminders_feature": "Activer les rappels",
|
||||
"pomodoro_time_log_default_text": "Session de concentration",
|
||||
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
|
||||
"set_code_language": "Définir le langage du code",
|
||||
"cut": "Couper",
|
||||
"copy": "Copier",
|
||||
"paste": "Coller",
|
||||
"start": "Démarrer",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre",
|
||||
"stop_and_log": "Arrêter et enregistrer",
|
||||
"once": "une fois",
|
||||
"daily": "quotidien",
|
||||
"weekdays": "jours de semaine",
|
||||
"weekly": "hebdomadaire",
|
||||
"edit_reminder": "Modifier le rappel",
|
||||
"time": "Heure",
|
||||
"once": "Une fois (aujourd'hui)",
|
||||
"every_day": "Tous les jours",
|
||||
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
||||
"every_week": "Toutes les semaines",
|
||||
"repeat": "Répéter",
|
||||
"monday": "Lundi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi",
|
||||
"thursday": "Jeudi",
|
||||
"friday": "Vendredi",
|
||||
"saturday": "Samedi",
|
||||
"sunday": "Dimanche",
|
||||
"day": "Jour"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
"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_key_incorrect": "La chiave è probabilmente errata",
|
||||
"db_database_error": "Errore del database",
|
||||
"database_path": "Percorso 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.",
|
||||
|
|
@ -33,9 +32,7 @@
|
|||
"system": "Sistema",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"behaviour": "Comportamento",
|
||||
"never": "Mai",
|
||||
"browse": "Sfoglia",
|
||||
"previous": "Precedente",
|
||||
"previous_day": "Giorno precedente",
|
||||
"next": "Successivo",
|
||||
|
|
@ -43,8 +40,6 @@
|
|||
"today": "Oggi",
|
||||
"show": "Mostra",
|
||||
"history": "Cronologia",
|
||||
"view_history": "Visualizza cronologia",
|
||||
"export": "Esporta",
|
||||
"export_accessible_flag": "&Esporta",
|
||||
"export_entries": "Esporta voci",
|
||||
"export_complete": "Esportazione completata",
|
||||
|
|
@ -73,10 +68,10 @@
|
|||
"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_due_to_inactivity": "Bloccato per inattività",
|
||||
"lock_overlay_locked": "Bloccato",
|
||||
"lock_overlay_unlock": "Sblocca",
|
||||
"main_window_ready": "Pronto",
|
||||
"main_window_save_a_version": "Salva una versione",
|
||||
"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!",
|
||||
|
|
@ -85,7 +80,7 @@
|
|||
"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_yesterdays_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati di ieri a oggi all'avvio",
|
||||
"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",
|
||||
|
|
@ -116,20 +111,51 @@
|
|||
"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.",
|
||||
"tag_name": "Nome del tag",
|
||||
"tag_color_hex": "Colore esadecimale",
|
||||
"color_hex": "Colore",
|
||||
"date": "Data",
|
||||
"pick_color": "Scegli colore",
|
||||
"invalid_color_title": "Colore non valido",
|
||||
"invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.",
|
||||
"add": "Aggiungi",
|
||||
"remove": "Rimuovi",
|
||||
"ok": "OK",
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from . import strings
|
||||
from .theme import ThemeManager
|
||||
|
|
@ -21,12 +21,13 @@ class LockOverlay(QWidget):
|
|||
lay = QVBoxLayout(self)
|
||||
lay.addStretch(1)
|
||||
|
||||
msg = QLabel(strings._("lock_overlay_locked_due_to_inactivity"), self)
|
||||
msg = QLabel(strings._("lock_overlay_locked"), self)
|
||||
msg.setObjectName("lockLabel")
|
||||
msg.setAlignment(Qt.AlignCenter)
|
||||
|
||||
self._btn = QPushButton(strings._("lock_overlay_unlock"), self)
|
||||
self._btn.setObjectName("unlockButton")
|
||||
self._btn.setShortcut("Ctrl+Shift+U")
|
||||
self._btn.setFixedWidth(200)
|
||||
self._btn.setCursor(Qt.PointingHandCursor)
|
||||
self._btn.setAutoDefault(True)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .settings import APP_NAME, APP_ORG, get_settings
|
||||
from .main_window import MainWindow
|
||||
from .theme import Theme, ThemeConfig, ThemeManager
|
||||
from . import strings
|
||||
from .main_window import MainWindow
|
||||
from .settings import APP_NAME, APP_ORG, get_settings
|
||||
from .theme import Theme, ThemeConfig, ThemeManager
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName(APP_NAME)
|
||||
app.setOrganizationName(APP_ORG)
|
||||
# Icon
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ICON_PATH = BASE_DIR / "icons" / "bouquin.svg"
|
||||
icon = QIcon(str(ICON_PATH))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
s = get_settings()
|
||||
theme_str = s.value("ui/theme", "system")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ from PySide6.QtGui import (
|
|||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QFontMetrics,
|
||||
QGuiApplication,
|
||||
QPalette,
|
||||
QSyntaxHighlighter,
|
||||
|
|
@ -13,15 +14,18 @@ from PySide6.QtGui import (
|
|||
QTextDocument,
|
||||
)
|
||||
|
||||
from .theme import ThemeManager, Theme
|
||||
from .theme import Theme, ThemeManager
|
||||
|
||||
|
||||
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||
"""Live syntax highlighter for markdown that applies formatting as you type."""
|
||||
|
||||
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
|
||||
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)
|
||||
|
|
@ -30,6 +34,14 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
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."""
|
||||
|
||||
|
|
@ -66,17 +78,18 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
self.theme_manager.current() == Theme.DARK
|
||||
or self.theme_manager._is_system_dark
|
||||
):
|
||||
# In dark mode, use a darker panel-like background
|
||||
bg = pal.color(QPalette.AlternateBase)
|
||||
fg = pal.color(QPalette.Text)
|
||||
# In dark mode, use a darker panel-like background for codeblocks
|
||||
code_bg = pal.color(QPalette.AlternateBase)
|
||||
code_fg = pal.color(QPalette.Text)
|
||||
else:
|
||||
# Light mode: keep the existing light gray
|
||||
bg = QColor(245, 245, 245)
|
||||
fg = QColor(
|
||||
# Light mode: keep the existing light gray for code blocks
|
||||
code_bg = QColor(245, 245, 245)
|
||||
code_fg = QColor( # pragma: no cover
|
||||
0, 0, 0
|
||||
) # avoiding using QPalette.Text as it can be white on macOS
|
||||
self.code_block_format.setBackground(bg)
|
||||
self.code_block_format.setForeground(fg)
|
||||
|
||||
self.code_block_format.setBackground(code_bg)
|
||||
self.code_block_format.setForeground(code_fg)
|
||||
|
||||
# Headings
|
||||
self.h1_format = QTextCharFormat()
|
||||
|
|
@ -91,12 +104,65 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
self.h3_format.setFontPointSize(14.0)
|
||||
self.h3_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
# Hyperlinks
|
||||
self.link_format = QTextCharFormat()
|
||||
link_color = pal.color(QPalette.Link)
|
||||
self.link_format.setForeground(link_color)
|
||||
self.link_format.setFontUnderline(True)
|
||||
self.link_format.setAnchor(True)
|
||||
|
||||
# ---- Completed-task text (for checked checkboxes) ----
|
||||
# Use the app palette so this works in both light and dark themes.
|
||||
text_fg = pal.color(QPalette.Text)
|
||||
text_bg = pal.color(QPalette.Base)
|
||||
|
||||
# Blend the text colour towards the background to "fade" it.
|
||||
# t closer to 1.0 = closer to background / more faded.
|
||||
t = 0.55
|
||||
faded = QColor(
|
||||
int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
|
||||
int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
|
||||
int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
|
||||
)
|
||||
|
||||
self.completed_task_format = QTextCharFormat()
|
||||
self.completed_task_format.setForeground(faded)
|
||||
|
||||
# Checkboxes
|
||||
self.checkbox_format = QTextCharFormat()
|
||||
self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle)
|
||||
|
||||
# Bullets
|
||||
self.bullet_format = QTextCharFormat()
|
||||
|
||||
# Use Symbols font for checkbox and bullet glyphs if present
|
||||
if self._editor is not None and hasattr(self._editor, "symbols_font_family"):
|
||||
base_font = QFont(self._editor.qfont) # copy of editor font
|
||||
symbols_font = QFont(self._editor.symbols_font_family)
|
||||
symbols_font.setPointSizeF(base_font.pointSizeF())
|
||||
|
||||
base_metrics = QFontMetrics(base_font)
|
||||
sym_metrics = QFontMetrics(symbols_font)
|
||||
|
||||
# If Symbols glyphs are noticeably shorter than the text,
|
||||
# scale them up so the visual heights roughly match.
|
||||
if sym_metrics.height() > 0:
|
||||
ratio = base_metrics.height() / sym_metrics.height()
|
||||
if ratio > 1.05: # more than ~5% smaller
|
||||
ratio = min(ratio, 1.4) # Oh, Tod, Tod. Don't overdo it.
|
||||
symbols_font.setPointSizeF(symbols_font.pointSizeF() * ratio)
|
||||
|
||||
self.checkbox_format.setFont(symbols_font)
|
||||
self.bullet_format.setFont(symbols_font)
|
||||
|
||||
# Markdown syntax (the markers themselves) - make invisible
|
||||
self.syntax_format = QTextCharFormat()
|
||||
# Use the editor background color so they blend in
|
||||
hidden = QColor(text_bg)
|
||||
hidden.setAlpha(0)
|
||||
self.syntax_format.setForeground(hidden)
|
||||
# Make the markers invisible by setting font size to 0.1 points
|
||||
self.syntax_format.setFontPointSize(0.1)
|
||||
# Also make them very faint in case they still show
|
||||
self.syntax_format.setForeground(QColor(250, 250, 250))
|
||||
|
||||
def _overlay_range(
|
||||
self, start: int, length: int, overlay_fmt: QTextCharFormat
|
||||
|
|
@ -135,6 +201,36 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
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
|
||||
|
||||
|
|
@ -193,7 +289,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
):
|
||||
start, end = m.span()
|
||||
if any(_overlaps((start, end), occ) for occ in occupied):
|
||||
continue
|
||||
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)
|
||||
|
|
@ -205,12 +301,12 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
):
|
||||
start, end = m.span()
|
||||
if any(_overlaps((start, end), occ) for occ in occupied):
|
||||
continue
|
||||
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
|
||||
continue # pragma: no cover
|
||||
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
||||
continue
|
||||
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)
|
||||
|
|
@ -243,3 +339,32 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
self.setFormat(start, 1, self.syntax_format)
|
||||
self.setFormat(end - 1, 1, self.syntax_format)
|
||||
self.setFormat(content_start, content_end - content_start, self.code_format)
|
||||
|
||||
# Hyperlinks
|
||||
url_pattern = re.compile(r"(https?://[^\s<>()]+)")
|
||||
for m in url_pattern.finditer(text):
|
||||
start, end = m.span(1)
|
||||
url = m.group(1)
|
||||
|
||||
# Clone link format so we can attach a per-link href
|
||||
fmt = QTextCharFormat(self.link_format)
|
||||
fmt.setAnchorHref(url)
|
||||
# Overlay link attributes on top of whatever formatting is already there
|
||||
self._overlay_range(start, end - start, fmt)
|
||||
|
||||
# Make checkbox glyphs bigger
|
||||
for m in re.finditer(r"[☐☑]", text):
|
||||
self._overlay_range(m.start(), 1, self.checkbox_format)
|
||||
|
||||
for m in re.finditer(r"•", text):
|
||||
self._overlay_range(m.start(), 1, self.bullet_format)
|
||||
|
||||
# Completed checkbox lines: fade the text after the checkbox.
|
||||
m = re.match(r"^(\s*☑\s+)(.+)$", text)
|
||||
if m and hasattr(self, "completed_task_format"):
|
||||
prefix = m.group(1)
|
||||
content = m.group(2)
|
||||
start = len(prefix)
|
||||
length = len(content)
|
||||
if length > 0:
|
||||
self._overlay_range(start, length, self.completed_task_format)
|
||||
|
|
|
|||
207
bouquin/pomodoro_timer.py
Normal file
207
bouquin/pomodoro_timer.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .time_log import TimeLogDialog
|
||||
|
||||
|
||||
class PomodoroTimer(QFrame):
|
||||
"""A simple timer for tracking work time on a specific task."""
|
||||
|
||||
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
|
||||
|
||||
def __init__(self, task_text: str, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._task_text = task_text
|
||||
self._elapsed_seconds = 0
|
||||
self._running = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Task label
|
||||
task_label = QLabel(task_text)
|
||||
task_label.setWordWrap(True)
|
||||
layout.addWidget(task_label)
|
||||
|
||||
# Timer display
|
||||
self.time_label = QLabel("00:00:00")
|
||||
font = self.time_label.font()
|
||||
font.setPointSize(20)
|
||||
font.setBold(True)
|
||||
self.time_label.setFont(font)
|
||||
self.time_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.time_label)
|
||||
|
||||
# Control buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
self.start_pause_btn = QPushButton(strings._("start"))
|
||||
self.start_pause_btn.clicked.connect(self._toggle_timer)
|
||||
btn_layout.addWidget(self.start_pause_btn)
|
||||
|
||||
self.stop_btn = QPushButton(strings._("stop_and_log"))
|
||||
self.stop_btn.clicked.connect(self._stop_and_log)
|
||||
self.stop_btn.setEnabled(False)
|
||||
btn_layout.addWidget(self.stop_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Internal timer (ticks every second)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
|
||||
@Slot()
|
||||
def _toggle_timer(self):
|
||||
"""Start or pause the timer."""
|
||||
if self._running:
|
||||
# Pause
|
||||
self._running = False
|
||||
self._timer.stop()
|
||||
self.start_pause_btn.setText(strings._("resume"))
|
||||
else:
|
||||
# Start/Resume
|
||||
self._running = True
|
||||
self._timer.start(1000) # 1 second
|
||||
self.start_pause_btn.setText(strings._("pause"))
|
||||
self.stop_btn.setEnabled(True)
|
||||
|
||||
@Slot()
|
||||
def _tick(self):
|
||||
"""Update the elapsed time display."""
|
||||
self._elapsed_seconds += 1
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the time display label."""
|
||||
hours = self._elapsed_seconds // 3600
|
||||
minutes = (self._elapsed_seconds % 3600) // 60
|
||||
seconds = self._elapsed_seconds % 60
|
||||
self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
|
||||
|
||||
@Slot()
|
||||
def _stop_and_log(self):
|
||||
"""Stop the timer and emit signal to open time log."""
|
||||
if self._running:
|
||||
self._running = False
|
||||
self._timer.stop()
|
||||
|
||||
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
|
||||
self.close()
|
||||
|
||||
|
||||
class PomodoroManager:
|
||||
"""Manages Pomodoro timers and integrates with time log."""
|
||||
|
||||
def __init__(self, db: DBManager, parent_window):
|
||||
self._db = db
|
||||
self._parent = parent_window
|
||||
self._active_timer: Optional[PomodoroTimer] = None
|
||||
|
||||
def start_timer_for_line(self, line_text: str, date_iso: str):
|
||||
"""
|
||||
Start a new timer for the given line of text and embed it into the
|
||||
TimeLogWidget in the main window sidebar.
|
||||
"""
|
||||
# Cancel any existing timer first
|
||||
self.cancel_timer()
|
||||
|
||||
# The timer lives inside the TimeLogWidget in the sidebar
|
||||
time_log_widget = getattr(self._parent, "time_log", None)
|
||||
if time_log_widget is None:
|
||||
return
|
||||
|
||||
self._active_timer = PomodoroTimer(line_text, time_log_widget)
|
||||
self._active_timer.timerStopped.connect(
|
||||
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
|
||||
)
|
||||
|
||||
# Ask the TimeLogWidget to own and display the widget
|
||||
if hasattr(time_log_widget, "show_pomodoro_widget"):
|
||||
time_log_widget.show_pomodoro_widget(self._active_timer)
|
||||
else:
|
||||
# Fallback - just attach it as a child widget
|
||||
self._active_timer.setParent(time_log_widget)
|
||||
self._active_timer.show()
|
||||
|
||||
def cancel_timer(self):
|
||||
"""Cancel any running timer without logging and remove it from the sidebar."""
|
||||
if not self._active_timer:
|
||||
return
|
||||
|
||||
time_log_widget = getattr(self._parent, "time_log", None)
|
||||
if time_log_widget is not None and hasattr(
|
||||
time_log_widget, "clear_pomodoro_widget"
|
||||
):
|
||||
time_log_widget.clear_pomodoro_widget()
|
||||
else:
|
||||
# Fallback if the widget API doesn't exist
|
||||
self._active_timer.setParent(None)
|
||||
|
||||
self._active_timer.deleteLater()
|
||||
self._active_timer = None
|
||||
|
||||
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
|
||||
"""Handle timer stop - open time log dialog with pre-filled data."""
|
||||
# Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes)
|
||||
quarter_hours = math.ceil(elapsed_seconds / 900)
|
||||
hours = quarter_hours * 0.25
|
||||
|
||||
# Ensure minimum of 0.25 hours
|
||||
if hours < 0.25:
|
||||
hours = 0.25
|
||||
|
||||
# Untoggle the toolbar button without retriggering the slot
|
||||
tool_bar = getattr(self._parent, "toolBar", None)
|
||||
if tool_bar is not None and hasattr(tool_bar, "actTimer"):
|
||||
action = tool_bar.actTimer
|
||||
was_blocked = action.blockSignals(True)
|
||||
try:
|
||||
action.setChecked(False)
|
||||
finally:
|
||||
action.blockSignals(was_blocked)
|
||||
|
||||
# Remove the embedded widget
|
||||
self.cancel_timer()
|
||||
|
||||
# Open time log dialog
|
||||
dlg = TimeLogDialog(
|
||||
self._db,
|
||||
date_iso,
|
||||
self._parent,
|
||||
True,
|
||||
themes=self._parent.themes,
|
||||
close_after_add=True,
|
||||
)
|
||||
|
||||
# Pre-fill the hours
|
||||
dlg.hours_spin.setValue(hours)
|
||||
|
||||
# Pre-fill the note with task text
|
||||
dlg.note.setText(task_text)
|
||||
|
||||
# Show the dialog
|
||||
dlg.exec()
|
||||
|
||||
time_log_widget = getattr(self._parent, "time_log", None)
|
||||
if time_log_widget is not None:
|
||||
# Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only:
|
||||
# reload the summary so the TimeLogWidget in sidebar updates its totals
|
||||
time_log_widget._reload_summary()
|
||||
if not time_log_widget.toggle_btn.isChecked():
|
||||
time_log_widget.summary_label.setText(
|
||||
strings._("time_log_collapsed_hint")
|
||||
)
|
||||
917
bouquin/reminders.py
Normal file
917
bouquin/reminders.py
Normal file
|
|
@ -0,0 +1,917 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QDateEdit,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTimeEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .settings import load_db_config
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class ReminderType(Enum):
|
||||
ONCE = strings._("once")
|
||||
DAILY = strings._("daily")
|
||||
WEEKDAYS = strings._("weekdays") # Mon-Fri
|
||||
WEEKLY = strings._("weekly") # specific day of week
|
||||
FORTNIGHTLY = strings._("fortnightly") # every 2 weeks
|
||||
MONTHLY_DATE = strings._("monthly_same_date") # same calendar date
|
||||
MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reminder:
|
||||
id: Optional[int]
|
||||
text: str
|
||||
time_str: str # HH:MM
|
||||
reminder_type: ReminderType
|
||||
weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type)
|
||||
active: bool = True
|
||||
date_iso: Optional[str] = None # For ONCE type
|
||||
|
||||
|
||||
class ReminderDialog(QDialog):
|
||||
"""Dialog for creating/editing reminders with recurrence support."""
|
||||
|
||||
def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self._reminder = reminder
|
||||
|
||||
self.setWindowTitle(
|
||||
strings._("set_reminder") if not reminder else strings._("edit_reminder")
|
||||
)
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.form = QFormLayout()
|
||||
|
||||
# Reminder text
|
||||
self.text_edit = QLineEdit()
|
||||
if reminder:
|
||||
self.text_edit.setText(reminder.text)
|
||||
self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
|
||||
|
||||
# Date
|
||||
self.date_edit = QDateEdit()
|
||||
self.date_edit.setCalendarPopup(True)
|
||||
self.date_edit.setDisplayFormat("yyyy-MM-dd")
|
||||
|
||||
if reminder and reminder.date_iso:
|
||||
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if d.isValid():
|
||||
self.date_edit.setDate(d)
|
||||
else:
|
||||
self.date_edit.setDate(QDate.currentDate())
|
||||
else:
|
||||
self.date_edit.setDate(QDate.currentDate())
|
||||
|
||||
self.form.addRow("&" + strings._("date") + ":", self.date_edit)
|
||||
|
||||
# Time
|
||||
self.time_edit = QTimeEdit()
|
||||
self.time_edit.setDisplayFormat("HH:mm")
|
||||
if reminder:
|
||||
parts = reminder.time_str.split(":")
|
||||
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
|
||||
else:
|
||||
# Default to 5 minutes in the future
|
||||
future = QTime.currentTime().addSecs(5 * 60)
|
||||
self.time_edit.setTime(future)
|
||||
self.form.addRow("&" + strings._("time") + ":", self.time_edit)
|
||||
|
||||
# Recurrence type
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.addItem(strings._("once"), ReminderType.ONCE)
|
||||
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
|
||||
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
|
||||
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
|
||||
self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY)
|
||||
self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE)
|
||||
self.type_combo.addItem(
|
||||
strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY
|
||||
)
|
||||
|
||||
if reminder:
|
||||
for i in range(self.type_combo.count()):
|
||||
if self.type_combo.itemData(i) == reminder.reminder_type:
|
||||
self.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_changed)
|
||||
self.form.addRow("&" + strings._("repeat") + ":", self.type_combo)
|
||||
|
||||
# Weekday selector (for weekly reminders)
|
||||
self.weekday_combo = QComboBox()
|
||||
days = [
|
||||
strings._("monday"),
|
||||
strings._("tuesday"),
|
||||
strings._("wednesday"),
|
||||
strings._("thursday"),
|
||||
strings._("friday"),
|
||||
strings._("saturday"),
|
||||
strings._("sunday"),
|
||||
]
|
||||
for i, day in enumerate(days):
|
||||
self.weekday_combo.addItem(day, i)
|
||||
|
||||
if reminder and reminder.weekday is not None:
|
||||
self.weekday_combo.setCurrentIndex(reminder.weekday)
|
||||
else:
|
||||
self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1)
|
||||
|
||||
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
|
||||
day_label = self.form.labelForField(self.weekday_combo)
|
||||
day_label.setVisible(False)
|
||||
|
||||
self.nth_spin = QSpinBox()
|
||||
self.nth_spin.setRange(1, 5) # up to 5th Monday, etc.
|
||||
self.nth_spin.setValue(1)
|
||||
# If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso
|
||||
if (
|
||||
reminder
|
||||
and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
|
||||
and reminder.date_iso
|
||||
):
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if anchor.isValid():
|
||||
nth_index = (anchor.day() - 1) // 7 # 0-based
|
||||
self.nth_spin.setValue(nth_index + 1)
|
||||
|
||||
self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin)
|
||||
nth_label = self.form.labelForField(self.nth_spin)
|
||||
nth_label.setVisible(False)
|
||||
self.nth_spin.setVisible(False)
|
||||
|
||||
layout.addLayout(self.form)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
save_btn = QPushButton("&" + strings._("save"))
|
||||
save_btn.clicked.connect(self.accept)
|
||||
save_btn.setDefault(True)
|
||||
btn_layout.addWidget(save_btn)
|
||||
|
||||
cancel_btn = QPushButton("&" + strings._("cancel"))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self._on_type_changed()
|
||||
|
||||
def _on_type_changed(self):
|
||||
"""Show/hide weekday / nth selectors based on reminder type."""
|
||||
reminder_type = self.type_combo.currentData()
|
||||
|
||||
show_weekday = reminder_type in (
|
||||
ReminderType.WEEKLY,
|
||||
ReminderType.MONTHLY_NTH_WEEKDAY,
|
||||
)
|
||||
self.weekday_combo.setVisible(show_weekday)
|
||||
day_label = self.form.labelForField(self.weekday_combo)
|
||||
day_label.setVisible(show_weekday)
|
||||
|
||||
show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
|
||||
nth_label = self.form.labelForField(self.nth_spin)
|
||||
self.nth_spin.setVisible(show_nth)
|
||||
nth_label.setVisible(show_nth)
|
||||
|
||||
# For new reminders, when switching to a type that uses a weekday,
|
||||
# snap the weekday to match the currently selected date.
|
||||
if reminder_type in (
|
||||
ReminderType.WEEKLY,
|
||||
ReminderType.MONTHLY_NTH_WEEKDAY,
|
||||
) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
|
||||
dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
|
||||
if 0 <= dow < self.weekday_combo.count():
|
||||
self.weekday_combo.setCurrentIndex(dow)
|
||||
|
||||
def get_reminder(self) -> Reminder:
|
||||
"""Get the configured reminder."""
|
||||
reminder_type = self.type_combo.currentData()
|
||||
time_obj = self.time_edit.time()
|
||||
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
|
||||
|
||||
weekday = None
|
||||
if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY):
|
||||
weekday = self.weekday_combo.currentData()
|
||||
|
||||
date_iso = None
|
||||
anchor_date = self.date_edit.date()
|
||||
|
||||
if reminder_type == ReminderType.ONCE:
|
||||
# Fire once, on the chosen calendar date at the chosen time
|
||||
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||
|
||||
elif reminder_type == ReminderType.FORTNIGHTLY:
|
||||
# Anchor: the chosen calendar date. Every 14 days from this date.
|
||||
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||
|
||||
elif reminder_type == ReminderType.MONTHLY_DATE:
|
||||
# Anchor: the chosen calendar date. "Same date each month"
|
||||
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||
|
||||
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||
# Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
|
||||
weekday = self.weekday_combo.currentData()
|
||||
nth_index = self.nth_spin.value() - 1 # 0-based
|
||||
|
||||
first = QDate(anchor_date.year(), anchor_date.month(), 1)
|
||||
target_dow = weekday + 1 # Qt: Monday=1
|
||||
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
||||
anchor = first.addDays(offset + nth_index * 7)
|
||||
|
||||
# If nth weekday doesn't exist in this month, fall back to the last such weekday
|
||||
if anchor.month() != anchor_date.month():
|
||||
anchor = anchor.addDays(-7)
|
||||
|
||||
date_iso = anchor.toString("yyyy-MM-dd")
|
||||
|
||||
return Reminder(
|
||||
id=self._reminder.id if self._reminder else None,
|
||||
text=self.text_edit.text(),
|
||||
time_str=time_str,
|
||||
reminder_type=reminder_type,
|
||||
weekday=weekday,
|
||||
active=self._reminder.active if self._reminder else True,
|
||||
date_iso=date_iso,
|
||||
)
|
||||
|
||||
|
||||
class UpcomingRemindersWidget(QFrame):
|
||||
"""Collapsible widget showing upcoming reminders for today and next 7 days."""
|
||||
|
||||
reminderTriggered = Signal(str) # Emits reminder text
|
||||
|
||||
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
# Header with toggle button
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText(strings._("upcoming_reminders"))
|
||||
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
self.toggle_btn.setArrowType(Qt.RightArrow)
|
||||
self.toggle_btn.clicked.connect(self._on_toggle)
|
||||
|
||||
self.add_btn = QToolButton()
|
||||
self.add_btn.setText("⏰")
|
||||
self.add_btn.setToolTip(strings._("add_reminder"))
|
||||
self.add_btn.setAutoRaise(True)
|
||||
self.add_btn.clicked.connect(self._add_reminder)
|
||||
|
||||
self.manage_btn = QToolButton()
|
||||
self.manage_btn.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||
)
|
||||
self.manage_btn.setToolTip(strings._("manage_reminders"))
|
||||
self.manage_btn.setAutoRaise(True)
|
||||
self.manage_btn.clicked.connect(self._manage_reminders)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.addWidget(self.toggle_btn)
|
||||
header.addStretch()
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.manage_btn)
|
||||
|
||||
# Body with reminder list
|
||||
self.body = QWidget()
|
||||
body_layout = QVBoxLayout(self.body)
|
||||
body_layout.setContentsMargins(0, 4, 0, 0)
|
||||
body_layout.setSpacing(2)
|
||||
|
||||
self.reminder_list = QListWidget()
|
||||
self.reminder_list.setMaximumHeight(200)
|
||||
self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.reminder_list.itemDoubleClicked.connect(self._edit_reminder)
|
||||
self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.reminder_list.customContextMenuRequested.connect(
|
||||
self._show_reminder_context_menu
|
||||
)
|
||||
body_layout.addWidget(self.reminder_list)
|
||||
|
||||
self.body.setVisible(False)
|
||||
|
||||
main = QVBoxLayout(self)
|
||||
main.setContentsMargins(0, 0, 0, 0)
|
||||
main.addLayout(header)
|
||||
main.addWidget(self.body)
|
||||
|
||||
# Timer to check and fire reminders
|
||||
#
|
||||
# We tick once per second, but only hit the DB when the clock is
|
||||
# exactly on a :00 second. That way a reminder for HH:MM fires at
|
||||
# HH:MM:00, independent of when it was created.
|
||||
self._tick_timer = QTimer(self)
|
||||
self._tick_timer.setInterval(1000) # 1 second
|
||||
self._tick_timer.timeout.connect(self._on_tick)
|
||||
self._tick_timer.start()
|
||||
|
||||
# Also check once on startup so we don't miss reminders that
|
||||
# should have fired a moment ago when the app wasn't running.
|
||||
QTimer.singleShot(0, self._check_reminders)
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Called every second; run reminder check only on exact minute boundaries."""
|
||||
now = QDateTime.currentDateTime()
|
||||
if now.time().second() == 0:
|
||||
# Only do the heavier DB work once per minute, at HH:MM:00,
|
||||
# so reminders are aligned to the clock and not to when they
|
||||
# were created.
|
||||
self._check_reminders(now)
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup timers when widget is destroyed."""
|
||||
try:
|
||||
if hasattr(self, "_tick_timer") and self._tick_timer:
|
||||
self._tick_timer.stop()
|
||||
except Exception:
|
||||
pass # Ignore any cleanup errors
|
||||
|
||||
def _on_toggle(self, checked: bool):
|
||||
"""Toggle visibility of reminder list."""
|
||||
self.body.setVisible(checked)
|
||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||
if checked:
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Reload and display upcoming reminders."""
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
self.reminder_list.clear()
|
||||
|
||||
reminders = self._db.get_all_reminders()
|
||||
now = QDateTime.currentDateTime()
|
||||
today = QDate.currentDate()
|
||||
|
||||
# Get reminders for the next 7 days
|
||||
upcoming = []
|
||||
for i in range(8): # Today + 7 days
|
||||
check_date = today.addDays(i)
|
||||
|
||||
for reminder in reminders:
|
||||
if not reminder.active:
|
||||
continue
|
||||
|
||||
if self._should_fire_on_date(reminder, check_date):
|
||||
# Parse time
|
||||
hour, minute = map(int, reminder.time_str.split(":"))
|
||||
dt = QDateTime(check_date, QTime(hour, minute))
|
||||
|
||||
# Skip past reminders
|
||||
if dt < now:
|
||||
continue
|
||||
|
||||
upcoming.append((dt, reminder))
|
||||
|
||||
# Sort by datetime
|
||||
upcoming.sort(key=lambda x: x[0])
|
||||
|
||||
# Display
|
||||
for dt, reminder in upcoming[:20]: # Show max 20
|
||||
date_str = dt.date().toString("ddd MMM d")
|
||||
time_str = dt.time().toString("HH:mm")
|
||||
|
||||
item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}")
|
||||
item.setData(Qt.UserRole, reminder)
|
||||
self.reminder_list.addItem(item)
|
||||
|
||||
if not upcoming:
|
||||
item = QListWidgetItem(strings._("no_upcoming_reminders"))
|
||||
item.setFlags(Qt.NoItemFlags)
|
||||
self.reminder_list.addItem(item)
|
||||
|
||||
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
|
||||
"""Check if a reminder should fire on a given date."""
|
||||
rtype = reminder.reminder_type
|
||||
|
||||
if rtype == ReminderType.ONCE:
|
||||
if reminder.date_iso:
|
||||
return date.toString("yyyy-MM-dd") == reminder.date_iso
|
||||
return False
|
||||
|
||||
if rtype == ReminderType.DAILY:
|
||||
return True
|
||||
|
||||
if rtype == ReminderType.WEEKDAYS:
|
||||
# Monday=1, Sunday=7
|
||||
return 1 <= date.dayOfWeek() <= 5
|
||||
|
||||
if rtype == ReminderType.WEEKLY:
|
||||
# Qt: Monday=1, reminder: Monday=0
|
||||
return date.dayOfWeek() - 1 == reminder.weekday
|
||||
|
||||
if rtype == ReminderType.FORTNIGHTLY:
|
||||
if not reminder.date_iso:
|
||||
return False
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if not anchor.isValid() or date < anchor:
|
||||
return False
|
||||
days = anchor.daysTo(date)
|
||||
return days % 14 == 0
|
||||
|
||||
if rtype == ReminderType.MONTHLY_DATE:
|
||||
if not reminder.date_iso:
|
||||
return False
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if not anchor.isValid():
|
||||
return False
|
||||
anchor_day = anchor.day()
|
||||
# Clamp to the last day of this month (for 29/30/31)
|
||||
first_of_month = QDate(date.year(), date.month(), 1)
|
||||
last_of_month = first_of_month.addMonths(1).addDays(-1)
|
||||
target_day = min(anchor_day, last_of_month.day())
|
||||
return date.day() == target_day
|
||||
|
||||
if rtype == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||
if not reminder.date_iso or reminder.weekday is None:
|
||||
return False
|
||||
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if not anchor.isValid():
|
||||
return False
|
||||
|
||||
# Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.)
|
||||
anchor_n = (anchor.day() - 1) // 7
|
||||
target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7)
|
||||
|
||||
# Compute the anchor_n-th target weekday in this month
|
||||
first = QDate(date.year(), date.month(), 1)
|
||||
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
||||
candidate = first.addDays(offset + anchor_n * 7)
|
||||
|
||||
# If that nth weekday doesn't exist this month (e.g. 5th Monday), skip
|
||||
if candidate.month() != date.month():
|
||||
return False
|
||||
|
||||
return date == candidate
|
||||
|
||||
return False
|
||||
|
||||
def _check_reminders(self, now: QDateTime | None = None):
|
||||
"""
|
||||
Check and trigger due reminders.
|
||||
|
||||
This uses absolute clock time, so a reminder for HH:MM will fire
|
||||
when the system clock reaches HH:MM:00, independent of when the
|
||||
reminder was created.
|
||||
"""
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
if now is None:
|
||||
now = QDateTime.currentDateTime()
|
||||
|
||||
today = now.date()
|
||||
reminders = self._db.get_all_reminders()
|
||||
|
||||
# Small grace window (in seconds) so we still fire reminders if
|
||||
# the app was just opened or the event loop was briefly busy.
|
||||
GRACE_WINDOW_SECS = 120 # 2 minutes
|
||||
|
||||
for reminder in reminders:
|
||||
if not reminder.active:
|
||||
continue
|
||||
|
||||
if not self._should_fire_on_date(reminder, today):
|
||||
continue
|
||||
|
||||
# Parse time: stored as "HH:MM", we treat that as HH:MM:00
|
||||
hour, minute = map(int, reminder.time_str.split(":"))
|
||||
target = QDateTime(today, QTime(hour, minute, 0))
|
||||
|
||||
# Skip if this reminder is still in the future
|
||||
if now < target:
|
||||
continue
|
||||
|
||||
# How long ago should this reminder have fired?
|
||||
seconds_late = target.secsTo(now) # target -> now
|
||||
|
||||
if 0 <= seconds_late <= GRACE_WINDOW_SECS:
|
||||
# Check if we haven't already fired this occurrence
|
||||
if not hasattr(self, "_fired_reminders"):
|
||||
self._fired_reminders = {}
|
||||
|
||||
reminder_key = (reminder.id, target.toString())
|
||||
|
||||
if reminder_key in self._fired_reminders:
|
||||
continue
|
||||
|
||||
# Mark as fired and emit
|
||||
self._fired_reminders[reminder_key] = now
|
||||
self.reminderTriggered.emit(reminder.text)
|
||||
|
||||
# For ONCE reminders, deactivate after firing
|
||||
if reminder.reminder_type == ReminderType.ONCE:
|
||||
self._db.update_reminder_active(reminder.id, False)
|
||||
self.refresh() # Refresh the list to show deactivated reminder
|
||||
|
||||
@Slot()
|
||||
def _add_reminder(self):
|
||||
"""Open dialog to add a new reminder."""
|
||||
dlg = ReminderDialog(self._db, self)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
reminder = dlg.get_reminder()
|
||||
self._db.save_reminder(reminder)
|
||||
self.refresh()
|
||||
|
||||
@Slot(QListWidgetItem)
|
||||
def _edit_reminder(self, item: QListWidgetItem):
|
||||
"""Edit an existing reminder."""
|
||||
reminder = item.data(Qt.UserRole)
|
||||
if not reminder:
|
||||
return
|
||||
|
||||
dlg = ReminderDialog(self._db, self, reminder)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
updated = dlg.get_reminder()
|
||||
self._db.save_reminder(updated)
|
||||
self.refresh()
|
||||
|
||||
@Slot()
|
||||
def _show_reminder_context_menu(self, pos):
|
||||
"""Show context menu for reminder list item(s)."""
|
||||
selected_items = self.reminder_list.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QMenu
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Only show Edit if single item selected
|
||||
if len(selected_items) == 1:
|
||||
reminder = selected_items[0].data(Qt.UserRole)
|
||||
if reminder:
|
||||
edit_action = QAction(strings._("edit"), self)
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_reminder(selected_items[0])
|
||||
)
|
||||
menu.addAction(edit_action)
|
||||
|
||||
# Delete option for any selection
|
||||
if len(selected_items) == 1:
|
||||
delete_text = strings._("delete")
|
||||
else:
|
||||
delete_text = (
|
||||
strings._("delete")
|
||||
+ f" {len(selected_items)} "
|
||||
+ strings._("reminders")
|
||||
)
|
||||
|
||||
delete_action = QAction(delete_text, self)
|
||||
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
|
||||
menu.addAction(delete_action)
|
||||
|
||||
menu.exec(self.reminder_list.mapToGlobal(pos))
|
||||
|
||||
def _delete_selected_reminders(self):
|
||||
"""Delete all selected reminders (handling duplicates)."""
|
||||
selected_items = self.reminder_list.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
# Collect unique reminder IDs
|
||||
unique_reminders = {}
|
||||
for item in selected_items:
|
||||
reminder = item.data(Qt.UserRole)
|
||||
if reminder and reminder.id not in unique_reminders:
|
||||
unique_reminders[reminder.id] = reminder
|
||||
|
||||
if not unique_reminders:
|
||||
return
|
||||
|
||||
# Confirmation message
|
||||
if len(unique_reminders) == 1:
|
||||
reminder = list(unique_reminders.values())[0]
|
||||
msg = (
|
||||
strings._("delete")
|
||||
+ " "
|
||||
+ strings._("reminder")
|
||||
+ f" '{reminder.text}'?"
|
||||
)
|
||||
if reminder.reminder_type != ReminderType.ONCE:
|
||||
msg += (
|
||||
"\n\n"
|
||||
+ strings._("this_is_a_reminder_of_type")
|
||||
+ f" '{reminder.reminder_type.value}'. "
|
||||
+ strings._("deleting_it_will_remove_all_future_occurrences")
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
strings._("delete")
|
||||
+ f"{len(unique_reminders)} "
|
||||
+ strings._("reminders")
|
||||
+ " ?\n\n"
|
||||
+ strings._("this_will_delete_the_actual_reminders")
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
strings._("delete_reminders"),
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
for reminder_id in unique_reminders:
|
||||
self._db.delete_reminder(reminder_id)
|
||||
self.refresh()
|
||||
|
||||
def _delete_reminder(self, reminder):
|
||||
"""Delete a single reminder after confirmation."""
|
||||
msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
|
||||
if reminder.reminder_type != ReminderType.ONCE:
|
||||
msg += (
|
||||
"\n\n"
|
||||
+ strings._("this_is_a_reminder_of_type")
|
||||
+ f" '{reminder.reminder_type.value}'. "
|
||||
+ strings._("deleting_it_will_remove_all_future_occurrences")
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
strings._("delete_reminder"),
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._db.delete_reminder(reminder.id)
|
||||
self.refresh()
|
||||
|
||||
@Slot()
|
||||
def _manage_reminders(self):
|
||||
"""Open dialog to manage all reminders."""
|
||||
dlg = ManageRemindersDialog(self._db, self)
|
||||
dlg.exec()
|
||||
self.refresh()
|
||||
|
||||
|
||||
class ManageRemindersDialog(QDialog):
|
||||
"""Dialog for managing all reminders."""
|
||||
|
||||
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
|
||||
self.setWindowTitle(strings._("manage_reminders"))
|
||||
self.setMinimumSize(700, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Reminder list table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(6)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[
|
||||
strings._("text"),
|
||||
strings._("date"),
|
||||
strings._("time"),
|
||||
strings._("type"),
|
||||
strings._("active"),
|
||||
strings._("actions"),
|
||||
]
|
||||
)
|
||||
self.table.horizontalHeader().setStretchLastSection(False)
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
add_btn = QPushButton(strings._("add_reminder"))
|
||||
add_btn.clicked.connect(self._add_reminder)
|
||||
btn_layout.addWidget(add_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self._load_reminders()
|
||||
|
||||
def _load_reminders(self):
|
||||
"""Load all reminders into the table."""
|
||||
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
reminders = self._db.get_all_reminders()
|
||||
self.table.setRowCount(len(reminders))
|
||||
|
||||
for row, reminder in enumerate(reminders):
|
||||
# Text
|
||||
text_item = QTableWidgetItem(reminder.text)
|
||||
text_item.setData(Qt.UserRole, reminder)
|
||||
self.table.setItem(row, 0, text_item)
|
||||
|
||||
# Date
|
||||
date_display = ""
|
||||
if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso:
|
||||
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if d.isValid():
|
||||
date_display = d.toString("yyyy-MM-dd")
|
||||
else:
|
||||
date_display = reminder.date_iso
|
||||
|
||||
date_item = QTableWidgetItem(date_display)
|
||||
self.table.setItem(row, 1, date_item)
|
||||
|
||||
# Time
|
||||
time_item = QTableWidgetItem(reminder.time_str)
|
||||
self.table.setItem(row, 2, time_item)
|
||||
|
||||
# Type
|
||||
base_type_strs = {
|
||||
ReminderType.ONCE: "Once",
|
||||
ReminderType.DAILY: "Daily",
|
||||
ReminderType.WEEKDAYS: "Weekdays",
|
||||
ReminderType.WEEKLY: "Weekly",
|
||||
ReminderType.FORTNIGHTLY: "Fortnightly",
|
||||
ReminderType.MONTHLY_DATE: "Monthly (date)",
|
||||
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
|
||||
}
|
||||
type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
|
||||
|
||||
# Short day names we can reuse
|
||||
days_short = [
|
||||
strings._("monday_short"),
|
||||
strings._("tuesday_short"),
|
||||
strings._("wednesday_short"),
|
||||
strings._("thursday_short"),
|
||||
strings._("friday_short"),
|
||||
strings._("saturday_short"),
|
||||
strings._("sunday_short"),
|
||||
]
|
||||
|
||||
if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||
# Show something like: Monthly (3rd Mon)
|
||||
day_name = ""
|
||||
if reminder.weekday is not None and 0 <= reminder.weekday < len(
|
||||
days_short
|
||||
):
|
||||
day_name = days_short[reminder.weekday]
|
||||
|
||||
nth_label = ""
|
||||
if reminder.date_iso:
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if anchor.isValid():
|
||||
nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
|
||||
ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
|
||||
if 0 <= nth_index < len(ordinals):
|
||||
nth_label = ordinals[nth_index]
|
||||
|
||||
parts = []
|
||||
if nth_label:
|
||||
parts.append(nth_label)
|
||||
if day_name:
|
||||
parts.append(day_name)
|
||||
|
||||
if parts:
|
||||
type_str = f"Monthly ({' '.join(parts)})"
|
||||
# else: fall back to the generic "Monthly (nth weekday)"
|
||||
|
||||
else:
|
||||
# For weekly / fortnightly types, still append the day name
|
||||
if (
|
||||
reminder.reminder_type
|
||||
in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY)
|
||||
and reminder.weekday is not None
|
||||
and 0 <= reminder.weekday < len(days_short)
|
||||
):
|
||||
type_str += f" ({days_short[reminder.weekday]})"
|
||||
|
||||
type_item = QTableWidgetItem(type_str)
|
||||
self.table.setItem(row, 3, type_item)
|
||||
|
||||
# Active
|
||||
active_item = QTableWidgetItem("✓" if reminder.active else "✗")
|
||||
self.table.setItem(row, 4, active_item)
|
||||
|
||||
# Actions
|
||||
actions_widget = QWidget()
|
||||
actions_layout = QHBoxLayout(actions_widget)
|
||||
actions_layout.setContentsMargins(2, 2, 2, 2)
|
||||
|
||||
edit_btn = QPushButton(strings._("edit"))
|
||||
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
|
||||
actions_layout.addWidget(edit_btn)
|
||||
|
||||
delete_btn = QPushButton(strings._("delete"))
|
||||
delete_btn.clicked.connect(
|
||||
lambda checked, r=reminder: self._delete_reminder(r)
|
||||
)
|
||||
actions_layout.addWidget(delete_btn)
|
||||
|
||||
self.table.setCellWidget(row, 5, actions_widget)
|
||||
|
||||
def _add_reminder(self):
|
||||
"""Add a new reminder."""
|
||||
dlg = ReminderDialog(self._db, self)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
reminder = dlg.get_reminder()
|
||||
self._db.save_reminder(reminder)
|
||||
self._load_reminders()
|
||||
|
||||
def _edit_reminder(self, reminder):
|
||||
"""Edit an existing reminder."""
|
||||
dlg = ReminderDialog(self._db, self, reminder)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
updated = dlg.get_reminder()
|
||||
self._db.save_reminder(updated)
|
||||
self._load_reminders()
|
||||
|
||||
def _delete_reminder(self, reminder):
|
||||
"""Delete a reminder."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
strings._("delete_reminder"),
|
||||
strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._db.delete_reminder(reminder.id)
|
||||
self._load_reminders()
|
||||
|
||||
|
||||
class ReminderWebHook:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
self.cfg = load_db_config()
|
||||
|
||||
def _send(self):
|
||||
payload: dict[str, str] = {
|
||||
"reminder": self.text,
|
||||
}
|
||||
|
||||
url = self.cfg.reminders_webhook_url
|
||||
secret = self.cfg.reminders_webhook_secret
|
||||
|
||||
_headers = {}
|
||||
if secret:
|
||||
_headers["X-Bouquin-Secret"] = secret
|
||||
|
||||
if url:
|
||||
try:
|
||||
requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=10,
|
||||
headers=_headers,
|
||||
)
|
||||
except Exception:
|
||||
# We did our best
|
||||
pass
|
||||
|
|
@ -2,13 +2,8 @@ from __future__ import annotations
|
|||
|
||||
import datetime
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QDialogButtonBox,
|
||||
)
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
|
||||
|
||||
from . import strings
|
||||
|
||||
|
|
@ -22,13 +17,24 @@ class SaveDialog(QDialog):
|
|||
Used for explicitly saving a new version of a page.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(strings._("enter_a_name_for_this_version"))
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.addWidget(QLabel(strings._("enter_a_name_for_this_version")))
|
||||
|
||||
self.note = QLineEdit()
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.note.setText(strings._("new_version_i_saved_at") + f" {now}")
|
||||
text = strings._("new_version_i_saved_at") + f" {now}"
|
||||
self.note.setText(text)
|
||||
v.addWidget(self.note)
|
||||
|
||||
# make dialog wide enough for the line edit text
|
||||
fm = QFontMetrics(self.note.font())
|
||||
text_width = fm.horizontalAdvance(text) + 20
|
||||
self.note.setMinimumWidth(text_width)
|
||||
self.adjustSize()
|
||||
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self.accept)
|
||||
bb.rejected.connect(self.reject)
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ from typing import Iterable, Tuple
|
|||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSizePolicy,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
Row = Tuple[str, str]
|
||||
Row = Tuple[str, str, str, str, str | None]
|
||||
|
||||
|
||||
class Search(QWidget):
|
||||
|
|
@ -52,9 +52,27 @@ class Search(QWidget):
|
|||
lay.addWidget(self.results)
|
||||
|
||||
def _open_selected(self, item: QListWidgetItem):
|
||||
date_str = item.data(Qt.ItemDataRole.UserRole)
|
||||
if date_str:
|
||||
self.openDateRequested.emit(date_str)
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
kind = data.get("kind")
|
||||
if kind == "page":
|
||||
date_iso = data.get("date")
|
||||
if date_iso:
|
||||
self.openDateRequested.emit(date_iso)
|
||||
elif kind == "document":
|
||||
doc_id = data.get("doc_id")
|
||||
file_name = data.get("file_name") or "document"
|
||||
if doc_id is None:
|
||||
return
|
||||
self._open_document(int(doc_id), file_name)
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""Open the selected document in the user's default app."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
def _search(self, text: str):
|
||||
"""
|
||||
|
|
@ -80,28 +98,28 @@ class Search(QWidget):
|
|||
self.resultDatesChanged.emit([]) # clear highlights
|
||||
return
|
||||
|
||||
self.resultDatesChanged.emit(sorted({d for d, _ in rows}))
|
||||
# Only highlight calendar dates for page results
|
||||
page_dates = sorted(
|
||||
{key for (kind, key, _title, _text, _aux) in rows if kind == "page"}
|
||||
)
|
||||
self.resultDatesChanged.emit(page_dates)
|
||||
self.results.show()
|
||||
|
||||
for date_str, content in rows:
|
||||
# Build an HTML fragment around the match and whether to show ellipses
|
||||
frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90)
|
||||
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||
for kind, key, title, text, aux in rows:
|
||||
# Build an HTML fragment around the match
|
||||
frag_html = self._make_html_snippet(text, query, radius=30, maxlen=90)
|
||||
|
||||
container = QWidget()
|
||||
outer = QVBoxLayout(container)
|
||||
outer.setContentsMargins(8, 6, 8, 6)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(2)
|
||||
|
||||
# Date label (plain text)
|
||||
date_lbl = QLabel()
|
||||
date_lbl.setTextFormat(Qt.TextFormat.RichText)
|
||||
date_lbl.setText(f"<h3><i>{date_str}</i></h3>")
|
||||
date_f = date_lbl.font()
|
||||
date_f.setPointSizeF(date_f.pointSizeF() + 1)
|
||||
date_lbl.setFont(date_f)
|
||||
outer.addWidget(date_lbl)
|
||||
# ---- Heading (date for pages, "Document" for docs) ----
|
||||
heading = QLabel(title)
|
||||
heading.setStyleSheet("font-weight:bold;")
|
||||
outer.addWidget(heading)
|
||||
|
||||
# Preview row with optional ellipses
|
||||
# ---- Preview row ----
|
||||
row = QWidget()
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
|
|
@ -117,9 +135,9 @@ class Search(QWidget):
|
|||
else "<span style='color:#888'>(no preview)</span>"
|
||||
)
|
||||
h.addWidget(preview, 1)
|
||||
|
||||
outer.addWidget(row)
|
||||
|
||||
# Separator line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
|
|
@ -127,9 +145,22 @@ class Search(QWidget):
|
|||
|
||||
# ---- Add to list ----
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.ItemDataRole.UserRole, date_str)
|
||||
item.setSizeHint(container.sizeHint())
|
||||
if kind == "page":
|
||||
item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"kind": "page", "date": key},
|
||||
)
|
||||
else: # document
|
||||
item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{
|
||||
"kind": "document",
|
||||
"doc_id": int(key),
|
||||
"file_name": aux or "",
|
||||
},
|
||||
)
|
||||
|
||||
item.setSizeHint(container.sizeHint())
|
||||
self.results.addItem(item)
|
||||
self.results.setItemWidget(item, container)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QSettings, QStandardPaths
|
||||
|
||||
from .db import DBConfig
|
||||
|
|
@ -13,34 +14,79 @@ def get_settings() -> QSettings:
|
|||
return QSettings(APP_ORG, APP_NAME)
|
||||
|
||||
|
||||
def _default_db_location() -> Path:
|
||||
"""Where we put the notebook if nothing has been configured yet."""
|
||||
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / "notebook.db"
|
||||
|
||||
|
||||
def load_db_config() -> DBConfig:
|
||||
s = get_settings()
|
||||
default_db_path = str(
|
||||
Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
|
||||
/ "notebook.db"
|
||||
)
|
||||
|
||||
path = Path(s.value("db/path", default_db_path))
|
||||
# --- DB Path -------------------------------------------------------
|
||||
# Prefer the new key; fall back to the legacy one.
|
||||
path_str = s.value("db/default_db", "", type=str)
|
||||
if not path_str:
|
||||
legacy = s.value("db/path", "", type=str)
|
||||
if legacy:
|
||||
path_str = legacy
|
||||
# migrate and clean up the old key
|
||||
s.setValue("db/default_db", legacy)
|
||||
s.remove("db/path")
|
||||
path = Path(path_str) if path_str else _default_db_location()
|
||||
|
||||
# --- Other settings ------------------------------------------------
|
||||
key = s.value("db/key", "")
|
||||
|
||||
idle = s.value("ui/idle_minutes", 15, type=int)
|
||||
theme = s.value("ui/theme", "system", type=str)
|
||||
move_todos = s.value("ui/move_todos", False, type=bool)
|
||||
move_todos_include_weekends = s.value(
|
||||
"ui/move_todos_include_weekends", False, type=bool
|
||||
)
|
||||
tags = s.value("ui/tags", True, type=bool)
|
||||
time_log = s.value("ui/time_log", True, type=bool)
|
||||
reminders = s.value("ui/reminders", True, type=bool)
|
||||
reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str)
|
||||
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
|
||||
documents = s.value("ui/documents", True, type=bool)
|
||||
invoicing = s.value("ui/invoicing", False, type=bool)
|
||||
locale = s.value("ui/locale", "en", type=str)
|
||||
font_size = s.value("ui/font_size", 11, type=int)
|
||||
return DBConfig(
|
||||
path=path,
|
||||
key=key,
|
||||
idle_minutes=idle,
|
||||
theme=theme,
|
||||
move_todos=move_todos,
|
||||
move_todos_include_weekends=move_todos_include_weekends,
|
||||
tags=tags,
|
||||
time_log=time_log,
|
||||
reminders=reminders,
|
||||
reminders_webhook_url=reminders_webhook_url,
|
||||
reminders_webhook_secret=reminders_webhook_secret,
|
||||
documents=documents,
|
||||
invoicing=invoicing,
|
||||
locale=locale,
|
||||
font_size=font_size,
|
||||
)
|
||||
|
||||
|
||||
def save_db_config(cfg: DBConfig) -> None:
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(cfg.path))
|
||||
s.setValue("db/default_db", str(cfg.path))
|
||||
s.setValue("db/key", str(cfg.key))
|
||||
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
|
||||
s.setValue("ui/theme", str(cfg.theme))
|
||||
s.setValue("ui/move_todos", str(cfg.move_todos))
|
||||
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
|
||||
s.setValue("ui/tags", str(cfg.tags))
|
||||
s.setValue("ui/time_log", str(cfg.time_log))
|
||||
s.setValue("ui/reminders", str(cfg.reminders))
|
||||
s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url))
|
||||
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
|
||||
s.setValue("ui/documents", str(cfg.documents))
|
||||
s.setValue("ui/invoicing", str(cfg.invoicing))
|
||||
s.setValue("ui/locale", str(cfg.locale))
|
||||
s.setValue("ui/font_size", str(cfg.font_size))
|
||||
|
|
|
|||
|
|
@ -2,36 +2,37 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QFileDialog,
|
||||
QDialogButtonBox,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QMessageBox,
|
||||
QTabWidget,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
from PySide6.QtGui import QPalette
|
||||
|
||||
|
||||
from .db import DBConfig, DBManager
|
||||
from .settings import load_db_config, save_db_config
|
||||
from .theme import Theme
|
||||
from .key_prompt import KeyPrompt
|
||||
|
||||
from . import strings
|
||||
from .db import DBConfig, DBManager
|
||||
from .key_prompt import KeyPrompt
|
||||
from .settings import load_db_config, save_db_config
|
||||
from .theme import Theme
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
|
|
@ -42,14 +43,45 @@ class SettingsDialog(QDialog):
|
|||
self._db = db
|
||||
self.key = ""
|
||||
|
||||
form = QFormLayout()
|
||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||
self.setMinimumWidth(560)
|
||||
self.current_settings = load_db_config()
|
||||
|
||||
self.setMinimumWidth(600)
|
||||
self.setSizeGripEnabled(True)
|
||||
|
||||
current_settings = load_db_config()
|
||||
# --- Tabs ----------------------------------------------------------
|
||||
tabs = QTabWidget()
|
||||
tabs.setTabPosition(QTabWidget.North)
|
||||
tabs.setDocumentMode(True)
|
||||
tabs.setMovable(False)
|
||||
|
||||
# Add theme selection
|
||||
tabs.addTab(self._create_appearance_page(cfg), strings._("appearance"))
|
||||
tabs.addTab(self._create_features_page(), strings._("features"))
|
||||
tabs.addTab(self._create_security_page(cfg), strings._("security"))
|
||||
tabs.addTab(self._create_database_page(), strings._("database"))
|
||||
|
||||
# --- Buttons -------------------------------------------------------
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self._save)
|
||||
bb.rejected.connect(self.reject)
|
||||
|
||||
# Root layout
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(12, 12, 12, 12)
|
||||
root.setSpacing(8)
|
||||
root.addWidget(tabs)
|
||||
root.addWidget(bb, 0, Qt.AlignRight)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pages
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _create_appearance_page(self, cfg: DBConfig) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# --- Theme group --------------------------------------------------
|
||||
theme_group = QGroupBox(strings._("theme"))
|
||||
theme_layout = QVBoxLayout(theme_group)
|
||||
|
||||
|
|
@ -57,8 +89,7 @@ class SettingsDialog(QDialog):
|
|||
self.theme_light = QRadioButton(strings._("light"))
|
||||
self.theme_dark = QRadioButton(strings._("dark"))
|
||||
|
||||
# Load current theme from settings
|
||||
current_theme = current_settings.theme
|
||||
current_theme = self.current_settings.theme
|
||||
if current_theme == Theme.DARK.value:
|
||||
self.theme_dark.setChecked(True)
|
||||
elif current_theme == Theme.LIGHT.value:
|
||||
|
|
@ -70,85 +101,271 @@ class SettingsDialog(QDialog):
|
|||
theme_layout.addWidget(self.theme_light)
|
||||
theme_layout.addWidget(self.theme_dark)
|
||||
|
||||
form.addRow(theme_group)
|
||||
# font size row
|
||||
font_row = QHBoxLayout()
|
||||
self.font_heading = QLabel(strings._("font_size"))
|
||||
self.font_size = QSpinBox()
|
||||
self.font_size.setRange(1, 24)
|
||||
self.font_size.setSingleStep(1)
|
||||
self.font_size.setAccelerated(True)
|
||||
self.font_size.setValue(getattr(cfg, "font_size", 11))
|
||||
font_row.addWidget(self.font_heading)
|
||||
font_row.addWidget(self.font_size)
|
||||
font_row.addStretch()
|
||||
theme_layout.addLayout(font_row)
|
||||
|
||||
# Locale settings
|
||||
# explanation
|
||||
self.font_size_label = QLabel(strings._("font_size_explanation"))
|
||||
self.font_size_label.setWordWrap(True)
|
||||
self.font_size_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
pal = self.font_size_label.palette()
|
||||
self.font_size_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.font_size_label.setPalette(pal)
|
||||
|
||||
font_exp_row = QHBoxLayout()
|
||||
font_exp_row.setContentsMargins(24, 0, 0, 0)
|
||||
font_exp_row.addWidget(self.font_size_label)
|
||||
theme_layout.addLayout(font_exp_row)
|
||||
|
||||
layout.addWidget(theme_group)
|
||||
|
||||
# --- Locale group -------------------------------------------------
|
||||
locale_group = QGroupBox(strings._("locale"))
|
||||
locale_layout = QVBoxLayout(locale_group)
|
||||
locale_layout.setContentsMargins(12, 8, 12, 12)
|
||||
locale_layout.setSpacing(6)
|
||||
|
||||
self.locale_combobox = QComboBox()
|
||||
self.locale_combobox.addItems(strings._AVAILABLE)
|
||||
self.locale_combobox.setCurrentText(current_settings.locale)
|
||||
self.locale_combobox.setCurrentText(self.current_settings.locale)
|
||||
locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft)
|
||||
|
||||
# Explanation for locale
|
||||
self.locale_label = QLabel(strings._("locale_restart"))
|
||||
self.locale_label.setWordWrap(True)
|
||||
self.locale_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
# make it look secondary
|
||||
lpal = self.locale_label.palette()
|
||||
self.locale_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.locale_label.setPalette(lpal)
|
||||
locale_row = QHBoxLayout()
|
||||
locale_row.setContentsMargins(24, 0, 0, 0)
|
||||
locale_row.addWidget(self.locale_label)
|
||||
locale_layout.addLayout(locale_row)
|
||||
form.addRow(locale_group)
|
||||
loc_row = QHBoxLayout()
|
||||
loc_row.setContentsMargins(24, 0, 0, 0)
|
||||
loc_row.addWidget(self.locale_label)
|
||||
locale_layout.addLayout(loc_row)
|
||||
|
||||
# Add Behaviour
|
||||
behaviour_group = QGroupBox(strings._("behaviour"))
|
||||
behaviour_layout = QVBoxLayout(behaviour_group)
|
||||
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(
|
||||
strings._("move_yesterdays_unchecked_todos_to_today_on_startup")
|
||||
strings._("move_unchecked_todos_to_today_on_startup")
|
||||
)
|
||||
self.move_todos.setChecked(current_settings.move_todos)
|
||||
self.move_todos.setChecked(self.current_settings.move_todos)
|
||||
self.move_todos.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.move_todos)
|
||||
|
||||
behaviour_layout.addWidget(self.move_todos)
|
||||
form.addRow(behaviour_group)
|
||||
# Optional: allow moving to the very next day even if it is a weekend.
|
||||
self.move_todos_include_weekends = QCheckBox(
|
||||
strings._("move_todos_include_weekends")
|
||||
)
|
||||
self.move_todos_include_weekends.setChecked(
|
||||
getattr(self.current_settings, "move_todos_include_weekends", False)
|
||||
)
|
||||
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
|
||||
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
|
||||
|
||||
self.path_edit = QLineEdit(str(self._cfg.path))
|
||||
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
browse_btn = QPushButton(strings._("browse"))
|
||||
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
browse_btn.clicked.connect(self._browse)
|
||||
path_row = QWidget()
|
||||
h = QHBoxLayout(path_row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
h.addWidget(self.path_edit, 1)
|
||||
h.addWidget(browse_btn, 0)
|
||||
h.setStretch(0, 1)
|
||||
h.setStretch(1, 0)
|
||||
form.addRow(strings._("database_path"), path_row)
|
||||
move_todos_opts = QWidget()
|
||||
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
|
||||
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
|
||||
move_todos_opts_layout.setSpacing(4)
|
||||
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
|
||||
features_layout.addWidget(move_todos_opts)
|
||||
|
||||
# Encryption settings
|
||||
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
|
||||
|
||||
self.tags = QCheckBox(strings._("enable_tags_feature"))
|
||||
self.tags.setChecked(self.current_settings.tags)
|
||||
self.tags.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.tags)
|
||||
|
||||
self.time_log = QCheckBox(strings._("enable_time_log_feature"))
|
||||
self.time_log.setChecked(self.current_settings.time_log)
|
||||
self.time_log.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.time_log)
|
||||
|
||||
self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
|
||||
invoicing_enabled = getattr(self.current_settings, "invoicing", False)
|
||||
self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
|
||||
self.invoicing.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.invoicing)
|
||||
# Invoicing only if time_log is enabled
|
||||
if not self.current_settings.time_log:
|
||||
self.invoicing.setChecked(False)
|
||||
self.invoicing.setEnabled(False)
|
||||
self.time_log.toggled.connect(self._on_time_log_toggled)
|
||||
|
||||
# --- Reminders feature + webhook options -------------------------
|
||||
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
||||
self.reminders.setChecked(self.current_settings.reminders)
|
||||
self.reminders.toggled.connect(self._on_reminders_toggled)
|
||||
self.reminders.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.reminders)
|
||||
|
||||
# Container for reminder-specific options, indented under the checkbox
|
||||
self.reminders_options_container = QWidget()
|
||||
reminders_options_layout = QVBoxLayout(self.reminders_options_container)
|
||||
reminders_options_layout.setContentsMargins(24, 0, 0, 0)
|
||||
reminders_options_layout.setSpacing(4)
|
||||
|
||||
self.reminders_options_toggle = QToolButton()
|
||||
self.reminders_options_toggle.setText(
|
||||
strings._("reminders_webhook_section_title")
|
||||
)
|
||||
self.reminders_options_toggle.setCheckable(True)
|
||||
self.reminders_options_toggle.setChecked(False)
|
||||
self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
|
||||
self.reminders_options_toggle.clicked.connect(
|
||||
self._on_reminders_options_toggled
|
||||
)
|
||||
|
||||
toggle_row = QHBoxLayout()
|
||||
toggle_row.addWidget(self.reminders_options_toggle)
|
||||
toggle_row.addStretch()
|
||||
reminders_options_layout.addLayout(toggle_row)
|
||||
|
||||
# Actual options (labels + QLineEdits)
|
||||
self.reminders_options_widget = QWidget()
|
||||
options_form = QFormLayout(self.reminders_options_widget)
|
||||
options_form.setContentsMargins(0, 0, 0, 0)
|
||||
options_form.setSpacing(4)
|
||||
|
||||
self.reminders_webhook_url = QLineEdit(
|
||||
self.current_settings.reminders_webhook_url or ""
|
||||
)
|
||||
self.reminders_webhook_secret = QLineEdit(
|
||||
self.current_settings.reminders_webhook_secret or ""
|
||||
)
|
||||
self.reminders_webhook_secret.setEchoMode(QLineEdit.Password)
|
||||
|
||||
options_form.addRow(
|
||||
strings._("reminders_webhook_url_label") + ":",
|
||||
self.reminders_webhook_url,
|
||||
)
|
||||
options_form.addRow(
|
||||
strings._("reminders_webhook_secret_label") + ":",
|
||||
self.reminders_webhook_secret,
|
||||
)
|
||||
|
||||
reminders_options_layout.addWidget(self.reminders_options_widget)
|
||||
|
||||
features_layout.addWidget(self.reminders_options_container)
|
||||
|
||||
self.reminders_options_container.setVisible(self.reminders.isChecked())
|
||||
self.reminders_options_widget.setVisible(False)
|
||||
|
||||
self.documents = QCheckBox(strings._("enable_documents_feature"))
|
||||
self.documents.setChecked(self.current_settings.documents)
|
||||
self.documents.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.documents)
|
||||
|
||||
layout.addWidget(features_group)
|
||||
|
||||
# --- Invoicing / company profile section -------------------------
|
||||
self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
|
||||
invoicing_layout = QFormLayout(self.invoicing_group)
|
||||
|
||||
profile = self._db.get_company_profile() or (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
name, address, phone, email, tax_id, payment_details, logo_bytes = profile
|
||||
|
||||
self.company_name_edit = QLineEdit(name or "")
|
||||
self.company_address_edit = QTextEdit(address or "")
|
||||
self.company_phone_edit = QLineEdit(phone or "")
|
||||
self.company_email_edit = QLineEdit(email or "")
|
||||
self.company_tax_id_edit = QLineEdit(tax_id or "")
|
||||
self.company_payment_details_edit = QTextEdit()
|
||||
self.company_payment_details_edit.setPlainText(payment_details or "")
|
||||
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_name") + ":", self.company_name_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_address") + ":", self.company_address_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_phone") + ":", self.company_phone_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_email") + ":", self.company_email_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_payment_details") + ":",
|
||||
self.company_payment_details_edit,
|
||||
)
|
||||
|
||||
# Logo picker - store bytes on self._logo_bytes
|
||||
self._logo_bytes = logo_bytes
|
||||
logo_row = QHBoxLayout()
|
||||
self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
|
||||
if logo_bytes:
|
||||
self.logo_label.setText(strings._("invoice_company_logo_set"))
|
||||
logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
|
||||
logo_btn.clicked.connect(self._on_choose_logo)
|
||||
logo_row.addWidget(self.logo_label)
|
||||
logo_row.addWidget(logo_btn)
|
||||
invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
|
||||
|
||||
# Show/hide this whole block based on invoicing checkbox
|
||||
self.invoicing_group.setVisible(self.invoicing.isChecked())
|
||||
self.invoicing.toggled.connect(self.invoicing_group.setVisible)
|
||||
|
||||
layout.addWidget(self.invoicing_group)
|
||||
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
def _create_security_page(self, cfg: DBConfig) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# --- Encryption group ---------------------------------------------
|
||||
enc_group = QGroupBox(strings._("encryption"))
|
||||
enc = QVBoxLayout(enc_group)
|
||||
enc.setContentsMargins(12, 8, 12, 12)
|
||||
enc.setSpacing(6)
|
||||
|
||||
# Checkbox to remember key
|
||||
self.save_key_btn = QCheckBox(strings._("remember_key"))
|
||||
self.key = current_settings.key or ""
|
||||
self.key = self.current_settings.key or ""
|
||||
self.save_key_btn.setChecked(bool(self.key))
|
||||
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
|
||||
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
||||
|
||||
# Explanation for remembering key
|
||||
self.save_key_label = QLabel(strings._("save_key_warning"))
|
||||
self.save_key_label.setWordWrap(True)
|
||||
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
# make it look secondary
|
||||
pal = self.save_key_label.palette()
|
||||
self.save_key_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.save_key_label.setPalette(pal)
|
||||
|
||||
exp_row = QHBoxLayout()
|
||||
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
|
||||
exp_row.setContentsMargins(24, 0, 0, 0)
|
||||
exp_row.addWidget(self.save_key_label)
|
||||
enc.addLayout(exp_row)
|
||||
|
||||
|
|
@ -157,62 +374,59 @@ class SettingsDialog(QDialog):
|
|||
line.setFrameShadow(QFrame.Sunken)
|
||||
enc.addWidget(line)
|
||||
|
||||
# Change key button
|
||||
self.rekey_btn = QPushButton(strings._("change_encryption_key"))
|
||||
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.rekey_btn.clicked.connect(self._change_key)
|
||||
|
||||
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
||||
|
||||
form.addRow(enc_group)
|
||||
layout.addWidget(enc_group)
|
||||
|
||||
# Privacy settings
|
||||
# --- Idle lock group ----------------------------------------------
|
||||
priv_group = QGroupBox(strings._("lock_screen_when_idle"))
|
||||
priv = QVBoxLayout(priv_group)
|
||||
priv.setContentsMargins(12, 8, 12, 12)
|
||||
priv.setSpacing(6)
|
||||
|
||||
self.idle_spin = QSpinBox()
|
||||
self.idle_spin.setRange(0, 240)
|
||||
self.idle_spin.setSingleStep(1)
|
||||
self.idle_spin.setAccelerated(True)
|
||||
self.idle_spin.setSuffix(" min")
|
||||
self.idle_spin.setSpecialValueText(strings._("Never"))
|
||||
self.idle_spin.setSpecialValueText(strings._("never"))
|
||||
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
|
||||
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
||||
# Explanation for idle option (autolock)
|
||||
|
||||
self.idle_spin_label = QLabel(strings._("autolock_explanation"))
|
||||
self.idle_spin_label.setWordWrap(True)
|
||||
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
# make it look secondary
|
||||
spal = self.idle_spin_label.palette()
|
||||
self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.idle_spin_label.setPalette(spal)
|
||||
|
||||
spin_row = QHBoxLayout()
|
||||
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
|
||||
spin_row.setContentsMargins(24, 0, 0, 0)
|
||||
spin_row.addWidget(self.idle_spin_label)
|
||||
priv.addLayout(spin_row)
|
||||
|
||||
form.addRow(priv_group)
|
||||
layout.addWidget(priv_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
def _create_database_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# Maintenance settings
|
||||
maint_group = QGroupBox(strings._("database_maintenance"))
|
||||
maint = QVBoxLayout(maint_group)
|
||||
maint.setContentsMargins(12, 8, 12, 12)
|
||||
maint.setSpacing(6)
|
||||
|
||||
self.compact_btn = QPushButton(strings._("database_compact"))
|
||||
self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.compact_btn.clicked.connect(self._compact_btn_clicked)
|
||||
|
||||
maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
|
||||
|
||||
# Explanation for compacting button
|
||||
self.compact_label = QLabel(strings._("database_compact_explanation"))
|
||||
self.compact_label.setWordWrap(True)
|
||||
self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
# make it look secondary
|
||||
cpal = self.compact_label.palette()
|
||||
self.compact_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.compact_label.setPalette(cpal)
|
||||
|
|
@ -222,32 +436,15 @@ class SettingsDialog(QDialog):
|
|||
maint_row.addWidget(self.compact_label)
|
||||
maint.addLayout(maint_row)
|
||||
|
||||
form.addRow(maint_group)
|
||||
layout.addWidget(maint_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
# Buttons
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self._save)
|
||||
bb.rejected.connect(self.reject)
|
||||
|
||||
# Root layout (adjust margins/spacing a bit)
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(12, 12, 12, 12)
|
||||
v.setSpacing(10)
|
||||
v.addLayout(form)
|
||||
v.addWidget(bb, 0, Qt.AlignRight)
|
||||
|
||||
def _browse(self):
|
||||
p, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
strings._("database_path"),
|
||||
self.path_edit.text(),
|
||||
"(*.db);;(*)",
|
||||
)
|
||||
if p:
|
||||
self.path_edit.setText(p)
|
||||
# ------------------------------------------------------------------ #
|
||||
# Save settings
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _save(self):
|
||||
# Save the selected theme into QSettings
|
||||
if self.theme_dark.isChecked():
|
||||
selected_theme = Theme.DARK
|
||||
elif self.theme_light.isChecked():
|
||||
|
|
@ -258,18 +455,97 @@ class SettingsDialog(QDialog):
|
|||
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
||||
|
||||
self._cfg = DBConfig(
|
||||
path=Path(self.path_edit.text()),
|
||||
path=Path(self.current_settings.path),
|
||||
key=key_to_save,
|
||||
idle_minutes=self.idle_spin.value(),
|
||||
theme=selected_theme.value,
|
||||
move_todos=self.move_todos.isChecked(),
|
||||
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
|
||||
tags=self.tags.isChecked(),
|
||||
time_log=self.time_log.isChecked(),
|
||||
reminders=self.reminders.isChecked(),
|
||||
reminders_webhook_url=self.reminders_webhook_url.text().strip() or None,
|
||||
reminders_webhook_secret=self.reminders_webhook_secret.text().strip()
|
||||
or None,
|
||||
documents=self.documents.isChecked(),
|
||||
invoicing=(
|
||||
self.invoicing.isChecked() if self.time_log.isChecked() else False
|
||||
),
|
||||
locale=self.locale_combobox.currentText(),
|
||||
font_size=self.font_size.value(),
|
||||
)
|
||||
|
||||
save_db_config(self._cfg)
|
||||
|
||||
# Save company profile only if invoicing is enabled
|
||||
if self.invoicing.isChecked() and self.time_log.isChecked():
|
||||
self._db.save_company_profile(
|
||||
name=self.company_name_edit.text().strip() or None,
|
||||
address=self.company_address_edit.toPlainText().strip() or None,
|
||||
phone=self.company_phone_edit.text().strip() or None,
|
||||
email=self.company_email_edit.text().strip() or None,
|
||||
tax_id=self.company_tax_id_edit.text().strip() or None,
|
||||
payment_details=self.company_payment_details_edit.toPlainText().strip()
|
||||
or None,
|
||||
logo=getattr(self, "_logo_bytes", None),
|
||||
)
|
||||
|
||||
self.parent().themes.set(selected_theme)
|
||||
self.accept()
|
||||
|
||||
def _on_reminders_options_toggled(self, checked: bool) -> None:
|
||||
"""
|
||||
Expand/collapse the advanced reminders options (webhook URL/secret).
|
||||
"""
|
||||
if checked:
|
||||
self.reminders_options_toggle.setArrowType(Qt.DownArrow)
|
||||
self.reminders_options_widget.show()
|
||||
else:
|
||||
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
|
||||
self.reminders_options_widget.hide()
|
||||
|
||||
def _on_reminders_toggled(self, checked: bool) -> None:
|
||||
"""
|
||||
Conditionally show reminder webhook options depending
|
||||
on if the reminders feature is toggled on or off.
|
||||
"""
|
||||
if hasattr(self, "reminders_options_container"):
|
||||
self.reminders_options_container.setVisible(checked)
|
||||
|
||||
# When turning reminders off, also collapse the section
|
||||
if not checked and hasattr(self, "reminders_options_toggle"):
|
||||
self.reminders_options_toggle.setChecked(False)
|
||||
self._on_reminders_options_toggled(False)
|
||||
|
||||
def _on_time_log_toggled(self, checked: bool) -> None:
|
||||
"""
|
||||
Enforce 'invoicing depends on time logging'.
|
||||
"""
|
||||
if not checked:
|
||||
# Turn off + disable invoicing if time logging is disabled
|
||||
self.invoicing.setChecked(False)
|
||||
self.invoicing.setEnabled(False)
|
||||
else:
|
||||
# Let the user enable invoicing when time logging is enabled
|
||||
self.invoicing.setEnabled(True)
|
||||
|
||||
def _on_choose_logo(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
strings._("company_logo_choose"),
|
||||
"",
|
||||
"Images (*.png *.jpg *.jpeg *.bmp)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
self._logo_bytes = f.read()
|
||||
self.logo_label.setText(Path(path).name)
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(self, strings._("error"), str(exc))
|
||||
|
||||
def _change_key(self):
|
||||
p1 = KeyPrompt(
|
||||
self,
|
||||
|
|
|
|||
543
bouquin/statistics_dialog.py
Normal file
543
bouquin/statistics_dialog.py
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Dict
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .settings import load_db_config
|
||||
|
||||
# ---------- Activity heatmap ----------
|
||||
|
||||
|
||||
class DateHeatmap(QWidget):
|
||||
"""
|
||||
Small calendar heatmap for activity by date.
|
||||
|
||||
Data is a mapping: datetime.date -> integer value.
|
||||
"""
|
||||
|
||||
date_clicked = Signal(_dt.date)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._data: Dict[_dt.date, int] = {}
|
||||
self._start: _dt.date | None = None
|
||||
self._end: _dt.date | None = None
|
||||
self._max_value: int = 0
|
||||
|
||||
self._cell = 12
|
||||
self._gap = 3
|
||||
self._margin_left = 30
|
||||
self._margin_top = 10
|
||||
self._margin_bottom = 24
|
||||
self._margin_right = 10
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
def set_data(self, data: Dict[_dt.date, int]) -> None:
|
||||
"""Replace dataset and recompute layout."""
|
||||
self._data = {k: int(v) for k, v in (data or {}).items() if v is not None}
|
||||
if not self._data:
|
||||
self._start = self._end = None
|
||||
self._max_value = 0
|
||||
else:
|
||||
earliest = min(self._data.keys())
|
||||
latest = max(self._data.keys())
|
||||
self._start = earliest - _dt.timedelta(days=earliest.weekday())
|
||||
self._end = latest
|
||||
self._max_value = max(self._data.values()) if self._data else 0
|
||||
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
|
||||
# QWidget overrides ---------------------------------------------------
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
if not self._start or not self._end:
|
||||
height = (
|
||||
self._margin_top + self._margin_bottom + 7 * (self._cell + self._gap)
|
||||
)
|
||||
# some default width
|
||||
width = (
|
||||
self._margin_left + self._margin_right + 20 * (self._cell + self._gap)
|
||||
)
|
||||
return QSize(width, height)
|
||||
|
||||
day_count = (self._end - self._start).days + 1
|
||||
weeks = (day_count + 6) // 7 # ceil
|
||||
|
||||
width = (
|
||||
self._margin_left
|
||||
+ self._margin_right
|
||||
+ weeks * (self._cell + self._gap)
|
||||
+ self._gap
|
||||
)
|
||||
height = (
|
||||
self._margin_top
|
||||
+ self._margin_bottom
|
||||
+ 7 * (self._cell + self._gap)
|
||||
+ self._gap
|
||||
)
|
||||
return QSize(width, height)
|
||||
|
||||
def minimumSizeHint(self) -> QSize:
|
||||
sz = self.sizeHint()
|
||||
return QSize(min(380, sz.width()), sz.height())
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
|
||||
if not self._start or not self._end:
|
||||
return
|
||||
|
||||
palette = self.palette()
|
||||
bg_no_data = palette.base().color()
|
||||
active = palette.highlight().color()
|
||||
|
||||
painter.setPen(QPen(Qt.NoPen))
|
||||
|
||||
day_count = (self._end - self._start).days + 1
|
||||
weeks = (day_count + 6) // 7
|
||||
|
||||
for week in range(weeks):
|
||||
for dow in range(7):
|
||||
idx = week * 7 + dow
|
||||
date = self._start + _dt.timedelta(days=idx)
|
||||
if date > self._end:
|
||||
value = 0
|
||||
else:
|
||||
value = self._data.get(date, 0)
|
||||
|
||||
x = self._margin_left + week * (self._cell + self._gap)
|
||||
y = self._margin_top + dow * (self._cell + self._gap)
|
||||
|
||||
if value <= 0 or self._max_value <= 0:
|
||||
color = bg_no_data
|
||||
else:
|
||||
ratio = max(0.1, min(1.0, value / float(self._max_value)))
|
||||
color = QColor(active)
|
||||
# Lighter for low values, darker for high values
|
||||
lighten = 150 - int(50 * ratio) # 150 ≈ light, 100 ≈ original
|
||||
color = color.lighter(lighten)
|
||||
|
||||
painter.fillRect(
|
||||
x,
|
||||
y,
|
||||
self._cell,
|
||||
self._cell,
|
||||
QBrush(color),
|
||||
)
|
||||
|
||||
painter.setPen(palette.text().color())
|
||||
fm = painter.fontMetrics()
|
||||
|
||||
# --- weekday labels on left -------------------------------------
|
||||
# Python's weekday(): Monday=0 ... Sunday=6
|
||||
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
|
||||
|
||||
for dow in range(7):
|
||||
label = weekday_labels[dow]
|
||||
text_width = fm.horizontalAdvance(label)
|
||||
|
||||
# Center text vertically in the cell
|
||||
y_center = (
|
||||
self._margin_top + dow * (self._cell + self._gap) + self._cell / 2
|
||||
)
|
||||
baseline_y = int(y_center + fm.ascent() / 2 - fm.descent() / 2)
|
||||
|
||||
# Right-align text just to the left of the first column
|
||||
x = self._margin_left - self._gap - 2 - text_width
|
||||
|
||||
painter.drawText(x, baseline_y, label)
|
||||
|
||||
prev_month = None
|
||||
for week in range(weeks):
|
||||
date = self._start + _dt.timedelta(days=week * 7)
|
||||
if date > self._end: # pragma: no cover
|
||||
break
|
||||
|
||||
if prev_month == date.month:
|
||||
continue
|
||||
prev_month = date.month
|
||||
|
||||
label = date.strftime("%b")
|
||||
|
||||
x_center = (
|
||||
self._margin_left + week * (self._cell + self._gap) + self._cell / 2
|
||||
)
|
||||
y = self._margin_top + 7 * (self._cell + self._gap) + fm.ascent()
|
||||
|
||||
text_width = fm.horizontalAdvance(label)
|
||||
painter.drawText(
|
||||
int(x_center - text_width / 2),
|
||||
int(y),
|
||||
label,
|
||||
)
|
||||
|
||||
painter.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() != Qt.LeftButton:
|
||||
return super().mousePressEvent(event)
|
||||
|
||||
# No data = nothing to click
|
||||
if not self._start or not self._end:
|
||||
return
|
||||
|
||||
# Qt6: position(), older: pos()
|
||||
pos = event.position() if hasattr(event, "position") else event.pos()
|
||||
x = pos.x()
|
||||
y = pos.y()
|
||||
|
||||
# Outside grid area (left of weekday labels or above rows)
|
||||
if x < self._margin_left or y < self._margin_top:
|
||||
return
|
||||
|
||||
cell_span = self._cell + self._gap
|
||||
col = int((x - self._margin_left) // cell_span) # week index
|
||||
row = int((y - self._margin_top) // cell_span) # dow (0..6)
|
||||
|
||||
# Only 7 rows (Mon-Sun)
|
||||
if not (0 <= row < 7):
|
||||
return
|
||||
|
||||
# Only as many weeks as we actually have
|
||||
day_count = (self._end - self._start).days + 1
|
||||
weeks = (day_count + 6) // 7
|
||||
if col < 0 or col >= weeks:
|
||||
return
|
||||
|
||||
idx = col * 7 + row
|
||||
date = self._start + _dt.timedelta(days=idx)
|
||||
|
||||
# Skip trailing empty cells beyond the last date
|
||||
if date > self._end:
|
||||
return
|
||||
|
||||
self.date_clicked.emit(date)
|
||||
|
||||
|
||||
# ---------- Statistics dialog itself ----------
|
||||
|
||||
|
||||
class StatisticsDialog(QDialog):
|
||||
"""
|
||||
Shows aggregate statistics and the date heatmap with a metric switcher.
|
||||
"""
|
||||
|
||||
def __init__(self, db: DBManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
|
||||
self.setWindowTitle(strings._("statistics"))
|
||||
self.setMinimumWidth(650)
|
||||
self.setMinimumHeight(650)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
page_most_revisions,
|
||||
page_most_revisions_count,
|
||||
words_by_date,
|
||||
total_words,
|
||||
unique_tags,
|
||||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
time_minutes_by_date,
|
||||
total_time_minutes,
|
||||
day_most_time,
|
||||
day_most_time_minutes,
|
||||
project_most_minutes_name,
|
||||
project_most_minutes,
|
||||
activity_most_minutes_name,
|
||||
activity_most_minutes,
|
||||
reminders_by_date,
|
||||
total_reminders,
|
||||
day_most_reminders,
|
||||
day_most_reminders_count,
|
||||
) = self._gather_stats()
|
||||
|
||||
self.cfg = load_db_config()
|
||||
|
||||
# Optional: per-date document counts for the heatmap.
|
||||
documents_by_date: Dict[_dt.date, int] = {}
|
||||
total_documents = 0
|
||||
date_most_documents: _dt.date | None = None
|
||||
date_most_documents_count = 0
|
||||
|
||||
if self.cfg.documents:
|
||||
try:
|
||||
documents_by_date = self._db.documents_by_date() or {}
|
||||
except Exception:
|
||||
documents_by_date = {}
|
||||
|
||||
if documents_by_date:
|
||||
total_documents = sum(documents_by_date.values())
|
||||
# Choose the date with the highest count, tie-breaking by earliest date.
|
||||
date_most_documents, date_most_documents_count = sorted(
|
||||
documents_by_date.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)[0]
|
||||
|
||||
# For the heatmap
|
||||
self._documents_by_date = documents_by_date
|
||||
self._time_by_date = time_minutes_by_date
|
||||
self._reminders_by_date = reminders_by_date
|
||||
self._words_by_date = words_by_date
|
||||
self._revisions_by_date = revisions_by_date
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Feature groups
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# --- Pages / words / revisions -----------------------------------
|
||||
pages_group = QGroupBox(strings._("stats_group_pages"))
|
||||
pages_form = QFormLayout(pages_group)
|
||||
|
||||
pages_form.addRow(
|
||||
strings._("stats_pages_with_content"),
|
||||
QLabel(str(pages_with_content)),
|
||||
)
|
||||
pages_form.addRow(
|
||||
strings._("stats_total_revisions"),
|
||||
QLabel(str(total_revisions)),
|
||||
)
|
||||
|
||||
if page_most_revisions:
|
||||
pages_form.addRow(
|
||||
strings._("stats_page_most_revisions"),
|
||||
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
|
||||
)
|
||||
else:
|
||||
pages_form.addRow(
|
||||
strings._("stats_page_most_revisions"),
|
||||
QLabel("—"),
|
||||
)
|
||||
|
||||
pages_form.addRow(
|
||||
strings._("stats_total_words"),
|
||||
QLabel(str(total_words)),
|
||||
)
|
||||
|
||||
root.addWidget(pages_group)
|
||||
|
||||
# --- Tags ---------------------------------------------------------
|
||||
if self.cfg.tags:
|
||||
tags_group = QGroupBox(strings._("stats_group_tags"))
|
||||
tags_form = QFormLayout(tags_group)
|
||||
|
||||
tags_form.addRow(
|
||||
strings._("stats_unique_tags"),
|
||||
QLabel(str(unique_tags)),
|
||||
)
|
||||
|
||||
if page_most_tags:
|
||||
tags_form.addRow(
|
||||
strings._("stats_page_most_tags"),
|
||||
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
|
||||
)
|
||||
else:
|
||||
tags_form.addRow(
|
||||
strings._("stats_page_most_tags"),
|
||||
QLabel("—"),
|
||||
)
|
||||
|
||||
root.addWidget(tags_group)
|
||||
|
||||
# --- Documents ----------------------------------------------------
|
||||
if self.cfg.documents:
|
||||
docs_group = QGroupBox(strings._("stats_group_documents"))
|
||||
docs_form = QFormLayout(docs_group)
|
||||
|
||||
docs_form.addRow(
|
||||
strings._("stats_total_documents"),
|
||||
QLabel(str(total_documents)),
|
||||
)
|
||||
|
||||
if date_most_documents:
|
||||
doc_most_label = (
|
||||
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
|
||||
)
|
||||
else:
|
||||
doc_most_label = "—"
|
||||
|
||||
docs_form.addRow(
|
||||
strings._("stats_date_most_documents"),
|
||||
QLabel(doc_most_label),
|
||||
)
|
||||
|
||||
root.addWidget(docs_group)
|
||||
|
||||
# --- Time logging -------------------------------------------------
|
||||
if self.cfg.time_log:
|
||||
time_group = QGroupBox(strings._("stats_group_time_logging"))
|
||||
time_form = QFormLayout(time_group)
|
||||
|
||||
total_hours = total_time_minutes / 60.0 if total_time_minutes else 0.0
|
||||
time_form.addRow(
|
||||
strings._("stats_time_total_hours"),
|
||||
QLabel(f"{total_hours:.2f}h"),
|
||||
)
|
||||
|
||||
if day_most_time:
|
||||
day_hours = (
|
||||
day_most_time_minutes / 60.0 if day_most_time_minutes else 0.0
|
||||
)
|
||||
day_label = f"{day_most_time} ({day_hours:.2f}h)"
|
||||
else:
|
||||
day_label = "—"
|
||||
time_form.addRow(
|
||||
strings._("stats_time_day_most_hours"),
|
||||
QLabel(day_label),
|
||||
)
|
||||
|
||||
if project_most_minutes_name:
|
||||
proj_hours = (
|
||||
project_most_minutes / 60.0 if project_most_minutes else 0.0
|
||||
)
|
||||
proj_label = f"{project_most_minutes_name} ({proj_hours:.2f}h)"
|
||||
else:
|
||||
proj_label = "—"
|
||||
time_form.addRow(
|
||||
strings._("stats_time_project_most_hours"),
|
||||
QLabel(proj_label),
|
||||
)
|
||||
|
||||
if activity_most_minutes_name:
|
||||
act_hours = (
|
||||
activity_most_minutes / 60.0 if activity_most_minutes else 0.0
|
||||
)
|
||||
act_label = f"{activity_most_minutes_name} ({act_hours:.2f}h)"
|
||||
else:
|
||||
act_label = "—"
|
||||
time_form.addRow(
|
||||
strings._("stats_time_activity_most_hours"),
|
||||
QLabel(act_label),
|
||||
)
|
||||
|
||||
root.addWidget(time_group)
|
||||
|
||||
# --- Reminders ----------------------------------------------------
|
||||
if self.cfg.reminders:
|
||||
rem_group = QGroupBox(strings._("stats_group_reminders"))
|
||||
rem_form = QFormLayout(rem_group)
|
||||
|
||||
rem_form.addRow(
|
||||
strings._("stats_total_reminders"),
|
||||
QLabel(str(total_reminders)),
|
||||
)
|
||||
|
||||
if day_most_reminders:
|
||||
rem_label = f"{day_most_reminders} ({day_most_reminders_count})"
|
||||
else:
|
||||
rem_label = "—"
|
||||
|
||||
rem_form.addRow(
|
||||
strings._("stats_date_most_reminders"),
|
||||
QLabel(rem_label),
|
||||
)
|
||||
|
||||
root.addWidget(rem_group)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Heatmap with metric switcher
|
||||
# ------------------------------------------------------------------
|
||||
if (
|
||||
words_by_date
|
||||
or revisions_by_date
|
||||
or documents_by_date
|
||||
or time_minutes_by_date
|
||||
or reminders_by_date
|
||||
):
|
||||
group = QGroupBox(strings._("stats_activity_heatmap"))
|
||||
group_layout = QVBoxLayout(group)
|
||||
|
||||
# Metric selector
|
||||
combo_row = QHBoxLayout()
|
||||
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
|
||||
self.metric_combo = QComboBox()
|
||||
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
|
||||
self.metric_combo.addItem(
|
||||
strings._("stats_metric_revisions"),
|
||||
"revisions",
|
||||
)
|
||||
if documents_by_date:
|
||||
self.metric_combo.addItem(
|
||||
strings._("stats_metric_documents"),
|
||||
"documents",
|
||||
)
|
||||
if self.cfg.time_log and time_minutes_by_date:
|
||||
self.metric_combo.addItem(
|
||||
strings._("stats_metric_hours"),
|
||||
"hours",
|
||||
)
|
||||
if self.cfg.reminders and reminders_by_date:
|
||||
self.metric_combo.addItem(
|
||||
strings._("stats_metric_reminders"),
|
||||
"reminders",
|
||||
)
|
||||
combo_row.addWidget(self.metric_combo)
|
||||
combo_row.addStretch(1)
|
||||
group_layout.addLayout(combo_row)
|
||||
|
||||
self._heatmap = DateHeatmap()
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setWidget(self._heatmap)
|
||||
group_layout.addWidget(scroll)
|
||||
|
||||
root.addWidget(group)
|
||||
|
||||
# Default to "words"
|
||||
self._apply_metric("words")
|
||||
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
|
||||
else:
|
||||
root.addWidget(QLabel(strings._("stats_no_data")))
|
||||
|
||||
self.resize(self.sizeHint().width(), self.sizeHint().height())
|
||||
|
||||
# ---------- internal helpers ----------
|
||||
|
||||
def _apply_metric(self, metric: str) -> None:
|
||||
if metric == "revisions":
|
||||
self._heatmap.set_data(self._revisions_by_date)
|
||||
elif metric == "documents":
|
||||
self._heatmap.set_data(self._documents_by_date)
|
||||
elif metric == "hours":
|
||||
self._heatmap.set_data(self._time_by_date)
|
||||
elif metric == "reminders":
|
||||
self._heatmap.set_data(self._reminders_by_date)
|
||||
else:
|
||||
self._heatmap.set_data(self._words_by_date)
|
||||
|
||||
def _on_metric_changed(self, index: int) -> None:
|
||||
metric = self.metric_combo.currentData()
|
||||
if metric:
|
||||
self._apply_metric(metric)
|
||||
|
||||
def _gather_stats(self):
|
||||
return self._db.gather_stats()
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from importlib.resources import files
|
||||
import json
|
||||
from importlib.resources import files
|
||||
|
||||
# Get list of locales
|
||||
root = files("bouquin") / "locales"
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
# tag_browser.py
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QColorDialog,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QColorDialog,
|
||||
QMessageBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from .db import DBManager
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .settings import load_db_config
|
||||
|
||||
|
||||
class TagBrowserDialog(QDialog):
|
||||
|
|
@ -25,6 +26,7 @@ class TagBrowserDialog(QDialog):
|
|||
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self.cfg = load_db_config()
|
||||
self.setWindowTitle(
|
||||
strings._("tag_browser_title") + " / " + strings._("manage_tags")
|
||||
)
|
||||
|
|
@ -38,9 +40,18 @@ class TagBrowserDialog(QDialog):
|
|||
layout.addWidget(instructions)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderLabels(
|
||||
[strings._("tag"), strings._("color_hex"), strings._("date")]
|
||||
)
|
||||
if not self.cfg.documents:
|
||||
self.tree.setHeaderLabels(
|
||||
[strings._("tag"), strings._("color_hex"), strings._("date")]
|
||||
)
|
||||
else:
|
||||
self.tree.setHeaderLabels(
|
||||
[
|
||||
strings._("tag"),
|
||||
strings._("color_hex"),
|
||||
strings._("page_or_document"),
|
||||
]
|
||||
)
|
||||
self.tree.setColumnWidth(0, 200)
|
||||
self.tree.setColumnWidth(1, 100)
|
||||
self.tree.itemActivated.connect(self._on_item_activated)
|
||||
|
|
@ -52,17 +63,21 @@ class TagBrowserDialog(QDialog):
|
|||
# Tag management buttons
|
||||
btn_row = QHBoxLayout()
|
||||
|
||||
self.edit_name_btn = QPushButton(strings._("edit_tag_name"))
|
||||
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 = 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 = QPushButton("&" + strings._("delete_tag"))
|
||||
self.delete_btn.clicked.connect(self._delete_tag)
|
||||
self.delete_btn.setEnabled(False)
|
||||
btn_row.addWidget(self.delete_btn)
|
||||
|
|
@ -115,6 +130,7 @@ class TagBrowserDialog(QDialog):
|
|||
|
||||
self.tree.addTopLevelItem(root)
|
||||
|
||||
# Pages with this tag
|
||||
pages = self._db.get_pages_for_tag(name)
|
||||
for date_iso, _content in pages:
|
||||
child = QTreeWidgetItem(["", "", date_iso])
|
||||
|
|
@ -123,6 +139,21 @@ class TagBrowserDialog(QDialog):
|
|||
)
|
||||
root.addChild(child)
|
||||
|
||||
# Documents with this tag
|
||||
if self.cfg.documents:
|
||||
docs = self._db.get_documents_for_tag(name)
|
||||
for doc_id, project_name, file_name in docs:
|
||||
label = file_name
|
||||
if project_name:
|
||||
label = f"{file_name} ({project_name})"
|
||||
child = QTreeWidgetItem(["", "", label])
|
||||
child.setData(
|
||||
0,
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"type": "document", "id": doc_id},
|
||||
)
|
||||
root.addChild(child)
|
||||
|
||||
if focus_tag and name.lower() == focus_tag.lower():
|
||||
focus_item = root
|
||||
|
||||
|
|
@ -149,12 +180,42 @@ class TagBrowserDialog(QDialog):
|
|||
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(data, dict):
|
||||
if data.get("type") == "page":
|
||||
item_type = data.get("type")
|
||||
|
||||
if item_type == "page":
|
||||
date_iso = data.get("date")
|
||||
if date_iso:
|
||||
self.openDateRequested.emit(date_iso)
|
||||
self.accept()
|
||||
|
||||
elif item_type == "document":
|
||||
doc_id = data.get("id")
|
||||
if doc_id is not None:
|
||||
self._open_document(int(doc_id), str(data.get("file_name")))
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""Open a tagged document from the list."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
def _add_a_tag(self):
|
||||
"""Add a new tag"""
|
||||
|
||||
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()
|
||||
|
|
@ -169,9 +230,6 @@ class TagBrowserDialog(QDialog):
|
|||
old_name = data["name"]
|
||||
color = data["color"]
|
||||
|
||||
# Simple input dialog
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
new_name, ok = QInputDialog.getText(
|
||||
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ from typing import Optional
|
|||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCompleter,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QToolButton,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QStyle,
|
||||
QCompleter,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from PySide6.QtGui import QPalette, QColor, QGuiApplication
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from weakref import WeakSet
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
|
||||
|
||||
class Theme(Enum):
|
||||
SYSTEM = "system"
|
||||
|
|
@ -174,6 +176,14 @@ class ThemeManager(QObject):
|
|||
cal.setPalette(app_pal)
|
||||
cal.setStyleSheet("")
|
||||
|
||||
# --- Normalise weekend colours on *all* themed calendars -------------
|
||||
# Qt's default is red for weekends; we want them to match normal text.
|
||||
weekday_color = app_pal.windowText().color()
|
||||
weekend_fmt = QTextCharFormat()
|
||||
weekend_fmt.setForeground(weekday_color)
|
||||
cal.setWeekdayTextFormat(Qt.Saturday, weekend_fmt)
|
||||
cal.setWeekdayTextFormat(Qt.Sunday, weekend_fmt)
|
||||
|
||||
cal.update()
|
||||
|
||||
def _calendar_qss(self, highlight_css: str) -> str:
|
||||
|
|
|
|||
1689
bouquin/time_log.py
Normal file
1689
bouquin/time_log.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence
|
||||
from PySide6.QtWidgets import QToolBar
|
||||
|
||||
from . import strings
|
||||
|
|
@ -18,6 +18,11 @@ class ToolBar(QToolBar):
|
|||
checkboxesRequested = Signal()
|
||||
historyRequested = Signal()
|
||||
insertImageRequested = Signal()
|
||||
alarmRequested = Signal()
|
||||
timerRequested = Signal()
|
||||
documentsRequested = Signal()
|
||||
fontSizeLargerRequested = Signal()
|
||||
fontSizeSmallerRequested = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(strings._("toolbar_format"), parent)
|
||||
|
|
@ -66,12 +71,22 @@ class ToolBar(QToolBar):
|
|||
self.actH3.setCheckable(True)
|
||||
self.actH3.setShortcut("Ctrl+3")
|
||||
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
|
||||
self.actNormal = QAction("N", self)
|
||||
self.actNormal = QAction("P", self)
|
||||
self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text"))
|
||||
self.actNormal.setCheckable(True)
|
||||
self.actNormal.setShortcut("Ctrl+N")
|
||||
self.actNormal.setShortcut("Ctrl+.")
|
||||
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
|
||||
|
||||
self.actFontSmaller = QAction("P-", self)
|
||||
self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller"))
|
||||
self.actFontSmaller.setShortcut("Ctrl+Shift+-")
|
||||
self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested)
|
||||
|
||||
self.actFontLarger = QAction("P+", self)
|
||||
self.actFontLarger.setToolTip(strings._("toolbar_font_larger"))
|
||||
self.actFontLarger.setShortcut("Ctrl+Shift+=")
|
||||
self.actFontLarger.triggered.connect(self.fontSizeLargerRequested)
|
||||
|
||||
# Lists
|
||||
self.actBullets = QAction("•", self)
|
||||
self.actBullets.setToolTip(strings._("toolbar_bulleted_list"))
|
||||
|
|
@ -81,20 +96,37 @@ class ToolBar(QToolBar):
|
|||
self.actNumbers.setToolTip(strings._("toolbar_numbered_list"))
|
||||
self.actNumbers.setCheckable(True)
|
||||
self.actNumbers.triggered.connect(self.numbersRequested)
|
||||
self.actCheckboxes = QAction("☐", self)
|
||||
self.actCheckboxes = QAction("☑", self)
|
||||
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
|
||||
self.actCheckboxes.triggered.connect(self.checkboxesRequested)
|
||||
|
||||
# Images
|
||||
self.actInsertImg = QAction(strings._("images"), self)
|
||||
self.actInsertImg = QAction("📸", self)
|
||||
self.actInsertImg.setToolTip(strings._("insert_images"))
|
||||
self.actInsertImg.setShortcut("Ctrl+Shift+I")
|
||||
self.actInsertImg.triggered.connect(self.insertImageRequested)
|
||||
|
||||
# History button
|
||||
self.actHistory = QAction(strings._("history"), self)
|
||||
self.actHistory = QAction("↺", self)
|
||||
self.actHistory.setToolTip(strings._("history"))
|
||||
self.actHistory.triggered.connect(self.historyRequested)
|
||||
|
||||
# Alarm / reminder
|
||||
self.actAlarm = QAction("⏰", self)
|
||||
self.actAlarm.setToolTip(strings._("toolbar_alarm"))
|
||||
self.actAlarm.triggered.connect(self.alarmRequested)
|
||||
|
||||
# Focus timer
|
||||
self.actTimer = QAction("⌛", self)
|
||||
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
|
||||
self.actTimer.setCheckable(True)
|
||||
self.actTimer.triggered.connect(self.timerRequested)
|
||||
|
||||
# Documents
|
||||
self.actDocuments = QAction("📁", self)
|
||||
self.actDocuments.setToolTip(strings._("toolbar_documents"))
|
||||
self.actDocuments.triggered.connect(self.documentsRequested)
|
||||
|
||||
# Set exclusive buttons in QActionGroups
|
||||
self.grpHeadings = QActionGroup(self)
|
||||
self.grpHeadings.setExclusive(True)
|
||||
|
|
@ -126,10 +158,15 @@ class ToolBar(QToolBar):
|
|||
self.actH2,
|
||||
self.actH3,
|
||||
self.actNormal,
|
||||
self.actFontSmaller,
|
||||
self.actFontLarger,
|
||||
self.actBullets,
|
||||
self.actNumbers,
|
||||
self.actCheckboxes,
|
||||
self.actInsertImg,
|
||||
self.actAlarm,
|
||||
self.actTimer,
|
||||
self.actDocuments,
|
||||
self.actHistory,
|
||||
]
|
||||
)
|
||||
|
|
@ -146,14 +183,20 @@ class ToolBar(QToolBar):
|
|||
self._style_letter_button(self.actH1, "H1")
|
||||
self._style_letter_button(self.actH2, "H2")
|
||||
self._style_letter_button(self.actH3, "H3")
|
||||
self._style_letter_button(self.actNormal, "N")
|
||||
self._style_letter_button(self.actNormal, "P")
|
||||
self._style_letter_button(self.actFontSmaller, "P-")
|
||||
self._style_letter_button(self.actFontLarger, "P+")
|
||||
|
||||
# Lists
|
||||
self._style_letter_button(self.actBullets, "•")
|
||||
self._style_letter_button(self.actNumbers, "1.")
|
||||
self._style_letter_button(self.actCheckboxes, "☑")
|
||||
self._style_letter_button(self.actAlarm, "⏰")
|
||||
self._style_letter_button(self.actTimer, "⌛")
|
||||
self._style_letter_button(self.actDocuments, "📁")
|
||||
|
||||
# History
|
||||
self._style_letter_button(self.actHistory, strings._("view_history"))
|
||||
self._style_letter_button(self.actHistory, "↺")
|
||||
|
||||
def _style_letter_button(
|
||||
self,
|
||||
|
|
|
|||
406
bouquin/version_check.py
Normal file
406
bouquin/version_check.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec
|
||||
import tempfile
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from PySide6.QtCore import QStandardPaths, Qt
|
||||
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
|
||||
|
||||
from . import strings
|
||||
from .settings import APP_NAME
|
||||
|
||||
# Where to fetch the latest version string from
|
||||
VERSION_URL = "https://mig5.net/bouquin/version.txt"
|
||||
|
||||
# Name of the installed distribution according to pyproject.toml
|
||||
# (used with importlib.metadata.version)
|
||||
DIST_NAME = "bouquin"
|
||||
|
||||
# Base URL where AppImages are hosted
|
||||
APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download"
|
||||
|
||||
# Where we expect to find the bundled public key, relative to the *installed* package.
|
||||
GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc")
|
||||
|
||||
|
||||
class VersionChecker:
|
||||
"""
|
||||
Handles:
|
||||
* showing the version dialog
|
||||
* checking for updates
|
||||
* downloading & verifying a new AppImage
|
||||
|
||||
All dialogs use `parent` as their parent widget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
self._parent = parent
|
||||
|
||||
# ---------- Version helpers ---------- #
|
||||
|
||||
def _logo_pixmap(self, logical_size: int = 96) -> QPixmap:
|
||||
"""
|
||||
Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp.
|
||||
"""
|
||||
svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg"
|
||||
|
||||
# Logical size (what Qt layouts see)
|
||||
dpr = QGuiApplication.primaryScreen().devicePixelRatio()
|
||||
img_size = int(logical_size * dpr)
|
||||
|
||||
image = QImage(img_size, img_size, QImage.Format_ARGB32)
|
||||
image.fill(Qt.transparent)
|
||||
|
||||
renderer = QSvgRenderer(str(svg_path))
|
||||
painter = QPainter(image)
|
||||
renderer.render(painter)
|
||||
painter.end()
|
||||
|
||||
pixmap = QPixmap.fromImage(image)
|
||||
pixmap.setDevicePixelRatio(dpr)
|
||||
return pixmap
|
||||
|
||||
def current_version(self) -> str:
|
||||
"""
|
||||
Return the current app version as reported by importlib.metadata
|
||||
"""
|
||||
try:
|
||||
return importlib.metadata.version(DIST_NAME)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
# Fallback for editable installs / dev trees
|
||||
return "0.0.0"
|
||||
|
||||
@staticmethod
|
||||
def _parse_version(v: str) -> tuple[int, ...]:
|
||||
"""
|
||||
Very small helper to compare simple semantic versions like 1.2.3.
|
||||
Extracts numeric components and returns them as a tuple.
|
||||
"""
|
||||
parts = re.findall(r"\d+", v)
|
||||
if not parts:
|
||||
return (0,)
|
||||
return tuple(int(p) for p in parts)
|
||||
|
||||
def _is_newer_version(self, available: str, current: str) -> bool:
|
||||
"""
|
||||
True if `available` > `current` according to _parse_version.
|
||||
"""
|
||||
return self._parse_version(available) > self._parse_version(current)
|
||||
|
||||
# ---------- Public entrypoint for Help → Version ---------- #
|
||||
|
||||
def show_version_dialog(self) -> None:
|
||||
"""
|
||||
Show the Version dialog with a 'Check for updates' button.
|
||||
"""
|
||||
version = self.current_version()
|
||||
version_formatted = f"{APP_NAME} {version}"
|
||||
|
||||
box = QMessageBox(self._parent)
|
||||
box.setWindowTitle(strings._("version"))
|
||||
|
||||
box.setIconPixmap(self._logo_pixmap(96))
|
||||
|
||||
box.setText(version_formatted)
|
||||
|
||||
check_button = box.addButton(
|
||||
strings._("check_for_updates"), QMessageBox.ActionRole
|
||||
)
|
||||
box.addButton(QMessageBox.Close)
|
||||
|
||||
box.exec()
|
||||
|
||||
if box.clickedButton() is check_button:
|
||||
self.check_for_updates()
|
||||
|
||||
# ---------- Core update logic ---------- #
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""
|
||||
Fetch VERSION_URL, compare against the current version, and optionally
|
||||
download + verify a new AppImage.
|
||||
"""
|
||||
current = self.current_version()
|
||||
|
||||
try:
|
||||
resp = requests.get(VERSION_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
available_raw = resp.text.strip()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_check_for_updates") + str(e),
|
||||
)
|
||||
return
|
||||
|
||||
if not available_raw:
|
||||
QMessageBox.warning(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("update_server_returned_an_empty_version_string"),
|
||||
)
|
||||
return
|
||||
|
||||
if not self._is_newer_version(available_raw, current):
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("you_are_running_the_latest_version") + f"({current}).",
|
||||
)
|
||||
return
|
||||
|
||||
# Newer version is available
|
||||
reply = QMessageBox.question(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
(
|
||||
strings._("there_is_a_new_version_available")
|
||||
+ available_raw
|
||||
+ "\n\n"
|
||||
+ strings._("download_the_appimage")
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
self._download_and_verify_appimage(available_raw)
|
||||
|
||||
# ---------- Download + verification helpers ---------- #
|
||||
def _download_file(
|
||||
self,
|
||||
url: str,
|
||||
dest_path: Path,
|
||||
timeout: int = 30,
|
||||
progress: QProgressDialog | None = None,
|
||||
label: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Stream a URL to a local file, optionally updating a QProgressDialog.
|
||||
If the user cancels via the dialog, raises RuntimeError.
|
||||
"""
|
||||
resp = requests.get(url, timeout=timeout, stream=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total_bytes: int | None = None
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
total_bytes = int(content_length)
|
||||
except ValueError:
|
||||
total_bytes = None
|
||||
|
||||
if progress is not None:
|
||||
progress.setLabelText(
|
||||
label or strings._("downloading") + f" {dest_path.name}..."
|
||||
)
|
||||
# Unknown size → busy indicator; known size → real range
|
||||
if total_bytes is not None and total_bytes > 0:
|
||||
progress.setRange(0, total_bytes)
|
||||
else:
|
||||
progress.setRange(0, 0) # pragma: no cover
|
||||
progress.setValue(0)
|
||||
progress.show()
|
||||
QApplication.processEvents()
|
||||
|
||||
downloaded = 0
|
||||
with dest_path.open("wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue # pragma: no cover
|
||||
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if progress is not None:
|
||||
if total_bytes is not None and total_bytes > 0:
|
||||
progress.setValue(downloaded)
|
||||
else:
|
||||
# Just bump a little so the dialog looks alive
|
||||
progress.setValue(progress.value() + 1) # pragma: no cover
|
||||
QApplication.processEvents()
|
||||
|
||||
if progress.wasCanceled():
|
||||
raise RuntimeError(strings._("download_cancelled"))
|
||||
|
||||
if progress is not None and total_bytes is not None and total_bytes > 0:
|
||||
progress.setValue(total_bytes)
|
||||
QApplication.processEvents()
|
||||
|
||||
def _download_and_verify_appimage(self, version: str) -> None:
|
||||
"""
|
||||
Download the AppImage + its GPG signature to the user's Downloads dir,
|
||||
then verify it with a bundled public key.
|
||||
"""
|
||||
# Where to put the file
|
||||
download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
|
||||
if not download_dir:
|
||||
download_dir = os.path.expanduser("~/Downloads")
|
||||
download_dir = Path(download_dir)
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Construct AppImage filename and URLs
|
||||
appimage_path = download_dir / "Bouquin.AppImage"
|
||||
sig_path = Path(str(appimage_path) + ".asc")
|
||||
|
||||
appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage"
|
||||
sig_url = f"{appimage_url}.asc"
|
||||
|
||||
# Progress dialog covering both downloads
|
||||
progress = QProgressDialog(
|
||||
"Downloading update...",
|
||||
"Cancel",
|
||||
0,
|
||||
100,
|
||||
self._parent,
|
||||
)
|
||||
progress.setWindowTitle(strings._("update"))
|
||||
progress.setWindowModality(Qt.WindowModal)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
|
||||
try:
|
||||
# AppImage download
|
||||
self._download_file(
|
||||
appimage_url,
|
||||
appimage_path,
|
||||
progress=progress,
|
||||
label=strings._("downloading") + " Bouquin.AppImage...",
|
||||
)
|
||||
# Signature download (usually tiny, but we still show it)
|
||||
self._download_file(
|
||||
sig_url,
|
||||
sig_path,
|
||||
progress=progress,
|
||||
label=strings._("downloading") + " signature...",
|
||||
)
|
||||
except RuntimeError:
|
||||
# User cancelled
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
progress.close()
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("download_cancelled"),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
# Other error
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
progress.close()
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("failed_to_download_update") + str(e),
|
||||
)
|
||||
return
|
||||
|
||||
progress.close()
|
||||
|
||||
# Load the bundled public key
|
||||
try:
|
||||
pkg, *rel = GPG_PUBKEY_RESOURCE
|
||||
pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes()
|
||||
except Exception as e: # pragma: no cover
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_read_bundled_gpg_public_key") + str(e),
|
||||
)
|
||||
# On failure, delete the downloaded files for safety
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
return
|
||||
|
||||
# Use a temporary GNUPGHOME so we don't touch the user's main keyring
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as gnupg_home:
|
||||
pubkey_path = Path(gnupg_home) / "pubkey.asc"
|
||||
pubkey_path.write_bytes(pubkey_bytes)
|
||||
|
||||
# Import the key
|
||||
subprocess.run(
|
||||
["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
) # nosec
|
||||
|
||||
# Verify the signature
|
||||
subprocess.run(
|
||||
[
|
||||
"gpg",
|
||||
"--homedir",
|
||||
gnupg_home,
|
||||
"--verify",
|
||||
str(sig_path),
|
||||
str(appimage_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
) # nosec
|
||||
except FileNotFoundError:
|
||||
# gpg not installed / not on PATH
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_find_gpg_executable"),
|
||||
)
|
||||
return
|
||||
except subprocess.CalledProcessError as e:
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("gpg_signature_verification_failed")
|
||||
+ e.stderr.decode(errors="ignore"),
|
||||
)
|
||||
return
|
||||
|
||||
# Success
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
|
||||
)
|
||||
259
find_unused_strings.py
Executable file
259
find_unused_strings.py
Executable 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()
|
||||
579
poetry.lock
generated
579
poetry.lock
generated
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.10.5"
|
||||
version = "2025.11.12"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"},
|
||||
{file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"},
|
||||
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
|
||||
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -146,115 +146,103 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.7"
|
||||
version = "7.13.0"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"},
|
||||
{file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"},
|
||||
{file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"},
|
||||
{file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"},
|
||||
{file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"},
|
||||
{file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"},
|
||||
{file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"},
|
||||
{file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"},
|
||||
{file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"},
|
||||
{file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"},
|
||||
{file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
|
||||
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
|
||||
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -265,27 +253,27 @@ toml = ["tomli"]
|
|||
|
||||
[[package]]
|
||||
name = "desktop-entry-lib"
|
||||
version = "3.2"
|
||||
version = "5.0"
|
||||
description = "A library for working with .desktop files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "desktop-entry-lib-3.2.tar.gz", hash = "sha256:12189249f86dde52d055ac28897ad1ed14ef965407a50fb3ad4ac6cb1a7e8cde"},
|
||||
{file = "desktop_entry_lib-3.2-py3-none-any.whl", hash = "sha256:600748b2aab2cafbb3ebc08b5c1ded024d66e0756868b6d26b5aff44d336c4b5"},
|
||||
{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]
|
||||
test = ["pyfakefs", "pytest", "pytest-cov"]
|
||||
xdg-desktop-portal = ["jeepney"]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
|
||||
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
||||
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
|
||||
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -310,15 +298,30 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
|
|||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.10"
|
||||
description = "Python implementation of John Gruber's Markdown."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"},
|
||||
{file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
|
||||
testing = ["coverage", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
|
|
@ -377,57 +380,57 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
|
|||
|
||||
[[package]]
|
||||
name = "pyside6"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "pyside6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
PySide6_Addons = "6.10.0"
|
||||
PySide6_Essentials = "6.10.0"
|
||||
shiboken6 = "6.10.0"
|
||||
PySide6_Addons = "6.10.1"
|
||||
PySide6_Essentials = "6.10.1"
|
||||
shiboken6 = "6.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-addons"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
PySide6_Essentials = "6.10.0"
|
||||
shiboken6 = "6.10.0"
|
||||
PySide6_Essentials = "6.10.1"
|
||||
shiboken6 = "6.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-essentials"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
shiboken6 = "6.10.0"
|
||||
shiboken6 = "6.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
|
|
@ -531,147 +534,153 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
|||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python/C++ bindings helper module"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlcipher3-wheels"
|
||||
version = "0.5.5.post0"
|
||||
version = "0.5.6"
|
||||
description = "DB-API 2.0 interface for SQLCipher 3.x"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:836cff85673ab9bdfe0f3e2bc38aefddb5f3a4c0de397b92f83546bb94ea38aa"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3fde9076a8810d19044f65fdfeeee5a9d044176ce91adc2258c8b18cb945474"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ad3ccb27f3fc9260b1bcebfd33fc5af1c2a1bf6a50e8e1bf7991d492458b438"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94bb8ab8cf7ae3dc0d51dcb75bf242ae4bd2f18549bfc975fd696c181e9ea8ca"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0bf0a169b480615ea2021e7266e1154990762216d1fd8105b93d1fee336f49"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79cc1af145345e9bd625c961e4efc8fc6c6eefcaec90fbcf1c6b981492c08031"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d8b9f1c6d283acc5a0da16574c0f7690ba5b14cb5935f3078ccf8404a530075"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:952a23069a149a192a5eb8a9e552772b38c012825238175bc810f445a3aa8000"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24f1a57a4aa18d9ecd38cfce69dd06e58cfb521151a8316e18183e603e7108f4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6568c64adc55f9882ba36c11a446810bd5d4c03796aab8ecb9024f3bca9eb2cd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26c2b58d2f2a9dd23ad4c310fb6c0f0c82ca4f36a0d4177a70f0efeb332798ee"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46827ffc7e705c5ecdf23ec69f56dd55b20857dc3c3c4893e360de8a38b4e708"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4033bbe2f0342936736ce7b8b2626f532509315576d5376764b410deae181cad"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win32.whl", hash = "sha256:bfb26dbba945860427bd3f82c132e6d2ef409baa062d315b952dd5a930b25870"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_amd64.whl", hash = "sha256:168270b8fb295314aa4ee9f74435ceee42207bd16fe908f646a829b3a9daedad"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_arm64.whl", hash = "sha256:1f1bb2c4c6defa812eb0238055a283cf3c2f400e956a57c25cf65cbdbac6783f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:55d557376a90f14baf0f35e917f8644c3a8cf48897947fcd7ecf51d490dd689f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1739264a971901088fe1670befb8a8a707543186c8eecc58158ca287e309b2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:920e19345b6c5335b61e9fbed2625f96dbc3b0269ab5120baeae2c9288f0be01"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462734f6d0703f863f5968419d229de75bbf2a829f762bfb257b6df2355f977"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c397305b65612da76f254c692ff866571aa98fd3817ed0e40fce6d568d704966"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf3467fe834075b58215c50f9db7355ef86a73d256ac8ba5fffb8c946741a5dc"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a76d783c095a3c95185757c418e3bad3eab69cbf986970d422cce5431e84d7f5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf8d78895ee0f04dc525942a1f40796fa7c3d7d7fb36c987f55c243ce34192d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d583a10dbe9a1752968788c2d6438461ec7068608ceaa72e6468d80727c3152e"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8c3156b39bb8f24dfbe17a49126d8fa404b00c01d7aa84e64a2293db1dae1a38"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:15c3cf77b31973aa008174518fa439d8620a093964c2d9edcb8d23d543345839"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5743db0f3492359c2ab3a56b6bed00ecba193f2c75c74e8e3d78a45f1eb7c95"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc40a213a3633f19c96432304a16f0cff7c4aeca1a3d2042d4be36e576e64a70"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win32.whl", hash = "sha256:433456ce962ae50887d6428d55bad46e5748a2cdd3d036180eb0bcdbe8bae9f9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_amd64.whl", hash = "sha256:ca4332b1890cc4f80587be8bd529e20475bd3291e07f11115b1fc773947b264a"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_arm64.whl", hash = "sha256:a4634300cb2440baf17a78d6481d10902de4a2a6518f83a5ab2fe081e6b20b42"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8df43c11d767c6aac5cc300c1957356a9fd1b25f1946891003cf20a0146241"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:797653f08ecffcef2948dfd907fb20dab402d9efde6217c96befafc236e96c5b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dca428fde0d1a522473f766465324d6539d324f219f4f7c159a3eb0d4f9983c5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e97922240a04b44637eabf39f86d243fe61fe7db1bd2ad219eb4053158f263"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8d3a366e52a6732b1ccff14f9ca77ecbee53abfce87c417bf05d4301484584f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dce28a2431260251d7acf253ea1950983e48dfec64245126b39a770d5a88f507"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea64cce27152cae453c353038336bda0dc1f885e5e8e30b5cd28b8c9b498bbeb"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02d9e6120a496f083c525efc34408d4f2ca282da05bebcc967a0aa1e12a0d6ca"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:875cfc61bbf694b8327c2485e5ed40573e8b715f4e583502f12c51c8d5a92dd5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0e9ed3ff9c00ba3888f8dbc0c7c84377ef66f21c5f4ac373fc690dcf5e9bd594"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08ad6d767502429e497b6d03b5ae11e43e896d36f05ac8e60c12d8f124378bc1"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecdefdcf7ab8cb14b3147a59af83e8e3e5e3bed46fc43ab86a657f5c306a83d2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75ffe5677407bf20a32486eb6facfbb07a353ce7c9aecc9fefd4e9d3275605d7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win32.whl", hash = "sha256:97b6c6556b430b5c0dff53e8f709f90ba53294c2a3958a8c38f573c6dbf467d9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_amd64.whl", hash = "sha256:248cae211991f1ffb3a885a1223e62abee70c6c208fc2224a8dbf73d4e825baa"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_arm64.whl", hash = "sha256:5a49fc3a859a53fd025dc2fa08410292d267018897fc63198de6c92860fa8be7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f798e1591fa5ba14d9da08a54f18e7000fd74973cde12eb862a3928a69b7996"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:369011b8dc68741313a8b77bb68a70b76052390eaf819e4cd6e13d0acbea602d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5658990462a728c1f4b472d23c1f7f577eb2bced5bbbf7c2b45158b8340484bd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907a166e4e563da67fe22c480244459512e32d3e00853b3f1e6fdb9da6aa2da6"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ba972405e9f16042e37cbcb4fef47248339c8410847390d41268bd45dc3f6ca"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5f1b380fe3b869f701f9d2a8c09e9edfeec261573c8bb009a3336717260d65"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2978c9d964ad643b0bc61e19d8d608a515ff270e9a2f1f6c5aeb8ad56255def"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29457feb1516a2542aa7676e6d03bf913191690bf1ed6c82353782a380388508"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:021af568414741a45bfca41d682da64916a7873498a31d896cc34ad540939c6b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7471d12eef489eea60cc3806bae0690f6d0733f7aea371a3ad5c5642f3bc04a9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f5faf683db9ade192a870e28b1eeeec2eb0aeca18e88fa52195a4639974c7cb"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:64fe6bac67d2b807b91102eef41d7f389e008ded80575ba597b454e05f9522e5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63ef1e23eb9729e79783e2ab4b19f64276c155ba0a85ba1eeb21e248c6ce0956"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win32.whl", hash = "sha256:4eafde00138dd3753085b4f5eab0811247207b699de862589f886a94ad3628a4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_amd64.whl", hash = "sha256:909864f275460646d0bf5475dc42e9c2cadd79cd40805ea32fe9a69300595301"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_arm64.whl", hash = "sha256:a831846cc6b01d7f99576efbf797b61a269dffa6885f530b6957573ce1a24f10"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:aaad03f3eb099401306fead806908c85b923064a9da7a99c33a72c3b7c9143bf"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d4b4cde184d9e354152fd1867bcbaee468529865703ad863840a0ce4eb60cd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac3ad3cf9e1d0f08e8d22a65115368f2b22b9e96403fa644e146e1221c65c454"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c4d3dca4acdbc5543bb00aee1e0715db797aa2819db5b7ca3feed3ab3366ff"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf7026851ea60d63c1a88f62439da78b68bfbfec192c781255e3cfb34b6efc12"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ae4a83678c41c2cdbf3c2b18fc46be32225260c7b4807087bdb43793ee90fa"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:678c67d60b35eced29777fe9398b6e6a6638156f143c80662a0c7c99ce115be7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:9830af5aef2c17686d6e7c78c20b92c7b57c9d7921a03e4c549b48fe0e98c5c0"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:08651e17c868a6a22124b6ab71e939a5bb4737e0535f381ce35077dc8116c4b3"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:b58b4c944d7ce20cd7b426ae8834433b5b1152391960960b778b37803f0ffc1c"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2b38818468ddb0c8fc4b172031d65ced3be22ba82360c45909a0546b2857d3e4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win32.whl", hash = "sha256:91d1f2284d13b68f213d05b51cd6242f4cfa065d291af6f353f9cbedd28d8c0d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:a5c724f95366ba7a2895147b0690609b6774384fa8621aa46a66cf332e4b612f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e37b33263fad4accdba45c8566566d45fc01f47fd4afa3e265df9e0e3581d4f4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f1e29495bc968e37352c315d23a38462d7e77fcfa1597d005d17ed93f9f3103"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad8a774f545eb5471587e0389fca4f855f36d58901c65547796d59fc13aee458"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a5a0251e4ceca127b26d18f0965b7f3c820a2dd2c51c25015c819300fd5859"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e40535f0f57e8b605e1cbce1399c96bcd5ab99e60992d2c7669c689d0cbe5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e04e1dd62d019cde936d18fcd21361f6c4695e0e73fd6dc509c4ccd9446d26d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2df377e3d04f5c427c9f79ef95bdf0b982bde76c1dbd4441f83268f3f1993a53"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:5f3cb8db19e7462ccb2e34b56feaccb2aac675ad8f77e28f8222b3e7c47d1f92"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:b40860b3e6d6108473836a29d3600e1b335192637e16e9421b43b58147ced3c1"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:ca4fd9e8b669e81bb74479bde61ee475d7a6832d667e2ce80e6136ddd7a0fedd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:771e74a22f48c40b6402d0ca1d569ced5a796e118d4472da388744b5aa0ebd3f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win32.whl", hash = "sha256:4589bfca18ecf787598262327f7329fe1f4fc2655c04899d84451562e2099a57"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:f646ab958a601bad8925a876f5aa68bdf0ec3584630143ed1ad8e9df4e447044"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dfb8106a05af1cb1eadeea996171b52c80f18851972e49ffe91539e4fc064b0f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b8b9b77a898b721fc634858fc43552119d3d303485adc6f28f3e76f028d5ea04"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c65efab1a0ab14f75314039694ac35d3181a5c8cf43584bd537b36caf2a6ccf9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b450eee7e201c48aae58e2d45ef5d309a19cd49952cfb58d546fefbeef0a100"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8f0997202d7628c4312f0398122bdc5ada7fa79939d248652af40d9da689ef8"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae69bef7628236d426e408fb14a40f0027bac1658a06efd29549b26ba369372"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef48e874bcc3ebf623672ec99f9aaa7b8a4f62fb270e33dad6db3739ea111086"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9006dc1a73e2b2a53421aa72decbcff08cb109f67a20f7d15a64ab140e0a1d2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:96c07a345740fa71c0d8fc5fa7ea182ee24f62ebbf33d4d10c8c72d866dc332d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:85a78af6f6e782e0df36f93c9e7c2dd568204f60a2ea55025c21d1837dea95ec"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:19eadc55bf69f9e9799a808cdcfc6657cf30511cb32849235d555adfa048a99f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:377e8ad3bb3c17c43f860b570fd15e048246ade92babc9b310f2c417500aca57"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f8e07aec529d6fa31516201c524b0cfac108a9a6044a148f236291aae7991195"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win32.whl", hash = "sha256:703ab55b77b1c1ebb80eb0b27574a8eadf109739d252de7f646dc41cb82f1a65"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win_amd64.whl", hash = "sha256:b4f4b2e019c6d1ad33d5fc3163d31d0f731a073a5a52cdfae7b85408548ce342"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a84e3b098a29b8c298b01291cf8bc850a507ca45507d43674a84a8d33b7595b2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9905b580cfdbd6945e44d81332483deace167d33e956ffae5c4b27eddeb676e7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c403a7418631dc7185ef8053acc765101f4f64cc0bf50d1bc44ae7d40fc28e"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d63f89bf28de4ce82a7c324275ce733bf31eb29ec1121e48261af89b5b7f30b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc6dc782f5be4883279079c79fa88578258a0fd24651f6d69b0f4be2716f7d7e"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743c177822c60e66c5c9579b4f384bd98e60fd4a2abe0eacdec0af4747d925bc"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5eeb87220e4d2abf6faad1ecb3b3ee88c4d9caad6cf2ce4c0a73a91c4c7ad9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9318b814363b4bc062e54852ea62f58b69e7da9e51211afd6c55e9170e1ae9a0"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7a1f58a2614f2ad9fcb4822f6da56313cbb88309880512bf5d01bd3d9142b87"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ae9427ddde670f605c84f704c12d840628467cc0f0a8e9ce6489577eef6a0479"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:be75ae311433254af3c6fe6eb68bf80ac6ef547ec0cf4594f5b301a044682186"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:73e1438437bbe67d453e2908b90b17b357a992a9ac0011ad20af1ea7c2b4cd58"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:393c0a9af080c1c1a0801cca9448eff3633dafc1a7934fdce58a8d1c15d8bd2b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win32.whl", hash = "sha256:13a79fc8e9b69bf6d70e7fa5a53bd42fab83dc3e6e93da7fa82871ec40874e43"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_amd64.whl", hash = "sha256:b1648708e5bf599b4455bf330faa4777c3963669acb2f3fa25240123a01f8b2b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_arm64.whl", hash = "sha256:4c3dd2f54bdd518b90e28b07c31cdfe34c8bd182c5107a30a9c2ef9569cd6cf9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e16c8caf59e86589fb5f52253420db07121f1f96e2a12e244f6fdcaf8b946530"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:337f2e059114729dd1529ee356c98e2aa06440d6a9772917514a3bda0647c61c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f6bd900658446e1cdeebda0760adb9a89f55888b460623db88b100845cb51bc2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dc6fcca569858145cb5ba3c878997d1788973e36f689090178f807b9a44d9ca6"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:eef50cc39554ad1fb82faa33d25c7f3cb11e2f7087b41109bc169db2c942f0c7"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:0fc36fc67f639a0e03cf6f7c6a5d1bc5cdd8005e8e07da3b21c54d4d81ed353b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:53d0b861668d6847c7cc0dc7b443263b95a5cd211bcc326a457bd3122ebbb5a0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:10aef293397a4ab25d8346ba5f96181214ab9c6a8836d83320cf23a2ad773a2c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1105e7edba36a29625a824bff0eca3685c1cf6e391182b85a9a73b4b1604eef3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5db9b4035e42a27672abbe75120908c74a235a496cd92b4c685fda1e95e9b19c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f9e3fb5e96c5067a8cfd7b2fa7d939e529e30439058bbc15d0e9adca5e4cff1b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6f3c1a8a4a2c04225f5159cf7f1c315101a89271afbaef4205c6fc50766c5535"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fc0504a1dbe6d478614ef55eb80d0c02ead24bc91f34b41c07d404452389f42d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win32.whl", hash = "sha256:05ef2b35f176e3b29092ec9aa03b09f4803feddbabdc2174e7ccc608758f2beb"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:0f6873e4badf64eb8c5771c9e8a726df46ac663bc8051dfefb51fe2a46358b37"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_arm64.whl", hash = "sha256:9fd30c1cffa10f63f504a33494564efc0e0a475bbf069487016a9d2462d115e5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a6c511bacd40ba769368b1abbf97fbefb285f525e6d2a399a704c22ba2aae37f"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa25610cda2b2a1b1cefddbd93488e939cf0059480f2fda5a8704acddd0e8935"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5a5258fb99e99b6fda6f011a0a4094ff99fe2e9b9ac7ce81cf646e0e779829a3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:459836d52904fa006bf36e2144959bd21577c32947fdd173db50b037108a8620"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:5b36f9949f4d35c72f0626aaac109b17688c1d6a9a6e11de2538b4cfc32cfad0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:87301b545556a1811780bb6fc6480ab1f2640d1d5b5e5e33ed404559ae383647"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:fcc4705b5b7bd3508d08a6389a45e14591071a3e575c2864c9c1c615df89e0da"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0a231eb677a8246c47e423c710198631850c0a090e8f02a7fb1ad266ba517c56"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71ef871c65ad7c61048acb4f57da29bc0d5e35874183006222c229b5f1f64c73"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3480298c9bc4117207535636fe74b01b4860ecd74a028c73b42f5f0ddaa8661"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d48cf218ed13f17e3037564f08fba7ddf2c260dac7993e3d4ac58ee30483f115"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ff57a80904b9bd55e18774cb59bffacad06e196298381ee576ce683d1c09b032"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50978685717cd9293ff5508c192695a894879f9faed5142d0e8d7b63310f87c2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win32.whl", hash = "sha256:24207dbb699ca68fc5fc7248385fdf33a92fb1e17a6ea88d3cf2345a18fb29ff"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:40b1f8188a0aa5bbec354a12561b014b43a6a0d0a0d230a8a9378ed7b826b0ec"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:107ef02bbd0f2ffb39a564c14ebf3bedfa4569949a0d72ec8e106f754d715b7c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:59a572b18d1ef8318e9f583a7b3e1a67b4b04ed4b783c3f29fa806635274d12a"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32dfb8903b24db5879b1f922114f650bc6a15df9d071c55eefeb6937e13b2d20"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f5770257736c43cbf910a22f74c1490ef1ecde0432e475904f038e64ffdacb0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c33f99ddfe08c0f34807046800e510316b8bac2974b3c5fb9ecb1ee25c391ac8"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:97d4c000deeb72c2421f555f3e55a8c161ddfb0499caabf60df2bfde6460a5fc"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:67d9889028b4adfcaecd32e1e60330e1764c209ad12438f0eec2a5145ebf4a2d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:00cf178b15da486ab43ee2bed41edb1b393c5cfe2a48cae68893a2b31260dbd3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:95bfa4c5ffdd72d9d8676c913d585b7885a42824824cf1d9e93d3669f01492dd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:030ab50a8f4153cfe8dd5c98724909b210243af2350b9c79914838905a99518e"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5dc3c3d9deea654f8ea9c1dbc7bc90561331e4da9c7055381fac6498ca7267a3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cc986e8aa89e5a4a30b4eb8fd841d913a4e22ada99ec42be83f69bde3d86a31"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a41f0d30fa63d8db915566ec6987e68f064d96052cd6492ed8384b3e4807e60b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fefe8a41e68334c545465813782fd45ef5cfe1082d012d95514c8a78e8015"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win32.whl", hash = "sha256:ac2332f44758794a2fa19c77b824853e2a57ce5c27cc71c61066a52845be22d0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:6f016ba5a2a531938f332a234865dfc25d3a69abc169c3bf1d5c06c3c3f24601"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:101ce0f7403801b6988d1f6c94244900e0f6c5378666e0ffd74b300687a6f9ef"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:94527fa3994c0fa1275c23d9fbb02512aacc675f1e45f566c660f4f9d5376e75"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0920a4b24362522ba83b36a47495d174221361213207191c325749a621fabeca"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5061b07b121ebd76aa697755b1b8f642cc3a27a0f6d392180ab249b35f1c2394"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:79de8511bb1fec62128e1b366cdc0cbd2ad1d725f3e29f9c91e96946a3c67945"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4b92c2f35bb8153cc20bcfc651536f51cc1194403782c542a852497ac789cbe2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2d55211e3d2addff8a2df7335927d7fe6d75aa9ed12b396a22a5a0bfe2773ed9"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8cb31de5d67799cc2bba92f23adc10281d66c2c16ca6418b94d80500a164aa60"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:123796de3e471db5ed8b4ee4f97ec562ad38347ad678dad71133eade280202e0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6d34fabacfad4f301a22b5d8466d7ee3481f735bdb327d8756f04c81d3516c4"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:91b02fc765485c5b65f2a3eacfd2e16059253e007d0b5a5f24bba5fcea9032dd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13db7f23c553ffdd35f6e3b26415bdb9f100dcf89038873965caef769e8f1af5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4ba79a81cd591d32a3a225e3e9b50a9871324d0e414fb6d0866049d8820e4e46"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f97be07997681ca90fb339d5411fcb957bd7cbe810389404baed207cb366badd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win32.whl", hash = "sha256:9e56e0a7aa778da3d46323fc1233da5dcede795a6c7fe4c11980fec0ce8c3fe3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:744845e4aa3cc614590f967aa1d38cc5d549177a2a83ed68c1821b5fb0505f8a"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:c92de0b940533ca3a5b43a45d0768e0698b6ca95020b2fd47ec269b6bfc228d1"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a3f558df797aabf51680b3fbce48c4b3df89c36ad7fcaa3886b2ed8057aa2786"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7e216586720663960c82f046c495ef6d828e8e95c8fcf4c767b555fb9b8feead"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e4ef70d3af8ebe6ababe8eff93b8bd4ad288d0a38ab29a2420c91d636fbfe14"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:11e34aac6cb7e29d23e339c5de9e87700ddf09886e104640578b5afb566a2c50"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:79e220312a075546e6be0a6062dda6315857b1478d78f97eb352f1383dde8ce2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b953af7b57867bcffeeab59681921671615ae4b42fd0a9234ad0be7e0e43dfd4"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:130ac318dbcb3a51a4377b0bf3e450c6c21d508a8b00d2d9d4b3ee6a46ab3595"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5154c8022e58722987522ddce30f19fb69d6f8f6314959100d9f37c3dc5cba5b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f91d1f5b7b927aa00a8d83724c58875d9d0e47bd81ca40445090ab521b5fa"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e1c140bfa6b0a7e08f414f2a9f8f529f7d8c4cfa8386ce588e6c747c4ccc6615"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:18fc56dfb32c6ce370d929897205027f78275c32446d6b1be712d462789ae8c2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c03ec5e058fbf3fd94ecd8e0448834e8e7f46418eaec5fe5c7a0982c6e62c13f"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08756c1b25aebabb25a55dfe6f323876caea0c69511e34553807ae1d7ab843dd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win32.whl", hash = "sha256:bdbc58d224d27c002aed8a6361b43f3651943ecbfac69cd2674bbe681cf83790"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:dcc313f4519922c1ec3406b010d53f700750c1cf5331b9633a3c8b196307e852"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:dc1f0c77cc0395680176913a1d634a4014a1ebf02e7a7b2ac03a180b44241842"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fc30e82d2b8f139ac1ab81a3b3d9a59da8e3ce3b1e753285727480667efd5417"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f11d1d2c41141dd95f7d45f03dbe9f69a6427463e69db50609d83c0cd29980b5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92beff11fd9683941de7b47b8fc280e834b135ba7966d139b0ce2159b551ebad"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3060403647df7d44844c2808a384e4c4cf4a2a1b65e509a8016aca971c08ad39"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:9380de7e8fc952f376c9dae9ba1cdbb6a24ff5e41fd8f3b3cf39f1e305ed3248"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:9a26be381b0fb1c8d4fcdfd48182c78217ae9458513e4fe51b5045d4f94d41cb"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:c3be08f8d81372a6d084062f969f88be0b942ac449b0ac01825b853c12705421"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c5bd4abbebc15f8a2a9a653500cd1abeb3aac13887fcc83de31ca40fce32e3a2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4bb3c2c8e9a1e16455b989b2c7598b8053029bcbb519dc22601fa82bc8896f89"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:aac8ca9d2b4e18637e61ea1d8193500a1186f0b113b9224dc74186190f41c8e7"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f237a41c3f08e69f2532aec29a2589097baa73886164537d90c744d3d2eb3b3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e6e59c3e0301cb04351b1cb12231aaadb40f56f779fb50a7857c6b4ed4c57297"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba2296a608081f4474f4447658a1e032d0b5506153baf68233471afde1463da9"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win32.whl", hash = "sha256:8c8edfbd38a49ebbec2d1d56a000a499da2ac80b00488c156a1e0b8a7b8c10c6"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:21df85bc14d5d86225c1e7466ff65cbcc10f0d1d4f466823b4534c4c0564554c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_arm64.whl", hash = "sha256:64df3e807fb0e6d89c1e90ce7c900bb82b695c474e1a0945a5f92862cac8b63d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3784b22a29e4a675b456ca6ff1841d61e0eb97a28d0ba23d3d8cb5fe6da88238"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e27881be24f03d8a67a6db763f5671aaa05205de2380b1793b5e20bdabe49fba"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:667b6eec50ed03111676a0f4565be133643c9ad8bc88e6eea1c96b2af590c417"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4eaaa5cf77b125e05908b1200681e2988b1a6a307c7e677967053a1e4b07fba5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:4ead5b8f2607718548c8571e4a89fe735dd53443a2b5e42d8147eecd11b0d94b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_ppc64le.whl", hash = "sha256:d82a8a7b478d23368320ad185533d063ec14d11a1d188f07ace513a66bfa9580"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_s390x.whl", hash = "sha256:39d871ee8c13d9b0326b24a02e5af21a7b1c8fb5e6f6f4ec62b935392202ec69"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:5a8737683621c2917a4ee9ff774e597a368c5b3d23f08ae53897d6bd1f8bfc0e"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:08b6922d5020384fa641c8dc416f6f2b143110c86dcf3aae086e7ce15b192eae"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9dcc7f830ec56c090884a83be265c51c0a4fd60bb033b000c69c3bee08d77d8"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:0848f628b1528dd6a19a36679d8cde4b6f1f8d288757ba2e3df5578b79d79e90"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1476bb15586ce27ea5fae7c54469b2be4efe51ca9cefa20871a6c394a18892cc"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:de17d373d9e7807236013950f598bf59b9ed7c375938fdb95378a7114e55ff95"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win32.whl", hash = "sha256:02fa9e7f98a8e9be871219014b9ac015ba630b51615d90a2c06d45547a4b0cf1"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win_amd64.whl", hash = "sha256:6b2d7daab225c578aec8109fde99624f281b4ccdc6c53c8cd8feb86d8e7d3cf2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:abef5e28b4d1ca518291a8ca27af1cf9e4d68dd4a264d83874ec4d0a69589395"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd4c12a5a60cbd533ba4a3b4131d23302283ba597739c7867066b4efefe178db"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b7672837f1b9a6a67e375b743d74371d0428ead79ff367591145d06f3711c96"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:61c33e2697b0d91f3cbe806104e1d5b93961d3ab55ba55ee53bb36efe83c9933"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:2e6eb09782dd719a1bb34af6e5ef25e5713c1f806231b472fcf64eb9288957af"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6469b756ced0293e74806db2f114e5307cd4b05a559e986d3cc0b2eeb1eb8153"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:b6492f9bcb9296ac2179b5c9f7e7f329449b580836c0e8e5cfc2f3fe9af3486c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2e4968d98917309463f02e4a48abebd95ed3d37968346f2693ed8a08e2fe9794"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:50214729697a1ee9e7603ba62b8ea46d78903ae1332caaa94fbaedde113944b7"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1ec9fd1dd5774d665903b8ba2e3e4f8ed72879dc42f6e9b2815040f0cb2d8ccd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ced8ab30d205c8b6225b5703885576e629266767b091158731ec76c8c490bef4"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3c7242a267dd802fee273084a5707a95d02df4102afbea133c8f716234c7edcc"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6c239d15085af4b0f3433fa274c1fc37369509b99a7c035a359d5142a0536d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win32.whl", hash = "sha256:cc29963df04a73d8420a4d023ba016c9013d86378969d8a11fe2148477282936"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_amd64.whl", hash = "sha256:38cc7bb3a371c4a5fe7f4236a409e64f1286796d780833243f9e15ef852f159d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_arm64.whl", hash = "sha256:186e49af3ddb98d260b95d436eaf58f2125712c268c8475627129c1f80a68164"},
|
||||
{file = "sqlcipher3_wheels-0.5.6.tar.gz", hash = "sha256:1d232c14be44db95a7f3018433cae01ecd18803fa2468fce3cc45ebd5e034942"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -738,22 +747,22 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
version = "2.6.2"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
|
||||
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
|
||||
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
zstd = ["backports-zstd (>=1.0.0)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.9,<3.14"
|
||||
content-hash = "8c65ccc55e84371f8695117dcd01ca9ad2d78b159327045eced824e5f425a7d0"
|
||||
python-versions = ">=3.10,<3.14"
|
||||
content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
[tool.poetry]
|
||||
name = "bouquin"
|
||||
version = "0.3"
|
||||
version = "0.7.3"
|
||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://git.mig5.net/mig5/bouquin"
|
||||
packages = [{ include = "bouquin" }]
|
||||
include = ["bouquin/locales/*.json"]
|
||||
include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/NotoSansSymbols2-Regular.ttf", "bouquin/fonts/OFL.txt"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9,<3.14"
|
||||
python = ">=3.10,<3.14"
|
||||
pyside6 = ">=6.8.1,<7.0.0"
|
||||
sqlcipher3-wheels = "^0.5.5.post0"
|
||||
requests = "^2.32.5"
|
||||
markdown = "^3.10"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
bouquin = "bouquin.__main__:main"
|
||||
|
|
@ -30,6 +32,9 @@ 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"]
|
||||
|
|
|
|||
34
release.sh
34
release.sh
|
|
@ -1,16 +1,48 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
# Parse the args
|
||||
while getopts "v:" OPTION
|
||||
do
|
||||
case $OPTION in
|
||||
v)
|
||||
VERSION=$OPTARG
|
||||
;;
|
||||
?)
|
||||
usage
|
||||
exit
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${VERSION}" ]]; then
|
||||
echo "You forgot to pass -v [version]!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml
|
||||
|
||||
git add pyproject.toml
|
||||
git commit -m "Bump to ${VERSION}"
|
||||
git push origin main
|
||||
|
||||
set -e
|
||||
|
||||
rm -rf dist
|
||||
# Clean caches etc
|
||||
filedust -y .
|
||||
|
||||
# Publish to Pypi
|
||||
poetry build
|
||||
poetry publish
|
||||
|
||||
# Make AppImage
|
||||
sudo apt-get -y install libfuse-dev
|
||||
poetry run pyproject-appimage
|
||||
mv Bouquin.AppImage dist/
|
||||
|
||||
# Sign packages
|
||||
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
||||
|
||||
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 161 KiB |
BIN
screenshots/statistics.png
Normal file
BIN
screenshots/statistics.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
screenshots/tags.png
Normal file
BIN
screenshots/tags.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
screenshots/time.png
Normal file
BIN
screenshots/time.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -33,10 +33,19 @@ def isolate_qsettings(tmp_path_factory):
|
|||
def tmp_db_cfg(tmp_path):
|
||||
from bouquin.db import DBConfig
|
||||
|
||||
db_path = tmp_path / "notebook.db"
|
||||
default_db = tmp_path / "notebook.db"
|
||||
key = "test-secret-key"
|
||||
return DBConfig(
|
||||
path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True
|
||||
path=default_db,
|
||||
key=key,
|
||||
idle_minutes=0,
|
||||
theme="light",
|
||||
move_todos=True,
|
||||
tags=True,
|
||||
time_log=True,
|
||||
reminders=True,
|
||||
locale="en",
|
||||
font_size=11,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -49,3 +58,65 @@ def fresh_db(tmp_db_cfg):
|
|||
assert ok, "DB connect() should succeed"
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_code_block_editor_dialog(monkeypatch):
|
||||
"""
|
||||
In tests, replace the interactive CodeBlockEditorDialog with a tiny stub
|
||||
that never shows a real QDialog and never blocks on exec().
|
||||
"""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class _TestCodeBlockEditorDialog:
|
||||
def __init__(
|
||||
self, code: str, language: str | None, parent=None, allow_delete=False
|
||||
):
|
||||
# Simulate what the real dialog would “start with”
|
||||
self._code = code
|
||||
self._language = language
|
||||
|
||||
def exec(self) -> int:
|
||||
# Pretend the user clicked OK immediately.
|
||||
# (If you prefer “Cancel by default”, return Rejected instead.)
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def code(self) -> str:
|
||||
# In tests we just return the initial code unchanged.
|
||||
return self._code
|
||||
|
||||
def language(self) -> str | None:
|
||||
# Ditto for language.
|
||||
return self._language
|
||||
|
||||
# MarkdownEditor imported CodeBlockEditorDialog into its own module,
|
||||
# so patch that name – everything in MarkdownEditor will use this stub.
|
||||
monkeypatch.setattr(
|
||||
markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog
|
||||
)
|
||||
|
||||
|
||||
# --- Freeze Qt time helper (for alarm parsing tests) ---
|
||||
@pytest.fixture
|
||||
def freeze_qt_time(monkeypatch):
|
||||
"""Freeze QDateTime.currentDateTime/QTime.currentTime to midday today.
|
||||
|
||||
This avoids flakiness when tests run close to midnight, so that
|
||||
QTime.currentTime().addSecs(3600) is still the same calendar day.
|
||||
"""
|
||||
import bouquin.main_window as _mwmod
|
||||
from PySide6.QtCore import QDate, QDateTime, QTime
|
||||
|
||||
today = QDate.currentDate()
|
||||
fixed_time = QTime(12, 0)
|
||||
fixed_dt = QDateTime(today, fixed_time)
|
||||
|
||||
# Patch the *imported* Qt symbols that main_window uses
|
||||
monkeypatch.setattr(
|
||||
_mwmod.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_mwmod.QTime, "currentTime", staticmethod(lambda: QTime(fixed_time))
|
||||
)
|
||||
yield
|
||||
|
|
|
|||
324
tests/test_bug_report_dialog.py
Normal file
324
tests/test_bug_report_dialog.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import bouquin.bug_report_dialog as bugmod
|
||||
from bouquin import strings
|
||||
from bouquin.bug_report_dialog import BugReportDialog
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
def test_bug_report_truncates_text_to_max_chars(qtbot):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
max_chars = getattr(dlg, "MAX_CHARS", 5000)
|
||||
|
||||
# Make a string longer than the allowed maximum
|
||||
long_text = "x" * (max_chars + 50)
|
||||
|
||||
# Setting the text should trigger textChanged -> _enforce_max_length
|
||||
dlg.text_edit.setPlainText(long_text)
|
||||
|
||||
# Let Qt process the signal/slot if needed
|
||||
qtbot.wait(10)
|
||||
|
||||
current = dlg.text_edit.toPlainText()
|
||||
assert len(current) == max_chars
|
||||
assert current == long_text[:max_chars]
|
||||
|
||||
|
||||
def test_bug_report_allows_up_to_max_chars_unchanged(qtbot):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
max_chars = getattr(dlg, "MAX_CHARS", 5000)
|
||||
exact_text = "y" * max_chars
|
||||
|
||||
dlg.text_edit.setPlainText(exact_text)
|
||||
qtbot.wait(10)
|
||||
|
||||
current = dlg.text_edit.toPlainText()
|
||||
# Should not be trimmed if it's exactly the limit
|
||||
assert len(current) == max_chars
|
||||
assert current == exact_text
|
||||
|
||||
|
||||
def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Non-empty message so we don't hit the "empty" warning branch
|
||||
dlg.text_edit.setPlainText("Hello, something broke.")
|
||||
qtbot.wait(10)
|
||||
|
||||
# Make version() deterministic
|
||||
def fake_version(pkg_name):
|
||||
assert pkg_name == "bouquin"
|
||||
return "1.2.3"
|
||||
|
||||
monkeypatch.setattr(
|
||||
bugmod.importlib.metadata, "version", fake_version, raising=True
|
||||
)
|
||||
|
||||
# Capture the POST call and fake a 201 Created response
|
||||
calls = {}
|
||||
|
||||
class DummyResp:
|
||||
status_code = 201
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls["url"] = url
|
||||
calls["json"] = json
|
||||
calls["timeout"] = timeout
|
||||
return DummyResp()
|
||||
|
||||
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
|
||||
|
||||
# Capture information / critical message boxes
|
||||
info_called = {}
|
||||
crit_called = {}
|
||||
|
||||
def fake_info(parent, title, text, *a, **k):
|
||||
info_called["title"] = title
|
||||
info_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
def fake_critical(parent, title, text, *a, **k):
|
||||
crit_called["title"] = title
|
||||
crit_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
|
||||
)
|
||||
|
||||
# Don't actually close the dialog in the test; just record that accept() was called
|
||||
accepted = {}
|
||||
|
||||
def fake_accept():
|
||||
accepted["called"] = True
|
||||
|
||||
dlg.accept = fake_accept
|
||||
|
||||
# Call the send logic directly
|
||||
dlg._send()
|
||||
|
||||
# --- Assertions ---------------------------------------------------------
|
||||
|
||||
# POST was called with the expected URL and JSON payload
|
||||
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
|
||||
assert calls["json"]["message"] == "Hello, something broke."
|
||||
assert calls["json"]["version"] == "1.2.3"
|
||||
# No attachment fields expected any more
|
||||
|
||||
# Success path: information dialog shown, critical not shown
|
||||
assert "title" in info_called
|
||||
assert "text" in info_called
|
||||
assert crit_called == {}
|
||||
|
||||
# Dialog accepted
|
||||
assert accepted.get("called") is True
|
||||
|
||||
|
||||
def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted(
|
||||
qtbot, monkeypatch
|
||||
):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
dlg.text_edit.setPlainText("Broken again.")
|
||||
qtbot.wait(10)
|
||||
|
||||
# Stub version() again
|
||||
monkeypatch.setattr(
|
||||
bugmod.importlib.metadata,
|
||||
"version",
|
||||
lambda name: "9.9.9",
|
||||
raising=True,
|
||||
)
|
||||
|
||||
# Fake a non-201 response (e.g. 500)
|
||||
calls = {}
|
||||
|
||||
class DummyResp:
|
||||
status_code = 500
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls["url"] = url
|
||||
calls["json"] = json
|
||||
calls["timeout"] = timeout
|
||||
return DummyResp()
|
||||
|
||||
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
|
||||
|
||||
info_called = {}
|
||||
crit_called = {}
|
||||
|
||||
def fake_info(parent, title, text, *a, **k):
|
||||
info_called["title"] = title
|
||||
info_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
def fake_critical(parent, title, text, *a, **k):
|
||||
crit_called["title"] = title
|
||||
crit_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
|
||||
)
|
||||
|
||||
accepted = {}
|
||||
|
||||
def fake_accept():
|
||||
accepted["called"] = True
|
||||
|
||||
dlg.accept = fake_accept
|
||||
|
||||
dlg._send()
|
||||
|
||||
# POST still called with JSON payload
|
||||
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
|
||||
assert calls["json"]["message"] == "Broken again."
|
||||
assert calls["json"]["version"] == "9.9.9"
|
||||
|
||||
# Failure path: critical dialog shown, information not shown
|
||||
assert crit_called # non-empty
|
||||
assert info_called == {}
|
||||
|
||||
# Dialog should NOT be accepted on failure
|
||||
assert accepted.get("called") is not True
|
||||
|
||||
|
||||
def test_bug_report_dialog_text_limit_clamps_cursor(qtbot):
|
||||
"""Test that cursor position is clamped when text exceeds limit."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Set text that exceeds MAX_CHARS
|
||||
max_chars = dialog.MAX_CHARS
|
||||
long_text = "A" * (max_chars + 100)
|
||||
|
||||
# Set text and move cursor to end
|
||||
dialog.text_edit.setPlainText(long_text)
|
||||
dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
# Text should be truncated
|
||||
assert len(dialog.text_edit.toPlainText()) == max_chars
|
||||
|
||||
# Cursor should be clamped to max position
|
||||
final_cursor = dialog.text_edit.textCursor()
|
||||
assert final_cursor.position() <= max_chars
|
||||
|
||||
|
||||
def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch):
|
||||
"""Test that sending empty report shows warning."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Clear any text
|
||||
dialog.text_edit.clear()
|
||||
|
||||
warning_shown = {"shown": False}
|
||||
|
||||
def mock_warning(*args):
|
||||
warning_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||
|
||||
# Try to send empty report
|
||||
dialog._send()
|
||||
|
||||
assert warning_shown["shown"]
|
||||
|
||||
|
||||
def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch):
|
||||
"""Test that sending whitespace-only report shows warning."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Set whitespace only
|
||||
dialog.text_edit.setPlainText(" \n\n \t\t ")
|
||||
|
||||
warning_shown = {"shown": False}
|
||||
|
||||
def mock_warning(*args):
|
||||
warning_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||
|
||||
dialog._send()
|
||||
|
||||
assert warning_shown["shown"]
|
||||
|
||||
|
||||
def test_bug_report_dialog_network_error(qtbot, monkeypatch):
|
||||
"""Test handling network error during send."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
dialog.text_edit.setPlainText("Test bug report")
|
||||
|
||||
# Mock requests.post to raise exception
|
||||
import requests
|
||||
|
||||
def mock_post(*args, **kwargs):
|
||||
raise requests.exceptions.ConnectionError("Network error")
|
||||
|
||||
monkeypatch.setattr(requests, "post", mock_post)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
dialog._send()
|
||||
|
||||
assert critical_shown["shown"]
|
||||
|
||||
|
||||
def test_bug_report_dialog_timeout_error(qtbot, monkeypatch):
|
||||
"""Test handling timeout error during send."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
dialog.text_edit.setPlainText("Test bug report")
|
||||
|
||||
# Mock requests.post to raise timeout
|
||||
import requests
|
||||
|
||||
def mock_post(*args, **kwargs):
|
||||
raise requests.exceptions.Timeout("Request timed out")
|
||||
|
||||
monkeypatch.setattr(requests, "post", mock_post)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
dialog._send()
|
||||
|
||||
assert critical_shown["shown"]
|
||||
325
tests/test_code_block_editor_dialog.py
Normal file
325
tests/test_code_block_editor_dialog.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
from bouquin import strings
|
||||
from bouquin.code_block_editor_dialog import (
|
||||
CodeBlockEditorDialog,
|
||||
CodeEditorWithLineNumbers,
|
||||
)
|
||||
from PySide6.QtCore import QRect, QSize
|
||||
from PySide6.QtGui import QFont, QPaintEvent
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
|
||||
def _find_button_by_text(widget, text):
|
||||
for btn in widget.findChildren(QPushButton):
|
||||
if text.lower() in btn.text().lower():
|
||||
return btn
|
||||
return None
|
||||
|
||||
|
||||
def test_code_block_dialog_delete_flow(qtbot):
|
||||
dlg = CodeBlockEditorDialog("print(1)", "python", allow_delete=True)
|
||||
qtbot.addWidget(dlg)
|
||||
delete_txt = strings._("delete_code_block")
|
||||
delete_btn = _find_button_by_text(dlg, delete_txt)
|
||||
assert delete_btn is not None
|
||||
assert not dlg.was_deleted()
|
||||
with qtbot.waitSignal(dlg.finished, timeout=2000):
|
||||
delete_btn.click()
|
||||
assert dlg.was_deleted()
|
||||
|
||||
|
||||
def test_code_block_dialog_language_and_code(qtbot):
|
||||
dlg = CodeBlockEditorDialog("x = 1", "not-a-lang", allow_delete=False)
|
||||
qtbot.addWidget(dlg)
|
||||
delete_txt = strings._("delete_code_block")
|
||||
assert _find_button_by_text(dlg, delete_txt) is None
|
||||
assert dlg.code() == "x = 1"
|
||||
assert dlg.language() is None
|
||||
|
||||
|
||||
def test_line_number_area_size_hint(qtbot, app):
|
||||
"""Test _LineNumberArea.sizeHint() method."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
line_area = editor._line_number_area
|
||||
size_hint = line_area.sizeHint()
|
||||
|
||||
# Should return a QSize with width from editor
|
||||
assert isinstance(size_hint, QSize)
|
||||
assert size_hint.width() > 0
|
||||
assert size_hint.height() == 0
|
||||
|
||||
|
||||
def test_line_number_area_paint_event(qtbot, app):
|
||||
"""Test _LineNumberArea.paintEvent() method."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Line 1\nLine 2\nLine 3")
|
||||
editor.show()
|
||||
|
||||
# Trigger a paint event on the line number area
|
||||
line_area = editor._line_number_area
|
||||
paint_event = QPaintEvent(QRect(0, 0, line_area.width(), line_area.height()))
|
||||
line_area.paintEvent(paint_event)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_line_number_font_pixel_size_fallback(qtbot, app):
|
||||
"""Test _line_number_font() with pixel-sized font."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Set a pixel-sized font (pointSize will be -1)
|
||||
font = QFont()
|
||||
font.setPixelSize(14)
|
||||
editor.setFont(font)
|
||||
|
||||
# Get line number font - should use the fallback
|
||||
line_font = editor._line_number_font()
|
||||
|
||||
# Should have calculated a size
|
||||
assert line_font.pointSizeF() > 0 or line_font.pixelSize() > 0
|
||||
|
||||
|
||||
def test_code_editor_resize_event(qtbot, app):
|
||||
"""Test CodeEditorWithLineNumbers.resizeEvent() method."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
|
||||
# Resize the editor
|
||||
editor.resize(400, 300)
|
||||
|
||||
# Line number area should be repositioned
|
||||
line_area = editor._line_number_area
|
||||
assert line_area.geometry().width() > 0
|
||||
assert line_area.geometry().height() == editor.contentsRect().height()
|
||||
|
||||
|
||||
def test_code_editor_update_with_scroll(qtbot, app):
|
||||
"""Test _update_line_number_area with dy (scroll) parameter."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Add enough text to enable scrolling
|
||||
text = "\n".join([f"Line {i}" for i in range(100)])
|
||||
editor.setPlainText(text)
|
||||
editor.show()
|
||||
|
||||
# Trigger update with scroll offset
|
||||
rect = QRect(0, 0, 100, 100)
|
||||
editor._update_line_number_area(rect, dy=10)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_code_editor_update_without_scroll(qtbot, app):
|
||||
"""Test _update_line_number_area without scroll (dy=0)."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Line 1\nLine 2")
|
||||
editor.show()
|
||||
|
||||
# Trigger update without scroll
|
||||
rect = QRect(0, 0, 100, 100)
|
||||
editor._update_line_number_area(rect, dy=0)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_code_editor_update_contains_viewport(qtbot, app):
|
||||
"""Test _update_line_number_area when rect contains viewport."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Test")
|
||||
editor.show()
|
||||
|
||||
# Trigger update with rect that contains viewport
|
||||
viewport_rect = editor.viewport().rect()
|
||||
editor._update_line_number_area(viewport_rect, dy=0)
|
||||
|
||||
# Should trigger width update (covers line 82)
|
||||
|
||||
|
||||
def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
|
||||
"""Test line_number_area_paint_event with multiple text blocks."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Add multiple lines
|
||||
text = "\n".join([f"Line {i}" for i in range(20)])
|
||||
editor.setPlainText(text)
|
||||
editor.show()
|
||||
|
||||
# Force a paint event
|
||||
line_area = editor._line_number_area
|
||||
rect = QRect(0, 0, line_area.width(), line_area.height())
|
||||
paint_event = QPaintEvent(rect)
|
||||
|
||||
# This should exercise the painting loop
|
||||
editor.line_number_area_paint_event(paint_event)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_line_number_area_paint_with_long_file(qtbot, app):
|
||||
"""Test line_number_area_paint_event with many lines."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Add 1000+ lines to test digit calculation and painting
|
||||
text = "\n".join([f"Line {i}" for i in range(1000)])
|
||||
editor.setPlainText(text)
|
||||
editor.show()
|
||||
|
||||
# Trigger paint event
|
||||
line_area = editor._line_number_area
|
||||
paint_event = QPaintEvent(line_area.rect())
|
||||
editor.line_number_area_paint_event(paint_event)
|
||||
|
||||
# Line number width should accommodate 4 digits
|
||||
width = editor.line_number_area_width()
|
||||
assert width > 30 # Should be wider for 4-digit numbers
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_with_delete(qtbot, app):
|
||||
"""Test CodeBlockEditorDialog with allow_delete=True."""
|
||||
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=True)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should have delete button functionality
|
||||
assert hasattr(dialog, "_delete_requested")
|
||||
assert dialog._delete_requested is False
|
||||
|
||||
# Simulate delete click
|
||||
dialog._on_delete_clicked()
|
||||
|
||||
assert dialog._delete_requested is True
|
||||
assert dialog.was_deleted() is True
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_without_delete(qtbot, app):
|
||||
"""Test CodeBlockEditorDialog with allow_delete=False."""
|
||||
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=False)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should not have been deleted
|
||||
assert dialog.was_deleted() is False
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_language_selection(qtbot, app):
|
||||
"""Test language selection in dialog."""
|
||||
dialog = CodeBlockEditorDialog("test", "javascript")
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should have selected javascript
|
||||
assert dialog.language() == "javascript"
|
||||
|
||||
# Change language
|
||||
dialog._lang_combo.setCurrentText("python")
|
||||
assert dialog.language() == "python"
|
||||
|
||||
# Empty language
|
||||
dialog._lang_combo.setCurrentText("")
|
||||
assert dialog.language() is None
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_code_retrieval(qtbot, app):
|
||||
"""Test getting code from dialog."""
|
||||
original_code = "def foo():\n pass"
|
||||
dialog = CodeBlockEditorDialog(original_code, None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should return the code
|
||||
assert dialog.code() == original_code
|
||||
|
||||
# Modify code
|
||||
new_code = "def bar():\n return 42"
|
||||
dialog._code_edit.setPlainText(new_code)
|
||||
assert dialog.code() == new_code
|
||||
|
||||
|
||||
def test_code_editor_with_empty_text(qtbot, app):
|
||||
"""Test editor with no text."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
|
||||
# Should still paint line numbers
|
||||
line_area = editor._line_number_area
|
||||
paint_event = QPaintEvent(line_area.rect())
|
||||
editor.line_number_area_paint_event(paint_event)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_code_editor_block_count_changed(qtbot, app):
|
||||
"""Test that block count changes trigger width updates."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
initial_width = editor.line_number_area_width()
|
||||
|
||||
# Add lots of lines (should require more digits)
|
||||
text = "\n".join([f"Line {i}" for i in range(1000)])
|
||||
editor.setPlainText(text)
|
||||
|
||||
new_width = editor.line_number_area_width()
|
||||
|
||||
# Width should increase for more digits
|
||||
assert new_width > initial_width
|
||||
|
||||
|
||||
def test_code_editor_cursor_position_changed(qtbot, app):
|
||||
"""Test that cursor position changes update line number area."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Line 1\nLine 2\nLine 3")
|
||||
editor.show()
|
||||
|
||||
# Move cursor
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(cursor.MoveOperation.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should trigger line number area update (via signal connection)
|
||||
# Just verify it doesn't crash
|
||||
|
||||
|
||||
def test_line_number_area_width_calculation(qtbot, app):
|
||||
"""Test line number area width calculation with various block counts."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Test with 1 line (should use minimum 2 digits)
|
||||
editor.setPlainText("One line")
|
||||
width_1 = editor.line_number_area_width()
|
||||
assert width_1 > 0
|
||||
|
||||
# Test with 10 lines (2 digits)
|
||||
editor.setPlainText("\n".join(["Line"] * 10))
|
||||
width_10 = editor.line_number_area_width()
|
||||
assert width_10 >= width_1
|
||||
|
||||
# Test with 100 lines (3 digits)
|
||||
editor.setPlainText("\n".join(["Line"] * 100))
|
||||
width_100 = editor.line_number_area_width()
|
||||
assert width_100 > width_10
|
||||
|
||||
|
||||
def test_code_editor_viewport_margins(qtbot, app):
|
||||
"""Test that viewport margins are set correctly."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Test")
|
||||
editor.show()
|
||||
|
||||
# Left margin should equal line number area width
|
||||
margins = editor.viewportMargins()
|
||||
line_width = editor.line_number_area_width()
|
||||
|
||||
assert margins.left() == line_width
|
||||
assert margins.top() == 0
|
||||
assert margins.right() == 0
|
||||
assert margins.bottom() == 0
|
||||
398
tests/test_code_highlighter.py
Normal file
398
tests/test_code_highlighter.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter
|
||||
from PySide6.QtGui import QFont, QTextCharFormat
|
||||
|
||||
|
||||
def test_get_language_patterns_python(app):
|
||||
"""Test getting highlighting patterns for Python."""
|
||||
patterns = CodeHighlighter.get_language_patterns("python")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have comment pattern
|
||||
assert any("#" in p[0] for p in patterns)
|
||||
# Should have string patterns
|
||||
assert any('"' in p[0] for p in patterns)
|
||||
# Should have keyword patterns
|
||||
assert any("keyword" == p[1] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_javascript(app):
|
||||
"""Test getting highlighting patterns for JavaScript."""
|
||||
patterns = CodeHighlighter.get_language_patterns("javascript")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have // comment pattern
|
||||
assert any("//" in p[0] for p in patterns)
|
||||
# Should have /* */ comment pattern (with escaped asterisks in regex)
|
||||
assert any(r"/\*" in p[0] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_php(app):
|
||||
"""Test getting highlighting patterns for PHP."""
|
||||
patterns = CodeHighlighter.get_language_patterns("php")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have # comment pattern
|
||||
assert any("#" in p[0] for p in patterns)
|
||||
# Should have // comment pattern
|
||||
assert any("//" in p[0] for p in patterns)
|
||||
# Should have /* */ comment pattern (with escaped asterisks in regex)
|
||||
assert any(r"/\*" in p[0] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_bash(app):
|
||||
"""Test getting highlighting patterns for Bash."""
|
||||
patterns = CodeHighlighter.get_language_patterns("bash")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have # comment pattern
|
||||
assert any("#" in p[0] for p in patterns)
|
||||
# Should have bash keywords
|
||||
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
|
||||
assert len(keyword_patterns) > 0
|
||||
|
||||
|
||||
def test_get_language_patterns_html(app):
|
||||
"""Test getting highlighting patterns for HTML."""
|
||||
patterns = CodeHighlighter.get_language_patterns("html")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have tag pattern
|
||||
assert any("tag" == p[1] for p in patterns)
|
||||
# Should have HTML comment pattern
|
||||
assert any("<!--" in p[0] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_css(app):
|
||||
"""Test getting highlighting patterns for CSS."""
|
||||
patterns = CodeHighlighter.get_language_patterns("css")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have // comment pattern
|
||||
assert any("//" in p[0] for p in patterns)
|
||||
# Should have CSS properties as keywords
|
||||
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
|
||||
assert len(keyword_patterns) > 0
|
||||
|
||||
|
||||
def test_get_language_patterns_unknown_language(app):
|
||||
"""Test getting patterns for an unknown language."""
|
||||
patterns = CodeHighlighter.get_language_patterns("unknown-lang")
|
||||
|
||||
# Should still return basic patterns (strings, numbers)
|
||||
assert len(patterns) > 0
|
||||
assert any("string" == p[1] for p in patterns)
|
||||
assert any("number" == p[1] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_case_insensitive(app):
|
||||
"""Test that language matching is case insensitive."""
|
||||
patterns_lower = CodeHighlighter.get_language_patterns("python")
|
||||
patterns_upper = CodeHighlighter.get_language_patterns("PYTHON")
|
||||
patterns_mixed = CodeHighlighter.get_language_patterns("PyThOn")
|
||||
|
||||
assert len(patterns_lower) == len(patterns_upper)
|
||||
assert len(patterns_lower) == len(patterns_mixed)
|
||||
|
||||
|
||||
def test_get_format_for_type_keyword(app):
|
||||
"""Test getting format for keyword type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
|
||||
|
||||
assert fmt.fontWeight() == QFont.Weight.Bold
|
||||
assert fmt.foreground().color().blue() > 0 # Should have blue-ish color
|
||||
|
||||
|
||||
def test_get_format_for_type_string(app):
|
||||
"""Test getting format for string type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("string", base_format)
|
||||
|
||||
# Should have orangish color
|
||||
color = fmt.foreground().color()
|
||||
assert color.red() > 100
|
||||
|
||||
|
||||
def test_get_format_for_type_comment(app):
|
||||
"""Test getting format for comment type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("comment", base_format)
|
||||
|
||||
assert fmt.fontItalic() is True
|
||||
# Should have greenish color
|
||||
color = fmt.foreground().color()
|
||||
assert color.green() > 0
|
||||
|
||||
|
||||
def test_get_format_for_type_number(app):
|
||||
"""Test getting format for number type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("number", base_format)
|
||||
|
||||
# Should have some color
|
||||
color = fmt.foreground().color()
|
||||
assert color.isValid()
|
||||
|
||||
|
||||
def test_get_format_for_type_tag(app):
|
||||
"""Test getting format for HTML tag type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("tag", base_format)
|
||||
|
||||
# Should have cyan-ish color
|
||||
color = fmt.foreground().color()
|
||||
assert color.green() > 0
|
||||
assert color.blue() > 0
|
||||
|
||||
|
||||
def test_get_format_for_type_unknown(app):
|
||||
"""Test getting format for unknown type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("unknown", base_format)
|
||||
|
||||
# Should return a valid format (based on base_format)
|
||||
assert fmt is not None
|
||||
|
||||
|
||||
def test_code_block_metadata_init(app):
|
||||
"""Test CodeBlockMetadata initialization."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
assert len(metadata._block_languages) == 0
|
||||
|
||||
|
||||
def test_code_block_metadata_set_get_language(app):
|
||||
"""Test setting and getting language for a block."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
metadata.set_language(0, "python")
|
||||
metadata.set_language(5, "javascript")
|
||||
|
||||
assert metadata.get_language(0) == "python"
|
||||
assert metadata.get_language(5) == "javascript"
|
||||
assert metadata.get_language(10) is None
|
||||
|
||||
|
||||
def test_code_block_metadata_set_language_case_normalization(app):
|
||||
"""Test that language is normalized to lowercase."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
metadata.set_language(0, "PYTHON")
|
||||
metadata.set_language(1, "JavaScript")
|
||||
|
||||
assert metadata.get_language(0) == "python"
|
||||
assert metadata.get_language(1) == "javascript"
|
||||
|
||||
|
||||
def test_code_block_metadata_serialize_empty(app):
|
||||
"""Test serializing empty metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
result = metadata.serialize()
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_code_block_metadata_serialize(app):
|
||||
"""Test serializing metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
metadata.set_language(0, "python")
|
||||
metadata.set_language(3, "javascript")
|
||||
|
||||
result = metadata.serialize()
|
||||
|
||||
assert "<!-- code-langs:" in result
|
||||
assert "0:python" in result
|
||||
assert "3:javascript" in result
|
||||
assert "-->" in result
|
||||
|
||||
|
||||
def test_code_block_metadata_serialize_sorted(app):
|
||||
"""Test that serialized metadata is sorted by block number."""
|
||||
metadata = CodeBlockMetadata()
|
||||
metadata.set_language(5, "python")
|
||||
metadata.set_language(2, "javascript")
|
||||
metadata.set_language(8, "bash")
|
||||
|
||||
result = metadata.serialize()
|
||||
|
||||
# Find positions in string
|
||||
pos_2 = result.find("2:")
|
||||
pos_5 = result.find("5:")
|
||||
pos_8 = result.find("8:")
|
||||
|
||||
# Should be in order
|
||||
assert pos_2 < pos_5 < pos_8
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize(app):
|
||||
"""Test deserializing metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
text = (
|
||||
"Some content\n<!-- code-langs: 0:python,3:javascript,5:bash -->\nMore content"
|
||||
)
|
||||
|
||||
metadata.deserialize(text)
|
||||
|
||||
assert metadata.get_language(0) == "python"
|
||||
assert metadata.get_language(3) == "javascript"
|
||||
assert metadata.get_language(5) == "bash"
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize_empty(app):
|
||||
"""Test deserializing from text without metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
metadata.set_language(0, "python") # Set some initial data
|
||||
|
||||
text = "Just some regular text with no metadata"
|
||||
metadata.deserialize(text)
|
||||
|
||||
# Should clear existing data
|
||||
assert len(metadata._block_languages) == 0
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize_invalid_format(app):
|
||||
"""Test deserializing with invalid format."""
|
||||
metadata = CodeBlockMetadata()
|
||||
text = "<!-- code-langs: invalid,format,here -->"
|
||||
|
||||
metadata.deserialize(text)
|
||||
|
||||
# Should handle gracefully, resulting in empty or minimal data
|
||||
# Pairs without ':' should be skipped
|
||||
assert len(metadata._block_languages) == 0
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize_invalid_block_number(app):
|
||||
"""Test deserializing with invalid block number."""
|
||||
metadata = CodeBlockMetadata()
|
||||
text = "<!-- code-langs: abc:python,3:javascript -->"
|
||||
|
||||
metadata.deserialize(text)
|
||||
|
||||
# Should skip invalid block number 'abc'
|
||||
assert metadata.get_language(3) == "javascript"
|
||||
assert "abc" not in str(metadata._block_languages)
|
||||
|
||||
|
||||
def test_code_block_metadata_round_trip(app):
|
||||
"""Test serializing and deserializing preserves data."""
|
||||
metadata1 = CodeBlockMetadata()
|
||||
metadata1.set_language(0, "python")
|
||||
metadata1.set_language(2, "javascript")
|
||||
metadata1.set_language(7, "bash")
|
||||
|
||||
serialized = metadata1.serialize()
|
||||
|
||||
metadata2 = CodeBlockMetadata()
|
||||
metadata2.deserialize(serialized)
|
||||
|
||||
assert metadata2.get_language(0) == "python"
|
||||
assert metadata2.get_language(2) == "javascript"
|
||||
assert metadata2.get_language(7) == "bash"
|
||||
|
||||
|
||||
def test_python_keywords_present(app):
|
||||
"""Test that Python keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("python", [])
|
||||
|
||||
assert "def" in keywords
|
||||
assert "class" in keywords
|
||||
assert "if" in keywords
|
||||
assert "for" in keywords
|
||||
assert "import" in keywords
|
||||
|
||||
|
||||
def test_javascript_keywords_present(app):
|
||||
"""Test that JavaScript keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("javascript", [])
|
||||
|
||||
assert "function" in keywords
|
||||
assert "const" in keywords
|
||||
assert "let" in keywords
|
||||
assert "var" in keywords
|
||||
assert "class" in keywords
|
||||
|
||||
|
||||
def test_php_keywords_present(app):
|
||||
"""Test that PHP keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("php", [])
|
||||
|
||||
assert "function" in keywords
|
||||
assert "class" in keywords
|
||||
assert "echo" in keywords
|
||||
assert "require" in keywords
|
||||
|
||||
|
||||
def test_bash_keywords_present(app):
|
||||
"""Test that Bash keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("bash", [])
|
||||
|
||||
assert "if" in keywords
|
||||
assert "then" in keywords
|
||||
assert "fi" in keywords
|
||||
assert "for" in keywords
|
||||
|
||||
|
||||
def test_html_keywords_present(app):
|
||||
"""Test that HTML keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("html", [])
|
||||
|
||||
assert "div" in keywords
|
||||
assert "span" in keywords
|
||||
assert "body" in keywords
|
||||
assert "html" in keywords
|
||||
|
||||
|
||||
def test_css_keywords_present(app):
|
||||
"""Test that CSS keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("css", [])
|
||||
|
||||
assert "color" in keywords
|
||||
assert "background" in keywords
|
||||
assert "margin" in keywords
|
||||
assert "padding" in keywords
|
||||
|
||||
|
||||
def test_all_patterns_have_string_and_number(app):
|
||||
"""Test that all languages have string and number patterns."""
|
||||
languages = ["python", "javascript", "php", "bash", "html", "css"]
|
||||
|
||||
for lang in languages:
|
||||
patterns = CodeHighlighter.get_language_patterns(lang)
|
||||
pattern_types = [p[1] for p in patterns]
|
||||
|
||||
assert "string" in pattern_types, f"{lang} should have string pattern"
|
||||
assert "number" in pattern_types, f"{lang} should have number pattern"
|
||||
|
||||
|
||||
def test_patterns_have_regex_format(app):
|
||||
"""Test that patterns are in regex format."""
|
||||
patterns = CodeHighlighter.get_language_patterns("python")
|
||||
|
||||
for pattern, pattern_type in patterns:
|
||||
# Each pattern should be a string (regex pattern)
|
||||
assert isinstance(pattern, str)
|
||||
# Each type should be a string
|
||||
assert isinstance(pattern_type, str)
|
||||
|
||||
|
||||
def test_code_block_metadata_update_language(app):
|
||||
"""Test updating language for existing block."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
metadata.set_language(0, "python")
|
||||
assert metadata.get_language(0) == "python"
|
||||
|
||||
metadata.set_language(0, "javascript")
|
||||
assert metadata.get_language(0) == "javascript"
|
||||
|
||||
|
||||
def test_get_format_preserves_base_format_properties(app):
|
||||
"""Test that get_format_for_type preserves base format properties."""
|
||||
base_format = QTextCharFormat()
|
||||
base_format.setFontPointSize(14)
|
||||
|
||||
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
|
||||
|
||||
# Should be based on the base_format
|
||||
assert isinstance(fmt, QTextCharFormat)
|
||||
432
tests/test_db.py
432
tests/test_db.py
|
|
@ -1,8 +1,11 @@
|
|||
import pytest
|
||||
import json, csv
|
||||
import csv
|
||||
import datetime as dt
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from bouquin.db import DBManager
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
|
||||
|
||||
def _today():
|
||||
|
|
@ -17,6 +20,10 @@ 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"
|
||||
|
||||
|
|
@ -56,8 +63,10 @@ def test_dates_with_content_and_search(fresh_db):
|
|||
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
|
||||
|
||||
hits = list(fresh_db.search_entries("alpha"))
|
||||
assert any(d == _today() for d, _ in hits)
|
||||
assert any(d == _tomorrow() for d, _ in hits)
|
||||
# search_entries now returns (kind, key, title, text, aux)
|
||||
page_dates = [key for (kind, key, _title, _text, _aux) in hits if kind == "page"]
|
||||
assert _today() in page_dates
|
||||
assert _tomorrow() in page_dates
|
||||
|
||||
|
||||
def test_get_all_entries_and_export(fresh_db, tmp_path):
|
||||
|
|
@ -75,10 +84,6 @@ def test_get_all_entries_and_export(fresh_db, tmp_path):
|
|||
fresh_db.export_csv(entries, str(csv_path))
|
||||
assert csv_path.exists() and list(csv.reader(open(csv_path)))
|
||||
|
||||
txt_path = tmp_path / "export.txt"
|
||||
fresh_db.export_txt(entries, str(txt_path))
|
||||
assert txt_path.exists() and txt_path.read_text().strip()
|
||||
|
||||
md_path = tmp_path / "export.md"
|
||||
fresh_db.export_markdown(entries, str(md_path))
|
||||
md_text = md_path.read_text()
|
||||
|
|
@ -205,3 +210,412 @@ def test_integrity_check_raises_without_details(tmp_db_cfg):
|
|||
db.conn = _Conn([(None,), (None,)])
|
||||
with pytest.raises(sqlite.IntegrityError):
|
||||
db._integrity_ok()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DB _strip_markdown and _count_words Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_db_strip_markdown_empty_text(fresh_db):
|
||||
"""Test strip_markdown with empty text."""
|
||||
result = fresh_db._strip_markdown("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_db_strip_markdown_none_text(fresh_db):
|
||||
"""Test strip_markdown with None."""
|
||||
result = fresh_db._strip_markdown(None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_db_strip_markdown_fenced_code_blocks(fresh_db):
|
||||
"""Test stripping fenced code blocks."""
|
||||
text = """
|
||||
Some text here
|
||||
```python
|
||||
def hello():
|
||||
print("world")
|
||||
```
|
||||
More text after
|
||||
"""
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "def hello" not in result
|
||||
assert "Some text" in result
|
||||
assert "More text" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_inline_code(fresh_db):
|
||||
"""Test stripping inline code."""
|
||||
text = "Here is some `inline code` in text"
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "`" not in result
|
||||
assert "inline code" not in result
|
||||
assert "Here is some" in result
|
||||
assert "in text" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_links(fresh_db):
|
||||
"""Test converting markdown links to plain text."""
|
||||
text = "Check out [this link](https://example.com) for more info"
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "this link" in result
|
||||
assert "https://example.com" not in result
|
||||
assert "[" not in result
|
||||
assert "]" not in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_emphasis_and_headers(fresh_db):
|
||||
"""Test stripping emphasis markers and headers."""
|
||||
text = """
|
||||
# Header 1
|
||||
## Header 2
|
||||
**bold text** and *italic text*
|
||||
> blockquote
|
||||
_underline_
|
||||
"""
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "#" not in result
|
||||
assert "*" not in result
|
||||
assert "_" not in result
|
||||
assert ">" not in result
|
||||
assert "bold text" in result
|
||||
assert "italic text" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_html_tags(fresh_db):
|
||||
"""Test stripping HTML tags."""
|
||||
text = "Some <b>bold</b> and <i>italic</i> text with <div>divs</div>"
|
||||
result = fresh_db._strip_markdown(text)
|
||||
# The regex replaces tags with spaces, may leave some angle brackets from malformed HTML
|
||||
# The important thing is that the words are preserved
|
||||
assert "bold" in result
|
||||
assert "italic" in result
|
||||
assert "divs" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_complex_document(fresh_db):
|
||||
"""Test stripping complex markdown document."""
|
||||
text = """
|
||||
# My Document
|
||||
|
||||
This is a paragraph with **bold** and *italic* text.
|
||||
|
||||
```javascript
|
||||
const x = 10;
|
||||
console.log(x);
|
||||
```
|
||||
|
||||
Here's a [link](https://example.com) and some `code`.
|
||||
|
||||
> A blockquote
|
||||
|
||||
<p>HTML paragraph</p>
|
||||
"""
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "My Document" in result
|
||||
assert "paragraph" in result
|
||||
assert "const x" not in result
|
||||
assert "https://example.com" not in result
|
||||
assert "<p>" not in result
|
||||
|
||||
|
||||
def test_db_count_words_simple(fresh_db):
|
||||
"""Test word counting on simple text."""
|
||||
text = "This is a simple test with seven words"
|
||||
count = fresh_db._count_words(text)
|
||||
assert count == 8
|
||||
|
||||
|
||||
def test_db_count_words_empty(fresh_db):
|
||||
"""Test word counting on empty text."""
|
||||
count = fresh_db._count_words("")
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_db_count_words_with_markdown(fresh_db):
|
||||
"""Test word counting strips markdown first."""
|
||||
text = "**Bold** and *italic* and `code` words"
|
||||
count = fresh_db._count_words(text)
|
||||
# Should count: Bold, and, italic, and, words (5 words, code is in backticks so stripped)
|
||||
assert count == 5
|
||||
|
||||
|
||||
def test_db_count_words_with_unicode(fresh_db):
|
||||
"""Test word counting with unicode characters."""
|
||||
text = "Hello 世界 café naïve résumé"
|
||||
count = fresh_db._count_words(text)
|
||||
# Should count all words including unicode
|
||||
assert count >= 5
|
||||
|
||||
|
||||
def test_db_count_words_with_numbers(fresh_db):
|
||||
"""Test word counting includes numbers."""
|
||||
text = "There are 123 apples and 456 oranges"
|
||||
count = fresh_db._count_words(text)
|
||||
assert count == 7
|
||||
|
||||
|
||||
def test_db_count_words_with_punctuation(fresh_db):
|
||||
"""Test word counting handles punctuation correctly."""
|
||||
text = "Hello, world! How are you? I'm fine, thanks."
|
||||
count = fresh_db._count_words(text)
|
||||
# Hello, world, How, are, you, I, m, fine, thanks = 9 words
|
||||
assert count == 9
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DB gather_stats Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_db_gather_stats_empty_database(fresh_db):
|
||||
"""Test gather_stats on empty database."""
|
||||
stats = fresh_db.gather_stats()
|
||||
|
||||
assert len(stats) == 22
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
page_most_revisions,
|
||||
page_most_revisions_count,
|
||||
words_by_date,
|
||||
total_words,
|
||||
unique_tags,
|
||||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
time_minutes_by_date,
|
||||
total_time_minutes,
|
||||
day_most_time,
|
||||
day_most_time_minutes,
|
||||
project_most_minutes_name,
|
||||
project_most_minutes,
|
||||
activity_most_minutes_name,
|
||||
activity_most_minutes,
|
||||
reminders_by_date,
|
||||
total_reminders,
|
||||
day_most_reminders,
|
||||
day_most_reminders_count,
|
||||
) = stats
|
||||
|
||||
assert pages_with_content == 0
|
||||
assert total_revisions == 0
|
||||
assert page_most_revisions is None
|
||||
assert page_most_revisions_count == 0
|
||||
assert len(words_by_date) == 0
|
||||
assert total_words == 0
|
||||
assert unique_tags == 0
|
||||
assert page_most_tags is None
|
||||
assert page_most_tags_count == 0
|
||||
assert len(revisions_by_date) == 0
|
||||
|
||||
|
||||
def test_db_gather_stats_with_content(fresh_db):
|
||||
"""Test gather_stats with actual content."""
|
||||
# Add multiple pages with different content
|
||||
fresh_db.save_new_version("2024-01-01", "Hello world this is a test", "v1")
|
||||
fresh_db.save_new_version(
|
||||
"2024-01-01", "Hello world this is version two", "v2"
|
||||
) # 2nd revision
|
||||
fresh_db.save_new_version("2024-01-02", "Another page with more words here", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
page_most_revisions,
|
||||
page_most_revisions_count,
|
||||
words_by_date,
|
||||
total_words,
|
||||
unique_tags,
|
||||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
*_rest,
|
||||
) = stats
|
||||
|
||||
assert pages_with_content == 2
|
||||
assert total_revisions == 3
|
||||
assert page_most_revisions == "2024-01-01"
|
||||
assert page_most_revisions_count == 2
|
||||
assert total_words > 0
|
||||
assert len(words_by_date) == 2
|
||||
|
||||
|
||||
def test_db_gather_stats_word_counting(fresh_db):
|
||||
"""Test that gather_stats counts words correctly."""
|
||||
# Add page with known word count
|
||||
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
|
||||
|
||||
assert total_words == 5
|
||||
|
||||
test_date = date(2024, 1, 1)
|
||||
assert test_date in words_by_date
|
||||
assert words_by_date[test_date] == 5
|
||||
|
||||
|
||||
def test_db_gather_stats_with_tags(fresh_db):
|
||||
"""Test gather_stats with tags."""
|
||||
# Add tags
|
||||
fresh_db.add_tag("tag1", "#ff0000")
|
||||
fresh_db.add_tag("tag2", "#00ff00")
|
||||
fresh_db.add_tag("tag3", "#0000ff")
|
||||
|
||||
# Add pages with tags
|
||||
fresh_db.save_new_version("2024-01-01", "Page 1", "test")
|
||||
fresh_db.save_new_version("2024-01-02", "Page 2", "test")
|
||||
|
||||
fresh_db.set_tags_for_page(
|
||||
"2024-01-01", ["tag1", "tag2", "tag3"]
|
||||
) # Page 1 has 3 tags
|
||||
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
|
||||
|
||||
assert unique_tags == 3
|
||||
assert page_most_tags == "2024-01-01"
|
||||
assert page_most_tags_count == 3
|
||||
|
||||
|
||||
def test_db_gather_stats_revisions_by_date(fresh_db):
|
||||
"""Test revisions_by_date tracking."""
|
||||
# Add multiple revisions on different dates
|
||||
fresh_db.save_new_version("2024-01-01", "First", "v1")
|
||||
fresh_db.save_new_version("2024-01-01", "Second", "v2")
|
||||
fresh_db.save_new_version("2024-01-01", "Third", "v3")
|
||||
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
|
||||
|
||||
assert date(2024, 1, 1) in revisions_by_date
|
||||
assert revisions_by_date[date(2024, 1, 1)] == 3
|
||||
assert date(2024, 1, 2) in revisions_by_date
|
||||
assert revisions_by_date[date(2024, 1, 2)] == 1
|
||||
|
||||
|
||||
def test_db_gather_stats_handles_malformed_dates(fresh_db):
|
||||
"""Test that gather_stats handles malformed dates gracefully."""
|
||||
# This is hard to test directly since the DB enforces date format
|
||||
# But we can test that normal dates work
|
||||
fresh_db.save_new_version("2024-01-15", "Test", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
|
||||
|
||||
# Should have parsed the date correctly
|
||||
assert date(2024, 1, 15) in revisions_by_date
|
||||
|
||||
|
||||
def test_db_gather_stats_current_version_only(fresh_db):
|
||||
"""Test that word counts use current version only, not all revisions."""
|
||||
# Add multiple revisions
|
||||
fresh_db.save_new_version("2024-01-01", "one two three", "v1")
|
||||
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
|
||||
|
||||
# Should count words from current version (5 words), not old version
|
||||
assert total_words == 5
|
||||
assert words_by_date[date(2024, 1, 1)] == 5
|
||||
|
||||
|
||||
def test_db_gather_stats_no_tags(fresh_db):
|
||||
"""Test gather_stats when there are no tags."""
|
||||
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
|
||||
|
||||
assert unique_tags == 0
|
||||
assert page_most_tags is None
|
||||
assert page_most_tags_count == 0
|
||||
|
||||
|
||||
def test_db_gather_stats_exception_in_dates_with_content(fresh_db, monkeypatch):
|
||||
"""Test that gather_stats handles exception in dates_with_content."""
|
||||
|
||||
def bad_dates():
|
||||
raise RuntimeError("Simulated error")
|
||||
|
||||
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates)
|
||||
|
||||
# Should still return stats without crashing
|
||||
stats = fresh_db.gather_stats()
|
||||
pages_with_content = stats[0]
|
||||
|
||||
# Should default to 0 when exception occurs
|
||||
assert pages_with_content == 0
|
||||
|
||||
|
||||
def test_delete_version(fresh_db):
|
||||
"""Test deleting a specific version by version_id."""
|
||||
d = date.today().isoformat()
|
||||
|
||||
# Create multiple versions
|
||||
vid1, _ = fresh_db.save_new_version(d, "version 1", "note1")
|
||||
vid2, _ = fresh_db.save_new_version(d, "version 2", "note2")
|
||||
vid3, _ = fresh_db.save_new_version(d, "version 3", "note3")
|
||||
|
||||
# Verify all versions exist
|
||||
versions = fresh_db.list_versions(d)
|
||||
assert len(versions) == 3
|
||||
|
||||
# Delete the second version
|
||||
fresh_db.delete_version(version_id=vid2)
|
||||
|
||||
# Verify it's deleted
|
||||
versions_after = fresh_db.list_versions(d)
|
||||
assert len(versions_after) == 2
|
||||
|
||||
# Make sure the deleted version is not in the list
|
||||
version_ids = [v["id"] for v in versions_after]
|
||||
assert vid2 not in version_ids
|
||||
assert vid1 in version_ids
|
||||
assert vid3 in version_ids
|
||||
|
||||
|
||||
def test_update_reminder_active(fresh_db):
|
||||
"""Test updating the active status of a reminder."""
|
||||
from bouquin.reminders import Reminder, ReminderType
|
||||
|
||||
# Create a reminder object
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Test reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str="14:30",
|
||||
date_iso=date.today().isoformat(),
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Save it
|
||||
reminder_id = fresh_db.save_reminder(reminder)
|
||||
|
||||
# Verify it's active
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
active_reminder = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert active_reminder.active is True
|
||||
|
||||
# Deactivate it
|
||||
fresh_db.update_reminder_active(reminder_id, False)
|
||||
|
||||
# Verify it's inactive
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
inactive_reminder = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert inactive_reminder.active is False
|
||||
|
||||
# Reactivate it
|
||||
fresh_db.update_reminder_active(reminder_id, True)
|
||||
|
||||
# Verify it's active again
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
reactivated_reminder = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert reactivated_reminder.active is True
|
||||
|
|
|
|||
289
tests/test_document_utils.py
Normal file
289
tests/test_document_utils.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_open_document_from_db_success(qtbot, app, fresh_db):
|
||||
"""Test successfully opening a document."""
|
||||
# Import here to avoid circular import issues
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Add a project and document
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test content for document")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
# Mock QDesktopServices.openUrl
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
# Call the function
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id, doc_path.name, parent_widget=None
|
||||
)
|
||||
|
||||
# Verify success
|
||||
assert success is True
|
||||
|
||||
# Verify openUrl was called with a QUrl
|
||||
assert mock_open.called
|
||||
args = mock_open.call_args[0]
|
||||
assert isinstance(args[0], QUrl)
|
||||
|
||||
# Verify the URL points to a local file
|
||||
url_string = args[0].toString()
|
||||
assert url_string.startswith("file://")
|
||||
assert "bouquin_doc_" in url_string
|
||||
assert doc_path.suffix in url_string
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db):
|
||||
"""Test opening a document with a parent widget provided."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Create a parent widget
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
# Add a project and document
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".pdf"))
|
||||
doc_path.write_text("PDF content")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id, doc_path.name, parent_widget=parent
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db):
|
||||
"""Test opening a non-existent document returns False."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Try to open a document that doesn't exist
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
|
||||
)
|
||||
|
||||
# Should return False
|
||||
assert success is False
|
||||
|
||||
|
||||
def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db):
|
||||
"""Test that error dialog is shown when parent widget is provided."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
# Mock QMessageBox.warning
|
||||
with patch.object(QMessageBox, "warning") as mock_warning:
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent
|
||||
)
|
||||
|
||||
# Should return False and show warning
|
||||
assert success is False
|
||||
assert mock_warning.called
|
||||
|
||||
# Verify warning was shown with correct parent
|
||||
call_args = mock_warning.call_args[0]
|
||||
assert call_args[0] is parent
|
||||
|
||||
|
||||
def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db):
|
||||
"""Test that no error dialog is shown when parent widget is None."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
with patch.object(QMessageBox, "warning") as mock_warning:
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
|
||||
)
|
||||
|
||||
# Should return False but NOT show warning
|
||||
assert success is False
|
||||
assert not mock_warning.called
|
||||
|
||||
|
||||
def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db):
|
||||
"""Test that the temporary file has the correct extension."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Test various file extensions
|
||||
extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"]
|
||||
|
||||
for ext in extensions:
|
||||
proj_id = fresh_db.add_project(f"Project for {ext}")
|
||||
doc_path = Path(tempfile.mktemp(suffix=ext))
|
||||
doc_path.write_text(f"content for {ext}")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(
|
||||
QDesktopServices, "openUrl", return_value=True
|
||||
) as mock_open:
|
||||
open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
# Get the URL that was opened
|
||||
url = mock_open.call_args[0][0]
|
||||
url_string = url.toString()
|
||||
|
||||
# Verify the extension is preserved
|
||||
assert ext in url_string, f"Extension {ext} not found in {url_string}"
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db):
|
||||
"""Test opening a document without a file extension."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp()) # No suffix
|
||||
doc_path.write_text("content without extension")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
# Should still succeed
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db):
|
||||
"""Test handling when QDesktopServices.openUrl returns False."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test content")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
# Mock openUrl to return False (failure)
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=False):
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
# Should return False
|
||||
assert success is False
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_binary_content(qtbot, app, fresh_db):
|
||||
"""Test opening a document with binary content."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".bin"))
|
||||
|
||||
# Write some binary data
|
||||
binary_data = bytes([0, 1, 2, 3, 255, 254, 253])
|
||||
doc_path.write_bytes(binary_data)
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_large_file(qtbot, app, fresh_db):
|
||||
"""Test opening a large document."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".bin"))
|
||||
|
||||
# Create a 1MB file
|
||||
large_data = b"x" * (1024 * 1024)
|
||||
doc_path.write_bytes(large_data)
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db):
|
||||
"""Test that temporary files have the correct prefix."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
url = mock_open.call_args[0][0]
|
||||
url_path = url.toLocalFile()
|
||||
|
||||
# Verify the temp file has the bouquin_doc_ prefix
|
||||
assert "bouquin_doc_" in url_path
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db):
|
||||
"""Test opening the same document multiple times."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test content")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
# Open the same document 3 times
|
||||
for _ in range(3):
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
assert success is True
|
||||
|
||||
# Should have been called 3 times
|
||||
assert mock_open.call_count == 3
|
||||
|
||||
# Each call should create a different temp file
|
||||
call_urls = [call[0][0].toString() for call in mock_open.call_args_list]
|
||||
# All URLs should be different (different temp files)
|
||||
assert len(set(call_urls)) == 3
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
1060
tests/test_documents.py
Normal file
1060
tests/test_documents.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,9 @@
|
|||
import pytest
|
||||
|
||||
from bouquin.find_bar import FindBar
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QTextEdit, QWidget
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.find_bar import FindBar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
||||
|
|
@ -167,3 +166,145 @@ def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
|
|||
|
||||
# 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
|
||||
|
|
|
|||
1346
tests/test_invoices.py
Normal file
1346
tests/test_invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,204 @@
|
|||
from bouquin.key_prompt import KeyPrompt
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QFileDialog, QLineEdit
|
||||
|
||||
|
||||
def test_key_prompt_roundtrip(qtbot):
|
||||
kp = KeyPrompt()
|
||||
qtbot.addWidget(kp)
|
||||
kp.show()
|
||||
kp.edit.setText("swordfish")
|
||||
kp.key_entry.setText("swordfish")
|
||||
assert kp.key() == "swordfish"
|
||||
|
||||
|
||||
def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test KeyPrompt with DB path selection - covers lines 57-67"""
|
||||
test_db = tmp_path / "test.db"
|
||||
test_db.touch()
|
||||
|
||||
# Create prompt with show_db_change=True
|
||||
prompt = KeyPrompt(show_db_change=True)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Mock the file dialog to return a file
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
return str(test_db), "SQLCipher DB (*.db)"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
||||
# Simulate clicking the browse button
|
||||
# Find the browse button by looking through the widget's children
|
||||
browse_btn = None
|
||||
for child in prompt.findChildren(object):
|
||||
if hasattr(child, "clicked") and hasattr(child, "text"):
|
||||
if (
|
||||
"select" in str(child.text()).lower()
|
||||
or "browse" in str(child.text()).lower()
|
||||
):
|
||||
browse_btn = child
|
||||
break
|
||||
|
||||
if browse_btn:
|
||||
browse_btn.click()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Verify the path was set
|
||||
assert prompt.path_edit is not None
|
||||
assert str(test_db) in prompt.path_edit.text()
|
||||
|
||||
|
||||
def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition"""
|
||||
# Create prompt with show_db_change=True
|
||||
prompt = KeyPrompt(show_db_change=True)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Mock the file dialog to return empty string (user cancelled)
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
return "", ""
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
||||
# Store original path text
|
||||
original_text = prompt.path_edit.text() if prompt.path_edit else ""
|
||||
|
||||
# Simulate clicking the browse button
|
||||
browse_btn = None
|
||||
for child in prompt.findChildren(object):
|
||||
if hasattr(child, "clicked") and hasattr(child, "text"):
|
||||
if (
|
||||
"select" in str(child.text()).lower()
|
||||
or "browse" in str(child.text()).lower()
|
||||
):
|
||||
browse_btn = child
|
||||
break
|
||||
|
||||
if browse_btn:
|
||||
browse_btn.click()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Path should not have changed since no file was selected
|
||||
if prompt.path_edit:
|
||||
assert prompt.path_edit.text() == original_text
|
||||
|
||||
|
||||
def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
|
||||
"""Test KeyPrompt with existing DB path provided"""
|
||||
test_db = tmp_path / "existing.db"
|
||||
test_db.touch()
|
||||
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Verify the path is pre-filled
|
||||
assert prompt.path_edit is not None
|
||||
assert str(test_db) in prompt.path_edit.text()
|
||||
|
||||
|
||||
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
|
||||
"""Test KeyPrompt with show_db_change but no initial_db_path"""
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Path edit should exist but be empty
|
||||
assert prompt.path_edit is not None
|
||||
assert prompt.path_edit.text() == ""
|
||||
|
||||
|
||||
def test_key_prompt_accept_with_valid_key(qtbot, app):
|
||||
"""Test accepting prompt with valid key"""
|
||||
prompt = KeyPrompt()
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Enter a key
|
||||
prompt.key_entry.setText("test-key-123")
|
||||
|
||||
# Accept
|
||||
QTimer.singleShot(0, prompt.accept)
|
||||
qtbot.wait(50)
|
||||
|
||||
assert prompt.key_entry.text() == "test-key-123"
|
||||
|
||||
|
||||
def test_key_prompt_without_db_change(qtbot, app):
|
||||
"""Test KeyPrompt without show_db_change"""
|
||||
prompt = KeyPrompt(show_db_change=False)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Path edit should not exist
|
||||
assert prompt.path_edit is None
|
||||
|
||||
|
||||
def test_key_prompt_password_visibility(qtbot, app):
|
||||
"""Test password entry mode"""
|
||||
prompt = KeyPrompt()
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Initially should be password mode
|
||||
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
|
||||
|
||||
# Enter some text
|
||||
prompt.key_entry.setText("secret")
|
||||
|
||||
# The text should be obscured
|
||||
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
|
||||
|
||||
|
||||
def test_key_prompt_key_method(qtbot, app):
|
||||
"""Test the key() method returns entered text"""
|
||||
prompt = KeyPrompt()
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
prompt.key_entry.setText("my-secret-key")
|
||||
|
||||
assert prompt.key() == "my-secret-key"
|
||||
|
||||
|
||||
def test_key_prompt_db_path_method(qtbot, app, tmp_path):
|
||||
"""Test the db_path() method returns selected path"""
|
||||
test_db = tmp_path / "test.db"
|
||||
test_db.touch()
|
||||
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Should return the db_path
|
||||
assert prompt.db_path() == test_db
|
||||
|
||||
|
||||
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test browsing when initial_db_path is set"""
|
||||
initial_db = tmp_path / "initial.db"
|
||||
initial_db.touch()
|
||||
|
||||
new_db = tmp_path / "new.db"
|
||||
new_db.touch()
|
||||
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Mock the file dialog to return a different file
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
# Verify that start_dir was passed correctly
|
||||
return str(new_db), "SQLCipher DB (*.db)"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
||||
# Find and click browse button
|
||||
browse_btn = None
|
||||
for child in prompt.findChildren(object):
|
||||
if hasattr(child, "clicked") and hasattr(child, "text"):
|
||||
if (
|
||||
"select" in str(child.text()).lower()
|
||||
or "browse" in str(child.text()).lower()
|
||||
):
|
||||
browse_btn = child
|
||||
break
|
||||
|
||||
if browse_btn:
|
||||
browse_btn.click()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Verify new path was set
|
||||
assert str(new_db) in prompt.path_edit.text()
|
||||
assert prompt.db_path() == new_db
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from bouquin.lock_overlay import LockOverlay
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.lock_overlay import LockOverlay
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
def test_lock_overlay_reacts_to_theme(app, qtbot):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import importlib
|
||||
import runpy
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
|
@ -42,6 +43,9 @@ def test_main_creates_and_shows(monkeypatch):
|
|||
def setOrganizationName(self, *_):
|
||||
pass
|
||||
|
||||
def setWindowIcon(self, *_):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
import pytest
|
||||
import importlib.metadata
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bouquin.main_window as mwmod
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
import bouquin.version_check as version_check
|
||||
import pytest
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
|
||||
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
|
||||
from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
|
||||
|
||||
|
||||
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
|
|
@ -48,7 +53,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
def _auto_accept_keyprompt():
|
||||
for wdg in QApplication.topLevelWidgets():
|
||||
if isinstance(wdg, KeyPrompt):
|
||||
wdg.edit.setText(tmp_db_cfg.key)
|
||||
wdg.key_entry.setText(tmp_db_cfg.key)
|
||||
wdg.accept()
|
||||
|
||||
w._enter_lock()
|
||||
|
|
@ -59,7 +64,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
|
||||
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
|
@ -73,14 +78,14 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
w._load_yesterday_todos()
|
||||
w._load_unchecked_todos()
|
||||
|
||||
assert "carry me" in w.editor.to_markdown()
|
||||
y_txt = fresh_db.get_entry(y)
|
||||
assert "carry me" not in y_txt or "- [ ]" not in y_txt
|
||||
|
||||
|
||||
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
|
|
@ -101,16 +106,12 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch)
|
|||
t = str(text)
|
||||
if "wiki" in t:
|
||||
called["docs"] = True
|
||||
if "forms/mig5/contact" in t or "contact" in t:
|
||||
called["bugs"] = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
|
||||
|
||||
# Trigger both actions
|
||||
w._open_docs()
|
||||
w._open_bugs()
|
||||
assert called["docs"] and called["bugs"]
|
||||
assert called["docs"]
|
||||
|
||||
|
||||
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
|
|
@ -122,7 +123,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(fresh_db.cfg.path))
|
||||
s.setValue("db/default_db", str(fresh_db.cfg.path))
|
||||
s.setValue("db/key", fresh_db.cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
|
@ -196,7 +197,7 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(fresh_db.cfg.path))
|
||||
s.setValue("db/default_db", str(fresh_db.cfg.path))
|
||||
s.setValue("db/key", fresh_db.cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
|
@ -251,7 +252,7 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
|
|||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(fresh_db.cfg.path))
|
||||
s.setValue("db/default_db", str(fresh_db.cfg.path))
|
||||
s.setValue("db/key", fresh_db.cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
|
@ -472,8 +473,9 @@ def test_try_connect_maps_errors(
|
|||
mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
|
||||
)
|
||||
|
||||
ok = w._try_connect()
|
||||
assert ok is False
|
||||
w._try_connect()
|
||||
|
||||
# And we still showed the right error message
|
||||
assert "database" in shown["title"].lower()
|
||||
if expect_key_msg:
|
||||
assert "key" in shown["text"].lower()
|
||||
|
|
@ -499,6 +501,9 @@ def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch
|
|||
def key(self):
|
||||
return ""
|
||||
|
||||
def db_path(self) -> Path | None:
|
||||
return "foo.db"
|
||||
|
||||
monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True)
|
||||
assert w._prompt_for_key_until_valid(first_time=False) is False
|
||||
|
||||
|
|
@ -517,6 +522,9 @@ def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch
|
|||
def key(self):
|
||||
return "abc"
|
||||
|
||||
def db_path(self) -> Path | None:
|
||||
return "foo.db"
|
||||
|
||||
monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True)
|
||||
monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True)
|
||||
assert w._prompt_for_key_until_valid(first_time=True) is True
|
||||
|
|
@ -744,7 +752,6 @@ def test_on_insert_image_calls_editor_insert(
|
|||
@pytest.mark.parametrize(
|
||||
"filter_label, method",
|
||||
[
|
||||
("Text (*.txt)", "export_txt"),
|
||||
("JSON (*.json)", "export_json"),
|
||||
("CSV (*.csv)", "export_csv"),
|
||||
("HTML (*.html)", "export_html"),
|
||||
|
|
@ -879,9 +886,7 @@ def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path)
|
|||
# ---- Help openers (1152-1169) ----
|
||||
|
||||
|
||||
def test_open_docs_and_bugs_show_warning_on_failure(
|
||||
qtbot, tmp_db_cfg, app, monkeypatch
|
||||
):
|
||||
def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch):
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
qtbot.addWidget(w)
|
||||
|
||||
|
|
@ -893,41 +898,92 @@ def test_open_docs_and_bugs_show_warning_on_failure(
|
|||
def warn(parent, title, text, *a, **k):
|
||||
if "documentation" in title.lower():
|
||||
seen["docs"] = True
|
||||
if "bug" in title.lower():
|
||||
seen["bugs"] = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)})
|
||||
)
|
||||
w._open_docs()
|
||||
w._open_bugs()
|
||||
|
||||
assert seen["docs"] and seen["bugs"]
|
||||
assert seen["docs"]
|
||||
|
||||
|
||||
def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
qtbot.addWidget(w)
|
||||
|
||||
called = {"title": None, "text": None}
|
||||
called = {"title": None, "text": None, "check_called": False}
|
||||
|
||||
def fake_information(parent, title, text, *a, **k):
|
||||
called["title"] = title
|
||||
called["text"] = text
|
||||
# Return value of QMessageBox.information is an int; 0 is fine.
|
||||
return 0
|
||||
# Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses
|
||||
class FakeMessageBox:
|
||||
# provide the enum attributes the code references
|
||||
Information = 0
|
||||
ActionRole = 1
|
||||
Close = 2
|
||||
|
||||
# Patch whichever one you actually use in _open_version
|
||||
monkeypatch.setattr(QMessageBox, "information", fake_information)
|
||||
def __init__(self, parent=None):
|
||||
self._parent = parent
|
||||
self._icon = None
|
||||
self._title = ""
|
||||
self._text = ""
|
||||
self._buttons = []
|
||||
self._clicked = None
|
||||
|
||||
def setIcon(self, icon):
|
||||
self._icon = icon
|
||||
|
||||
def setIconPixmap(self, icon):
|
||||
self._icon = icon
|
||||
|
||||
def setWindowTitle(self, title):
|
||||
self._title = title
|
||||
called["title"] = title
|
||||
|
||||
def setText(self, text):
|
||||
self._text = text
|
||||
called["text"] = text
|
||||
|
||||
def addButton(self, *args, **kwargs):
|
||||
# We don't care about the label/role, we just need a distinct object
|
||||
btn = object()
|
||||
self._buttons.append(btn)
|
||||
return btn
|
||||
|
||||
def exec(self):
|
||||
# Simulate user clicking the *Close* button, i.e. the second button
|
||||
if self._buttons:
|
||||
# show_version_dialog adds buttons in order:
|
||||
# 0 -> "Check for updates"
|
||||
# 1 -> Close
|
||||
self._clicked = self._buttons[-1]
|
||||
|
||||
def clickedButton(self):
|
||||
return self._clicked
|
||||
|
||||
# Patch the QMessageBox used *inside* version_check.py
|
||||
monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox)
|
||||
|
||||
# Optional: track if check_for_updates would be called
|
||||
def fake_check_for_updates(self):
|
||||
called["check_called"] = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
version_check.VersionChecker, "check_for_updates", fake_check_for_updates
|
||||
)
|
||||
|
||||
# Call the entrypoint
|
||||
w._open_version()
|
||||
|
||||
# Assertions: title and text got set correctly
|
||||
assert called["title"] is not None
|
||||
assert "version" in called["title"].lower()
|
||||
|
||||
version = importlib.metadata.version("bouquin")
|
||||
assert version in called["text"]
|
||||
|
||||
# And we simulated closing, so "Check for updates" should not have fired
|
||||
assert called["check_called"] is False
|
||||
|
||||
|
||||
# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ----
|
||||
|
||||
|
|
@ -1172,7 +1228,7 @@ def test_export_cancel_then_empty_filename(
|
|||
monkeypatch.setattr(
|
||||
mwmod.QFileDialog,
|
||||
"getSaveFileName",
|
||||
staticmethod(lambda *a, **k: ("", "Text (*.txt)")),
|
||||
staticmethod(lambda *a, **k: ("", "Markdown (*.md)")),
|
||||
raising=False,
|
||||
)
|
||||
w._export() # returns early at filename check
|
||||
|
|
@ -1631,6 +1687,7 @@ def test_main_window_settings_path_change_success(
|
|||
new_cfg.theme = "light"
|
||||
new_cfg.move_todos = True
|
||||
new_cfg.locale = "en"
|
||||
new_cfg.font_size = 11
|
||||
|
||||
mock_instance.config = new_cfg
|
||||
mock_dialog.return_value = mock_instance
|
||||
|
|
@ -1674,6 +1731,7 @@ def test_main_window_settings_path_change_failure(
|
|||
new_cfg.theme = "light"
|
||||
new_cfg.move_todos = True
|
||||
new_cfg.locale = "en"
|
||||
new_cfg.font_size = 11
|
||||
|
||||
mock_instance.config = new_cfg
|
||||
mock_dialog.return_value = mock_instance
|
||||
|
|
@ -1715,6 +1773,7 @@ def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypa
|
|||
new_cfg.theme = "dark" # Changed
|
||||
new_cfg.move_todos = False # Changed
|
||||
new_cfg.locale = "fr" # Changed
|
||||
new_cfg.font_size = 12 # Changed
|
||||
|
||||
mock_instance.config = new_cfg
|
||||
mock_dialog.return_value = mock_instance
|
||||
|
|
@ -1726,6 +1785,7 @@ def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypa
|
|||
assert window.cfg.idle_minutes == 20
|
||||
assert window.cfg.theme == "dark"
|
||||
assert window.cfg.path == old_path
|
||||
assert window.cfg.font_size == 12
|
||||
|
||||
|
||||
def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch):
|
||||
|
|
@ -1796,3 +1856,575 @@ def test_main_window_update_tag_views_no_tags_widget(
|
|||
window._update_tag_views_for_date("2024-01-15")
|
||||
|
||||
assert True
|
||||
|
||||
|
||||
def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when tags feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", False) # Disabled
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify tags widget is hidden
|
||||
assert window.tags.isHidden()
|
||||
|
||||
|
||||
def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when time_log feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", False) # Disabled
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify time_log widget is hidden
|
||||
assert window.time_log.isHidden()
|
||||
assert not window.toolBar.actTimer.isVisible()
|
||||
|
||||
|
||||
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when documents feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/documents", False) # Disabled
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify documents widget is hidden
|
||||
assert window.todays_documents.isHidden()
|
||||
assert not window.toolBar.actDocuments.isVisible()
|
||||
|
||||
|
||||
def test_export_csv_format(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test exporting to CSV format - covers export path lines"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Add some data
|
||||
w.db.save_new_version("2024-01-01", "Test content", "test")
|
||||
|
||||
# Mock file dialog to return CSV
|
||||
dest = tmp_path / "export_test.csv"
|
||||
monkeypatch.setattr(
|
||||
mwmod.QFileDialog,
|
||||
"getSaveFileName",
|
||||
staticmethod(lambda *a, **k: (str(dest), "CSV (*.csv)")),
|
||||
)
|
||||
|
||||
# Mock QMessageBox to auto-accept
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
|
||||
)
|
||||
|
||||
w._export()
|
||||
assert dest.exists()
|
||||
|
||||
|
||||
def test_settings_dialog_with_locale_change(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test opening settings dialog and changing locale - covers settings dialog paths"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Mock the settings dialog to auto-accept
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
|
||||
SettingsDialog.exec
|
||||
|
||||
def fake_exec(self):
|
||||
# Change locale before accepting
|
||||
idx = self.locale_combobox.findData("fr")
|
||||
if idx >= 0:
|
||||
self.locale_combobox.setCurrentIndex(idx)
|
||||
return mwmod.QDialog.Accepted
|
||||
|
||||
monkeypatch.setattr(SettingsDialog, "exec", fake_exec)
|
||||
|
||||
w._open_settings()
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_statistics_dialog_open(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test opening statistics dialog - covers statistics dialog paths"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Add some data
|
||||
w.db.save_new_version("2024-01-01", "Test content", "test")
|
||||
|
||||
from bouquin.statistics_dialog import StatisticsDialog
|
||||
|
||||
StatisticsDialog.exec
|
||||
|
||||
def fake_exec(self):
|
||||
# Just accept immediately
|
||||
return mwmod.QDialog.Accepted
|
||||
|
||||
monkeypatch.setattr(StatisticsDialog, "exec", fake_exec)
|
||||
|
||||
w._open_statistics()
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_bug_report_dialog_open(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test opening bug report dialog"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
from bouquin.bug_report_dialog import BugReportDialog
|
||||
|
||||
BugReportDialog.exec
|
||||
|
||||
def fake_exec(self):
|
||||
return mwmod.QDialog.Rejected
|
||||
|
||||
monkeypatch.setattr(BugReportDialog, "exec", fake_exec)
|
||||
|
||||
w._open_bugs()
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_history_dialog_open_and_restore(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test opening history dialog and restoring a version"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Add some data
|
||||
date_str = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
w.db.save_new_version(date_str, "Version 1", "v1")
|
||||
w.db.save_new_version(date_str, "Version 2", "v2")
|
||||
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
def fake_exec(self):
|
||||
# Simulate selecting first version and accepting
|
||||
if self.list.count() > 0:
|
||||
self.list.setCurrentRow(0)
|
||||
self._revert()
|
||||
return mwmod.QDialog.Accepted
|
||||
|
||||
monkeypatch.setattr(HistoryDialog, "exec", fake_exec)
|
||||
|
||||
w._open_history()
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_goto_today_button(qtbot, app, tmp_path):
|
||||
"""Test going to today's date"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Move to a different date
|
||||
past_date = QDate.currentDate().addDays(-30)
|
||||
w.calendar.setSelectedDate(past_date)
|
||||
|
||||
# Go back to today
|
||||
w._adjust_today()
|
||||
qtbot.wait(50)
|
||||
|
||||
assert w.calendar.selectedDate() == QDate.currentDate()
|
||||
|
||||
|
||||
def test_adjust_font_size(qtbot, app, tmp_path):
|
||||
"""Test adjusting font size"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/font_size", 12)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
initial_size = w.editor.font().pointSize()
|
||||
|
||||
# Increase font size
|
||||
w._on_font_larger_requested()
|
||||
qtbot.wait(50)
|
||||
assert w.editor.font().pointSize() > initial_size
|
||||
|
||||
# Decrease font size
|
||||
w._on_font_smaller_requested()
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_calendar_date_selection(qtbot, app, tmp_path):
|
||||
"""Test selecting a date from calendar"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Select a specific date
|
||||
test_date = QDate(2024, 6, 15)
|
||||
w.calendar.setSelectedDate(test_date)
|
||||
qtbot.wait(50)
|
||||
|
||||
# The window should load that date
|
||||
assert test_date.toString("yyyy-MM-dd") in str(w._current_date_iso())
|
||||
|
||||
|
||||
def test_main_window_without_reminders(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when reminders feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", False) # Disabled
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify reminders widget is hidden
|
||||
assert window.upcoming_reminders.isHidden()
|
||||
assert not window.toolBar.actAlarm.isVisible()
|
||||
|
||||
|
||||
def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
"""Test closing the current tab via _close_current_tab."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Open multiple tabs
|
||||
today = date.today().isoformat()
|
||||
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
||||
|
||||
window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
|
||||
window._open_date_in_tab(QDate.fromString(tomorrow, "yyyy-MM-dd"))
|
||||
|
||||
initial_count = window.tab_widget.count()
|
||||
assert initial_count >= 2
|
||||
|
||||
# Close current tab
|
||||
window._close_current_tab()
|
||||
|
||||
# Verify tab was closed
|
||||
assert window.tab_widget.count() == initial_count - 1
|
||||
|
||||
|
||||
def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
|
||||
"""Test parsing inline alarms from markdown (⏰ HH:MM format)."""
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Open today's date
|
||||
today_qdate = QDate.currentDate()
|
||||
window._open_date_in_tab(today_qdate)
|
||||
|
||||
# Set content with a future alarm
|
||||
future_time = QTime.currentTime().addSecs(3600)
|
||||
alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
|
||||
|
||||
# Set the editor's current_date attribute
|
||||
window.editor.current_date = today_qdate
|
||||
window.editor.setPlainText(alarm_text)
|
||||
|
||||
# Clear any existing timers
|
||||
window._reminder_timers = []
|
||||
|
||||
# Trigger alarm parsing
|
||||
window._rebuild_reminders_for_today()
|
||||
|
||||
# Verify timer was created (not DB reminder)
|
||||
assert len(window._reminder_timers) > 0
|
||||
|
||||
|
||||
def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
"""Test that invalid time formats are skipped."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Open today's date
|
||||
today_qdate = QDate.currentDate()
|
||||
window._open_date_in_tab(today_qdate)
|
||||
|
||||
# Set content with invalid time
|
||||
alarm_text = "Do something ⏰ 25:99" # Invalid time
|
||||
|
||||
window.editor.current_date = today_qdate
|
||||
window.editor.setPlainText(alarm_text)
|
||||
|
||||
# Clear any existing timers
|
||||
window._reminder_timers = []
|
||||
|
||||
# Trigger alarm parsing - should not crash
|
||||
window._rebuild_reminders_for_today()
|
||||
|
||||
# No timer should be created for invalid time
|
||||
assert len(window._reminder_timers) == 0
|
||||
|
||||
|
||||
def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
|
||||
"""Test that past alarms are skipped."""
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Open today's date
|
||||
today_qdate = QDate.currentDate()
|
||||
window._open_date_in_tab(today_qdate)
|
||||
|
||||
# Set content with past time
|
||||
past_time = QTime.currentTime().addSecs(-3600) # 1 hour ago
|
||||
alarm_text = f"Do something ⏰ {past_time.hour():02d}:{past_time.minute():02d}"
|
||||
|
||||
window.editor.current_date = today_qdate
|
||||
window.editor.setPlainText(alarm_text)
|
||||
|
||||
# Clear any existing timers
|
||||
window._reminder_timers = []
|
||||
|
||||
# Trigger alarm parsing
|
||||
window._rebuild_reminders_for_today()
|
||||
|
||||
# Past alarms should not create timers
|
||||
assert len(window._reminder_timers) == 0
|
||||
|
||||
|
||||
def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
|
||||
"""Test alarm with no text before emoji uses fallback."""
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Open today's date
|
||||
today_qdate = QDate.currentDate()
|
||||
window._open_date_in_tab(today_qdate)
|
||||
|
||||
# Set content with alarm but no text
|
||||
future_time = QTime.currentTime().addSecs(3600)
|
||||
alarm_text = f"⏰ {future_time.hour():02d}:{future_time.minute():02d}"
|
||||
|
||||
window.editor.current_date = today_qdate
|
||||
window.editor.setPlainText(alarm_text)
|
||||
|
||||
# Clear any existing timers
|
||||
window._reminder_timers = []
|
||||
|
||||
# Trigger alarm parsing
|
||||
window._rebuild_reminders_for_today()
|
||||
|
||||
# Timer should be created even without text
|
||||
assert len(window._reminder_timers) > 0
|
||||
|
||||
|
||||
def test_open_history_with_editor(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
"""Test opening history when editor has content."""
|
||||
from unittest.mock import patch
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Create some history
|
||||
today = date.today().isoformat()
|
||||
fresh_db.save_new_version(today, "v1", "note1")
|
||||
fresh_db.save_new_version(today, "v2", "note2")
|
||||
|
||||
# Open today's date
|
||||
window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
|
||||
|
||||
# Open history
|
||||
with patch("bouquin.history_dialog.HistoryDialog.exec") as mock_exec:
|
||||
mock_exec.return_value = False # User cancels
|
||||
window._open_history()
|
||||
|
||||
# HistoryDialog should have been created and shown
|
||||
mock_exec.assert_called_once()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
932
tests/test_markdown_editor_additional.py
Normal file
932
tests/test_markdown_editor_additional.py
Normal file
|
|
@ -0,0 +1,932 @@
|
|||
"""
|
||||
Additional tests for markdown_editor.py to improve test coverage.
|
||||
These tests should be added to test_markdown_editor.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QImage,
|
||||
QKeyEvent,
|
||||
QMouseEvent,
|
||||
QTextCursor,
|
||||
QTextDocument,
|
||||
)
|
||||
|
||||
|
||||
def text(editor) -> str:
|
||||
return editor.toPlainText()
|
||||
|
||||
|
||||
def lines_keep(editor):
|
||||
"""Split preserving a trailing empty line if the text ends with '\\n'."""
|
||||
return text(editor).split("\n")
|
||||
|
||||
|
||||
def press_backtick(qtbot, widget, n=1):
|
||||
"""Send physical backtick key events (avoid IME/dead-key issues)."""
|
||||
for _ in range(n):
|
||||
qtbot.keyClick(widget, Qt.Key_QuoteLeft)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
ed.show()
|
||||
qtbot.waitExposed(ed)
|
||||
ed.setFocus()
|
||||
return ed
|
||||
|
||||
|
||||
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
||||
"""Test _update_code_block_row_backgrounds when document is None."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Create a new empty document to replace the current one
|
||||
new_doc = QTextDocument()
|
||||
editor.setDocument(new_doc)
|
||||
editor.setDocument(None)
|
||||
|
||||
# Should not crash even with no document
|
||||
editor._update_code_block_row_backgrounds()
|
||||
|
||||
|
||||
def test_find_code_block_bounds_invalid_block(editor):
|
||||
"""Test _find_code_block_bounds with invalid block."""
|
||||
editor.setPlainText("some text")
|
||||
|
||||
# Create an invalid block
|
||||
doc = editor.document()
|
||||
invalid_block = doc.findBlockByNumber(999) # doesn't exist
|
||||
|
||||
result = editor._find_code_block_bounds(invalid_block)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_find_code_block_bounds_on_closing_fence(editor):
|
||||
"""Test _find_code_block_bounds when on a closing fence."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
|
||||
doc = editor.document()
|
||||
closing_fence = doc.findBlockByNumber(2) # the closing ```
|
||||
|
||||
result = editor._find_code_block_bounds(closing_fence)
|
||||
assert result is not None
|
||||
open_block, close_block = result
|
||||
assert open_block.blockNumber() == 0
|
||||
assert close_block.blockNumber() == 2
|
||||
|
||||
|
||||
def test_find_code_block_bounds_on_opening_fence(editor):
|
||||
"""Test _find_code_block_bounds when on an opening fence."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
|
||||
doc = editor.document()
|
||||
opening_fence = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._find_code_block_bounds(opening_fence)
|
||||
assert result is not None
|
||||
open_block, close_block = result
|
||||
assert open_block.blockNumber() == 0
|
||||
assert close_block.blockNumber() == 2
|
||||
|
||||
|
||||
def test_find_code_block_bounds_no_closing_fence(editor):
|
||||
"""Test _find_code_block_bounds when closing fence is missing."""
|
||||
editor.setPlainText("```\ncode without closing")
|
||||
|
||||
doc = editor.document()
|
||||
opening_fence = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._find_code_block_bounds(opening_fence)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_find_code_block_bounds_no_opening_fence(editor):
|
||||
"""Test _find_code_block_bounds from inside code block with no opening."""
|
||||
# Simulate a malformed block (shouldn't happen in practice)
|
||||
editor.setPlainText("code\n```")
|
||||
|
||||
doc = editor.document()
|
||||
code_line = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._find_code_block_bounds(code_line)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_edit_code_block_checks_document(app, qtbot):
|
||||
"""Test _edit_code_block when editor has no document."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Set up editor with code block
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
# Now remove the document
|
||||
editor.setDocument(None)
|
||||
|
||||
# The method will try to work but should handle gracefully
|
||||
# It actually returns True because it processes the block from the old doc
|
||||
# This tests that it doesn't crash
|
||||
editor._edit_code_block(block)
|
||||
# Just verify it doesn't crash - return value is implementation dependent
|
||||
|
||||
|
||||
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
|
||||
"""Test _edit_code_block when dialog is cancelled."""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class CancelledDialog:
|
||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
||||
self._code = code
|
||||
self._language = language
|
||||
|
||||
def exec(self):
|
||||
return QDialog.DialogCode.Rejected
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def language(self):
|
||||
return self._language
|
||||
|
||||
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", CancelledDialog)
|
||||
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._edit_code_block(block)
|
||||
# Should return True (event handled) even though cancelled
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
|
||||
"""Test _edit_code_block when user deletes the block."""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class DeleteDialog:
|
||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
||||
self._code = code
|
||||
self._language = language
|
||||
self._deleted = True
|
||||
|
||||
def exec(self):
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def language(self):
|
||||
return self._language
|
||||
|
||||
def was_deleted(self):
|
||||
return self._deleted
|
||||
|
||||
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", DeleteDialog)
|
||||
|
||||
editor.setPlainText("```python\noriginal code\n```\nafter")
|
||||
editor.toPlainText()
|
||||
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._edit_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Code block should be deleted
|
||||
new_text = editor.toPlainText()
|
||||
assert "original code" not in new_text
|
||||
|
||||
|
||||
def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
|
||||
"""Test _edit_code_block with language change."""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class LanguageChangeDialog:
|
||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
||||
self._code = code
|
||||
self._language = "javascript" # Change from python
|
||||
|
||||
def exec(self):
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def language(self):
|
||||
return self._language
|
||||
|
||||
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", LanguageChangeDialog)
|
||||
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._edit_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Verify metadata was updated
|
||||
assert hasattr(editor, "_code_metadata")
|
||||
lang = editor._code_metadata.get_language(0)
|
||||
assert lang == "javascript"
|
||||
|
||||
|
||||
def test_delete_code_block_no_bounds(editor):
|
||||
"""Test _delete_code_block when bounds can't be found."""
|
||||
editor.setPlainText("not a code block")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._delete_code_block(block)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_delete_code_block_checks_document(app, qtbot):
|
||||
"""Test _delete_code_block when editor has no document."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Set up with code block
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
# Remove the document
|
||||
editor.setDocument(None)
|
||||
|
||||
# The method will attempt to work but should handle gracefully
|
||||
# Just verify it doesn't crash
|
||||
editor._delete_code_block(block)
|
||||
|
||||
|
||||
def test_delete_code_block_at_end_of_document(editor):
|
||||
"""Test _delete_code_block when code block is at end of document."""
|
||||
editor.setPlainText("```\ncode\n```") # No trailing newline
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._delete_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Should be empty or minimal
|
||||
assert "code" not in editor.toPlainText()
|
||||
|
||||
|
||||
def test_delete_code_block_with_text_after(editor):
|
||||
"""Test _delete_code_block when there's text after the block."""
|
||||
editor.setPlainText("```\ncode\n```\ntext after")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._delete_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Code should be gone, text after should remain
|
||||
new_text = editor.toPlainText()
|
||||
assert "code" not in new_text
|
||||
assert "text after" in new_text
|
||||
|
||||
|
||||
def test_apply_line_spacing_no_document(app):
|
||||
"""Test _apply_line_spacing when document is None."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
|
||||
editor.setDocument(None)
|
||||
|
||||
# Should not crash
|
||||
editor._apply_line_spacing(125.0)
|
||||
|
||||
|
||||
def test_apply_code_block_spacing(editor):
|
||||
"""Test _apply_code_block_spacing applies correct spacing."""
|
||||
editor.setPlainText("```\nline1\nline2\n```")
|
||||
|
||||
# Apply spacing
|
||||
editor._apply_code_block_spacing()
|
||||
|
||||
# Verify blocks have spacing applied
|
||||
doc = editor.document()
|
||||
for i in range(doc.blockCount()):
|
||||
block = doc.findBlockByNumber(i)
|
||||
assert block.isValid()
|
||||
|
||||
|
||||
def test_to_markdown_with_code_metadata(editor):
|
||||
"""Test to_markdown includes code block metadata."""
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
|
||||
# Set some metadata
|
||||
editor._code_metadata.set_language(0, "python")
|
||||
|
||||
md = editor.to_markdown()
|
||||
|
||||
# Should include metadata comment
|
||||
assert "code-langs" in md or "code" in md
|
||||
|
||||
|
||||
def test_from_markdown_creates_code_metadata(app):
|
||||
"""Test from_markdown creates _code_metadata if missing."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
|
||||
# Remove the attribute
|
||||
if hasattr(editor, "_code_metadata"):
|
||||
delattr(editor, "_code_metadata")
|
||||
|
||||
# Should recreate it
|
||||
editor.from_markdown("# test")
|
||||
|
||||
assert hasattr(editor, "_code_metadata")
|
||||
|
||||
|
||||
def test_embed_images_preserves_original_size(editor, tmp_path):
|
||||
"""Test that embedded images preserve their original dimensions."""
|
||||
# Create a test image
|
||||
img = tmp_path / "test.png"
|
||||
qimg = QImage(100, 50, QImage.Format_RGBA8888)
|
||||
qimg.fill(QColor(255, 0, 0))
|
||||
qimg.save(str(img))
|
||||
|
||||
# Create markdown with image
|
||||
import base64
|
||||
|
||||
with open(img, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
md = f""
|
||||
editor.from_markdown(md)
|
||||
|
||||
# Image should be embedded with original size
|
||||
doc = editor.document()
|
||||
assert doc is not None
|
||||
|
||||
|
||||
def test_trim_list_prefix_no_selection(editor):
|
||||
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
|
||||
editor.setPlainText("- item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.clearSelection()
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should not crash
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_multiline_selection(editor):
|
||||
"""Test _maybe_trim_list_prefix_from_line_selection across multiple lines."""
|
||||
editor.setPlainText("- item1\n- item2")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should not trim multi-line selections
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_not_full_line(editor):
|
||||
"""Test _maybe_trim_list_prefix_from_line_selection with partial selection."""
|
||||
editor.setPlainText("- item text here")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 5)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Partial line selection should not be trimmed
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_already_after_prefix(editor):
|
||||
"""Test _maybe_trim_list_prefix when selection already after prefix."""
|
||||
editor.setPlainText("- item text")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3) # After "- "
|
||||
cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should not need adjustment
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_during_adjustment(editor):
|
||||
"""Test _maybe_trim_list_prefix re-entry guard."""
|
||||
editor.setPlainText("- item")
|
||||
editor._adjusting_selection = True
|
||||
|
||||
# Should return early due to guard
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
editor._adjusting_selection = False
|
||||
|
||||
|
||||
def test_detect_list_type_checkbox_checked(editor):
|
||||
"""Test _detect_list_type with checked checkbox."""
|
||||
list_type, prefix = editor._detect_list_type(
|
||||
f"{editor._CHECK_CHECKED_DISPLAY} done"
|
||||
)
|
||||
assert list_type == "checkbox"
|
||||
assert editor._CHECK_UNCHECKED_DISPLAY in prefix
|
||||
|
||||
|
||||
def test_detect_list_type_numbered(editor):
|
||||
"""Test _detect_list_type with numbered list."""
|
||||
list_type, prefix = editor._detect_list_type("1. item")
|
||||
assert list_type == "number"
|
||||
# The prefix will be "2. " because it increments for the next item
|
||||
assert "." in prefix
|
||||
|
||||
|
||||
def test_detect_list_type_markdown_bullet(editor):
|
||||
"""Test _detect_list_type with markdown bullet."""
|
||||
list_type, prefix = editor._detect_list_type("- item")
|
||||
assert list_type == "bullet"
|
||||
|
||||
|
||||
def test_detect_list_type_not_a_list(editor):
|
||||
"""Test _detect_list_type with regular text."""
|
||||
list_type, prefix = editor._detect_list_type("regular text")
|
||||
assert list_type is None
|
||||
assert prefix == ""
|
||||
|
||||
|
||||
def test_list_prefix_length_numbered(editor):
|
||||
"""Test _list_prefix_length_for_block with numbered list."""
|
||||
editor.setPlainText("123. item")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(0)
|
||||
|
||||
length = editor._list_prefix_length_for_block(block)
|
||||
assert length > 0
|
||||
|
||||
|
||||
def test_key_press_ctrl_home(editor, qtbot):
|
||||
"""Test Ctrl+Home key combination."""
|
||||
editor.setPlainText("line1\nline2\nline3")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.ControlModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should move to start of document
|
||||
assert editor.textCursor().position() == 0
|
||||
|
||||
|
||||
def test_key_press_ctrl_left(editor, qtbot):
|
||||
"""Test Ctrl+Left key combination."""
|
||||
editor.setPlainText("word1 word2 word3")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.ControlModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should move left by word
|
||||
|
||||
|
||||
def test_key_press_home_in_list(editor, qtbot):
|
||||
"""Test Home key in list item."""
|
||||
editor.setPlainText("- item text")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should jump to after "- "
|
||||
pos = editor.textCursor().position()
|
||||
assert pos > 0
|
||||
|
||||
|
||||
def test_key_press_left_in_list_prefix(editor, qtbot):
|
||||
"""Test Left key when in list prefix region."""
|
||||
editor.setPlainText("- item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right) # Inside "- "
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should snap to after prefix
|
||||
|
||||
|
||||
def test_key_press_up_in_code_block(editor, qtbot):
|
||||
"""Test Up key inside code block."""
|
||||
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Down)
|
||||
cursor.movePosition(QTextCursor.Down) # On "code line 2"
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Up, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should move up normally in code block
|
||||
|
||||
|
||||
def test_key_press_down_in_list_item(editor, qtbot):
|
||||
"""Test Down key in list item."""
|
||||
editor.setPlainText("- item1\n- item2")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right) # In prefix of first item
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Down, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should snap to after prefix on next line
|
||||
|
||||
|
||||
def test_key_press_enter_after_markers(editor, qtbot):
|
||||
"""Test Enter key after style markers."""
|
||||
editor.setPlainText("text **")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should handle markers
|
||||
|
||||
|
||||
def test_key_press_enter_on_closing_fence(editor, qtbot):
|
||||
"""Test Enter key on closing fence line."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(QTextCursor.StartOfLine)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should create new line after fence
|
||||
|
||||
|
||||
def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
||||
"""Test Backspace in empty checkbox item."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should remove checkbox
|
||||
|
||||
|
||||
def test_key_press_backspace_numbered_list(editor, qtbot):
|
||||
"""Test Backspace at start of numbered list item."""
|
||||
editor.setPlainText("1. ")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
|
||||
def test_key_press_tab_in_bullet_list(editor, qtbot):
|
||||
"""Test Tab key in bullet list."""
|
||||
editor.setPlainText("- item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should indent
|
||||
|
||||
|
||||
def test_key_press_shift_tab_in_bullet_list(editor, qtbot):
|
||||
"""Test Shift+Tab in indented bullet list."""
|
||||
editor.setPlainText(" - item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.ShiftModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should unindent
|
||||
|
||||
|
||||
def test_key_press_tab_in_checkbox(editor, qtbot):
|
||||
"""Test Tab in checkbox item."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
|
||||
def test_apply_weight_to_selection(editor, qtbot):
|
||||
"""Test apply_weight makes text bold."""
|
||||
editor.setPlainText("text to bold")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_weight()
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "**" in md
|
||||
|
||||
|
||||
def test_apply_italic_to_selection(editor, qtbot):
|
||||
"""Test apply_italic makes text italic."""
|
||||
editor.setPlainText("text to italicize")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_italic()
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "*" in md or "_" in md
|
||||
|
||||
|
||||
def test_apply_strikethrough_to_selection(editor, qtbot):
|
||||
"""Test apply_strikethrough."""
|
||||
editor.setPlainText("text to strike")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_strikethrough()
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "~~" in md
|
||||
|
||||
|
||||
def test_apply_code_on_selection(editor, qtbot):
|
||||
"""Test apply_code with selected text."""
|
||||
editor.setPlainText("some code")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# apply_code opens dialog - with test stub it accepts
|
||||
editor.apply_code()
|
||||
|
||||
# The stub dialog will create a code block
|
||||
editor.toPlainText()
|
||||
# May contain code block elements depending on dialog behavior
|
||||
|
||||
|
||||
def test_toggle_numbers_on_plain_text(editor, qtbot):
|
||||
"""Test toggle_numbers converts text to numbered list."""
|
||||
editor.setPlainText("item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_numbers()
|
||||
|
||||
text = editor.toPlainText()
|
||||
assert "1." in text
|
||||
|
||||
|
||||
def test_toggle_bullets_on_plain_text(editor, qtbot):
|
||||
"""Test toggle_bullets converts text to bullet list."""
|
||||
editor.setPlainText("item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_bullets()
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Will have unicode bullet
|
||||
assert editor._BULLET_DISPLAY in text
|
||||
|
||||
|
||||
def test_toggle_bullets_removes_bullets(editor, qtbot):
|
||||
"""Test toggle_bullets removes existing bullets."""
|
||||
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_bullets()
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should have removed bullet
|
||||
assert text.strip() == "item 1"
|
||||
|
||||
|
||||
def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
||||
"""Test toggle_checkboxes converts bullets to checkboxes."""
|
||||
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_checkboxes()
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should have checkbox characters
|
||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||
|
||||
|
||||
def test_apply_heading_various_levels(editor, qtbot):
|
||||
"""Test apply_heading with different levels."""
|
||||
test_cases = [
|
||||
(24, "#"), # H1
|
||||
(18, "##"), # H2
|
||||
(14, "###"), # H3
|
||||
(12, ""), # Normal (no heading)
|
||||
]
|
||||
|
||||
for size, expected_marker in test_cases:
|
||||
editor.setPlainText("heading text")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_heading(size)
|
||||
|
||||
text = editor.toPlainText()
|
||||
if expected_marker:
|
||||
assert text.startswith(expected_marker)
|
||||
|
||||
|
||||
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
|
||||
"""Test insert_image_from_path with invalid extension."""
|
||||
invalid_file = tmp_path / "file.txt"
|
||||
invalid_file.write_text("not an image")
|
||||
|
||||
# Should not crash
|
||||
editor.insert_image_from_path(invalid_file)
|
||||
|
||||
|
||||
def test_insert_image_from_path_nonexistent(editor, tmp_path):
|
||||
"""Test insert_image_from_path with nonexistent file."""
|
||||
nonexistent = tmp_path / "doesnt_exist.png"
|
||||
|
||||
# Should not crash
|
||||
editor.insert_image_from_path(nonexistent)
|
||||
|
||||
|
||||
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
|
||||
"""Test clicking checkbox toggles it from unchecked to checked."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
rect = editor.cursorRect()
|
||||
pos = QPoint(rect.left() + 2, rect.center().y())
|
||||
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier
|
||||
)
|
||||
|
||||
editor.mousePressEvent(event)
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should toggle to checked
|
||||
assert editor._CHECK_CHECKED_DISPLAY in text
|
||||
|
||||
|
||||
def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
|
||||
"""Test clicking checked checkbox toggles it to unchecked."""
|
||||
editor.setPlainText(f"{editor._CHECK_CHECKED_DISPLAY} completed task")
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
rect = editor.cursorRect()
|
||||
pos = QPoint(rect.left() + 2, rect.center().y())
|
||||
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier
|
||||
)
|
||||
|
||||
editor.mousePressEvent(event)
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should toggle to unchecked
|
||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||
|
||||
|
||||
def test_mouse_double_click_suppression(editor, qtbot):
|
||||
"""Test double-click suppression for checkboxes."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||
|
||||
# Simulate the suppression flag being set
|
||||
editor._suppress_next_checkbox_double_click = True
|
||||
|
||||
pos = QPoint(10, 10)
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.MouseButtonDblClick,
|
||||
pos,
|
||||
Qt.LeftButton,
|
||||
Qt.LeftButton,
|
||||
Qt.NoModifier,
|
||||
)
|
||||
|
||||
editor.mouseDoubleClickEvent(event)
|
||||
|
||||
# Flag should be cleared
|
||||
assert not editor._suppress_next_checkbox_double_click
|
||||
|
||||
|
||||
def test_context_menu_in_code_block(editor, qtbot):
|
||||
"""Test context menu when in code block."""
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
|
||||
from PySide6.QtGui import QContextMenuEvent
|
||||
|
||||
# Position in the code block
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Down)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
rect = editor.cursorRect()
|
||||
QContextMenuEvent(QContextMenuEvent.Mouse, rect.center())
|
||||
|
||||
# Should not crash
|
||||
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
|
||||
|
||||
|
||||
def test_set_code_block_language(editor, qtbot):
|
||||
"""Test _set_code_block_language sets metadata."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
editor._set_code_block_language(block, "python")
|
||||
|
||||
# Metadata should be set
|
||||
lang = editor._code_metadata.get_language(0)
|
||||
assert lang == "python"
|
||||
|
||||
|
||||
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
|
||||
"""Test get_current_line_task_text removes list/checkbox prefixes."""
|
||||
test_cases = [
|
||||
(f"{editor._CHECK_UNCHECKED_DISPLAY} task text", "task text"),
|
||||
(f"{editor._BULLET_DISPLAY} bullet text", "bullet text"),
|
||||
("- markdown bullet", "markdown bullet"),
|
||||
("1. numbered item", "numbered item"),
|
||||
]
|
||||
|
||||
for input_text, expected in test_cases:
|
||||
editor.setPlainText(input_text)
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
result = editor.get_current_line_task_text()
|
||||
assert result == expected
|
||||
|
||||
|
||||
# Test for selection changed event
|
||||
def test_selection_changed_in_list(editor, qtbot):
|
||||
"""Test selectionChanged event in list items."""
|
||||
editor.setPlainText("- item one\n- item two")
|
||||
|
||||
# Select text in first item
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3)
|
||||
cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Trigger selection changed
|
||||
editor.selectionChanged.emit()
|
||||
|
||||
# Should handle gracefully
|
||||
408
tests/test_pomodoro_timer.py
Normal file
408
tests/test_pomodoro_timer.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
from unittest.mock import Mock, patch
|
||||
|
||||
from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class DummyTimeLogWidget(QWidget):
|
||||
"""Minimal stand-in for the real TimeLogWidget used by PomodoroManager."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.summary_label = QLabel(self)
|
||||
# toggle_btn and _reload_summary are used by PomodoroManager._on_timer_stopped
|
||||
self.toggle_btn = Mock()
|
||||
self.toggle_btn.isChecked.return_value = True
|
||||
|
||||
def show_pomodoro_widget(self, widget):
|
||||
# Manager calls this when embedding the timer
|
||||
if widget is not None:
|
||||
self.layout.addWidget(widget)
|
||||
|
||||
def clear_pomodoro_widget(self):
|
||||
# Manager calls this when removing the embedded timer
|
||||
while self.layout.count():
|
||||
item = self.layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.setParent(None)
|
||||
|
||||
def _reload_summary(self):
|
||||
# Called after TimeLogDialog closes; no-op is fine for tests
|
||||
pass
|
||||
|
||||
|
||||
class DummyMainWindow(QWidget):
|
||||
"""Minimal stand-in for MainWindow that PomodoroManager expects."""
|
||||
|
||||
def __init__(self, app, parent=None):
|
||||
super().__init__(parent)
|
||||
# Sidebar time log widget
|
||||
self.time_log = DummyTimeLogWidget(self)
|
||||
|
||||
# Toolbar with an actTimer QAction so QSignalBlocker works
|
||||
self.toolBar = QToolBar(self)
|
||||
self.toolBar.actTimer = QAction(self)
|
||||
self.toolBar.addAction(self.toolBar.actTimer)
|
||||
|
||||
# Themes attribute used when constructing TimeLogDialog
|
||||
self.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
|
||||
def test_pomodoro_timer_init(qtbot, app, fresh_db):
|
||||
"""Test PomodoroTimer initialization."""
|
||||
task_text = "Write unit tests"
|
||||
timer = PomodoroTimer(task_text)
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
assert timer._task_text == task_text
|
||||
assert timer._elapsed_seconds == 0
|
||||
assert timer._running is False
|
||||
assert timer.time_label.text() == "00:00:00"
|
||||
assert timer.stop_btn.isEnabled() is False
|
||||
|
||||
|
||||
def test_pomodoro_timer_start(qtbot, app):
|
||||
"""Test starting the timer."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
timer._toggle_timer()
|
||||
|
||||
assert timer._running is True
|
||||
assert timer.stop_btn.isEnabled() is True
|
||||
|
||||
|
||||
def test_pomodoro_timer_pause(qtbot, app):
|
||||
"""Test pausing the timer."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Start the timer
|
||||
timer._toggle_timer()
|
||||
assert timer._running is True
|
||||
|
||||
# Pause the timer
|
||||
timer._toggle_timer()
|
||||
assert timer._running is False
|
||||
|
||||
|
||||
def test_pomodoro_timer_resume(qtbot, app):
|
||||
"""Test resuming the timer after pause."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Start, pause, then resume
|
||||
timer._toggle_timer() # Start
|
||||
timer._toggle_timer() # Pause
|
||||
timer._toggle_timer() # Resume
|
||||
|
||||
assert timer._running is True
|
||||
|
||||
|
||||
def test_pomodoro_timer_tick(qtbot, app):
|
||||
"""Test timer tick increments elapsed time."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
initial_time = timer._elapsed_seconds
|
||||
timer._tick()
|
||||
|
||||
assert timer._elapsed_seconds == initial_time + 1
|
||||
|
||||
|
||||
def test_pomodoro_timer_display_update(qtbot, app):
|
||||
"""Test display updates with various elapsed times."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Test 0 seconds
|
||||
timer._elapsed_seconds = 0
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "00:00:00"
|
||||
|
||||
# Test 65 seconds (1 min 5 sec)
|
||||
timer._elapsed_seconds = 65
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "00:01:05"
|
||||
|
||||
# Test 3665 seconds (1 hour 1 min 5 sec)
|
||||
timer._elapsed_seconds = 3665
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "01:01:05"
|
||||
|
||||
# Test 3600 seconds (1 hour exactly)
|
||||
timer._elapsed_seconds = 3600
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "01:00:00"
|
||||
|
||||
|
||||
def test_pomodoro_timer_stop_and_log_running(qtbot, app):
|
||||
"""Test stopping the timer while it's running."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Start the timer
|
||||
timer._toggle_timer()
|
||||
timer._elapsed_seconds = 100
|
||||
|
||||
# Connect a mock to the signal
|
||||
signal_received = []
|
||||
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
|
||||
|
||||
timer._stop_and_log()
|
||||
|
||||
assert timer._running is False
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == 100 # elapsed seconds
|
||||
assert signal_received[0][1] == "Test task"
|
||||
|
||||
|
||||
def test_pomodoro_timer_stop_and_log_paused(qtbot, app):
|
||||
"""Test stopping the timer when it's paused."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
timer._elapsed_seconds = 50
|
||||
|
||||
signal_received = []
|
||||
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
|
||||
|
||||
timer._stop_and_log()
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == 50
|
||||
|
||||
|
||||
def test_pomodoro_timer_multiple_ticks(qtbot, app):
|
||||
"""Test multiple timer ticks."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
for i in range(10):
|
||||
timer._tick()
|
||||
|
||||
assert timer._elapsed_seconds == 10
|
||||
assert "00:00:10" in timer.time_label.text()
|
||||
|
||||
|
||||
def test_pomodoro_timer_modal_state(qtbot, app):
|
||||
"""Test that timer is non-modal."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
assert timer.isModal() is False
|
||||
|
||||
|
||||
def test_pomodoro_manager_init(app, fresh_db):
|
||||
"""Test PomodoroManager initialization."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
assert manager._db is fresh_db
|
||||
assert manager._parent is parent
|
||||
assert manager._active_timer is None
|
||||
|
||||
|
||||
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
|
||||
"""Test starting a timer through the manager."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
line_text = "Important task"
|
||||
date_iso = "2024-01-15"
|
||||
|
||||
manager.start_timer_for_line(line_text, date_iso)
|
||||
|
||||
assert manager._active_timer is not None
|
||||
assert manager._active_timer._task_text == line_text
|
||||
# Timer should be embedded in the sidebar time log widget
|
||||
assert manager._active_timer.parent() is parent.time_log
|
||||
|
||||
|
||||
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
|
||||
"""Test that starting a new timer closes/replaces the previous one."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Start first timer
|
||||
manager.start_timer_for_line("Task 1", "2024-01-15")
|
||||
first_timer = manager._active_timer
|
||||
qtbot.addWidget(first_timer)
|
||||
first_timer.show()
|
||||
|
||||
# Start second timer
|
||||
manager.start_timer_for_line("Task 2", "2024-01-16")
|
||||
second_timer = manager._active_timer
|
||||
qtbot.addWidget(second_timer)
|
||||
|
||||
assert first_timer is not second_timer
|
||||
assert second_timer._task_text == "Task 2"
|
||||
assert second_timer.parent() is parent.time_log
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Timer stopped with very short time logs should enforce minimum hours."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Mock TimeLogDialog to avoid showing it
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
manager._on_timer_stopped(10, "Quick task", "2024-01-15")
|
||||
|
||||
# Should set minimum of 0.25 hours
|
||||
mock_dialog.hours_spin.setValue.assert_called_once()
|
||||
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
|
||||
assert hours_set >= 0.25
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
|
||||
"""Elapsed time should be rounded up to the nearest 0.25 hours."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
# 1800 seconds (30 min) should round up to 0.5
|
||||
manager._on_timer_stopped(1800, "Task", "2024-01-15")
|
||||
|
||||
mock_dialog.hours_spin.setValue.assert_called_once()
|
||||
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
|
||||
|
||||
assert hours_set > 0
|
||||
# Should be a multiple of 0.25
|
||||
assert hours_set * 4 == int(hours_set * 4)
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_prefills_note(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Timer stopped should pre-fill the note in the time log dialog."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
task_text = "Write documentation"
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
manager._on_timer_stopped(3600, task_text, "2024-01-15")
|
||||
|
||||
mock_dialog.note.setText.assert_called_once_with(task_text)
|
||||
|
||||
|
||||
def test_pomodoro_manager_timer_stopped_signal_connection(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Timer's stop button should result in TimeLogDialog being executed."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Mock TimeLogDialog
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
manager.start_timer_for_line("Task", "2024-01-15")
|
||||
timer = manager._active_timer
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Simulate timer having run for a bit
|
||||
timer._elapsed_seconds = 1000
|
||||
|
||||
# Clicking "Stop and log" should emit timerStopped and open the dialog
|
||||
timer._stop_and_log()
|
||||
|
||||
assert mock_dialog.exec.called
|
||||
|
||||
|
||||
def test_pomodoro_timer_accepts_parent(qtbot, app):
|
||||
"""Test that timer accepts a parent widget."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
timer = PomodoroTimer("Task", parent)
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
assert timer.parent() is parent
|
||||
|
||||
|
||||
def test_pomodoro_manager_no_active_timer_initially(app, fresh_db):
|
||||
"""Test that manager starts with no active timer."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
assert manager._active_timer is None
|
||||
|
||||
|
||||
def test_pomodoro_timer_start_stop_cycle(qtbot, app):
|
||||
"""Test a complete start-stop cycle."""
|
||||
timer = PomodoroTimer("Complete cycle")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
signal_received = []
|
||||
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
|
||||
|
||||
# Start
|
||||
timer._toggle_timer()
|
||||
assert timer._running is True
|
||||
|
||||
# Simulate some ticks
|
||||
for _ in range(5):
|
||||
timer._tick()
|
||||
|
||||
# Stop
|
||||
timer._stop_and_log()
|
||||
assert timer._running is False
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == 5
|
||||
|
||||
|
||||
def test_pomodoro_timer_long_elapsed_time(qtbot, app):
|
||||
"""Test display with very long elapsed time."""
|
||||
timer = PomodoroTimer("Long task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Set to 2 hours, 34 minutes, 56 seconds
|
||||
timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56
|
||||
timer._update_display()
|
||||
|
||||
assert timer.time_label.text() == "02:34:56"
|
||||
937
tests/test_reminders.py
Normal file
937
tests/test_reminders.py
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
from datetime import date, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bouquin.reminders import (
|
||||
ManageRemindersDialog,
|
||||
Reminder,
|
||||
ReminderDialog,
|
||||
ReminderType,
|
||||
UpcomingRemindersWidget,
|
||||
)
|
||||
from PySide6.QtCore import QDate, QDateTime, QTime
|
||||
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def freeze_reminders_time(monkeypatch):
|
||||
# Freeze 'now' used inside bouquin.reminders to 12:00 today
|
||||
import bouquin.reminders as rem
|
||||
|
||||
today = QDate.currentDate()
|
||||
fixed_time = QTime(12, 0)
|
||||
fixed_dt = QDateTime(today, fixed_time)
|
||||
monkeypatch.setattr(
|
||||
rem.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt))
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def _add_daily_reminder(db, text="Standup", time_str="23:59"):
|
||||
r = Reminder(
|
||||
id=None,
|
||||
text=text,
|
||||
time_str=time_str,
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
r.id = db.save_reminder(r)
|
||||
return r
|
||||
|
||||
|
||||
def test_reminder_type_enum(app):
|
||||
"""Test ReminderType enum values."""
|
||||
assert ReminderType.ONCE is not None
|
||||
assert ReminderType.DAILY is not None
|
||||
assert ReminderType.WEEKDAYS is not None
|
||||
assert ReminderType.WEEKLY is not None
|
||||
|
||||
|
||||
def test_reminder_dataclass_creation(app):
|
||||
"""Test creating a Reminder instance."""
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test reminder",
|
||||
time_str="10:30",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
weekday=None,
|
||||
active=True,
|
||||
date_iso=None,
|
||||
)
|
||||
|
||||
assert reminder.id == 1
|
||||
assert reminder.text == "Test reminder"
|
||||
assert reminder.time_str == "10:30"
|
||||
assert reminder.reminder_type == ReminderType.DAILY
|
||||
assert reminder.active is True
|
||||
|
||||
|
||||
def test_reminder_dialog_init_new(qtbot, app, fresh_db):
|
||||
"""Test ReminderDialog initialization for new reminder."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog._db is fresh_db
|
||||
assert dialog._reminder is None
|
||||
assert dialog.text_edit.text() == ""
|
||||
|
||||
|
||||
def test_reminder_dialog_init_existing(qtbot, app, fresh_db):
|
||||
"""Test ReminderDialog initialization with existing reminder."""
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Existing reminder",
|
||||
time_str="14:30",
|
||||
reminder_type=ReminderType.WEEKLY,
|
||||
weekday=2,
|
||||
active=True,
|
||||
)
|
||||
|
||||
dialog = ReminderDialog(fresh_db, reminder=reminder)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.text_edit.text() == "Existing reminder"
|
||||
assert dialog.time_edit.time().hour() == 14
|
||||
assert dialog.time_edit.time().minute() == 30
|
||||
|
||||
|
||||
def test_reminder_dialog_type_changed(qtbot, app, fresh_db):
|
||||
"""Test that weekday combo visibility changes with type."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show() # Show the dialog so child widgets can be visible
|
||||
|
||||
# Find weekly type in combo
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
qtbot.wait(10) # Wait for Qt event processing
|
||||
assert dialog.weekday_combo.isVisible() is True
|
||||
|
||||
# Switch to daily
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.DAILY:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
qtbot.wait(10) # Wait for Qt event processing
|
||||
assert dialog.weekday_combo.isVisible() is False
|
||||
|
||||
|
||||
def test_reminder_dialog_get_reminder_once(qtbot, app, fresh_db):
|
||||
"""Test getting reminder with ONCE type."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.text_edit.setText("Test task")
|
||||
dialog.time_edit.setTime(QTime(10, 30))
|
||||
|
||||
# Set to ONCE type
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.ONCE:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
reminder = dialog.get_reminder()
|
||||
|
||||
assert reminder.text == "Test task"
|
||||
assert reminder.time_str == "10:30"
|
||||
assert reminder.reminder_type == ReminderType.ONCE
|
||||
assert reminder.date_iso is not None
|
||||
|
||||
|
||||
def test_reminder_dialog_get_reminder_weekly(qtbot, app, fresh_db):
|
||||
"""Test getting reminder with WEEKLY type."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.text_edit.setText("Weekly meeting")
|
||||
dialog.time_edit.setTime(QTime(15, 0))
|
||||
|
||||
# Set to WEEKLY type
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
dialog.weekday_combo.setCurrentIndex(1) # Tuesday
|
||||
|
||||
reminder = dialog.get_reminder()
|
||||
|
||||
assert reminder.text == "Weekly meeting"
|
||||
assert reminder.reminder_type == ReminderType.WEEKLY
|
||||
assert reminder.weekday == 1
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_init(qtbot, app, fresh_db):
|
||||
"""Test UpcomingRemindersWidget initialization."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._db is fresh_db
|
||||
assert widget.body.isVisible() is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_toggle(qtbot, app, fresh_db):
|
||||
"""Test toggling reminder list visibility."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show() # Show the widget so child widgets can be visible
|
||||
|
||||
# Initially hidden
|
||||
assert widget.body.isVisible() is False
|
||||
|
||||
# Click toggle
|
||||
widget.toggle_btn.click()
|
||||
qtbot.wait(10) # Wait for Qt event processing
|
||||
|
||||
assert widget.body.isVisible() is True
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_once(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for ONCE type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
date_iso="2024-01-15",
|
||||
)
|
||||
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_daily(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for DAILY type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
# Should fire every day
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is True
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_weekdays(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for WEEKDAYS type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.WEEKDAYS,
|
||||
)
|
||||
|
||||
# Monday (dayOfWeek = 1)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
|
||||
# Friday (dayOfWeek = 5)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 19)) is True
|
||||
# Saturday (dayOfWeek = 6)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 20)) is False
|
||||
# Sunday (dayOfWeek = 7)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 21)) is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_weekly(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for WEEKLY type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Fire on Wednesday (weekday = 2)
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.WEEKLY,
|
||||
weekday=2,
|
||||
)
|
||||
|
||||
# Wednesday (dayOfWeek = 3, so weekday = 2)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 17)) is True
|
||||
# Thursday (dayOfWeek = 4, so weekday = 3)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 18)) is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_refresh_no_db(qtbot, app):
|
||||
"""Test refresh with no database connection."""
|
||||
widget = UpcomingRemindersWidget(None)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not crash
|
||||
widget.refresh()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_refresh_with_reminders(qtbot, app, fresh_db):
|
||||
"""Test refresh displays reminders."""
|
||||
# Add a reminder to the database
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Test reminder",
|
||||
time_str="23:59", # Late time so it's in the future
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.refresh()
|
||||
|
||||
# Should have at least one item (or "No upcoming reminders")
|
||||
assert widget.reminder_list.count() > 0
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
|
||||
"""Test adding a reminder through the widget."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
new_reminder = Reminder(
|
||||
id=None,
|
||||
text="New reminder",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = new_reminder
|
||||
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
widget._add_reminder()
|
||||
|
||||
# Reminder should be saved
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
assert len(reminders) > 0
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
|
||||
"""Test editing a reminder through the widget."""
|
||||
# Add a reminder first
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Original",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
# Get the list item
|
||||
if widget.reminder_list.count() > 0:
|
||||
item = widget.reminder_list.item(0)
|
||||
|
||||
updated = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = updated
|
||||
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
widget._edit_reminder(item)
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db):
|
||||
"""Test deleting a single selected reminder."""
|
||||
# Add a reminder
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="To delete",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
if widget.reminder_list.count() > 0:
|
||||
widget.reminder_list.setCurrentRow(0)
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
widget._delete_selected_reminders()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_delete_selected_multiple(qtbot, app, fresh_db):
|
||||
"""Test deleting multiple selected reminders."""
|
||||
# Add multiple reminders
|
||||
for i in range(3):
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text=f"Reminder {i}",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
# Select all items
|
||||
for i in range(widget.reminder_list.count()):
|
||||
widget.reminder_list.item(i).setSelected(True)
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
widget._delete_selected_reminders()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
|
||||
"""Test check_reminders with no database."""
|
||||
widget = UpcomingRemindersWidget(None)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not crash
|
||||
widget._check_reminders()
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
|
||||
"""Test ManageRemindersDialog initialization."""
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog._db is fresh_db
|
||||
assert dialog.table is not None
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_load_reminders(qtbot, app, fresh_db):
|
||||
"""Test loading reminders into the table."""
|
||||
# Add some reminders
|
||||
for i in range(3):
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text=f"Reminder {i}",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.table.rowCount() == 3
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_load_reminders_no_db(qtbot, app):
|
||||
"""Test loading reminders with no database."""
|
||||
dialog = ManageRemindersDialog(None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should not crash
|
||||
dialog._load_reminders()
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
|
||||
"""Test adding a reminder through the manage dialog."""
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
new_reminder = Reminder(
|
||||
id=None,
|
||||
text="New",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = new_reminder
|
||||
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
dialog._add_reminder()
|
||||
|
||||
# Table should have one more row
|
||||
assert dialog.table.rowCount() == initial_count + 1
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
|
||||
"""Test editing a reminder through the manage dialog."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Original",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
updated = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = updated
|
||||
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
dialog._edit_reminder(reminder)
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db):
|
||||
"""Test deleting a reminder through the manage dialog."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="To delete",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
saved_reminders = fresh_db.get_all_reminders()
|
||||
reminder_to_delete = saved_reminders[0]
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
dialog._delete_reminder(reminder_to_delete)
|
||||
|
||||
# Table should have one fewer row
|
||||
assert dialog.table.rowCount() == initial_count - 1
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_delete_reminder_declined(qtbot, app, fresh_db):
|
||||
"""Test declining to delete a reminder."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Keep me",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
saved_reminders = fresh_db.get_all_reminders()
|
||||
reminder_to_keep = saved_reminders[0]
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
|
||||
dialog._delete_reminder(reminder_to_keep)
|
||||
|
||||
# Table should have same number of rows
|
||||
assert dialog.table.rowCount() == initial_count
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
|
||||
"""Test that weekly reminders display the day name."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Weekly",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.WEEKLY,
|
||||
weekday=2, # Wednesday
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Check that the type column shows the day
|
||||
type_item = dialog.table.item(0, 3)
|
||||
assert "Wed" in type_item.text()
|
||||
|
||||
|
||||
def test_reminder_dialog_accept(qtbot, app, fresh_db):
|
||||
"""Test accepting the reminder dialog."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.text_edit.setText("Test")
|
||||
dialog.accept()
|
||||
|
||||
|
||||
def test_reminder_dialog_reject(qtbot, app, fresh_db):
|
||||
"""Test rejecting the reminder dialog."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.reject()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_signal_emitted(qtbot, app, fresh_db):
|
||||
"""Test that reminderTriggered signal is emitted."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
signal_received = []
|
||||
widget.reminderTriggered.connect(lambda text: signal_received.append(text))
|
||||
|
||||
# Manually emit for testing
|
||||
widget.reminderTriggered.emit("Test reminder")
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == "Test reminder"
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_no_upcoming_message(qtbot, app, fresh_db):
|
||||
"""Test that 'No upcoming reminders' message is shown when appropriate."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.refresh()
|
||||
|
||||
# Should show message when no reminders
|
||||
if widget.reminder_list.count() > 0:
|
||||
item = widget.reminder_list.item(0)
|
||||
if "No upcoming" in item.text():
|
||||
assert True
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
|
||||
"""Test clicking the manage button."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Mock the entire ManageRemindersDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
|
||||
with patch("bouquin.reminders.ManageRemindersDialog", return_value=mock_dialog):
|
||||
widget._manage_reminders()
|
||||
|
||||
|
||||
def test_reminder_dialog_time_format(qtbot, app, fresh_db):
|
||||
"""Test that time is formatted correctly."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.time_edit.setTime(QTime(9, 5))
|
||||
reminder = dialog.get_reminder()
|
||||
|
||||
assert reminder.time_str == "09:05"
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_past_reminders_filtered(qtbot, app, fresh_db):
|
||||
"""Test that past reminders are not shown in upcoming list."""
|
||||
# Create a reminder that's in the past
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Past reminder",
|
||||
time_str="00:01", # Very early morning
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Current time should be past 00:01
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
if QTime.currentTime().hour() > 0:
|
||||
widget.refresh()
|
||||
# The past reminder for today should be filtered out
|
||||
# but tomorrow's occurrence should be shown
|
||||
|
||||
|
||||
def test_reminder_with_inactive_status(qtbot, app, fresh_db):
|
||||
"""Test that inactive reminders are not displayed."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Inactive",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=False,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
# Should not show inactive reminder
|
||||
for i in range(widget.reminder_list.count()):
|
||||
item = widget.reminder_list.item(i)
|
||||
assert "Inactive" not in item.text() or "No upcoming" in item.text()
|
||||
|
||||
|
||||
def test_reminder_triggers_and_deactivates(qtbot, fresh_db):
|
||||
"""Test that ONCE reminders deactivate after firing."""
|
||||
# Add a ONCE reminder for right now
|
||||
now = QTime.currentTime()
|
||||
hour = now.hour()
|
||||
minute = now.minute()
|
||||
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Test once reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str=f"{hour:02d}:{minute:02d}",
|
||||
date_iso=date.today().isoformat(),
|
||||
active=True,
|
||||
)
|
||||
reminder_id = fresh_db.save_reminder(reminder)
|
||||
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Set up signal spy
|
||||
triggered_texts = []
|
||||
reminders_widget.reminderTriggered.connect(
|
||||
lambda text: triggered_texts.append(text)
|
||||
)
|
||||
|
||||
# Trigger the check
|
||||
reminders_widget._check_reminders()
|
||||
|
||||
# Verify reminder was triggered
|
||||
assert len(triggered_texts) > 0
|
||||
assert "Test once reminder" in triggered_texts
|
||||
|
||||
# Verify reminder was deactivated
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
deactivated = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert deactivated.active is False
|
||||
|
||||
|
||||
def test_reminder_not_active_skipped(qtbot, fresh_db):
|
||||
"""Test that inactive reminders are not triggered."""
|
||||
now = QTime.currentTime()
|
||||
hour = now.hour()
|
||||
minute = now.minute()
|
||||
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Inactive reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str=f"{hour:02d}:{minute:02d}",
|
||||
date_iso=date.today().isoformat(),
|
||||
active=False, # Not active
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Set up signal spy
|
||||
triggered_texts = []
|
||||
reminders_widget.reminderTriggered.connect(
|
||||
lambda text: triggered_texts.append(text)
|
||||
)
|
||||
|
||||
# Trigger the check
|
||||
reminders_widget._check_reminders()
|
||||
|
||||
# Should not trigger inactive reminder
|
||||
assert len(triggered_texts) == 0
|
||||
|
||||
|
||||
def test_reminder_not_today_skipped(qtbot, fresh_db):
|
||||
"""Test that reminders not scheduled for today are skipped."""
|
||||
now = QTime.currentTime()
|
||||
hour = now.hour()
|
||||
minute = now.minute()
|
||||
|
||||
# Schedule for tomorrow
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Tomorrow's reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str=f"{hour:02d}:{minute:02d}",
|
||||
date_iso=tomorrow.isoformat(),
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Set up signal spy
|
||||
triggered_texts = []
|
||||
reminders_widget.reminderTriggered.connect(
|
||||
lambda text: triggered_texts.append(text)
|
||||
)
|
||||
|
||||
# Trigger the check
|
||||
reminders_widget._check_reminders()
|
||||
|
||||
# Should not trigger tomorrow's reminder
|
||||
assert len(triggered_texts) == 0
|
||||
|
||||
|
||||
def test_reminder_context_menu_no_selection(qtbot, fresh_db):
|
||||
"""Test context menu with no selection returns early."""
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Clear selection
|
||||
reminders_widget.reminder_list.clearSelection()
|
||||
|
||||
# Show context menu - should return early
|
||||
reminders_widget._show_reminder_context_menu(reminders_widget.reminder_list.pos())
|
||||
|
||||
|
||||
def test_edit_reminder_dialog(qtbot, fresh_db):
|
||||
"""Test editing a reminder through the dialog."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Original text",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
time_str="14:30",
|
||||
date_iso=None,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = QWidget()
|
||||
|
||||
# Create edit dialog
|
||||
reminder_obj = fresh_db.get_all_reminders()[0]
|
||||
dlg = ReminderDialog(fresh_db, widget, reminder=reminder_obj)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Verify fields are populated
|
||||
assert dlg.text_edit.text() == "Original text"
|
||||
assert dlg.time_edit.time().toString("HH:mm") == "14:30"
|
||||
|
||||
|
||||
def test_upcoming_reminders_context_menu_shows(
|
||||
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
||||
):
|
||||
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
# Add a future reminder for today
|
||||
r = Reminder(
|
||||
id=None,
|
||||
text="Ping",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
r.id = fresh_db.save_reminder(r)
|
||||
|
||||
w = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(w)
|
||||
w.refresh()
|
||||
|
||||
# Select first upcoming item so context menu code path runs
|
||||
assert w.reminder_list.count() > 0
|
||||
w.reminder_list.setCurrentItem(w.reminder_list.item(0))
|
||||
|
||||
called = {"exec": False, "actions": []}
|
||||
|
||||
class DummyAction:
|
||||
def __init__(self, text, parent=None):
|
||||
self._text = text
|
||||
|
||||
class _Sig:
|
||||
def connect(self, fn):
|
||||
pass
|
||||
|
||||
self.triggered = _Sig()
|
||||
|
||||
class DummyMenu:
|
||||
def __init__(self, parent=None):
|
||||
pass
|
||||
|
||||
def addAction(self, action):
|
||||
called["actions"].append(getattr(action, "_text", str(action)))
|
||||
|
||||
def exec(self, *_, **__):
|
||||
called["exec"] = True
|
||||
|
||||
# Patch the modules that the inline imports will read from
|
||||
monkeypatch.setattr(QtWidgets, "QMenu", DummyMenu, raising=True)
|
||||
monkeypatch.setattr(QtGui, "QAction", DummyAction, raising=True)
|
||||
|
||||
# Invoke directly (normally via right-click)
|
||||
w._show_reminder_context_menu(QPoint(5, 5))
|
||||
|
||||
assert called["exec"] is True
|
||||
assert len(called["actions"]) >= 2 # at least Edit/Deactivate/Delete
|
||||
|
||||
|
||||
def test_upcoming_reminders_delete_selected_dedupes(
|
||||
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
||||
):
|
||||
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
||||
from PySide6.QtCore import QItemSelectionModel
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
r = Reminder(
|
||||
id=None,
|
||||
text="Duplicate target",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
r.id = fresh_db.save_reminder(r)
|
||||
|
||||
w = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(w)
|
||||
w.refresh()
|
||||
|
||||
assert w.reminder_list.count() >= 2 # daily -> multiple upcoming occurrences
|
||||
|
||||
# First selects & clears; second adds to selection
|
||||
w.reminder_list.setCurrentRow(0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
|
||||
w.reminder_list.setCurrentRow(1, QItemSelectionModel.SelectionFlag.Select)
|
||||
|
||||
deleted_ids = []
|
||||
|
||||
def fake_delete(rem_id):
|
||||
deleted_ids.append(rem_id)
|
||||
|
||||
# Auto-confirm deletion
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.Yes)
|
||||
)
|
||||
monkeypatch.setattr(fresh_db, "delete_reminder", fake_delete)
|
||||
|
||||
w._delete_selected_reminders()
|
||||
|
||||
# Should de-duplicate to a single DB delete call
|
||||
assert deleted_ids == [r.id]
|
||||
|
|
@ -33,7 +33,10 @@ def test_open_selected_with_data(qtbot, fresh_db):
|
|||
it = QListWidgetItem("dummy")
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
|
||||
it.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"kind": "page", "date": "1999-12-31"},
|
||||
)
|
||||
s.results.addItem(it)
|
||||
s._open_selected(it)
|
||||
assert seen == ["1999-12-31"]
|
||||
|
|
@ -95,6 +98,6 @@ def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
|
|||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
long = "X" * 40 + "alpha" + "Y" * 40
|
||||
rows = [("2000-01-01", long)]
|
||||
rows = [("page", "2000-01-01", "2000-01-01", long, None)]
|
||||
s._populate_results("alpha", rows)
|
||||
assert s.results.count() >= 1
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
from bouquin.settings import (
|
||||
get_settings,
|
||||
load_db_config,
|
||||
save_db_config,
|
||||
)
|
||||
from bouquin.db import DBConfig
|
||||
from bouquin.settings import get_settings, load_db_config, save_db_config
|
||||
|
||||
|
||||
def _clear_db_settings():
|
||||
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):
|
||||
s = get_settings()
|
||||
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]:
|
||||
s.remove(k)
|
||||
_clear_db_settings()
|
||||
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "notes.db",
|
||||
|
|
@ -17,6 +29,11 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
|||
idle_minutes=7,
|
||||
theme="dark",
|
||||
move_todos=True,
|
||||
tags=True,
|
||||
time_log=True,
|
||||
reminders=True,
|
||||
locale="en",
|
||||
font_size=11,
|
||||
)
|
||||
save_db_config(cfg)
|
||||
|
||||
|
|
@ -26,3 +43,25 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
|||
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) == ""
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
import bouquin.settings_dialog as sd
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
||||
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
|
||||
app = QApplication.instance()
|
||||
parent = QWidget()
|
||||
|
|
@ -17,10 +17,12 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path)
|
|||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
dlg.path_edit.setText(str(tmp_path / "alt.db"))
|
||||
dlg.idle_spin.setValue(3)
|
||||
dlg.theme_light.setChecked(True)
|
||||
dlg.move_todos.setChecked(True)
|
||||
dlg.tags.setChecked(False)
|
||||
dlg.time_log.setChecked(False)
|
||||
dlg.reminders.setChecked(False)
|
||||
|
||||
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
|
||||
def _auto_accept_msgbox():
|
||||
|
|
@ -34,8 +36,11 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path)
|
|||
|
||||
dlg._save()
|
||||
cfg = dlg.config
|
||||
assert cfg.path.name == "alt.db"
|
||||
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")
|
||||
|
||||
|
||||
|
|
@ -55,7 +60,7 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
|
|||
def _pump():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.edit.setText("supersecret")
|
||||
w.key_entry.setText("supersecret")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
|
@ -99,7 +104,7 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
|
|||
def _pump_popups():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.edit.setText(keys.pop(0) if keys else "zzz")
|
||||
w.key_entry.setText(keys.pop(0) if keys else "zzz")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
|
@ -141,7 +146,7 @@ def test_change_key_success(qtbot, tmp_path, app):
|
|||
def _pump():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.edit.setText(keys.pop(0) if keys else "newkey")
|
||||
w.key_entry.setText(keys.pop(0) if keys else "newkey")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
|
@ -203,27 +208,6 @@ def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
|
|||
assert called["text"]
|
||||
|
||||
|
||||
def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "x.db", key="k", idle_minutes=0, theme="light", move_todos=True
|
||||
)
|
||||
dlg = SettingsDialog(cfg, fresh_db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
p = tmp_path / "new_file.db"
|
||||
monkeypatch.setattr(
|
||||
sd.QFileDialog,
|
||||
"getSaveFileName",
|
||||
staticmethod(lambda *a, **k: (str(p), "DB Files (*.db)")),
|
||||
raising=False,
|
||||
)
|
||||
dlg._browse()
|
||||
assert dlg.path_edit.text().endswith("new_file.db")
|
||||
|
||||
|
||||
class _Host(QWidget):
|
||||
def __init__(self, themes):
|
||||
super().__init__()
|
||||
|
|
|
|||
660
tests/test_statistics_dialog.py
Normal file
660
tests/test_statistics_dialog.py
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import datetime as _dt
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from bouquin import strings
|
||||
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
|
||||
class FakeStatsDB:
|
||||
"""Minimal stub that returns a fixed stats payload."""
|
||||
|
||||
def __init__(self):
|
||||
d1 = _dt.date(2024, 1, 1)
|
||||
d2 = _dt.date(2024, 1, 2)
|
||||
|
||||
self.stats = (
|
||||
2, # pages_with_content
|
||||
5, # total_revisions
|
||||
"2024-01-02", # page_most_revisions
|
||||
3, # page_most_revisions_count
|
||||
{d1: 10, d2: 20}, # words_by_date
|
||||
30, # total_words
|
||||
4, # unique_tags
|
||||
"2024-01-02", # page_most_tags
|
||||
2, # page_most_tags_count
|
||||
{d1: 1, d2: 2}, # revisions_by_date
|
||||
{d1: 60, d2: 120}, # time_minutes_by_date
|
||||
180, # total_time_minutes
|
||||
"2024-01-02", # day_most_time
|
||||
120, # day_most_time_minutes
|
||||
"Project A", # project_most_minutes_name
|
||||
120, # project_most_minutes
|
||||
"Activity A", # activity_most_minutes_name
|
||||
120, # activity_most_minutes
|
||||
{d1: 1, d2: 3}, # reminders_by_date
|
||||
4, # total_reminders
|
||||
"2024-01-02", # day_most_reminders
|
||||
3, # day_most_reminders_count
|
||||
)
|
||||
|
||||
self.called = False
|
||||
|
||||
def gather_stats(self):
|
||||
self.called = True
|
||||
return self.stats
|
||||
|
||||
|
||||
def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
|
||||
# Make sure we have a known language for label texts
|
||||
strings.load_strings("en")
|
||||
|
||||
db = FakeStatsDB()
|
||||
dlg = StatisticsDialog(db)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Stats were actually requested from the DB
|
||||
assert db.called
|
||||
|
||||
# Window title comes from translations
|
||||
assert dlg.windowTitle() == strings._("statistics")
|
||||
|
||||
# Grab all label texts for simple content checks
|
||||
label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)}
|
||||
|
||||
# Page with most revisions / tags are rendered as "DATE (COUNT)"
|
||||
assert "2024-01-02 (3)" in label_texts
|
||||
assert "2024-01-02 (2)" in label_texts
|
||||
|
||||
# Heatmap is created and uses "words" by default
|
||||
words_by_date = db.stats[4]
|
||||
revisions_by_date = db.stats[9]
|
||||
|
||||
assert hasattr(dlg, "_heatmap")
|
||||
assert dlg._heatmap._data == words_by_date
|
||||
|
||||
# Switching the metric to "revisions" should swap the dataset
|
||||
dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions
|
||||
qtbot.wait(10)
|
||||
assert dlg._heatmap._data == revisions_by_date
|
||||
|
||||
|
||||
class EmptyStatsDB:
|
||||
"""Stub that returns a 'no data yet' stats payload."""
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def gather_stats(self):
|
||||
self.called = True
|
||||
return (
|
||||
0, # pages_with_content
|
||||
0, # total_revisions
|
||||
None, # page_most_revisions
|
||||
0, # page_most_revisions_count
|
||||
{}, # words_by_date
|
||||
0, # total_words
|
||||
0, # unique_tags
|
||||
None, # page_most_tags
|
||||
0, # page_most_tags_count
|
||||
{}, # revisions_by_date
|
||||
{}, # time_minutes_by_date
|
||||
0, # total_time_minutes
|
||||
None, # day_most_time
|
||||
0, # day_most_time_minutes
|
||||
None, # project_most_minutes_name
|
||||
0, # project_most_minutes
|
||||
None, # activity_most_minutes_name
|
||||
0, # activity_most_minutes
|
||||
{}, # reminders_by_date
|
||||
0, # total_reminders
|
||||
None, # day_most_reminders
|
||||
0, # day_most_reminders_count
|
||||
)
|
||||
|
||||
|
||||
def test_statistics_dialog_no_data_shows_placeholder(qtbot):
|
||||
strings.load_strings("en")
|
||||
|
||||
db = EmptyStatsDB()
|
||||
dlg = StatisticsDialog(db)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
assert db.called
|
||||
|
||||
label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)]
|
||||
assert strings._("stats_no_data") in label_texts
|
||||
|
||||
# When there's no data, the heatmap and metric combo shouldn't exist
|
||||
assert not hasattr(dlg, "metric_combo")
|
||||
assert not hasattr(dlg, "_heatmap")
|
||||
|
||||
|
||||
def _date(year, month, day):
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DateHeatmapTests - Missing Coverage
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_activity_heatmap_empty_data(qtbot):
|
||||
"""Test heatmap with empty data dict."""
|
||||
strings.load_strings("en")
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set empty data
|
||||
heatmap.set_data({})
|
||||
|
||||
# Should handle empty data gracefully
|
||||
assert heatmap._start is None
|
||||
assert heatmap._end is None
|
||||
assert heatmap._max_value == 0
|
||||
|
||||
# Size hint should return default dimensions
|
||||
size = heatmap.sizeHint()
|
||||
assert size.width() > 0
|
||||
assert size.height() > 0
|
||||
|
||||
# Paint should not crash
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_none_data(qtbot):
|
||||
"""Test heatmap with None data."""
|
||||
strings.load_strings("en")
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set None data
|
||||
heatmap.set_data(None)
|
||||
|
||||
assert heatmap._start is None
|
||||
assert heatmap._end is None
|
||||
|
||||
# Paint event should return early
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_click_when_no_data(qtbot):
|
||||
"""Test clicking heatmap when there's no data."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
heatmap.set_data({})
|
||||
|
||||
# Simulate click - should not crash or emit signal
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click in the middle of widget
|
||||
pos = QPoint(100, 100)
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
# Should not have clicked any date
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_click_outside_grid(qtbot):
|
||||
"""Test clicking outside the grid area."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set some data
|
||||
data = {
|
||||
date(2024, 1, 1): 5,
|
||||
date(2024, 1, 2): 10,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click in top-left margin (before grid starts)
|
||||
pos = QPoint(5, 5)
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_click_beyond_end_date(qtbot):
|
||||
"""Test clicking on trailing empty cells beyond the last date."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set data that doesn't fill a complete week
|
||||
data = {
|
||||
date(2024, 1, 1): 5, # Monday
|
||||
date(2024, 1, 2): 10, # Tuesday
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Try clicking far to the right (beyond end date)
|
||||
# This is tricky to target precisely, but we can simulate
|
||||
pos = QPoint(1000, 50) # Far right
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
# Should either not click or only click valid dates
|
||||
# If it did click, it should be a valid date within range
|
||||
if clicked_dates:
|
||||
assert clicked_dates[0] <= date(2024, 1, 2)
|
||||
|
||||
|
||||
def test_activity_heatmap_click_invalid_row(qtbot):
|
||||
"""Test clicking below the 7 weekday rows."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {
|
||||
date(2024, 1, 1): 5,
|
||||
date(2024, 1, 8): 10,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click below the grid (row 8 or higher)
|
||||
pos = QPoint(100, 500) # Very low Y
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_right_click_ignored(qtbot):
|
||||
"""Test that right-click is ignored."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {date(2024, 1, 1): 5}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Right click should be ignored
|
||||
pos = QPoint(100, 100)
|
||||
QTest.mouseClick(heatmap, Qt.RightButton, pos=pos)
|
||||
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_month_label_rendering(qtbot):
|
||||
"""Test heatmap spanning multiple months renders month labels."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Data spanning multiple months
|
||||
data = {
|
||||
date(2024, 1, 1): 5,
|
||||
date(2024, 1, 15): 10,
|
||||
date(2024, 2, 1): 8,
|
||||
date(2024, 2, 15): 12,
|
||||
date(2024, 3, 1): 6,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Should calculate proper size
|
||||
size = heatmap.sizeHint()
|
||||
assert size.width() > 0
|
||||
assert size.height() > 0
|
||||
|
||||
# Paint should work without crashing
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_same_month_continues(qtbot):
|
||||
"""Test that month labels skip weeks in the same month."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Multiple dates in same month
|
||||
data = {}
|
||||
for day in range(1, 29): # January 1-28
|
||||
data[date(2024, 1, day)] = day
|
||||
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Should render without issues
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_data_with_zero_values(qtbot):
|
||||
"""Test heatmap with zero values in data."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {
|
||||
date(2024, 1, 1): 0,
|
||||
date(2024, 1, 2): 5,
|
||||
date(2024, 1, 3): 0,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
assert heatmap._max_value == 5
|
||||
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_single_day(qtbot):
|
||||
"""Test heatmap with just one day of data."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {date(2024, 1, 15): 10}
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Should handle single day
|
||||
assert heatmap._start is not None
|
||||
assert heatmap._end is not None
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click should work
|
||||
pos = QPoint(100, 100)
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# StatisticsDialog Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_statistics_dialog_with_empty_database(qtbot, fresh_db):
|
||||
"""Test statistics dialog with an empty database."""
|
||||
strings.load_strings("en")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Should handle empty database gracefully
|
||||
assert dialog.isVisible()
|
||||
|
||||
# Heatmap should be empty
|
||||
heatmap = dialog.findChild(DateHeatmap)
|
||||
if heatmap:
|
||||
# No crash when displaying empty heatmap
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_statistics_dialog_with_data(qtbot, fresh_db):
|
||||
"""Test statistics dialog with actual data."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Add some content
|
||||
fresh_db.save_new_version("2024-01-01", "Hello world", "test")
|
||||
fresh_db.save_new_version("2024-01-02", "More content here", "test")
|
||||
fresh_db.save_new_version("2024-01-03", "Even more text", "test")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Should display statistics
|
||||
assert dialog.isVisible()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_statistics_dialog_gather_stats_exception_handling(
|
||||
qtbot, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that gather_stats handles exceptions gracefully."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Make dates_with_content raise an exception
|
||||
def bad_dates_with_content():
|
||||
raise RuntimeError("Simulated DB error")
|
||||
|
||||
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content)
|
||||
|
||||
# Should still create dialog without crashing
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Should handle error gracefully
|
||||
assert dialog.isVisible()
|
||||
|
||||
|
||||
def test_statistics_dialog_with_sparse_data(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test statistics dialog with sparse data"""
|
||||
# Add some entries on non-consecutive days
|
||||
dates = ["2024-01-01", "2024-01-05", "2024-01-10", "2024-01-20"]
|
||||
for _date in dates:
|
||||
content = "Word " * 100 # 100 words
|
||||
fresh_db.save_new_version(_date, content, "note")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should create without crashing
|
||||
assert dialog is not None
|
||||
|
||||
|
||||
def test_statistics_dialog_with_empty_data(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test statistics dialog with no data"""
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should handle empty data gracefully
|
||||
assert dialog is not None
|
||||
|
||||
|
||||
def test_statistics_dialog_date_range_selection(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test changing metric in statistics dialog"""
|
||||
# Add some test data
|
||||
for i in range(10):
|
||||
date = QDate.currentDate().addDays(-i).toString("yyyy-MM-dd")
|
||||
fresh_db.save_new_version(date, f"Content for day {i}", "note")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Change metric to revisions
|
||||
idx = dialog.metric_combo.findData("revisions")
|
||||
if idx >= 0:
|
||||
dialog.metric_combo.setCurrentIndex(idx)
|
||||
qtbot.wait(50)
|
||||
|
||||
# Change back to words
|
||||
idx = dialog.metric_combo.findData("words")
|
||||
if idx >= 0:
|
||||
dialog.metric_combo.setCurrentIndex(idx)
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_heatmap_with_varying_word_counts(qtbot):
|
||||
"""Test heatmap color scaling with varying word counts"""
|
||||
today = datetime.now().date()
|
||||
start = today - timedelta(days=30)
|
||||
|
||||
entries = {}
|
||||
# Create entries with varying word counts
|
||||
for i in range(31):
|
||||
date = start + timedelta(days=i)
|
||||
entries[date] = i * 50 # Increasing word counts
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
heatmap.set_data(entries)
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Should paint without errors
|
||||
assert heatmap.isVisible()
|
||||
|
||||
|
||||
def test_heatmap_single_day(qtbot):
|
||||
"""Test heatmap with single day of data"""
|
||||
today = datetime.now().date()
|
||||
entries = {today: 500}
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
heatmap.set_data(entries)
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
assert heatmap.isVisible()
|
||||
|
||||
|
||||
def test_statistics_dialog_metric_changes(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test various metric selections"""
|
||||
# Add data spanning multiple months
|
||||
base_date = QDate.currentDate().addDays(-90)
|
||||
for i in range(90):
|
||||
date = base_date.addDays(i).toString("yyyy-MM-dd")
|
||||
fresh_db.save_new_version(date, f"Day {i} content with many words", "note")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Test each metric option
|
||||
for i in range(dialog.metric_combo.count()):
|
||||
dialog.metric_combo.setCurrentIndex(i)
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_heatmap_date_beyond_end(qtbot, fresh_db):
|
||||
"""Test clicking on a date beyond the end date in heatmap."""
|
||||
# Create entries spanning a range
|
||||
today = date.today()
|
||||
start = today - timedelta(days=30)
|
||||
|
||||
data = {}
|
||||
for i in range(20):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set data
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Try to click beyond the end date - should return early
|
||||
# Calculate a position that would be beyond the end
|
||||
if heatmap._start and heatmap._end:
|
||||
cell_span = heatmap._cell + heatmap._gap
|
||||
weeks = ((heatmap._end - heatmap._start).days + 6) // 7
|
||||
|
||||
# Click beyond the last week
|
||||
x = heatmap._margin_left + (weeks + 1) * cell_span + 5
|
||||
y = heatmap._margin_top + 3 * cell_span + 5
|
||||
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
|
||||
|
||||
|
||||
def test_heatmap_click_outside_grid(qtbot, fresh_db):
|
||||
"""Test clicking outside the heatmap grid area."""
|
||||
today = date.today()
|
||||
start = today - timedelta(days=7)
|
||||
|
||||
data = {}
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Click in the margin (outside grid)
|
||||
x = heatmap._margin_left - 10 # Before the grid
|
||||
y = heatmap._margin_top - 10 # Above the grid
|
||||
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
|
||||
|
||||
# Should not crash, just return early
|
||||
|
||||
|
||||
def test_heatmap_click_invalid_row(qtbot, fresh_db):
|
||||
"""Test clicking on an invalid row (>= 7)."""
|
||||
today = date.today()
|
||||
start = today - timedelta(days=7)
|
||||
|
||||
data = {}
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Click below row 6 (day of week > Sunday)
|
||||
cell_span = heatmap._cell + heatmap._gap
|
||||
x = heatmap._margin_left + 5
|
||||
y = heatmap._margin_top + 7 * cell_span + 5 # Row 7, which is invalid
|
||||
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
|
||||
|
||||
# Should return early, not crash
|
||||
|
||||
|
||||
def test_heatmap_month_label_continuation(qtbot, fresh_db):
|
||||
"""Test that month labels don't repeat when continuing in same month."""
|
||||
# Create a date range that spans multiple weeks within the same month
|
||||
today = date.today()
|
||||
# Use a date that's guaranteed to be mid-month
|
||||
start = date(today.year, today.month, 1)
|
||||
|
||||
data = {}
|
||||
for i in range(21):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Force a repaint to execute paintEvent
|
||||
heatmap.repaint()
|
||||
|
||||
# The month continuation logic should prevent duplicate labels
|
||||
# We can't easily test the visual output, but we ensure no crash
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
import types
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
# point to the temp encrypted DB
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
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))
|
||||
|
|
@ -45,7 +44,7 @@ def test_toolbar_signals_dispatch_once_per_click(
|
|||
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
|
||||
):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
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))
|
||||
|
|
@ -116,7 +115,7 @@ def test_history_and_insert_image_not_duplicated(
|
|||
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
|
||||
):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
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))
|
||||
|
|
@ -156,7 +155,7 @@ def test_history_and_insert_image_not_duplicated(
|
|||
|
||||
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
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))
|
||||
|
|
@ -171,7 +170,7 @@ def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
|
||||
def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import bouquin.strings as strings
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, QPoint, QEvent
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QInputDialog, QColorDialog
|
||||
from bouquin.db import DBManager
|
||||
from bouquin.strings import load_strings
|
||||
from bouquin.tags_widget import PageTagsWidget, TagChip
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
from bouquin.flow_layout import FlowLayout
|
||||
from bouquin.strings import load_strings
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
from bouquin.tags_widget import PageTagsWidget, TagChip
|
||||
from PySide6.QtCore import QDate, QEvent, QPoint, Qt
|
||||
from PySide6.QtGui import QColor, QMouseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QColorDialog,
|
||||
QDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DB Layer Tag Tests
|
||||
# ============================================================================
|
||||
|
|
@ -141,6 +147,16 @@ def test_list_all_tags(fresh_db):
|
|||
assert "tag3" in tag_names
|
||||
|
||||
|
||||
def test_add_tag_name_and_color(fresh_db):
|
||||
"""Test adding a tag's name and color"""
|
||||
fresh_db.add_tag("new123", "#FF0000")
|
||||
|
||||
updated_tags = fresh_db.list_tags()
|
||||
assert len(updated_tags) == 1
|
||||
assert updated_tags[0][1] == "new123"
|
||||
assert updated_tags[0][2] == "#FF0000"
|
||||
|
||||
|
||||
def test_update_tag_name_and_color(fresh_db):
|
||||
"""Test updating a tag's name and color"""
|
||||
date_iso = "2024-01-15"
|
||||
|
|
@ -1630,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db):
|
|||
|
||||
def test_flow_layout_take_at_invalid_index(app):
|
||||
"""Test FlowLayout.takeAt with out-of-bounds index"""
|
||||
from PySide6.QtWidgets import QWidget, QLabel
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
widget = QWidget()
|
||||
layout = FlowLayout(widget)
|
||||
|
|
@ -1654,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app):
|
|||
|
||||
def test_flow_layout_take_at_boundary(app):
|
||||
"""Test FlowLayout.takeAt at exact boundary"""
|
||||
from PySide6.QtWidgets import QWidget, QLabel
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
widget = QWidget()
|
||||
layout = FlowLayout(widget)
|
||||
|
|
@ -1788,3 +1804,513 @@ def test_multiple_widgets_same_database(app, fresh_db):
|
|||
widget2._on_toggle(True)
|
||||
|
||||
assert widget2.chip_layout.count() == 1
|
||||
|
||||
|
||||
def test_tag_browser_add_tag_with_color(qtbot, fresh_db, monkeypatch):
|
||||
"""Test adding a new tag with color selection."""
|
||||
strings.load_strings("en")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
# Mock input dialog and color dialog
|
||||
def mock_get_text(*args, **kwargs):
|
||||
return "NewTag", True
|
||||
|
||||
def mock_get_color(initial, parent):
|
||||
return QColor("#ff0000")
|
||||
|
||||
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
|
||||
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
|
||||
|
||||
tags_before = len(fresh_db.list_tags())
|
||||
|
||||
# Trigger add tag
|
||||
browser._add_a_tag()
|
||||
|
||||
# Should have added tag
|
||||
tags_after = len(fresh_db.list_tags())
|
||||
assert tags_after == tags_before + 1
|
||||
|
||||
|
||||
def test_tag_browser_add_tag_cancelled_at_name(qtbot, fresh_db, monkeypatch):
|
||||
"""Test cancelling tag addition at name input."""
|
||||
strings.load_strings("en")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
# Mock cancelled input
|
||||
def mock_get_text(*args, **kwargs):
|
||||
return "", False
|
||||
|
||||
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
|
||||
|
||||
tags_before = len(fresh_db.list_tags())
|
||||
|
||||
browser._add_a_tag()
|
||||
|
||||
# Should not have added tag
|
||||
tags_after = len(fresh_db.list_tags())
|
||||
assert tags_after == tags_before
|
||||
|
||||
|
||||
def test_tag_browser_add_tag_cancelled_at_color(qtbot, fresh_db, monkeypatch):
|
||||
"""Test cancelling tag addition at color selection."""
|
||||
strings.load_strings("en")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
# Name input succeeds, color cancelled
|
||||
def mock_get_text(*args, **kwargs):
|
||||
return "NewTag", True
|
||||
|
||||
def mock_get_color(initial, parent):
|
||||
return QColor() # Invalid color = cancelled
|
||||
|
||||
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
|
||||
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
|
||||
|
||||
tags_before = len(fresh_db.list_tags())
|
||||
|
||||
browser._add_a_tag()
|
||||
|
||||
# Should not have added tag
|
||||
tags_after = len(fresh_db.list_tags())
|
||||
assert tags_after == tags_before
|
||||
|
||||
|
||||
def test_tag_browser_add_duplicate_tag_shows_error(qtbot, fresh_db, monkeypatch):
|
||||
"""Test adding duplicate tag shows error."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Add existing tag
|
||||
fresh_db.add_tag("Existing", "#ff0000")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
# Try to add same tag
|
||||
def mock_get_text(*args, **kwargs):
|
||||
return "Existing", True
|
||||
|
||||
def mock_get_color(initial, parent):
|
||||
return QColor("#00ff00")
|
||||
|
||||
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
|
||||
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
browser._add_a_tag()
|
||||
|
||||
# Should show error
|
||||
assert critical_shown["shown"]
|
||||
|
||||
|
||||
def test_tag_browser_edit_tag_integrity_error(qtbot, fresh_db, monkeypatch):
|
||||
"""Test editing tag to duplicate name shows error."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Add two tags
|
||||
fresh_db.add_tag("Tag1", "#ff0000")
|
||||
fresh_db.add_tag("Tag2", "#00ff00")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
# Select first tag
|
||||
browser._populate(None)
|
||||
tree = browser.tree
|
||||
if tree.topLevelItemCount() > 0:
|
||||
tree.setCurrentItem(tree.topLevelItem(0))
|
||||
|
||||
# Try to rename to Tag2 (duplicate)
|
||||
def mock_get_text(*args, **kwargs):
|
||||
return "Tag2", True
|
||||
|
||||
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
browser._edit_tag_name()
|
||||
|
||||
# Should show error
|
||||
assert critical_shown["shown"]
|
||||
|
||||
|
||||
def test_tag_browser_change_tag_color_integrity_error(qtbot, fresh_db, monkeypatch):
|
||||
"""Test changing tag color with integrity error."""
|
||||
strings.load_strings("en")
|
||||
|
||||
fresh_db.add_tag("TestTag", "#ff0000")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
browser._populate(None)
|
||||
tree = browser.tree
|
||||
if tree.topLevelItemCount() > 0:
|
||||
tree.setCurrentItem(tree.topLevelItem(0))
|
||||
|
||||
# Mock color dialog
|
||||
def mock_get_color(initial, parent):
|
||||
return QColor("#00ff00")
|
||||
|
||||
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
|
||||
|
||||
# Mock update_tag to raise IntegrityError
|
||||
fresh_db.update_tag
|
||||
|
||||
def bad_update(*args):
|
||||
raise IntegrityError("Simulated error")
|
||||
|
||||
monkeypatch.setattr(fresh_db, "update_tag", bad_update)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
browser._change_tag_color()
|
||||
|
||||
# Should show error
|
||||
assert critical_shown["shown"]
|
||||
|
||||
|
||||
def test_tag_browser_change_tag_color_cancelled(qtbot, fresh_db, monkeypatch):
|
||||
"""Test cancelling color change."""
|
||||
strings.load_strings("en")
|
||||
|
||||
fresh_db.add_tag("TestTag", "#ff0000")
|
||||
|
||||
browser = TagBrowserDialog(fresh_db)
|
||||
qtbot.addWidget(browser)
|
||||
browser.show()
|
||||
|
||||
browser._populate(None)
|
||||
tree = browser.tree
|
||||
if tree.topLevelItemCount() > 0:
|
||||
tree.setCurrentItem(tree.topLevelItem(0))
|
||||
|
||||
# Mock cancelled color dialog
|
||||
def mock_get_color(initial, parent):
|
||||
return QColor() # Invalid = cancelled
|
||||
|
||||
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
|
||||
|
||||
# Should not crash
|
||||
browser._change_tag_color()
|
||||
|
||||
|
||||
def test_tag_chip_runtime_error_on_mouse_release(qtbot, monkeypatch):
|
||||
"""Test TagChip handles RuntimeError on mouseReleaseEvent."""
|
||||
chip = TagChip(1, "test", "#ff0000")
|
||||
qtbot.addWidget(chip)
|
||||
chip.show()
|
||||
|
||||
# Mock super().mouseReleaseEvent to raise RuntimeError
|
||||
from PySide6.QtWidgets import QFrame
|
||||
|
||||
original_mouse_release = QFrame.mouseReleaseEvent
|
||||
|
||||
def bad_mouse_release(self, event):
|
||||
raise RuntimeError("Widget deleted")
|
||||
|
||||
monkeypatch.setattr(QFrame, "mouseReleaseEvent", bad_mouse_release)
|
||||
|
||||
clicked_names = []
|
||||
chip.clicked.connect(clicked_names.append)
|
||||
|
||||
# Simulate left click
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
QTest.mouseClick(chip, Qt.LeftButton)
|
||||
|
||||
# Should have emitted signal despite RuntimeError
|
||||
assert "test" in clicked_names
|
||||
|
||||
# Restore original
|
||||
monkeypatch.setattr(QFrame, "mouseReleaseEvent", original_mouse_release)
|
||||
|
||||
|
||||
def test_page_tags_widget_many_tags(qtbot, fresh_db):
|
||||
"""Test page tags widget with many tags."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Add many tags
|
||||
for i in range(20):
|
||||
fresh_db.add_tag(f"Tag{i}", f"#{i:02x}0000")
|
||||
|
||||
fresh_db.save_new_version("2024-01-01", "Content", "test")
|
||||
|
||||
# Add all tags to page
|
||||
tag_names = [f"Tag{i}" for i in range(20)]
|
||||
fresh_db.set_tags_for_page("2024-01-01", tag_names)
|
||||
|
||||
widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
|
||||
# Set current date
|
||||
widget.set_current_date("2024-01-01")
|
||||
|
||||
# Should display all tags
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_page_tags_widget_tag_click(qtbot, fresh_db):
|
||||
"""Test clicking on a tag in PageTagsWidget."""
|
||||
strings.load_strings("en")
|
||||
|
||||
fresh_db.add_tag("Clickable", "#ff0000")
|
||||
fresh_db.save_new_version("2024-01-01", "Content", "test")
|
||||
fresh_db.set_tags_for_page("2024-01-01", ["Clickable"])
|
||||
|
||||
widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
|
||||
widget.set_current_date("2024-01-01")
|
||||
widget.toggle_btn.setChecked(True)
|
||||
widget._on_toggle(True)
|
||||
|
||||
# Find the tag chip
|
||||
chips = widget.findChildren(TagChip)
|
||||
assert len(chips) > 0
|
||||
|
||||
# Click it - shouldn't crash
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
QTest.mouseClick(chips[0], Qt.LeftButton)
|
||||
|
||||
|
||||
def test_page_tags_widget_no_date_set(qtbot, fresh_db):
|
||||
"""Test PageTagsWidget with no date set."""
|
||||
strings.load_strings("en")
|
||||
|
||||
widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
|
||||
# Should handle no date gracefully
|
||||
widget.set_current_date(None)
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_page_tags_widget_date_with_no_tags(qtbot, fresh_db):
|
||||
"""Test PageTagsWidget for date with no tags."""
|
||||
strings.load_strings("en")
|
||||
|
||||
fresh_db.save_new_version("2024-01-01", "Content", "test")
|
||||
|
||||
widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
|
||||
widget.set_current_date("2024-01-01")
|
||||
|
||||
# Should show no tags
|
||||
pills = widget.findChildren(TagChip)
|
||||
assert len(pills) == 0
|
||||
|
||||
|
||||
def test_page_tags_widget_updates_on_tag_change(qtbot, fresh_db):
|
||||
"""Test PageTagsWidget updates when tags change."""
|
||||
strings.load_strings("en")
|
||||
|
||||
fresh_db.add_tag("Initial", "#ff0000")
|
||||
fresh_db.save_new_version("2024-01-01", "Content", "test")
|
||||
fresh_db.set_tags_for_page("2024-01-01", ["Initial"])
|
||||
|
||||
widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
|
||||
widget.set_current_date("2024-01-01")
|
||||
widget.toggle_btn.setChecked(True)
|
||||
widget._on_toggle(True)
|
||||
|
||||
assert widget.chip_layout.count() == 1
|
||||
|
||||
# Add another tag
|
||||
fresh_db.add_tag("Second", "#00ff00")
|
||||
fresh_db.set_tags_for_page("2024-01-01", ["Initial", "Second"])
|
||||
|
||||
# Reload
|
||||
widget.set_current_date("2024-01-01")
|
||||
qtbot.wait(100)
|
||||
|
||||
assert widget.chip_layout.count() == 2
|
||||
|
||||
|
||||
def test_tags_widget_open_manager_and_accept(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||
"""Test opening tag manager dialog and accepting - covers lines 248-256"""
|
||||
tags_widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(tags_widget)
|
||||
|
||||
# Set a current date
|
||||
date = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
tags_widget.set_current_date(date)
|
||||
|
||||
# Add some tags first
|
||||
fresh_db.add_tag("Test Tag", date)
|
||||
tags_widget._reload_tags()
|
||||
|
||||
# Mock the tag browser dialog
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
|
||||
dialog_executed = []
|
||||
|
||||
def fake_exec(self):
|
||||
dialog_executed.append(True)
|
||||
# Simulate the dialog being accepted
|
||||
return QDialog.Accepted
|
||||
|
||||
monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec)
|
||||
|
||||
# Open the manager
|
||||
tags_widget._open_manager()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Dialog should have been executed
|
||||
assert len(dialog_executed) > 0
|
||||
|
||||
|
||||
def test_tags_widget_open_manager_and_reject(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||
"""Test opening tag manager dialog and rejecting"""
|
||||
tags_widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(tags_widget)
|
||||
|
||||
# Set a current date
|
||||
date = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
tags_widget.set_current_date(date)
|
||||
|
||||
# Mock the tag browser dialog
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
|
||||
dialog_executed = []
|
||||
|
||||
def fake_exec(self):
|
||||
dialog_executed.append(True)
|
||||
# Simulate the dialog being rejected
|
||||
return QDialog.Rejected
|
||||
|
||||
monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec)
|
||||
|
||||
# Open the manager
|
||||
tags_widget._open_manager()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Dialog should have been executed
|
||||
assert len(dialog_executed) > 0
|
||||
|
||||
|
||||
def test_tags_widget_open_manager_without_current_date(
|
||||
qtbot, tmp_db_cfg, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test opening tag manager when no current date is set"""
|
||||
tags_widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(tags_widget)
|
||||
|
||||
# Don't set a current date
|
||||
tags_widget._current_date = None
|
||||
|
||||
# Mock the tag browser dialog
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
|
||||
dialog_executed = []
|
||||
|
||||
def fake_exec(self):
|
||||
dialog_executed.append(True)
|
||||
return QDialog.Accepted
|
||||
|
||||
monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec)
|
||||
|
||||
# Open the manager
|
||||
tags_widget._open_manager()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Dialog should still execute
|
||||
assert len(dialog_executed) > 0
|
||||
|
||||
|
||||
def test_tags_widget_manager_with_date_click_signal(
|
||||
qtbot, tmp_db_cfg, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test tag manager emitting openDateRequested signal"""
|
||||
tags_widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(tags_widget)
|
||||
|
||||
date = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
tags_widget.set_current_date(date)
|
||||
|
||||
activated_tags = []
|
||||
|
||||
def capture_tag(tag):
|
||||
activated_tags.append(tag)
|
||||
|
||||
tags_widget.tagActivated.connect(capture_tag)
|
||||
|
||||
# Mock the tag browser dialog
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
|
||||
def fake_exec(self):
|
||||
# Simulate clicking a date in the browser
|
||||
self.openDateRequested.emit("2024-01-01")
|
||||
return QDialog.Accepted
|
||||
|
||||
monkeypatch.setattr(TagBrowserDialog, "exec", fake_exec)
|
||||
|
||||
# Open the manager
|
||||
tags_widget._open_manager()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Should have captured the activated tag/date
|
||||
assert len(activated_tags) > 0
|
||||
assert "2024-01-01" in activated_tags
|
||||
|
||||
|
||||
def test_tags_widget_chip_click(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test clicking on a tag chip"""
|
||||
tags_widget = PageTagsWidget(fresh_db)
|
||||
qtbot.addWidget(tags_widget)
|
||||
|
||||
date = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
tags_widget.set_current_date(date)
|
||||
|
||||
# Add a tag
|
||||
fresh_db.add_tag("ClickMe", date)
|
||||
tags_widget._reload_tags()
|
||||
|
||||
activated_tags = []
|
||||
|
||||
def capture_tag(tag):
|
||||
activated_tags.append(tag)
|
||||
|
||||
tags_widget.tagActivated.connect(capture_tag)
|
||||
|
||||
# Simulate chip click
|
||||
tags_widget._on_chip_clicked("ClickMe")
|
||||
qtbot.wait(50)
|
||||
|
||||
assert "ClickMe" in activated_tags
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
|
||||
|
||||
def test_theme_manager_apply_light_and_dark(app):
|
||||
cfg = ThemeConfig(theme=Theme.LIGHT)
|
||||
|
|
|
|||
3012
tests/test_time_log.py
Normal file
3012
tests/test_time_log.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
import pytest
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.toolbar import ToolBar
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
535
tests/test_version_check.py
Normal file
535
tests/test_version_check.py
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
import subprocess
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from bouquin.version_check import VersionChecker
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_version_checker_init(app):
|
||||
"""Test VersionChecker initialization."""
|
||||
parent = QWidget()
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
assert checker._parent is parent
|
||||
|
||||
|
||||
def test_version_checker_init_no_parent(app):
|
||||
"""Test VersionChecker initialization without parent."""
|
||||
checker = VersionChecker()
|
||||
|
||||
assert checker._parent is None
|
||||
|
||||
|
||||
def test_current_version_returns_version(app):
|
||||
"""Test getting current version."""
|
||||
checker = VersionChecker()
|
||||
|
||||
with patch("importlib.metadata.version", return_value="1.2.3"):
|
||||
version = checker.current_version()
|
||||
assert version == "1.2.3"
|
||||
|
||||
|
||||
def test_current_version_fallback_on_error(app):
|
||||
"""Test current version fallback when package not found."""
|
||||
checker = VersionChecker()
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
with patch(
|
||||
"importlib.metadata.version",
|
||||
side_effect=importlib.metadata.PackageNotFoundError("Not found"),
|
||||
):
|
||||
version = checker.current_version()
|
||||
assert version == "0.0.0"
|
||||
|
||||
|
||||
def test_parse_version_simple(app):
|
||||
"""Test parsing simple version string."""
|
||||
result = VersionChecker._parse_version("1.2.3")
|
||||
assert result == (1, 2, 3)
|
||||
|
||||
|
||||
def test_parse_version_complex(app):
|
||||
"""Test parsing complex version string with extra text."""
|
||||
result = VersionChecker._parse_version("v1.2.3-beta")
|
||||
assert result == (1, 2, 3)
|
||||
|
||||
|
||||
def test_parse_version_no_numbers(app):
|
||||
"""Test parsing version string with no numbers."""
|
||||
result = VersionChecker._parse_version("invalid")
|
||||
assert result == (0,)
|
||||
|
||||
|
||||
def test_parse_version_single_number(app):
|
||||
"""Test parsing version with single number."""
|
||||
result = VersionChecker._parse_version("5")
|
||||
assert result == (5,)
|
||||
|
||||
|
||||
def test_is_newer_version_true(app):
|
||||
"""Test detecting newer version."""
|
||||
checker = VersionChecker()
|
||||
|
||||
assert checker._is_newer_version("1.2.3", "1.2.2") is True
|
||||
assert checker._is_newer_version("2.0.0", "1.9.9") is True
|
||||
assert checker._is_newer_version("1.3.0", "1.2.9") is True
|
||||
|
||||
|
||||
def test_is_newer_version_false(app):
|
||||
"""Test detecting same or older version."""
|
||||
checker = VersionChecker()
|
||||
|
||||
assert checker._is_newer_version("1.2.3", "1.2.3") is False
|
||||
assert checker._is_newer_version("1.2.2", "1.2.3") is False
|
||||
assert checker._is_newer_version("0.9.9", "1.0.0") is False
|
||||
|
||||
|
||||
def test_logo_pixmap(app):
|
||||
"""Test generating logo pixmap."""
|
||||
checker = VersionChecker()
|
||||
|
||||
pixmap = checker._logo_pixmap(96)
|
||||
|
||||
assert isinstance(pixmap, QPixmap)
|
||||
assert not pixmap.isNull()
|
||||
|
||||
|
||||
def test_logo_pixmap_different_sizes(app):
|
||||
"""Test generating logo pixmap with different sizes."""
|
||||
checker = VersionChecker()
|
||||
|
||||
pixmap_small = checker._logo_pixmap(48)
|
||||
pixmap_large = checker._logo_pixmap(128)
|
||||
|
||||
assert not pixmap_small.isNull()
|
||||
assert not pixmap_large.isNull()
|
||||
|
||||
|
||||
def test_show_version_dialog(qtbot, app):
|
||||
"""Test showing version dialog."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
with patch.object(QMessageBox, "exec") as mock_exec:
|
||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
||||
checker.show_version_dialog()
|
||||
|
||||
# Dialog should have been shown
|
||||
assert mock_exec.called
|
||||
|
||||
|
||||
def test_check_for_updates_network_error(qtbot, app):
|
||||
"""Test check for updates when network request fails."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
with patch("requests.get", side_effect=Exception("Network error")):
|
||||
with patch.object(QMessageBox, "warning") as mock_warning:
|
||||
checker.check_for_updates()
|
||||
|
||||
# Should show warning
|
||||
assert mock_warning.called
|
||||
|
||||
|
||||
def test_check_for_updates_empty_response(qtbot, app):
|
||||
"""Test check for updates with empty version string."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.text = " "
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
with patch.object(QMessageBox, "warning") as mock_warning:
|
||||
checker.check_for_updates()
|
||||
|
||||
# Should show warning about empty version
|
||||
assert mock_warning.called
|
||||
|
||||
|
||||
def test_check_for_updates_already_latest(qtbot, app):
|
||||
"""Test check for updates when already on latest version."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.text = "1.0.0"
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
||||
with patch.object(QMessageBox, "information") as mock_info:
|
||||
checker.check_for_updates()
|
||||
|
||||
# Should show info that we're on latest
|
||||
assert mock_info.called
|
||||
|
||||
|
||||
def test_check_for_updates_new_version_available_declined(qtbot, app):
|
||||
"""Test check for updates when new version is available but user declines."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.text = "2.0.0"
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
|
||||
# Should not proceed to download
|
||||
checker.check_for_updates()
|
||||
|
||||
|
||||
def test_check_for_updates_new_version_available_accepted(qtbot, app):
|
||||
"""Test check for updates when new version is available and user accepts."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.text = "2.0.0"
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
with patch.object(
|
||||
checker, "_download_and_verify_appimage"
|
||||
) as mock_download:
|
||||
checker.check_for_updates()
|
||||
|
||||
# Should call download
|
||||
mock_download.assert_called_once_with("2.0.0")
|
||||
|
||||
|
||||
def test_download_file_success(qtbot, app, tmp_path):
|
||||
"""Test downloading a file successfully."""
|
||||
checker = VersionChecker()
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.headers = {"Content-Length": "1000"}
|
||||
mock_response.iter_content = Mock(return_value=[b"data" * 25]) # 100 bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
dest_path = tmp_path / "test_file.bin"
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
checker._download_file("http://example.com/file", dest_path)
|
||||
|
||||
assert dest_path.exists()
|
||||
|
||||
|
||||
def test_download_file_with_progress(qtbot, app, tmp_path):
|
||||
"""Test downloading a file with progress dialog."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.headers = {"Content-Length": "1000"}
|
||||
mock_response.iter_content = Mock(return_value=[b"x" * 100, b"y" * 100])
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
dest_path = tmp_path / "test_file.bin"
|
||||
|
||||
from PySide6.QtWidgets import QProgressDialog
|
||||
|
||||
mock_progress = Mock(spec=QProgressDialog)
|
||||
mock_progress.wasCanceled = Mock(return_value=False)
|
||||
mock_progress.value = Mock(return_value=0)
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
checker._download_file(
|
||||
"http://example.com/file", dest_path, progress=mock_progress
|
||||
)
|
||||
|
||||
# Progress should have been updated
|
||||
assert mock_progress.setValue.called
|
||||
|
||||
|
||||
def test_download_file_cancelled(qtbot, app, tmp_path):
|
||||
"""Test cancelling a file download."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.headers = {"Content-Length": "1000"}
|
||||
mock_response.iter_content = Mock(return_value=[b"x" * 100])
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
dest_path = tmp_path / "test_file.bin"
|
||||
|
||||
from PySide6.QtWidgets import QProgressDialog
|
||||
|
||||
mock_progress = Mock(spec=QProgressDialog)
|
||||
mock_progress.wasCanceled = Mock(return_value=True)
|
||||
mock_progress.value = Mock(return_value=0)
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError):
|
||||
checker._download_file(
|
||||
"http://example.com/file", dest_path, progress=mock_progress
|
||||
)
|
||||
|
||||
|
||||
def test_download_file_no_content_length(qtbot, app, tmp_path):
|
||||
"""Test downloading file without Content-Length header."""
|
||||
checker = VersionChecker()
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.headers = {}
|
||||
mock_response.iter_content = Mock(return_value=[b"data"])
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
dest_path = tmp_path / "test_file.bin"
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
checker._download_file("http://example.com/file", dest_path)
|
||||
|
||||
assert dest_path.exists()
|
||||
|
||||
|
||||
def test_download_and_verify_appimage_download_cancelled(qtbot, app, tmp_path):
|
||||
"""Test AppImage download when user cancels."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
with patch(
|
||||
"bouquin.version_check.QStandardPaths.writableLocation",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
with patch.object(
|
||||
checker, "_download_file", side_effect=RuntimeError("Download cancelled")
|
||||
):
|
||||
with patch.object(QMessageBox, "information") as mock_info:
|
||||
checker._download_and_verify_appimage("2.0.0")
|
||||
|
||||
# Should show cancellation message
|
||||
assert mock_info.called
|
||||
|
||||
|
||||
def test_download_and_verify_appimage_download_error(qtbot, app, tmp_path):
|
||||
"""Test AppImage download when download fails."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
with patch(
|
||||
"bouquin.version_check.QStandardPaths.writableLocation",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
with patch.object(
|
||||
checker, "_download_file", side_effect=Exception("Network error")
|
||||
):
|
||||
with patch.object(QMessageBox, "critical") as mock_critical:
|
||||
checker._download_and_verify_appimage("2.0.0")
|
||||
|
||||
# Should show error message
|
||||
assert mock_critical.called
|
||||
|
||||
|
||||
def test_download_and_verify_appimage_gpg_key_error(qtbot, app, tmp_path):
|
||||
"""Test AppImage verification when GPG key cannot be read."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
with patch(
|
||||
"bouquin.version_check.QStandardPaths.writableLocation",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
with patch.object(checker, "_download_file"):
|
||||
with patch(
|
||||
"importlib.resources.files", side_effect=Exception("Key not found")
|
||||
):
|
||||
with patch.object(QMessageBox, "critical") as mock_critical:
|
||||
checker._download_and_verify_appimage("2.0.0")
|
||||
|
||||
# Should show error about GPG key
|
||||
assert mock_critical.called
|
||||
|
||||
|
||||
def test_download_and_verify_appimage_gpg_not_found(qtbot, app, tmp_path):
|
||||
"""Test AppImage verification when GPG is not installed."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_files = Mock()
|
||||
mock_files.read_bytes = Mock(return_value=b"fake key data")
|
||||
|
||||
with patch(
|
||||
"bouquin.version_check.QStandardPaths.writableLocation",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
with patch.object(checker, "_download_file"):
|
||||
with patch("importlib.resources.files", return_value=mock_files):
|
||||
with patch(
|
||||
"subprocess.run", side_effect=FileNotFoundError("gpg not found")
|
||||
):
|
||||
with patch.object(QMessageBox, "critical") as mock_critical:
|
||||
checker._download_and_verify_appimage("2.0.0")
|
||||
|
||||
# Should show error about GPG not found
|
||||
assert mock_critical.called
|
||||
|
||||
|
||||
def test_download_and_verify_appimage_verification_failed(qtbot, app, tmp_path):
|
||||
"""Test AppImage verification when signature verification fails."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_files = Mock()
|
||||
mock_files.read_bytes = Mock(return_value=b"fake key data")
|
||||
|
||||
with patch(
|
||||
"bouquin.version_check.QStandardPaths.writableLocation",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
with patch.object(checker, "_download_file"):
|
||||
with patch("importlib.resources.files", return_value=mock_files):
|
||||
# First subprocess call (import) succeeds, second (verify) fails
|
||||
mock_error = subprocess.CalledProcessError(1, "gpg")
|
||||
mock_error.stderr = b"Verification failed"
|
||||
with patch("subprocess.run", side_effect=[None, mock_error]):
|
||||
with patch.object(QMessageBox, "critical") as mock_critical:
|
||||
checker._download_and_verify_appimage("2.0.0")
|
||||
|
||||
# Should show error about verification
|
||||
assert mock_critical.called
|
||||
|
||||
|
||||
def test_download_and_verify_appimage_success(qtbot, app, tmp_path):
|
||||
"""Test successful AppImage download and verification."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_files = Mock()
|
||||
mock_files.read_bytes = Mock(return_value=b"fake key data")
|
||||
|
||||
with patch(
|
||||
"bouquin.version_check.QStandardPaths.writableLocation",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
with patch.object(checker, "_download_file"):
|
||||
with patch("importlib.resources.files", return_value=mock_files):
|
||||
with patch("subprocess.run"): # Both calls succeed
|
||||
with patch.object(QMessageBox, "information") as mock_info:
|
||||
checker._download_and_verify_appimage("2.0.0")
|
||||
|
||||
# Should show success message
|
||||
assert mock_info.called
|
||||
|
||||
|
||||
def test_version_comparison_edge_cases(app):
|
||||
"""Test version comparison with edge cases."""
|
||||
checker = VersionChecker()
|
||||
|
||||
# Different lengths
|
||||
assert checker._is_newer_version("1.0.0.1", "1.0.0") is True
|
||||
assert checker._is_newer_version("1.0", "1.0.0") is False
|
||||
|
||||
# Large numbers
|
||||
assert checker._is_newer_version("10.0.0", "9.9.9") is True
|
||||
assert checker._is_newer_version("1.100.0", "1.99.0") is True
|
||||
|
||||
|
||||
def test_download_file_creates_parent_directory(qtbot, app, tmp_path):
|
||||
"""Test that download creates parent directory if needed."""
|
||||
checker = VersionChecker()
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.headers = {}
|
||||
mock_response.iter_content = Mock(return_value=[b"data"])
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
dest_path = tmp_path / "subdir" / "nested" / "test_file.bin"
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
checker._download_file("http://example.com/file", dest_path)
|
||||
|
||||
assert dest_path.exists()
|
||||
assert dest_path.parent.exists()
|
||||
|
||||
|
||||
def test_show_version_dialog_check_button_clicked(qtbot, app):
|
||||
"""Test clicking 'Check for updates' button in version dialog."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
checker = VersionChecker(parent)
|
||||
|
||||
mock_box = Mock(spec=QMessageBox)
|
||||
check_button = Mock()
|
||||
mock_box.clickedButton = Mock(return_value=check_button)
|
||||
mock_box.addButton = Mock(return_value=check_button)
|
||||
|
||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
||||
with patch("bouquin.version_check.QMessageBox", return_value=mock_box):
|
||||
with patch.object(checker, "check_for_updates") as mock_check:
|
||||
checker.show_version_dialog()
|
||||
|
||||
# check_for_updates should be called when button is clicked
|
||||
if mock_box.clickedButton() is check_button:
|
||||
assert mock_check.called
|
||||
|
||||
|
||||
def test_parse_version_with_letters(app):
|
||||
"""Test parsing version strings with letters."""
|
||||
result = VersionChecker._parse_version("1.2.3rc1")
|
||||
assert 1 in result
|
||||
assert 2 in result
|
||||
assert 3 in result
|
||||
|
||||
|
||||
def test_download_file_invalid_content_length(qtbot, app, tmp_path):
|
||||
"""Test downloading file with invalid Content-Length header."""
|
||||
checker = VersionChecker()
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.headers = {"Content-Length": "invalid"}
|
||||
mock_response.iter_content = Mock(return_value=[b"data"])
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
dest_path = tmp_path / "test_file.bin"
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
# Should handle gracefully
|
||||
checker._download_file("http://example.com/file", dest_path)
|
||||
|
||||
assert dest_path.exists()
|
||||
|
||||
|
||||
def test_version_checker_creation(qtbot):
|
||||
"""Test creating a VersionChecker instance."""
|
||||
widget = QWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
checker = VersionChecker(widget)
|
||||
assert checker is not None
|
||||
|
||||
|
||||
def test_current_version(qtbot):
|
||||
"""Test getting the current version."""
|
||||
widget = QWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
checker = VersionChecker(widget)
|
||||
version = checker.current_version()
|
||||
|
||||
# Version should be a string
|
||||
assert isinstance(version, str)
|
||||
assert len(version) > 0
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
from bouquin.db import DBManager
|
||||
from bouquin.flow_layout import FlowLayout
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.markdown_highlighter import MarkdownHighlighter
|
||||
from bouquin.db import DBManager
|
||||
from bouquin.statistics_dialog import DateHeatMap
|
||||
|
||||
DBManager.row_factory
|
||||
|
||||
DateHeatMap.minimumSizeHint
|
||||
|
||||
FlowLayout.itemAt
|
||||
FlowLayout.expandingDirections
|
||||
FlowLayout.hasHeightForWidth
|
||||
|
|
@ -15,6 +18,7 @@ MarkdownEditor.apply_italic
|
|||
MarkdownEditor.apply_strikethrough
|
||||
MarkdownEditor.apply_code
|
||||
MarkdownEditor.apply_heading
|
||||
MarkdownEditor.contextMenuEvent
|
||||
MarkdownEditor.toggle_bullets
|
||||
MarkdownEditor.toggle_numbers
|
||||
MarkdownEditor.toggle_checkboxes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue