Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a9d2c4bcc |
|
|
@ -1,37 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
python3-venv pipx libgl1 libxcb-cursor0 libxkbcommon-x11-0 libegl1 libdbus-1-3 \
|
||||
libopengl0 libx11-6 libxext6 libxi6 libxrender1 libxrandr2 \
|
||||
libxcb1 libxcb-render0 libxcb-keysyms1 libxcb-image0 libxcb-shm0 \
|
||||
libxcb-icccm4 libxcb-xfixes0 libxcb-shape0 libxcb-randr0 libxcb-xinerama0 \
|
||||
libxkbcommon0
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
pipx install poetry==1.8.3
|
||||
/root/.local/bin/poetry --version
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install project deps (including test extras)
|
||||
run: |
|
||||
poetry install --with test
|
||||
|
||||
- name: Run test script
|
||||
run: |
|
||||
./tests.sh
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
black pyflakes3 vulture python3-bandit
|
||||
|
||||
- name: Run linters
|
||||
run: |
|
||||
black --diff --check bouquin/*
|
||||
black --diff --check tests/*
|
||||
pyflakes3 bouquin/*
|
||||
pyflakes3 tests/*
|
||||
vulture
|
||||
bandit -s B110 -r bouquin/
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
name: Trivy
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
push:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
|
||||
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
|
||||
|
||||
- name: Run trivy
|
||||
run: |
|
||||
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
|
||||
1
.gitignore
vendored
|
|
@ -4,4 +4,3 @@ __pycache__
|
|||
.pytest_cache
|
||||
dist
|
||||
.coverage
|
||||
*.db
|
||||
|
|
|
|||
152
CHANGELOG.md
|
|
@ -1,155 +1,3 @@
|
|||
# 0.5.2
|
||||
|
||||
* Update icon again to remove background
|
||||
* Adjust History icon and reorder toolbar items
|
||||
* Try to address checkbox/bullet size issues (again)
|
||||
* Fix HTML export of markdown (with newlines, tables and other styling preserved)
|
||||
* Remove table tool
|
||||
|
||||
# 0.5.1
|
||||
|
||||
* Try to address Noto Sans font issue that works for both numbers and checkbox/bullets.
|
||||
* Update icon
|
||||
* Update French translations
|
||||
* Improve size of flashing reminder dialog
|
||||
|
||||
# 0.5
|
||||
|
||||
* More Italian translations, thank you @mdaleo404
|
||||
* Set locked status on window title when locked
|
||||
* Don't exit on incorrect key, let it be tried again
|
||||
* Make reminders be its own dataset rather than tied to current string.
|
||||
* Add support for repeated reminders
|
||||
* Make reminders be a feature that can be turned on and off
|
||||
* Add syntax highlighting for code blocks (right-click to set it)
|
||||
* Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
|
||||
* Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
|
||||
|
||||
# 0.4.5
|
||||
|
||||
* Make it possible to delete revisions
|
||||
* Make it possible to force-lock the screen even if idle timer hasn't tripped
|
||||
* Add shortcuts for lock and unlock of screen
|
||||
* Other misc bug fixes
|
||||
|
||||
# 0.4.4.1
|
||||
|
||||
* Adjust some widget heights/settings text wrap
|
||||
* Adjust shortcuts
|
||||
* History unicode symbol
|
||||
* Icon in version dialog
|
||||
|
||||
# 0.4.4
|
||||
|
||||
* Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
|
||||
* Moving unchecked TODOs now skips placing them on weekends.
|
||||
* Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
|
||||
* Check for new version / download new AppImage via the Help -> Version screen.
|
||||
* Remove extra newline after headings
|
||||
|
||||
# 0.4.3
|
||||
|
||||
* Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora
|
||||
|
||||
# 0.4.2
|
||||
|
||||
* Improve Statistics widget height
|
||||
* Improve SaveDialog widget width
|
||||
* Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
|
||||
* Make it possible to change regular text size
|
||||
* Refactored Settings dialog to use tabs to reduce its size
|
||||
|
||||
# 0.4.1
|
||||
|
||||
* Allow time log entries to be edited directly in their table cells
|
||||
* Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline)
|
||||
* Add 'Close tab' nav item and shortcut
|
||||
|
||||
# 0.4
|
||||
|
||||
* Remove screenshot tool
|
||||
* Improve width of bug report dialog
|
||||
* Improve size of checkboxes
|
||||
* Convert bullet - to actual unicode bullets
|
||||
* Add alarm option to set reminders
|
||||
* Add time logging and reporting
|
||||
|
||||
# 0.3.2
|
||||
|
||||
* Add weekday letters on left axis of Statistics page
|
||||
* Allow clicking on a date in the Statistics heatmap and have it open that page
|
||||
* Add the ability to choose the database path at startup
|
||||
* Add in-app bug report functionality
|
||||
|
||||
# 0.3.1
|
||||
|
||||
* Make it possible to add a tag from the Tag Browser
|
||||
* Add a statistics dialog with heatmap
|
||||
* Remove export to .txt (just use .md)
|
||||
* Restore link styling and clickability
|
||||
|
||||
# 0.3
|
||||
|
||||
* Introduce Tags
|
||||
* Make translations dynamically detected from the locales dir rather than hardcoded
|
||||
* Add Italian translations (thanks @mdaleo404)
|
||||
* Add version information in the navigation
|
||||
* Increase line spacing between lines (except for code blocks)
|
||||
* Prevent being able to left-click a date and have it load in current tab if it is already open in another tab
|
||||
* Avoid second checkbox/bullet on second newline after first newline
|
||||
* Avoid Home/left arrow jumping to the left side of a list symbol
|
||||
* Various test additions/fixes
|
||||
|
||||
# 0.2.1.8
|
||||
|
||||
* Translate all strings, add French, add locale choice in settings
|
||||
* Fix hiding status bar (including find bar) when locked
|
||||
|
||||
# 0.2.1.7
|
||||
|
||||
* Fix being able to set bold, italic and strikethrough at the same time.
|
||||
* Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager
|
||||
* Add AppImage
|
||||
|
||||
# 0.2.1.6
|
||||
|
||||
* Some code cleanup and more coverage
|
||||
* Improve code block styling / escaping out of the block in various scenarios
|
||||
|
||||
# 0.2.1.5
|
||||
|
||||
* Go back to font size 10 (I might add a switcher later)
|
||||
* Fix bug with not syncing the right calendar date on search (History item would then be wrong too)
|
||||
|
||||
# 0.2.1.4
|
||||
|
||||
* Increase font size of normal text
|
||||
* Fix auto-save of a tab if we are moving to another tab and it has not yet saved
|
||||
* DRY up some code
|
||||
|
||||
# 0.2.1.3
|
||||
|
||||
* Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line
|
||||
* Fix code backticks to not show but still be able to type code easily
|
||||
|
||||
# 0.2.1.2
|
||||
|
||||
* Ensure tabs are ordered by calendar date
|
||||
* Some other code cleanups
|
||||
|
||||
# 0.2.1.1
|
||||
|
||||
* Fix history preview pane to be in markdown
|
||||
* Some other code cleanups
|
||||
|
||||
# 0.2.1
|
||||
|
||||
* Introduce tabs!
|
||||
|
||||
# 0.2.0.1
|
||||
|
||||
* Fix chomping images when TODO is typed and converts to a checkbox
|
||||
|
||||
# 0.2.0
|
||||
|
||||
* Switch back to Markdown editor
|
||||
|
|
|
|||
73
README.md
|
|
@ -1,16 +1,9 @@
|
|||
# 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 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.
|
||||
Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
|
||||
|
||||
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.
|
||||
|
|
@ -18,78 +11,42 @@ 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, other than the option to send a bug
|
||||
report from within the app, or optionally to check for new versions to upgrade to.
|
||||
There is deliberately no network connectivity or syncing intended.
|
||||
|
||||
## Screenshots
|
||||
## Screenshot
|
||||
|
||||
### 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>
|
||||

|
||||
|
||||
### 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
|
||||
## Features
|
||||
|
||||
* Data is encrypted at rest
|
||||
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
||||
* All changes are version controlled, with ability to view/diff versions, revert or delete revisions
|
||||
* Automatic rendering of basic Markdown syntax
|
||||
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
||||
* 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
|
||||
* Images are supported
|
||||
* Search all pages, or find text on current page
|
||||
* Add and manage tags
|
||||
* Search
|
||||
* 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, html, csv, markdown or .sql (for sqlite3)
|
||||
* Export the database to json, txt, 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 theme support
|
||||
* Dark and light themes
|
||||
* Automatically generate checkboxes when typing 'TODO'
|
||||
* 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
|
||||
* Optionally automatically move unchecked checkboxes from yesterday to today, on startup
|
||||
|
||||
|
||||
## How to install
|
||||
|
||||
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||
|
||||
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
|
||||
|
||||
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
|
||||
|
||||
### From PyPi/pip
|
||||
|
||||
* `pip install bouquin`
|
||||
|
||||
### From AppImage
|
||||
|
||||
* Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it.
|
||||
|
||||
### From source
|
||||
|
||||
* Clone this repo or download the tarball from the releases page
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Bouquin
|
||||
Exec=Bouquin.AppImage
|
||||
Icon=bouquin
|
||||
Categories=Office
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
import requests
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QTextEdit,
|
||||
QDialogButtonBox,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
BUG_REPORT_HOST = "https://nr.mig5.net"
|
||||
ROUTE = "forms/bouquin/bugs"
|
||||
|
||||
|
||||
class BugReportDialog(QDialog):
|
||||
"""
|
||||
Dialog to collect a bug report
|
||||
"""
|
||||
|
||||
MAX_CHARS = 5000
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(strings._("report_a_bug"))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QLabel(strings._("bug_report_explanation"))
|
||||
header.setWordWrap(True)
|
||||
layout.addWidget(header)
|
||||
|
||||
self.text_edit = QTextEdit()
|
||||
self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
self.text_edit.textChanged.connect(self._enforce_max_length)
|
||||
|
||||
# Buttons: Cancel / Send
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
|
||||
button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
|
||||
button_box.accepted.connect(self._send)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
self.setMinimumWidth(560)
|
||||
|
||||
self.text_edit.setFocus()
|
||||
|
||||
# ------------Helpers ------------ #
|
||||
|
||||
def _enforce_max_length(self):
|
||||
text = self.text_edit.toPlainText()
|
||||
if len(text) <= self.MAX_CHARS:
|
||||
return
|
||||
|
||||
# Remember cursor position
|
||||
cursor = self.text_edit.textCursor()
|
||||
pos = cursor.position()
|
||||
|
||||
# Trim and restore without re-entering this slot
|
||||
self.text_edit.blockSignals(True)
|
||||
self.text_edit.setPlainText(text[: self.MAX_CHARS])
|
||||
self.text_edit.blockSignals(False)
|
||||
|
||||
cursor.setPosition(pos)
|
||||
self.text_edit.setTextCursor(cursor)
|
||||
|
||||
def _send(self):
|
||||
text = self.text_edit.toPlainText().strip()
|
||||
if not text:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_empty"),
|
||||
)
|
||||
return
|
||||
|
||||
# Get current app version
|
||||
version = importlib.metadata.version("bouquin")
|
||||
|
||||
payload: dict[str, str] = {
|
||||
"message": text,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
# POST as JSON
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{BUG_REPORT_HOST}/{ROUTE}",
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_send_failed") + f"\n{e}",
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status_code == 201:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_sent_ok"),
|
||||
)
|
||||
self.accept()
|
||||
else:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
strings._("report_a_bug"),
|
||||
strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
|
||||
)
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
|
||||
from PySide6.QtGui import QColor, QTextCharFormat, QFont
|
||||
|
||||
|
||||
class CodeHighlighter:
|
||||
"""Syntax highlighter for different programming languages."""
|
||||
|
||||
# Language keywords
|
||||
KEYWORDS = {
|
||||
"python": [
|
||||
"False",
|
||||
"None",
|
||||
"True",
|
||||
"and",
|
||||
"as",
|
||||
"assert",
|
||||
"async",
|
||||
"await",
|
||||
"break",
|
||||
"class",
|
||||
"continue",
|
||||
"def",
|
||||
"del",
|
||||
"elif",
|
||||
"else",
|
||||
"except",
|
||||
"finally",
|
||||
"for",
|
||||
"from",
|
||||
"global",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"is",
|
||||
"lambda",
|
||||
"nonlocal",
|
||||
"not",
|
||||
"or",
|
||||
"pass",
|
||||
"print",
|
||||
"raise",
|
||||
"return",
|
||||
"try",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
],
|
||||
"javascript": [
|
||||
"abstract",
|
||||
"arguments",
|
||||
"await",
|
||||
"boolean",
|
||||
"break",
|
||||
"byte",
|
||||
"case",
|
||||
"catch",
|
||||
"char",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"double",
|
||||
"else",
|
||||
"enum",
|
||||
"eval",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"final",
|
||||
"finally",
|
||||
"float",
|
||||
"for",
|
||||
"function",
|
||||
"goto",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"int",
|
||||
"interface",
|
||||
"let",
|
||||
"long",
|
||||
"native",
|
||||
"new",
|
||||
"null",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"short",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"synchronized",
|
||||
"this",
|
||||
"throw",
|
||||
"throws",
|
||||
"transient",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"volatile",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
],
|
||||
"php": [
|
||||
"abstract",
|
||||
"and",
|
||||
"array",
|
||||
"as",
|
||||
"break",
|
||||
"callable",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"clone",
|
||||
"const",
|
||||
"continue",
|
||||
"declare",
|
||||
"default",
|
||||
"die",
|
||||
"do",
|
||||
"echo",
|
||||
"else",
|
||||
"elseif",
|
||||
"empty",
|
||||
"enddeclare",
|
||||
"endfor",
|
||||
"endforeach",
|
||||
"endif",
|
||||
"endswitch",
|
||||
"endwhile",
|
||||
"eval",
|
||||
"exit",
|
||||
"extends",
|
||||
"final",
|
||||
"for",
|
||||
"foreach",
|
||||
"function",
|
||||
"global",
|
||||
"goto",
|
||||
"if",
|
||||
"implements",
|
||||
"include",
|
||||
"include_once",
|
||||
"instanceof",
|
||||
"insteadof",
|
||||
"interface",
|
||||
"isset",
|
||||
"list",
|
||||
"namespace",
|
||||
"new",
|
||||
"or",
|
||||
"print",
|
||||
"print_r",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"require",
|
||||
"require_once",
|
||||
"return",
|
||||
"static",
|
||||
"syslog",
|
||||
"switch",
|
||||
"throw",
|
||||
"trait",
|
||||
"try",
|
||||
"unset",
|
||||
"use",
|
||||
"var",
|
||||
"while",
|
||||
"xor",
|
||||
"yield",
|
||||
],
|
||||
"bash": [
|
||||
"if",
|
||||
"then",
|
||||
"echo",
|
||||
"else",
|
||||
"elif",
|
||||
"fi",
|
||||
"case",
|
||||
"esac",
|
||||
"for",
|
||||
"select",
|
||||
"while",
|
||||
"until",
|
||||
"do",
|
||||
"done",
|
||||
"in",
|
||||
"function",
|
||||
"time",
|
||||
"coproc",
|
||||
],
|
||||
"html": [
|
||||
"DOCTYPE",
|
||||
"html",
|
||||
"head",
|
||||
"title",
|
||||
"meta",
|
||||
"link",
|
||||
"style",
|
||||
"script",
|
||||
"body",
|
||||
"div",
|
||||
"span",
|
||||
"p",
|
||||
"a",
|
||||
"img",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"table",
|
||||
"tr",
|
||||
"td",
|
||||
"th",
|
||||
"form",
|
||||
"input",
|
||||
"button",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"br",
|
||||
"hr",
|
||||
],
|
||||
"css": [
|
||||
"color",
|
||||
"background",
|
||||
"background-color",
|
||||
"border",
|
||||
"margin",
|
||||
"padding",
|
||||
"width",
|
||||
"height",
|
||||
"font",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"left",
|
||||
"right",
|
||||
"bottom",
|
||||
"float",
|
||||
"clear",
|
||||
"overflow",
|
||||
"z-index",
|
||||
"opacity",
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_language_patterns(language: str) -> list:
|
||||
"""Get highlighting patterns for a language."""
|
||||
patterns = []
|
||||
|
||||
keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
|
||||
|
||||
if language.lower() in ["python", "bash", "php"]:
|
||||
# Comments (#)
|
||||
patterns.append((r"#.*$", "comment"))
|
||||
|
||||
if language.lower() in ["javascript", "php", "css"]:
|
||||
# Comments (//)
|
||||
patterns.append((r"//.*$", "comment"))
|
||||
# Multi-line comments (/* */)
|
||||
patterns.append((r"/\*.*?\*/", "comment"))
|
||||
|
||||
if language.lower() in ["html", "xml"]:
|
||||
# HTML/XML tags
|
||||
patterns.append((r"<[^>]+>", "tag"))
|
||||
# HTML comments
|
||||
patterns.append((r"<!--.*?-->", "comment"))
|
||||
|
||||
# Numbers
|
||||
patterns.append((r"\b\d+\.?\d*\b", "number"))
|
||||
|
||||
# Keywords
|
||||
for keyword in keywords:
|
||||
patterns.append((r"\b" + keyword + r"\b", "keyword"))
|
||||
|
||||
# Do strings last so they override any of the above (e.g reserved keywords in strings)
|
||||
|
||||
# Strings (double quotes)
|
||||
patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
|
||||
|
||||
# Strings (single quotes)
|
||||
patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
|
||||
|
||||
return patterns
|
||||
|
||||
@staticmethod
|
||||
def get_format_for_type(
|
||||
format_type: str, base_format: QTextCharFormat
|
||||
) -> QTextCharFormat:
|
||||
"""Get text format for a specific syntax type."""
|
||||
fmt = QTextCharFormat(base_format)
|
||||
|
||||
if format_type == "keyword":
|
||||
fmt.setForeground(QColor(86, 156, 214)) # Blue
|
||||
fmt.setFontWeight(QFont.Weight.Bold)
|
||||
elif format_type == "string":
|
||||
fmt.setForeground(QColor(206, 145, 120)) # Orange
|
||||
elif format_type == "comment":
|
||||
fmt.setForeground(QColor(106, 153, 85)) # Green
|
||||
fmt.setFontItalic(True)
|
||||
elif format_type == "number":
|
||||
fmt.setForeground(QColor(181, 206, 168)) # Light green
|
||||
elif format_type == "tag":
|
||||
fmt.setForeground(QColor(78, 201, 176)) # Cyan
|
||||
|
||||
return fmt
|
||||
|
||||
|
||||
class CodeBlockMetadata:
|
||||
"""Stores metadata about code blocks (language, etc.) for a document."""
|
||||
|
||||
def __init__(self):
|
||||
self._block_languages: Dict[int, str] = {} # block_number -> language
|
||||
|
||||
def set_language(self, block_number: int, language: str):
|
||||
"""Set the language for a code block."""
|
||||
self._block_languages[block_number] = language.lower()
|
||||
|
||||
def get_language(self, block_number: int) -> Optional[str]:
|
||||
"""Get the language for a code block."""
|
||||
return self._block_languages.get(block_number)
|
||||
|
||||
def serialize(self) -> str:
|
||||
"""Serialize metadata to a string."""
|
||||
# Store as JSON-like format in a comment at the end
|
||||
if not self._block_languages:
|
||||
return ""
|
||||
|
||||
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
|
||||
return "<!-- code-langs: " + ",".join(items) + " -->"
|
||||
|
||||
def deserialize(self, text: str):
|
||||
"""Deserialize metadata from text."""
|
||||
self._block_languages.clear()
|
||||
|
||||
# Look for metadata comment at the end
|
||||
match = re.search(r"<!-- code-langs: ([^>]+) -->", text)
|
||||
if match:
|
||||
pairs = match.group(1).split(",")
|
||||
for pair in pairs:
|
||||
if ":" in pair:
|
||||
block_num, lang = pair.split(":", 1)
|
||||
try:
|
||||
self._block_languages[int(block_num)] = lang
|
||||
except ValueError:
|
||||
pass
|
||||
909
bouquin/db.py
|
|
@ -17,14 +17,13 @@ from PySide6.QtWidgets import (
|
|||
QTextEdit,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
class FindBar(QWidget):
|
||||
"""Widget for finding text in the Editor"""
|
||||
|
||||
# emitted when the bar is hidden (Esc/✕), so caller can refocus editor
|
||||
closed = Signal()
|
||||
closed = (
|
||||
Signal()
|
||||
) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -32,28 +31,23 @@ class FindBar(QWidget):
|
|||
shortcut_parent: QWidget | None = None,
|
||||
parent: QWidget | None = None,
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.editor = editor
|
||||
|
||||
# store how to get the current editor
|
||||
self._editor_getter = editor if callable(editor) else (lambda: editor)
|
||||
self.shortcut_parent = shortcut_parent
|
||||
|
||||
# UI (build ONCE)
|
||||
# UI
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
layout.addWidget(QLabel(strings._("find")))
|
||||
|
||||
layout.addWidget(QLabel("Find:"))
|
||||
self.edit = QLineEdit(self)
|
||||
self.edit.setPlaceholderText(strings._("find_bar_type_to_search"))
|
||||
self.edit.setPlaceholderText("Type to search…")
|
||||
layout.addWidget(self.edit)
|
||||
|
||||
self.case = QCheckBox(strings._("find_bar_match_case"), self)
|
||||
self.case = QCheckBox("Match case", self)
|
||||
layout.addWidget(self.case)
|
||||
|
||||
self.prevBtn = QPushButton(strings._("previous"), self)
|
||||
self.nextBtn = QPushButton(strings._("next"), self)
|
||||
self.prevBtn = QPushButton("Prev", self)
|
||||
self.nextBtn = QPushButton("Next", self)
|
||||
self.closeBtn = QPushButton("✕", self)
|
||||
self.closeBtn.setFlat(True)
|
||||
layout.addWidget(self.prevBtn)
|
||||
|
|
@ -62,15 +56,11 @@ class FindBar(QWidget):
|
|||
|
||||
self.setVisible(False)
|
||||
|
||||
# Shortcut (press Esc to hide bar)
|
||||
sp = (
|
||||
self.shortcut_parent
|
||||
if self.shortcut_parent is not None
|
||||
else (self.parent() or self)
|
||||
)
|
||||
QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
|
||||
# Shortcut escape key to close findBar
|
||||
sp = shortcut_parent if shortcut_parent is not None else (parent or self)
|
||||
self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
|
||||
|
||||
# Signals (connect ONCE)
|
||||
# Signals
|
||||
self.edit.returnPressed.connect(self.find_next)
|
||||
self.edit.textChanged.connect(self._update_highlight)
|
||||
self.case.toggled.connect(self._update_highlight)
|
||||
|
|
@ -78,17 +68,10 @@ class FindBar(QWidget):
|
|||
self.prevBtn.clicked.connect(self.find_prev)
|
||||
self.closeBtn.clicked.connect(self.hide_bar)
|
||||
|
||||
@property
|
||||
def editor(self) -> QTextEdit | None:
|
||||
"""Get the current editor"""
|
||||
return self._editor_getter()
|
||||
|
||||
# ----- Public API -----
|
||||
|
||||
def show_bar(self):
|
||||
"""Show the bar, seed with current selection if sensible, focus the line edit."""
|
||||
if not self.editor:
|
||||
return
|
||||
tc = self.editor.textCursor()
|
||||
sel = tc.selectedText().strip()
|
||||
if sel and "\u2029" not in sel: # ignore multi-paragraph selections
|
||||
|
|
@ -172,8 +155,6 @@ class FindBar(QWidget):
|
|||
self._update_highlight()
|
||||
|
||||
def _update_highlight(self):
|
||||
if not self.editor:
|
||||
return
|
||||
txt = self.edit.text()
|
||||
if not txt:
|
||||
self._clear_highlight()
|
||||
|
|
@ -202,5 +183,4 @@ class FindBar(QWidget):
|
|||
self.editor.setExtraSelections(selections)
|
||||
|
||||
def _clear_highlight(self):
|
||||
if self.editor:
|
||||
self.editor.setExtraSelections([])
|
||||
self.editor.setExtraSelections([])
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||
from PySide6.QtWidgets import QLayout
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
def __init__(
|
||||
self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._items = []
|
||||
self._hspace = hspacing
|
||||
self._vspace = vspacing
|
||||
self.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def addItem(self, item):
|
||||
self._items.append(item)
|
||||
|
||||
def itemAt(self, index):
|
||||
if 0 <= index < len(self._items):
|
||||
return self._items[index]
|
||||
return None
|
||||
|
||||
def takeAt(self, index):
|
||||
if 0 <= index < len(self._items):
|
||||
return self._items.pop(index)
|
||||
return None
|
||||
|
||||
def count(self):
|
||||
return len(self._items)
|
||||
|
||||
def expandingDirections(self):
|
||||
return Qt.Orientations(Qt.Orientation(0))
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int:
|
||||
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
||||
|
||||
def setGeometry(self, rect: QRect):
|
||||
super().setGeometry(rect)
|
||||
self._do_layout(rect, test_only=False)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self) -> QSize:
|
||||
size = QSize()
|
||||
for item in self._items:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
left, top, right, bottom = self.getContentsMargins()
|
||||
size += QSize(left + right, top + bottom)
|
||||
return size
|
||||
|
||||
def _do_layout(self, rect: QRect, test_only: bool) -> int:
|
||||
x = rect.x()
|
||||
y = rect.y()
|
||||
line_height = 0
|
||||
|
||||
left, top, right, bottom = self.getContentsMargins()
|
||||
effective_rect = rect.adjusted(+left, +top, -right, -bottom)
|
||||
x = effective_rect.x()
|
||||
y = effective_rect.y()
|
||||
max_right = effective_rect.right()
|
||||
|
||||
for item in self._items:
|
||||
wid = item.widget()
|
||||
if wid is None or not wid.isVisible():
|
||||
continue
|
||||
space_x = self._hspace
|
||||
space_y = self._vspace
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
if next_x - space_x > max_right and line_height > 0:
|
||||
# Wrap
|
||||
x = effective_rect.x()
|
||||
y = y + line_height + space_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
line_height = 0
|
||||
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||||
|
||||
x = next_x
|
||||
line_height = max(line_height, item.sizeHint().height())
|
||||
|
||||
return y + line_height - rect.y() + bottom
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
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.
|
||||
|
|
@ -13,11 +13,8 @@ from PySide6.QtWidgets import (
|
|||
QMessageBox,
|
||||
QTextBrowser,
|
||||
QTabWidget,
|
||||
QAbstractItemView,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
def _markdown_to_text(s: str) -> str:
|
||||
"""Convert markdown to plain text for diff comparison."""
|
||||
|
|
@ -46,9 +43,7 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
|||
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
||||
a = _markdown_to_text(old_md).splitlines()
|
||||
b = _markdown_to_text(new_md).splitlines()
|
||||
ud = difflib.unified_diff(
|
||||
a, b, fromfile=strings._("current"), tofile=strings._("selected"), lineterm=""
|
||||
)
|
||||
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
||||
lines = []
|
||||
for line in ud:
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
|
|
@ -72,7 +67,7 @@ class HistoryDialog(QDialog):
|
|||
|
||||
def __init__(self, db, date_iso: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
|
||||
self.setWindowTitle(f"History — {date_iso}")
|
||||
self._db = db
|
||||
self._date = date_iso
|
||||
self._versions = [] # list[dict] from DB
|
||||
|
|
@ -83,7 +78,6 @@ class HistoryDialog(QDialog):
|
|||
# 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)
|
||||
|
|
@ -94,8 +88,8 @@ class HistoryDialog(QDialog):
|
|||
self.preview.setOpenExternalLinks(True)
|
||||
self.diff = QTextBrowser()
|
||||
self.diff.setOpenExternalLinks(False)
|
||||
self.tabs.addTab(self.preview, strings._("history_dialog_preview"))
|
||||
self.tabs.addTab(self.diff, strings._("history_dialog_diff"))
|
||||
self.tabs.addTab(self.preview, "Preview")
|
||||
self.tabs.addTab(self.diff, "Diff")
|
||||
self.tabs.setMinimumSize(500, 650)
|
||||
top.addWidget(self.tabs, 2)
|
||||
|
||||
|
|
@ -104,38 +98,39 @@ class HistoryDialog(QDialog):
|
|||
# Buttons
|
||||
row = QHBoxLayout()
|
||||
row.addStretch(1)
|
||||
self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected"))
|
||||
self.btn_revert = QPushButton("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 = QPushButton("Close")
|
||||
self.btn_close.clicked.connect(self.reject)
|
||||
row.addWidget(self.btn_revert)
|
||||
row.addWidget(self.btn_delete)
|
||||
row.addWidget(self.btn_close)
|
||||
root.addLayout(row)
|
||||
|
||||
self._load_versions()
|
||||
|
||||
# --- Data/UX helpers ---
|
||||
def _load_versions(self):
|
||||
# [{id,version_no,created_at,note,is_current}]
|
||||
self._versions = self._db.list_versions(self._date)
|
||||
def _fmt_local(self, iso_utc: str) -> str:
|
||||
"""
|
||||
Convert UTC in the database to user's local tz
|
||||
"""
|
||||
dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
|
||||
local = dt.astimezone()
|
||||
return local.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
|
||||
def _load_versions(self):
|
||||
self._versions = self._db.list_versions(
|
||||
self._date
|
||||
) # [{id,version_no,created_at,note,is_current}]
|
||||
self._current_id = next(
|
||||
(v["id"] for v in self._versions if v["is_current"]), None
|
||||
)
|
||||
self.list.clear()
|
||||
for v in self._versions:
|
||||
created_at = datetime.fromisoformat(
|
||||
v["created_at"].replace("Z", "+00:00")
|
||||
).astimezone()
|
||||
created_at_local = created_at.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
label = f"v{v['version_no']} — {created_at_local}"
|
||||
label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}"
|
||||
if v.get("note"):
|
||||
label += f" · {v['note']}"
|
||||
if v["is_current"]:
|
||||
label += " **(" + strings._("current") + ")**"
|
||||
label += " **(current)**"
|
||||
it = QListWidgetItem(label)
|
||||
it.setData(Qt.UserRole, v["id"])
|
||||
self.list.addItem(it)
|
||||
|
|
@ -150,28 +145,31 @@ class HistoryDialog(QDialog):
|
|||
|
||||
@Slot()
|
||||
def _on_select(self):
|
||||
selected_items = self.list.selectedItems()
|
||||
item = self.list.currentItem()
|
||||
if not item or len(selected_items) > 1:
|
||||
if not item:
|
||||
self.preview.clear()
|
||||
self.diff.clear()
|
||||
self.btn_revert.setEnabled(False)
|
||||
return
|
||||
|
||||
sel_id = item.data(Qt.UserRole)
|
||||
# Preview selected as plain text (markdown)
|
||||
sel = self._db.get_version(version_id=sel_id)
|
||||
self.preview.setMarkdown(sel["content"])
|
||||
# Show markdown as plain text with monospace font for better readability
|
||||
self.preview.setPlainText(sel["content"])
|
||||
self.preview.setStyleSheet(
|
||||
"font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
|
||||
)
|
||||
# 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 and delete buttons only if selecting a non-current version
|
||||
# Enable revert only if selecting a non-current version
|
||||
self.btn_revert.setEnabled(sel_id != self._current_id)
|
||||
self.btn_delete.setEnabled(sel_id != self._current_id)
|
||||
|
||||
@Slot()
|
||||
def _revert(self):
|
||||
item = self.list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
sel_id = item.data(Qt.UserRole)
|
||||
if sel_id == self._current_id:
|
||||
return
|
||||
|
|
@ -179,24 +177,6 @@ class HistoryDialog(QDialog):
|
|||
try:
|
||||
self._db.revert_to_version(self._date, version_id=sel_id)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self, strings._("history_dialog_revert_failed"), str(e)
|
||||
)
|
||||
QMessageBox.critical(self, "Revert failed", str(e))
|
||||
return
|
||||
self.accept()
|
||||
|
||||
@Slot()
|
||||
def _delete(self):
|
||||
selected_items = self.list.selectedItems()
|
||||
for item in selected_items:
|
||||
sel_id = item.data(Qt.UserRole)
|
||||
if sel_id == self._current_id:
|
||||
return
|
||||
try:
|
||||
self._db.delete_version(version_id=sel_id)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self, strings._("history_dialog_delete_failed"), str(e)
|
||||
)
|
||||
return
|
||||
return self._load_versions()
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 733 B |
|
|
@ -1,107 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
class KeyPrompt(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
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,
|
||||
title: str = "Enter key",
|
||||
message: str = "Enter key",
|
||||
):
|
||||
"""
|
||||
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))
|
||||
|
||||
# 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"))
|
||||
self.edit = QLineEdit()
|
||||
self.edit.setEchoMode(QLineEdit.Password)
|
||||
v.addWidget(self.edit)
|
||||
toggle = QPushButton("Show")
|
||||
toggle.setCheckable(True)
|
||||
toggle.toggled.connect(
|
||||
lambda c: self.key_entry.setEchoMode(
|
||||
lambda c: self.edit.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.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
|
||||
return self.edit.text()
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
-----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-----
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
{
|
||||
"db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed",
|
||||
"db_issues_reported": "issue(s) reported",
|
||||
"db_reopen_failed_after_rekey": "Re-open failed after rekey",
|
||||
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
|
||||
"db_key_incorrect": "The key is probably incorrect",
|
||||
"db_database_error": "Database error",
|
||||
"database_maintenance": "Database maintenance",
|
||||
"database_compact": "Compact the database",
|
||||
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
|
||||
"database_compacted_successfully": "Database compacted successfully!",
|
||||
"encryption": "Encryption",
|
||||
"remember_key": "Remember key",
|
||||
"change_encryption_key": "Change encryption key",
|
||||
"enter_a_new_encryption_key": "Enter a new encryption key",
|
||||
"reenter_the_new_key": "Re-enter the new key",
|
||||
"key_mismatch": "Key mismatch",
|
||||
"key_mismatch_explanation": "The two entries did not match.",
|
||||
"empty_key": "Empty key",
|
||||
"empty_key_explanation": "The key cannot be empty.",
|
||||
"key_changed": "Key changed",
|
||||
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"close": "&Close",
|
||||
"find": "Find",
|
||||
"file": "File",
|
||||
"locale": "Language",
|
||||
"locale_restart": "Please restart the application to load the new language.",
|
||||
"settings": "Settings",
|
||||
"theme": "Theme",
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"never": "Never",
|
||||
"close_tab": "Close tab",
|
||||
"previous": "Previous",
|
||||
"previous_day": "Previous day",
|
||||
"next": "Next",
|
||||
"next_day": "Next day",
|
||||
"today": "Today",
|
||||
"show": "Show",
|
||||
"history": "History",
|
||||
"export_accessible_flag": "&Export",
|
||||
"export_entries": "Export entries",
|
||||
"export_complete": "Export complete",
|
||||
"export_failed": "Export failed",
|
||||
"backup": "Backup",
|
||||
"backup_complete": "Backup complete",
|
||||
"backup_failed": "Backup failed",
|
||||
"quit": "Quit",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"help": "Help",
|
||||
"saved": "Saved",
|
||||
"saved_to": "Saved to",
|
||||
"documentation": "Documentation",
|
||||
"couldnt_open": "Couldn't open",
|
||||
"report_a_bug": "Report a bug",
|
||||
"version": "Version",
|
||||
"update": "Update",
|
||||
"check_for_updates": "Check for updates",
|
||||
"could_not_check_for_updates": "Could not check for updates:\n",
|
||||
"update_server_returned_an_empty_version_string": "Update server returned an empty version string",
|
||||
"you_are_running_the_latest_version": "You are running the latest version:\n",
|
||||
"there_is_a_new_version_available": "There is a new version available:\n",
|
||||
"download_the_appimage": "Download the AppImage?",
|
||||
"downloading": "Downloading",
|
||||
"download_cancelled": "Download cancelled",
|
||||
"failed_to_download_update": "Failed to download update:\n",
|
||||
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
|
||||
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
|
||||
"gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
|
||||
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
|
||||
"navigate": "Navigate",
|
||||
"current": "current",
|
||||
"selected": "selected",
|
||||
"find_on_page": "Find on page",
|
||||
"find_next": "Find next",
|
||||
"find_previous": "Find previous",
|
||||
"find_bar_type_to_search": "Type to search",
|
||||
"find_bar_match_case": "Match case",
|
||||
"history_dialog_preview": "Preview",
|
||||
"history_dialog_diff": "Diff",
|
||||
"history_dialog_revert_to_selected": "&Revert to selected",
|
||||
"history_dialog_revert_failed": "Revert failed",
|
||||
"history_dialog_delete": "&Delete revision",
|
||||
"history_dialog_delete_failed": "Could not delete revision",
|
||||
"key_prompt_enter_key": "Enter key",
|
||||
"lock_overlay_locked": "Locked",
|
||||
"lock_overlay_unlock": "Unlock",
|
||||
"main_window_lock_screen_accessibility": "&Lock screen",
|
||||
"main_window_ready": "Ready",
|
||||
"main_window_save_a_version": "Save a version",
|
||||
"main_window_settings_accessible_flag": "Settin&gs",
|
||||
"set_an_encryption_key": "Set an encryption key",
|
||||
"set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!",
|
||||
"unlock_encrypted_notebook": "Unlock encrypted notebook",
|
||||
"unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook",
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"autosave": "autosave",
|
||||
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
|
||||
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
|
||||
"insert_images": "Insert images",
|
||||
"images": "Images",
|
||||
"reopen_failed": "Re-open failed",
|
||||
"unlock_failed": "Unlock failed",
|
||||
"could_not_unlock_database_at_new_path": "Could not unlock database at new path.",
|
||||
"unencrypted_export": "Unencrypted export",
|
||||
"unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.",
|
||||
"unrecognised_extension": "Unrecognised extension!",
|
||||
"backup_encrypted_notebook": "Backup encrypted notebook",
|
||||
"enter_a_name_for_this_version": "Enter a name for this version",
|
||||
"new_version_i_saved_at": "New version I saved at",
|
||||
"appearance": "Appearance",
|
||||
"security": "Security",
|
||||
"features": "Features",
|
||||
"database": "Database",
|
||||
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
|
||||
"lock_screen_when_idle": "Lock screen when idle",
|
||||
"autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
|
||||
"font_size": "Font size",
|
||||
"font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
|
||||
"search_for_notes_here": "Search for notes here",
|
||||
"toolbar_format": "Format",
|
||||
"toolbar_bold": "Bold",
|
||||
"toolbar_italic": "Italic",
|
||||
"toolbar_strikethrough": "Strikethrough",
|
||||
"toolbar_normal_paragraph_text": "Normal paragraph text",
|
||||
"toolbar_font_smaller": "Smaller text",
|
||||
"toolbar_font_larger": "Larger text",
|
||||
"toolbar_bulleted_list": "Bulleted list",
|
||||
"toolbar_numbered_list": "Numbered list",
|
||||
"toolbar_code_block": "Code block",
|
||||
"toolbar_heading": "Heading",
|
||||
"toolbar_toggle_checkboxes": "Toggle checkboxes",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"manage_tags": "Manage tags",
|
||||
"add_tag_placeholder": "Add a tag and press Enter",
|
||||
"tag_browser_title": "Tag Browser",
|
||||
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
|
||||
"color_hex": "Colour",
|
||||
"date": "Date",
|
||||
"add_a_tag": "Add a tag",
|
||||
"edit_tag_name": "Edit tag name",
|
||||
"new_tag_name": "New tag name:",
|
||||
"change_color": "Change colour",
|
||||
"delete_tag": "Delete tag",
|
||||
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
|
||||
"tag_already_exists_with_that_name": "A tag already exists with that name",
|
||||
"statistics": "Statistics",
|
||||
"main_window_statistics_accessible_flag": "Stat&istics",
|
||||
"stats_pages_with_content": "Pages with content (current version)",
|
||||
"stats_total_revisions": "Total revisions",
|
||||
"stats_page_most_revisions": "Page with most revisions",
|
||||
"stats_total_words": "Total words (current versions)",
|
||||
"stats_unique_tags": "Unique tags",
|
||||
"stats_page_most_tags": "Page with most tags",
|
||||
"stats_activity_heatmap": "Activity heatmap",
|
||||
"stats_heatmap_metric": "Colour by",
|
||||
"stats_metric_words": "Words",
|
||||
"stats_metric_revisions": "Revisions",
|
||||
"stats_no_data": "No statistics available yet.",
|
||||
"select_notebook": "Select notebook",
|
||||
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
|
||||
"bug_report_placeholder": "Type your bug report here",
|
||||
"bug_report_empty": "Please enter some details about the bug before sending.",
|
||||
"bug_report_send_failed": "Could not send bug report.",
|
||||
"bug_report_sent_ok": "Bug report sent. Thank you!",
|
||||
"send": "Send",
|
||||
"reminder": "Reminder",
|
||||
"set_reminder": "Set reminder prompt",
|
||||
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
|
||||
"invalid_time_title": "Invalid time",
|
||||
"invalid_time_message": "Please enter a time in the format HH:MM",
|
||||
"dismiss": "Dismiss",
|
||||
"toolbar_alarm": "Set reminder alarm",
|
||||
"activities": "Activities",
|
||||
"activity": "Activity",
|
||||
"note": "Note",
|
||||
"activity_delete_error_message": "A problem occurred deleting the activity",
|
||||
"activity_delete_error_title": "Problem deleting activity",
|
||||
"activity_rename_error_message": "A problem occurred renaming the activity",
|
||||
"activity_rename_error_title": "Problem renaming activity",
|
||||
"activity_required_message": "An activity name is required",
|
||||
"activity_required_title": "Activity name required",
|
||||
"add_activity": "Add activity",
|
||||
"add_project": "Add project",
|
||||
"add_time_entry": "Add time entry",
|
||||
"time_period": "Time period",
|
||||
"by_day": "by day",
|
||||
"by_month": "by month",
|
||||
"by_week": "by week",
|
||||
"date_range": "Date range",
|
||||
"delete_activity": "Delete activity",
|
||||
"delete_activity_confirm": "Are you sure you want to delete this activity?",
|
||||
"delete_activity_title": "Delete activity - are you sure?",
|
||||
"delete_project": "Delete project",
|
||||
"delete_project_confirm": "Are you sure you want to delete this project?",
|
||||
"delete_project_title": "Delete project - are you sure?",
|
||||
"delete_time_entry": "Delete time entry",
|
||||
"group_by": "Group by",
|
||||
"hours": "Hours",
|
||||
"invalid_activity_message": "The activity is invalid",
|
||||
"invalid_activity_title": "Invalid activity",
|
||||
"invalid_project_message": "The project is invalid",
|
||||
"invalid_project_title": "Invalid project",
|
||||
"manage_activities": "Manage activities",
|
||||
"manage_projects": "Manage projects",
|
||||
"manage_projects_activities": "Manage project activities",
|
||||
"open_time_log": "Open time log",
|
||||
"project": "Project",
|
||||
"project_delete_error_message": "A problem occurred deleting the project",
|
||||
"project_delete_error_title": "Problem deleting project",
|
||||
"project_rename_error_message": "A problem occurred renaming the project",
|
||||
"project_rename_error_title": "Problem renaming project",
|
||||
"project_required_message": "A project is required",
|
||||
"project_required_title": "Project required",
|
||||
"projects": "Projects",
|
||||
"rename_activity": "Rename activity",
|
||||
"rename_project": "Rename project",
|
||||
"run_report": "Run report",
|
||||
"add_activity_title": "Add activity",
|
||||
"add_activity_label": "Add an activity",
|
||||
"rename_activity_label": "Rename activity",
|
||||
"add_project_title": "Add project",
|
||||
"add_project_label": "Add a project",
|
||||
"rename_activity_title": "Rename this activity",
|
||||
"rename_project_label": "Rename project",
|
||||
"rename_project_title": "Rename this project",
|
||||
"select_activity_message": "Select an activity",
|
||||
"select_activity_title": "Select activity",
|
||||
"select_project_message": "Select a project",
|
||||
"select_project_title": "Select project",
|
||||
"time_log": "Time log",
|
||||
"time_log_collapsed_hint": "Time log",
|
||||
"time_log_date_label": "Time log date: {date}",
|
||||
"time_log_for": "Time log for {date}",
|
||||
"time_log_no_date": "Time log",
|
||||
"time_log_no_entries": "No time entries yet",
|
||||
"time_log_report": "Time log report",
|
||||
"time_log_report_title": "Time log for {project}",
|
||||
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
|
||||
"time_log_total_hours": "Total time spent",
|
||||
"time_log_with_total": "Time log ({hours:.2f}h)",
|
||||
"time_log_total_hours": "Total for day: {hours:.2f}h",
|
||||
"update_time_entry": "Update time entry",
|
||||
"time_report_total": "Total: {hours:.2f} hours",
|
||||
"no_report_title": "No report",
|
||||
"no_report_message": "Please run a report before exporting.",
|
||||
"total": "Total",
|
||||
"export_csv": "Export CSV",
|
||||
"export_csv_error_title": "Export failed",
|
||||
"export_csv_error_message": "Could not write CSV file:\n{error}",
|
||||
"export_pdf": "Export PDF",
|
||||
"export_pdf_error_title": "PDF export failed",
|
||||
"export_pdf_error_message": "Could not write PDF file:\n{error}",
|
||||
"enable_tags_feature": "Enable Tags",
|
||||
"enable_time_log_feature": "Enable Time Logging",
|
||||
"enable_reminders_feature": "Enable Reminders",
|
||||
"pomodoro_time_log_default_text": "Focus session",
|
||||
"toolbar_pomodoro_timer": "Time-logging timer",
|
||||
"set_code_language": "Set code language",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"stop_and_log": "Stop and log",
|
||||
"once": "once",
|
||||
"daily": "daily",
|
||||
"weekdays": "weekdays",
|
||||
"weekly": "weekly",
|
||||
"set_reminder": "Set reminder",
|
||||
"edit_reminder": "Edit reminder",
|
||||
"reminder": "Reminder",
|
||||
"time": "Time",
|
||||
"once_today": "Once (today)",
|
||||
"every_day": "Every day",
|
||||
"every_weekday": "Every weekday (Mon-Fri)",
|
||||
"every_week": "Every week",
|
||||
"repeat": "Repeat",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"day": "Day"
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
{
|
||||
"db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher",
|
||||
"db_issues_reported": "problème(s) signalé(s)",
|
||||
"db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé",
|
||||
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
|
||||
"db_key_incorrect": "La clé est probablement incorrecte",
|
||||
"db_database_error": "Erreur de base de données",
|
||||
"database_maintenance": "Maintenance de la base de données",
|
||||
"database_compact": "Compacter la base de données",
|
||||
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
|
||||
"database_compacted_successfully": "Base de données compactée avec succès !",
|
||||
"encryption": "Chiffrement",
|
||||
"remember_key": "Se souvenir de la clé",
|
||||
"change_encryption_key": "Changer la clé de chiffrement",
|
||||
"enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement",
|
||||
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
|
||||
"key_mismatch": "Les clés ne correspondent pas",
|
||||
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
|
||||
"empty_key": "La clé est vide",
|
||||
"empty_key_explanation": "La clé ne peut pas être vide.",
|
||||
"key_changed": "La clé a été modifiée",
|
||||
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"close": "Fermer",
|
||||
"find": "Rechercher",
|
||||
"file": "Fichier",
|
||||
"locale": "Langue",
|
||||
"locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
|
||||
"settings": "Paramètres",
|
||||
"theme": "Thème",
|
||||
"system": "Système",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"never": "Jamais",
|
||||
"close_tab": "Fermer l'onglet",
|
||||
"previous": "Précédent",
|
||||
"previous_day": "Jour précédent",
|
||||
"next": "Suivant",
|
||||
"next_day": "Jour suivant",
|
||||
"today": "Aujourd'hui",
|
||||
"show": "Afficher",
|
||||
"history": "Historique",
|
||||
"export_accessible_flag": "E&xporter",
|
||||
"export_entries": "Exporter les entrées",
|
||||
"export_complete": "Exportation terminée",
|
||||
"export_failed": "Échec de l'exportation",
|
||||
"backup": "Sauvegarder",
|
||||
"backup_complete": "Sauvegarde terminée",
|
||||
"backup_failed": "Échec de la sauvegarde",
|
||||
"quit": "Quitter",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"help": "Aide",
|
||||
"saved": "Enregistré",
|
||||
"saved_to": "Enregistré dans",
|
||||
"documentation": "Documentation",
|
||||
"couldnt_open": "Impossible d'ouvrir",
|
||||
"report_a_bug": "Signaler un bug",
|
||||
"version": "Version",
|
||||
"update": "Mise à jour",
|
||||
"check_for_updates": "Rechercher des mises à jour",
|
||||
"could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
|
||||
"update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
|
||||
"you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
|
||||
"there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
|
||||
"download_the_appimage": "Télécharger l'AppImage ?",
|
||||
"downloading": "Téléchargement en cours",
|
||||
"download_cancelled": "Téléchargement annulé",
|
||||
"failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
|
||||
"could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
|
||||
"could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
|
||||
"gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
|
||||
"downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
|
||||
"navigate": "Naviguer",
|
||||
"current": "actuel",
|
||||
"selected": "sélectionné",
|
||||
"find_on_page": "Rechercher dans la page",
|
||||
"find_next": "Rechercher le suivant",
|
||||
"find_previous": "Rechercher le précédent",
|
||||
"find_bar_type_to_search": "Tapez pour rechercher",
|
||||
"find_bar_match_case": "Respecter la casse",
|
||||
"history_dialog_preview": "Aperçu",
|
||||
"history_dialog_diff": "Différences",
|
||||
"history_dialog_revert_to_selected": "Revenir à la sélection",
|
||||
"history_dialog_revert_failed": "Échec de la restauration",
|
||||
"history_dialog_delete": "Supprimer la révision",
|
||||
"history_dialog_delete_failed": "Impossible de supprimer la révision",
|
||||
"key_prompt_enter_key": "Saisir la clé",
|
||||
"lock_overlay_locked": "Verrouillé",
|
||||
"lock_overlay_unlock": "Déverrouiller",
|
||||
"main_window_lock_screen_accessibility": "&Verrouiller l'écran",
|
||||
"main_window_ready": "Prêt",
|
||||
"main_window_save_a_version": "Enregistrer une version",
|
||||
"main_window_settings_accessible_flag": "&Paramètres",
|
||||
"set_an_encryption_key": "Définir une clé de chiffrement",
|
||||
"set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !",
|
||||
"unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré",
|
||||
"unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin",
|
||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||
"autosave": "enregistrement automatique",
|
||||
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
|
||||
"move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable",
|
||||
"insert_images": "Insérer des images",
|
||||
"images": "Images",
|
||||
"reopen_failed": "Échec de la réouverture",
|
||||
"unlock_failed": "Échec du déverrouillage",
|
||||
"could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.",
|
||||
"unencrypted_export": "Export non chiffré",
|
||||
"unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.",
|
||||
"unrecognised_extension": "Extension non reconnue !",
|
||||
"backup_encrypted_notebook": "Sauvegarder le bouquin chiffré",
|
||||
"enter_a_name_for_this_version": "Saisir un nom pour cette version",
|
||||
"new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à",
|
||||
"appearance": "Apparence",
|
||||
"security": "Sécurité",
|
||||
"features": "Fonctionnalités",
|
||||
"database": "Base de données",
|
||||
"save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
|
||||
"lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité",
|
||||
"autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
|
||||
"font_size": "Taille de police",
|
||||
"font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.",
|
||||
"search_for_notes_here": "Recherchez des notes ici",
|
||||
"toolbar_format": "Format",
|
||||
"toolbar_bold": "Gras",
|
||||
"toolbar_italic": "Italique",
|
||||
"toolbar_strikethrough": "Barré",
|
||||
"toolbar_normal_paragraph_text": "Texte de paragraphe normal",
|
||||
"toolbar_font_smaller": "Texte plus petit",
|
||||
"toolbar_font_larger": "Texte plus grand",
|
||||
"toolbar_bulleted_list": "Liste à puces",
|
||||
"toolbar_numbered_list": "Liste numérotée",
|
||||
"toolbar_code_block": "Bloc de code",
|
||||
"toolbar_heading": "Titre",
|
||||
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
|
||||
"tags": "Étiquettes",
|
||||
"tag": "Étiquette",
|
||||
"manage_tags": "Gérer les étiquettes",
|
||||
"add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée",
|
||||
"tag_browser_title": "Navigateur d'étiquettes",
|
||||
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
|
||||
"color_hex": "Couleur",
|
||||
"date": "Date",
|
||||
"add_a_tag": "Ajouter une étiquette",
|
||||
"edit_tag_name": "Modifier le nom de l'étiquette",
|
||||
"new_tag_name": "Nouveau nom de l'étiquette :",
|
||||
"change_color": "Changer la couleur",
|
||||
"delete_tag": "Supprimer l'étiquette",
|
||||
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
|
||||
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
|
||||
"statistics": "Statistiques",
|
||||
"main_window_statistics_accessible_flag": "Stat&istiques",
|
||||
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
|
||||
"stats_total_revisions": "Nombre total de révisions",
|
||||
"stats_page_most_revisions": "Page avec le plus de révisions",
|
||||
"stats_total_words": "Nombre total de mots (versions actuelles)",
|
||||
"stats_unique_tags": "Étiquettes uniques",
|
||||
"stats_page_most_tags": "Page avec le plus d'étiquettes",
|
||||
"stats_activity_heatmap": "Carte de chaleur d'activité",
|
||||
"stats_heatmap_metric": "Colorer selon",
|
||||
"stats_metric_words": "Mots",
|
||||
"stats_metric_revisions": "Révisions",
|
||||
"stats_no_data": "Aucune statistique disponible pour le moment.",
|
||||
"select_notebook": "Sélectionner un bouquin",
|
||||
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
|
||||
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
|
||||
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
|
||||
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
|
||||
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
|
||||
"send": "Envoyer",
|
||||
"reminder": "Rappel",
|
||||
"set_reminder": "Définir le rappel",
|
||||
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
|
||||
"invalid_time_title": "Heure invalide",
|
||||
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
|
||||
"dismiss": "Ignorer",
|
||||
"toolbar_alarm": "Régler l'alarme de rappel",
|
||||
"activities": "Activités",
|
||||
"activity": "Activité",
|
||||
"note": "Note",
|
||||
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
|
||||
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
|
||||
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
|
||||
"activity_rename_error_title": "Problème lors du renommage de l'activité",
|
||||
"activity_required_message": "Un nom d'activité est requis",
|
||||
"activity_required_title": "Nom d'activité requis",
|
||||
"add_activity": "Ajouter une activité",
|
||||
"add_project": "Ajouter un projet",
|
||||
"add_time_entry": "Ajouter une entrée de temps",
|
||||
"time_period": "Période",
|
||||
"by_day": "par jour",
|
||||
"by_month": "par mois",
|
||||
"by_week": "par semaine",
|
||||
"date_range": "Plage de dates",
|
||||
"delete_activity": "Supprimer l'activité",
|
||||
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
|
||||
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
|
||||
"delete_project": "Supprimer le projet",
|
||||
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
|
||||
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
|
||||
"delete_time_entry": "Supprimer l'entrée de temps",
|
||||
"group_by": "Grouper par",
|
||||
"hours": "Heures",
|
||||
"invalid_activity_message": "L'activité est invalide",
|
||||
"invalid_activity_title": "Activité invalide",
|
||||
"invalid_project_message": "Le projet est invalide",
|
||||
"invalid_project_title": "Projet invalide",
|
||||
"manage_activities": "Gérer les activités",
|
||||
"manage_projects": "Gérer les projets",
|
||||
"manage_projects_activities": "Gérer les activités du projet",
|
||||
"open_time_log": "Ouvrir le journal de temps",
|
||||
"project": "Projet",
|
||||
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
|
||||
"project_delete_error_title": "Problème lors de la suppression du projet",
|
||||
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
|
||||
"project_rename_error_title": "Problème lors du renommage du projet",
|
||||
"project_required_message": "Un projet est requis",
|
||||
"project_required_title": "Projet requis",
|
||||
"projects": "Projets",
|
||||
"rename_activity": "Renommer l'activité",
|
||||
"rename_project": "Renommer le projet",
|
||||
"run_report": "Exécuter le rapport",
|
||||
"add_activity_title": "Ajouter une activité",
|
||||
"add_activity_label": "Ajouter une activité",
|
||||
"rename_activity_label": "Renommer l'activité",
|
||||
"add_project_title": "Ajouter un projet",
|
||||
"add_project_label": "Ajouter un projet",
|
||||
"rename_activity_title": "Renommer cette activité",
|
||||
"rename_project_label": "Renommer le projet",
|
||||
"rename_project_title": "Renommer ce projet",
|
||||
"select_activity_message": "Sélectionner une activité",
|
||||
"select_activity_title": "Sélectionner une activité",
|
||||
"select_project_message": "Sélectionner un projet",
|
||||
"select_project_title": "Sélectionner un projet",
|
||||
"time_log": "Journal de temps",
|
||||
"time_log_collapsed_hint": "Journal de temps",
|
||||
"time_log_date_label": "Date du journal de temps : {date}",
|
||||
"time_log_for": "Journal de temps pour {date}",
|
||||
"time_log_no_date": "Journal de temps",
|
||||
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
|
||||
"time_log_report": "Rapport de temps",
|
||||
"time_log_report_title": "Journal de temps pour {project}",
|
||||
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
|
||||
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
|
||||
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
|
||||
"update_time_entry": "Mettre à jour l'entrée de temps",
|
||||
"time_report_total": "Total : {hours:.2f} heures",
|
||||
"no_report_title": "Aucun rapport",
|
||||
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
|
||||
"total": "Total",
|
||||
"export_csv": "Exporter en CSV",
|
||||
"export_csv_error_title": "Échec de l'exportation",
|
||||
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
|
||||
"export_pdf": "Exporter en PDF",
|
||||
"export_pdf_error_title": "Échec de l'exportation PDF",
|
||||
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
|
||||
"enable_tags_feature": "Activer les étiquettes",
|
||||
"enable_time_log_feature": "Activer le journal de temps",
|
||||
"enable_reminders_feature": "Activer les rappels",
|
||||
"pomodoro_time_log_default_text": "Session de concentration",
|
||||
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
|
||||
"set_code_language": "Définir le langage du code",
|
||||
"cut": "Couper",
|
||||
"copy": "Copier",
|
||||
"paste": "Coller",
|
||||
"start": "Démarrer",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre",
|
||||
"stop_and_log": "Arrêter et enregistrer",
|
||||
"once": "une fois",
|
||||
"daily": "quotidien",
|
||||
"weekdays": "jours de semaine",
|
||||
"weekly": "hebdomadaire",
|
||||
"edit_reminder": "Modifier le rappel",
|
||||
"time": "Heure",
|
||||
"once_today": "Une fois (aujourd'hui)",
|
||||
"every_day": "Tous les jours",
|
||||
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
||||
"every_week": "Toutes les semaines",
|
||||
"repeat": "Répéter",
|
||||
"monday": "Lundi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi",
|
||||
"thursday": "Jeudi",
|
||||
"friday": "Vendredi",
|
||||
"saturday": "Samedi",
|
||||
"sunday": "Dimanche",
|
||||
"day": "Jour"
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
{
|
||||
"db_sqlcipher_integrity_check_failed": "Controllo di integrità SQLCipher fallito",
|
||||
"db_issues_reported": "problema/i segnalato/i",
|
||||
"db_reopen_failed_after_rekey": "Riapertura fallita dopo il cambio chiave",
|
||||
"db_version_id_does_not_belong_to_the_given_date": "version_id non appartiene alla data indicata",
|
||||
"db_database_error": "Errore del database",
|
||||
"db_key_incorrect": "La chiave è probabilmente errata",
|
||||
"database_maintenance": "Manutenzione del database",
|
||||
"database_compact": "Compatta il database",
|
||||
"database_compact_explanation": "La compattazione esegue VACUUM sul database. Può aiutare a ridurne le dimensioni.",
|
||||
"database_compacted_successfully": "Database compattato con successo!",
|
||||
"encryption": "Crittografia",
|
||||
"remember_key": "Ricorda la chiave",
|
||||
"change_encryption_key": "Cambia chiave di crittografia",
|
||||
"enter_a_new_encryption_key": "Inserisci una nuova chiave di crittografia",
|
||||
"reenter_the_new_key": "Reinserisci la nuova chiave",
|
||||
"key_mismatch": "Le chiavi non corrispondono",
|
||||
"key_mismatch_explanation": "Le due chiavi inserite non corrispondono.",
|
||||
"empty_key": "Chiave vuota",
|
||||
"empty_key_explanation": "La chiave non può essere vuota.",
|
||||
"key_changed": "Chiave cambiata",
|
||||
"key_changed_explanation": "Il blocco note è stato criptato nuovamente con la nuova chiave!",
|
||||
"error": "Errore",
|
||||
"success": "Successo",
|
||||
"close": "Chiudi",
|
||||
"find": "Trova",
|
||||
"file": "File",
|
||||
"locale": "Lingua",
|
||||
"locale_restart": "Per favore riavvia l'applicazione per caricare la nuova lingua.",
|
||||
"settings": "Impostazioni",
|
||||
"theme": "Tema",
|
||||
"system": "Sistema",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"never": "Mai",
|
||||
"previous": "Precedente",
|
||||
"previous_day": "Giorno precedente",
|
||||
"next": "Successivo",
|
||||
"next_day": "Giorno successivo",
|
||||
"today": "Oggi",
|
||||
"show": "Mostra",
|
||||
"history": "Cronologia",
|
||||
"export_accessible_flag": "&Esporta",
|
||||
"export_entries": "Esporta voci",
|
||||
"export_complete": "Esportazione completata",
|
||||
"export_failed": "Esportazione fallita",
|
||||
"backup": "Backup",
|
||||
"backup_complete": "Backup completato",
|
||||
"backup_failed": "Backup fallito",
|
||||
"quit": "Esci",
|
||||
"help": "Aiuto",
|
||||
"saved": "Salvato",
|
||||
"saved_to": "Salvato in",
|
||||
"documentation": "Documentazione",
|
||||
"couldnt_open": "Impossibile aprire",
|
||||
"report_a_bug": "Segnala un bug",
|
||||
"version": "Versione",
|
||||
"navigate": "Naviga",
|
||||
"current": "corrente",
|
||||
"selected": "selezionato",
|
||||
"find_on_page": "Trova nella pagina",
|
||||
"find_next": "Trova successivo",
|
||||
"find_previous": "Trova precedente",
|
||||
"find_bar_type_to_search": "Digita per cercare",
|
||||
"find_bar_match_case": "Distingui maiuscole/minuscole",
|
||||
"history_dialog_preview": "Anteprima",
|
||||
"history_dialog_diff": "Differenze",
|
||||
"history_dialog_revert_to_selected": "Ripristina alla versione selezionata",
|
||||
"history_dialog_revert_failed": "Ripristino fallito",
|
||||
"key_prompt_enter_key": "Inserisci la chiave",
|
||||
"lock_overlay_locked": "Bloccato",
|
||||
"lock_overlay_unlock": "Sblocca",
|
||||
"main_window_ready": "Pronto",
|
||||
"main_window_save_a_version": "Salva versione",
|
||||
"main_window_settings_accessible_flag": "Impo&stazioni",
|
||||
"set_an_encryption_key": "Imposta una chiave di crittografia",
|
||||
"set_an_encryption_key_explanation": "Bouquin cripta i tuoi dati.\n\nCrea una passphrase sicura per criptare il blocco note.\n\nPuoi sempre cambiarla in seguito!",
|
||||
"unlock_encrypted_notebook": "Sblocca il blocco note criptato",
|
||||
"unlock_encrypted_notebook_explanation": "Inserisci la chiave per sbloccare il blocco note",
|
||||
"open_in_new_tab": "Apri in una nuova scheda",
|
||||
"autosave": "salvataggio automatico",
|
||||
"unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo",
|
||||
"move_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati a oggi all'avvio",
|
||||
"insert_images": "Inserisci immagini",
|
||||
"images": "Immagini",
|
||||
"reopen_failed": "Riapertura fallita",
|
||||
"unlock_failed": "Sblocco fallito",
|
||||
"could_not_unlock_database_at_new_path": "Impossibile sbloccare il database nel nuovo percorso.",
|
||||
"unencrypted_export": "Esportazione non criptata",
|
||||
"unencrypted_export_warning": "L'esportazione del database sarà non criptata!\nVuoi davvero continuare?\nSe desideri un backup criptato, scegli Backup invece di Esporta.",
|
||||
"unrecognised_extension": "Estensione non riconosciuta!",
|
||||
"backup_encrypted_notebook": "Backup del blocco note criptato",
|
||||
"enter_a_name_for_this_version": "Inserisci un nome per questa versione",
|
||||
"new_version_i_saved_at": "Nuova versione salvata il",
|
||||
"save_key_warning": "Se non vuoi che ti venga richiesta la chiave di crittografia, seleziona questa opzione per ricordarla.\nATTENZIONE: la chiave viene salvata sul disco e potrebbe essere recuperabile se il disco fosse compromesso.",
|
||||
"lock_screen_when_idle": "Blocca lo schermo quando inattivo",
|
||||
"autolock_explanation": "Bouquin bloccherà automaticamente il blocco note dopo questo intervallo di tempo, dopodiché sarà necessario reinserire la chiave per sbloccarlo.\nImposta a 0 (mai) per non bloccarlo mai.",
|
||||
"search_for_notes_here": "Cerca note qui",
|
||||
"toolbar_format": "Formato",
|
||||
"toolbar_bold": "Grassetto",
|
||||
"toolbar_italic": "Corsivo",
|
||||
"toolbar_strikethrough": "Barrato",
|
||||
"toolbar_normal_paragraph_text": "Testo normale",
|
||||
"toolbar_bulleted_list": "Elenco puntato",
|
||||
"toolbar_numbered_list": "Elenco numerato",
|
||||
"toolbar_code_block": "Blocco di codice",
|
||||
"toolbar_heading": "Titolo",
|
||||
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo",
|
||||
"tags": "Tag",
|
||||
"manage_tags": "Gestisci tag",
|
||||
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
|
||||
"tag_browser_title": "Browser dei tag",
|
||||
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
|
||||
"color_hex": "Colore",
|
||||
"date": "Data",
|
||||
"add_a_tag": "Aggiungi un tag",
|
||||
"edit_tag_name": "Modifica nome tag",
|
||||
"new_tag_name": "Nuovo nome tag:",
|
||||
"change_color": "Cambia colore",
|
||||
"delete_tag": "Elimina tag",
|
||||
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.",
|
||||
"tag_already_exists_with_that_name": "Esiste già un tag con questo nome",
|
||||
"cancel": "Annulla",
|
||||
"select_notebook": "Seleziona blocco note",
|
||||
"save": "Salva",
|
||||
"history_dialog_delete": "Cancella versione",
|
||||
"check_for_updates": "Controlla aggiornamenti",
|
||||
"close": "Chiudi",
|
||||
"send": "Invia",
|
||||
"time_log": "Registro Attività",
|
||||
"time_log_no_entries": "Nessuna Attività",
|
||||
"close_tab": "Chiudi scheda",
|
||||
"toolbar_font_smaller": "Rimpicciolisci testo",
|
||||
"toolbar_font_larger": "Ingrandisci testo",
|
||||
"toolbar_alarm": "Imposta promemoria",
|
||||
"statistics": "Statistiche",
|
||||
"main_window_statistics_accessible_flag": "Stat&istiche",
|
||||
"main_window_lock_screen_accessibility": "B&locca Schermo",
|
||||
"font_size": "Dimensione carattere",
|
||||
"font_size_explanation": "Cambiare questo valore camberà la dimensione di tutto il testo in tutte le schede. Dimensione di titoli e blocchi di codice rimarranno invariati",
|
||||
"enable_tags_feature": "Abilita Tags",
|
||||
"enable_time_log_feature": "Abilita Traccuamento del tempo",
|
||||
"appearance": "Interfaccia",
|
||||
"features": "Funzionalità",
|
||||
"security": "Sicurezza",
|
||||
"bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.",
|
||||
"bug_report_placeholder": "Scrivi la tua segnalazione qui",
|
||||
"update": "Aggiornamento",
|
||||
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n",
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"monday": "Lunedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì",
|
||||
"thursday": "Giovedì",
|
||||
"friday": "Venerdì",
|
||||
"saturday": "Sabato",
|
||||
"sunday": "Domenica",
|
||||
"day": "Giorno"
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
||||
|
||||
from . import strings
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
||||
class LockOverlay(QWidget):
|
||||
def __init__(self, parent: QWidget, on_unlock: callable, themes: ThemeManager):
|
||||
def __init__(self, parent: QWidget, on_unlock: callable):
|
||||
"""
|
||||
Widget that 'locks' the screen after a configured idle time.
|
||||
"""
|
||||
|
|
@ -18,16 +16,18 @@ class LockOverlay(QWidget):
|
|||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
self.setGeometry(parent.rect())
|
||||
|
||||
self._styling = False # <-- reentrancy guard
|
||||
self._last_dark: bool | None = None
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addStretch(1)
|
||||
|
||||
msg = QLabel(strings._("lock_overlay_locked"), self)
|
||||
msg = QLabel("Locked due to inactivity", self)
|
||||
msg.setObjectName("lockLabel")
|
||||
msg.setAlignment(Qt.AlignCenter)
|
||||
|
||||
self._btn = QPushButton(strings._("lock_overlay_unlock"), self)
|
||||
self._btn = QPushButton("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)
|
||||
|
|
@ -38,9 +38,91 @@ class LockOverlay(QWidget):
|
|||
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
||||
lay.addStretch(1)
|
||||
|
||||
themes.register_lock_overlay(self)
|
||||
self._apply_overlay_style()
|
||||
self.hide()
|
||||
|
||||
def _is_dark(self, pal: QPalette) -> bool:
|
||||
"""
|
||||
Detect if dark mode is in use.
|
||||
"""
|
||||
c = pal.color(QPalette.Window)
|
||||
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
|
||||
return luma < 0.5
|
||||
|
||||
def _apply_overlay_style(self):
|
||||
if self._styling:
|
||||
return
|
||||
dark = self._is_dark(self.palette())
|
||||
if dark == self._last_dark:
|
||||
return
|
||||
self._styling = True
|
||||
try:
|
||||
if dark:
|
||||
link = self.palette().color(QPalette.Link)
|
||||
accent_hex = link.name() # e.g. "#FFA500"
|
||||
r, g, b = link.red(), link.green(), link.blue()
|
||||
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
#LockOverlay {{ background-color: rgb(0,0,0); }}
|
||||
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
||||
|
||||
#LockOverlay QPushButton#unlockButton {{
|
||||
color: {accent_hex};
|
||||
background-color: rgba({r},{g},{b},0.10);
|
||||
border: 1px solid {accent_hex};
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
#LockOverlay QPushButton#unlockButton:hover {{
|
||||
background-color: rgba({r},{g},{b},0.16);
|
||||
border-color: {accent_hex};
|
||||
}}
|
||||
#LockOverlay QPushButton#unlockButton:pressed {{
|
||||
background-color: rgba({r},{g},{b},0.24);
|
||||
}}
|
||||
#LockOverlay QPushButton#unlockButton:focus {{
|
||||
outline: none;
|
||||
border-color: {accent_hex};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# (light mode unchanged)
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
#LockOverlay { background-color: rgba(0,0,0,120); }
|
||||
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
|
||||
#LockOverlay QPushButton#unlockButton {
|
||||
color: palette(button-text);
|
||||
background-color: rgba(255,255,255,0.92);
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
#LockOverlay QPushButton#unlockButton:hover {
|
||||
background-color: rgba(255,255,255,1.0);
|
||||
border-color: rgba(0,0,0,0.35);
|
||||
}
|
||||
#LockOverlay QPushButton#unlockButton:pressed {
|
||||
background-color: rgba(245,245,245,1.0);
|
||||
}
|
||||
#LockOverlay QPushButton#unlockButton:focus {
|
||||
outline: none;
|
||||
border-color: palette(highlight);
|
||||
}
|
||||
"""
|
||||
)
|
||||
self._last_dark = dark
|
||||
finally:
|
||||
self._styling = False
|
||||
|
||||
def changeEvent(self, ev):
|
||||
super().changeEvent(ev)
|
||||
# Only re-style on palette flips (user changed theme)
|
||||
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
|
||||
self._apply_overlay_style()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
||||
self.setGeometry(obj.rect())
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from .settings import APP_NAME, APP_ORG, get_settings
|
||||
from .main_window import MainWindow
|
||||
from .theme import Theme, ThemeConfig, ThemeManager
|
||||
from . import strings
|
||||
|
||||
|
||||
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")
|
||||
|
|
@ -27,7 +19,6 @@ def main():
|
|||
themes = ThemeManager(app, cfg)
|
||||
themes.apply(cfg.theme)
|
||||
|
||||
strings.load_strings(s.value("ui/locale", "en"))
|
||||
win = MainWindow(themes=themes)
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
|
|
|||
|
|
@ -1,344 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QFontMetrics,
|
||||
QGuiApplication,
|
||||
QPalette,
|
||||
QSyntaxHighlighter,
|
||||
QTextCharFormat,
|
||||
QTextDocument,
|
||||
)
|
||||
|
||||
from .theme import ThemeManager, Theme
|
||||
|
||||
|
||||
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||
"""Live syntax highlighter for markdown that applies formatting as you type."""
|
||||
|
||||
def __init__(
|
||||
self, document: QTextDocument, theme_manager: ThemeManager, editor=None
|
||||
):
|
||||
super().__init__(document)
|
||||
self.theme_manager = theme_manager
|
||||
self._editor = editor # Reference to the MarkdownEditor
|
||||
self._setup_formats()
|
||||
# Recompute formats whenever the app theme changes
|
||||
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||
|
||||
def _on_theme_changed(self, *_):
|
||||
self._setup_formats()
|
||||
self.rehighlight()
|
||||
|
||||
def refresh_for_font_change(self) -> None:
|
||||
"""
|
||||
Called when the editor's base font changes (zoom / settings).
|
||||
It rebuilds any formats that depend on the editor font metrics.
|
||||
"""
|
||||
self._setup_formats()
|
||||
self.rehighlight()
|
||||
|
||||
def _setup_formats(self):
|
||||
"""Setup text formats for different markdown elements."""
|
||||
|
||||
# Bold: **text** or __text__
|
||||
self.bold_format = QTextCharFormat()
|
||||
self.bold_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
# Italic: *text* or _text_
|
||||
self.italic_format = QTextCharFormat()
|
||||
self.italic_format.setFontItalic(True)
|
||||
|
||||
# Allow combination of bold/italic
|
||||
self.bold_italic_format = QTextCharFormat()
|
||||
self.bold_italic_format.setFontWeight(QFont.Weight.Bold)
|
||||
self.bold_italic_format.setFontItalic(True)
|
||||
|
||||
# Strikethrough: ~~text~~
|
||||
self.strike_format = QTextCharFormat()
|
||||
self.strike_format.setFontStrikeOut(True)
|
||||
|
||||
# Inline code: `code`
|
||||
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
self.code_format = QTextCharFormat()
|
||||
self.code_format.setFont(mono)
|
||||
self.code_format.setFontFixedPitch(True)
|
||||
|
||||
# Code block: ```
|
||||
self.code_block_format = QTextCharFormat()
|
||||
self.code_block_format.setFont(mono)
|
||||
self.code_block_format.setFontFixedPitch(True)
|
||||
|
||||
pal = QGuiApplication.palette()
|
||||
if (
|
||||
self.theme_manager.current() == Theme.DARK
|
||||
or self.theme_manager._is_system_dark
|
||||
):
|
||||
# In dark mode, use a darker panel-like background
|
||||
bg = pal.color(QPalette.AlternateBase)
|
||||
fg = pal.color(QPalette.Text)
|
||||
else:
|
||||
# Light mode: keep the existing light gray
|
||||
bg = QColor(245, 245, 245)
|
||||
fg = QColor( # pragma: no cover
|
||||
0, 0, 0
|
||||
) # avoiding using QPalette.Text as it can be white on macOS
|
||||
self.code_block_format.setBackground(bg)
|
||||
self.code_block_format.setForeground(fg)
|
||||
|
||||
# Headings
|
||||
self.h1_format = QTextCharFormat()
|
||||
self.h1_format.setFontPointSize(24.0)
|
||||
self.h1_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
self.h2_format = QTextCharFormat()
|
||||
self.h2_format.setFontPointSize(18.0)
|
||||
self.h2_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
self.h3_format = QTextCharFormat()
|
||||
self.h3_format.setFontPointSize(14.0)
|
||||
self.h3_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
# Hyperlinks
|
||||
self.link_format = QTextCharFormat()
|
||||
link_color = pal.color(QPalette.Link)
|
||||
self.link_format.setForeground(link_color)
|
||||
self.link_format.setFontUnderline(True)
|
||||
self.link_format.setAnchor(True)
|
||||
|
||||
# Checkboxes
|
||||
self.checkbox_format = QTextCharFormat()
|
||||
self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle)
|
||||
|
||||
# Bullets
|
||||
self.bullet_format = QTextCharFormat()
|
||||
|
||||
# Use Symbols font for checkbox and bullet glyphs if present
|
||||
if self._editor is not None and hasattr(self._editor, "symbols_font_family"):
|
||||
base_font = QFont(self._editor.qfont) # copy of editor font
|
||||
symbols_font = QFont(self._editor.symbols_font_family)
|
||||
symbols_font.setPointSizeF(base_font.pointSizeF())
|
||||
|
||||
base_metrics = QFontMetrics(base_font)
|
||||
sym_metrics = QFontMetrics(symbols_font)
|
||||
|
||||
# If Symbols glyphs are noticeably shorter than the text,
|
||||
# scale them up so the visual heights roughly match.
|
||||
if sym_metrics.height() > 0:
|
||||
ratio = base_metrics.height() / sym_metrics.height()
|
||||
if ratio > 1.05: # more than ~5% smaller
|
||||
ratio = min(ratio, 1.4) # Oh, Tod, Tod. Don't overdo it.
|
||||
symbols_font.setPointSizeF(symbols_font.pointSizeF() * ratio)
|
||||
|
||||
self.checkbox_format.setFont(symbols_font)
|
||||
self.bullet_format.setFont(symbols_font)
|
||||
|
||||
# Markdown syntax (the markers themselves) - make invisible
|
||||
self.syntax_format = QTextCharFormat()
|
||||
# Use the editor background color so they blend in
|
||||
bg = pal.color(QPalette.Base)
|
||||
hidden = QColor(bg)
|
||||
hidden.setAlpha(0)
|
||||
self.syntax_format.setForeground(hidden)
|
||||
# Make the markers invisible by setting font size to 0.1 points
|
||||
self.syntax_format.setFontPointSize(0.1)
|
||||
|
||||
def _overlay_range(
|
||||
self, start: int, length: int, overlay_fmt: QTextCharFormat
|
||||
) -> None:
|
||||
"""Merge overlay_fmt onto the existing format for each char in [start, start+length)."""
|
||||
end = start + length
|
||||
i = start
|
||||
while i < end:
|
||||
base = QTextCharFormat(self.format(i)) # current format at this position
|
||||
base.merge(overlay_fmt) # add only the properties we set
|
||||
self.setFormat(i, 1, base) # write back one char
|
||||
i += 1
|
||||
|
||||
def highlightBlock(self, text: str):
|
||||
"""Apply formatting to a block of text based on markdown syntax."""
|
||||
|
||||
# Track if we're in a code block (multiline)
|
||||
prev_state = self.previousBlockState()
|
||||
in_code_block = prev_state == 1
|
||||
|
||||
# Check for code block fences
|
||||
if text.strip().startswith("```"):
|
||||
# background for the whole fence line (so block looks continuous)
|
||||
self.setFormat(0, len(text), self.code_block_format)
|
||||
|
||||
# hide the three backticks themselves
|
||||
idx = text.find("```")
|
||||
if idx != -1:
|
||||
self.setFormat(idx, 3, self.syntax_format)
|
||||
|
||||
# toggle code-block state and stop; next line picks up state
|
||||
in_code_block = not in_code_block
|
||||
self.setCurrentBlockState(1 if in_code_block else 0)
|
||||
return
|
||||
|
||||
if in_code_block:
|
||||
# inside code: apply block bg and language rules
|
||||
self.setFormat(0, len(text), self.code_block_format)
|
||||
|
||||
# Try to apply language-specific highlighting
|
||||
if self._editor and hasattr(self._editor, "_code_metadata"):
|
||||
from .code_highlighter import CodeHighlighter
|
||||
|
||||
# Find the opening fence block
|
||||
prev_block = self.currentBlock().previous()
|
||||
fence_block_num = None
|
||||
temp_inside = in_code_block
|
||||
|
||||
while prev_block.isValid():
|
||||
if prev_block.text().strip().startswith("```"):
|
||||
temp_inside = not temp_inside
|
||||
if not temp_inside:
|
||||
fence_block_num = prev_block.blockNumber()
|
||||
break
|
||||
prev_block = prev_block.previous()
|
||||
|
||||
if fence_block_num is not None:
|
||||
language = self._editor._code_metadata.get_language(fence_block_num)
|
||||
if language:
|
||||
patterns = CodeHighlighter.get_language_patterns(language)
|
||||
for pattern, syntax_type in patterns:
|
||||
for match in re.finditer(pattern, text):
|
||||
start, end = match.span()
|
||||
fmt = CodeHighlighter.get_format_for_type(
|
||||
syntax_type, self.code_block_format
|
||||
)
|
||||
self.setFormat(start, end - start, fmt)
|
||||
|
||||
self.setCurrentBlockState(1)
|
||||
return
|
||||
|
||||
# ---- Normal markdown (outside code)
|
||||
self.setCurrentBlockState(0)
|
||||
|
||||
# If the line is empty and not in a code block, nothing else to do
|
||||
if not text:
|
||||
return
|
||||
|
||||
# Headings (must be at start of line)
|
||||
heading_match = re.match(r"^(#{1,3})\s+", text)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
marker_len = len(heading_match.group(0))
|
||||
|
||||
# Format the # markers
|
||||
self.setFormat(0, marker_len, self.syntax_format)
|
||||
|
||||
# Format the heading text
|
||||
heading_fmt = (
|
||||
self.h1_format
|
||||
if level == 1
|
||||
else self.h2_format if level == 2 else self.h3_format
|
||||
)
|
||||
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
|
||||
return
|
||||
|
||||
# Bold+Italic (*** or ___): do these first and record occupied spans.
|
||||
# --- Triple emphasis: detect first, hide markers now, but DEFER applying content style
|
||||
triple_contents: list[tuple[int, int]] = [] # (start, length) for content only
|
||||
occupied: list[tuple[int, int]] = (
|
||||
[]
|
||||
) # full spans including markers, for overlap checks
|
||||
|
||||
for m in re.finditer(
|
||||
r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*|(?<!_)___(.+?)(?<!_)___", text
|
||||
):
|
||||
start, end = m.span()
|
||||
content_start, content_end = start + 3, end - 3
|
||||
# hide the *** / ___ markers now
|
||||
self.setFormat(start, 3, self.syntax_format)
|
||||
self.setFormat(end - 3, 3, self.syntax_format)
|
||||
|
||||
# remember the full occupied span and the content span
|
||||
occupied.append((start, end))
|
||||
triple_contents.append((content_start, content_end - content_start))
|
||||
|
||||
def _overlaps(a, b): # a, b are (start, end)
|
||||
return not (a[1] <= b[0] or b[1] <= a[0])
|
||||
|
||||
# --- Bold (**) or (__): skip if it overlaps any triple
|
||||
for m in re.finditer(
|
||||
r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)|(?<!_)__(?!_)(.+?)(?<!_)__(?!_)",
|
||||
text,
|
||||
):
|
||||
start, end = m.span()
|
||||
if any(_overlaps((start, end), occ) for occ in occupied):
|
||||
continue # pragma: no cover
|
||||
content_start, content_end = start + 2, end - 2
|
||||
self.setFormat(start, 2, self.syntax_format)
|
||||
self.setFormat(end - 2, 2, self.syntax_format)
|
||||
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
||||
|
||||
# --- Italic (*) or (_): skip if it overlaps any triple
|
||||
for m in re.finditer(
|
||||
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
||||
):
|
||||
start, end = m.span()
|
||||
if any(_overlaps((start, end), occ) for occ in occupied):
|
||||
continue # pragma: no cover
|
||||
# avoid stealing a single marker that is part of a double
|
||||
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
|
||||
continue # pragma: no cover
|
||||
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
||||
continue # pragma: no cover
|
||||
content_start, content_end = start + 1, end - 1
|
||||
self.setFormat(start, 1, self.syntax_format)
|
||||
self.setFormat(end - 1, 1, self.syntax_format)
|
||||
self.setFormat(
|
||||
content_start, content_end - content_start, self.italic_format
|
||||
)
|
||||
|
||||
# --- NOW overlay bold+italic for triple contents LAST (so nothing clobbers it)
|
||||
for cs, length in triple_contents:
|
||||
self._overlay_range(cs, length, self.bold_italic_format)
|
||||
|
||||
# Strikethrough: ~~text~~
|
||||
for m in re.finditer(r"~~(.+?)~~", text):
|
||||
start, end = m.span()
|
||||
content_start, content_end = start + 2, end - 2
|
||||
# Fade the markers
|
||||
self.setFormat(start, 2, self.syntax_format)
|
||||
self.setFormat(end - 2, 2, self.syntax_format)
|
||||
# Merge strikeout with whatever is already applied (bold, italic, both, links, etc.)
|
||||
self._overlay_range(
|
||||
content_start, content_end - content_start, self.strike_format
|
||||
)
|
||||
|
||||
# Inline code: `code`
|
||||
for match in re.finditer(r"`([^`]+)`", text):
|
||||
start, end = match.span()
|
||||
content_start = start + 1
|
||||
content_end = end - 1
|
||||
|
||||
self.setFormat(start, 1, self.syntax_format)
|
||||
self.setFormat(end - 1, 1, self.syntax_format)
|
||||
self.setFormat(content_start, content_end - content_start, self.code_format)
|
||||
|
||||
# Hyperlinks
|
||||
url_pattern = re.compile(r"(https?://[^\s<>()]+)")
|
||||
for m in url_pattern.finditer(text):
|
||||
start, end = m.span(1)
|
||||
url = m.group(1)
|
||||
|
||||
# Clone link format so we can attach a per-link href
|
||||
fmt = QTextCharFormat(self.link_format)
|
||||
fmt.setAnchorHref(url)
|
||||
# Overlay link attributes on top of whatever formatting is already there
|
||||
self._overlay_range(start, end - start, fmt)
|
||||
|
||||
# Make checkbox glyphs bigger
|
||||
for m in re.finditer(r"[☐☑]", text):
|
||||
self._overlay_range(m.start(), 1, self.checkbox_format)
|
||||
|
||||
# (If you add Unicode bullets later…)
|
||||
for m in re.finditer(r"•", text):
|
||||
self._overlay_range(m.start(), 1, self.bullet_format)
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .time_log import TimeLogDialog
|
||||
|
||||
|
||||
class PomodoroTimer(QDialog):
|
||||
"""A simple timer dialog for tracking work time on a specific task."""
|
||||
|
||||
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
|
||||
|
||||
def __init__(self, task_text: str, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
|
||||
self.setModal(False)
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
self._task_text = task_text
|
||||
self._elapsed_seconds = 0
|
||||
self._running = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Task label
|
||||
task_label = QLabel(task_text)
|
||||
task_label.setWordWrap(True)
|
||||
layout.addWidget(task_label)
|
||||
|
||||
# Timer display
|
||||
self.time_label = QLabel("00:00:00")
|
||||
font = self.time_label.font()
|
||||
font.setPointSize(24)
|
||||
font.setBold(True)
|
||||
self.time_label.setFont(font)
|
||||
self.time_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.time_label)
|
||||
|
||||
# Control buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
self.start_pause_btn = QPushButton(strings._("start"))
|
||||
self.start_pause_btn.clicked.connect(self._toggle_timer)
|
||||
btn_layout.addWidget(self.start_pause_btn)
|
||||
|
||||
self.stop_btn = QPushButton(strings._("stop_and_log"))
|
||||
self.stop_btn.clicked.connect(self._stop_and_log)
|
||||
self.stop_btn.setEnabled(False)
|
||||
btn_layout.addWidget(self.stop_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Internal timer (ticks every second)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
|
||||
@Slot()
|
||||
def _toggle_timer(self):
|
||||
"""Start or pause the timer."""
|
||||
if self._running:
|
||||
# Pause
|
||||
self._running = False
|
||||
self._timer.stop()
|
||||
self.start_pause_btn.setText(strings._("resume"))
|
||||
else:
|
||||
# Start/Resume
|
||||
self._running = True
|
||||
self._timer.start(1000) # 1 second
|
||||
self.start_pause_btn.setText(strings._("pause"))
|
||||
self.stop_btn.setEnabled(True)
|
||||
|
||||
@Slot()
|
||||
def _tick(self):
|
||||
"""Update the elapsed time display."""
|
||||
self._elapsed_seconds += 1
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the time display label."""
|
||||
hours = self._elapsed_seconds // 3600
|
||||
minutes = (self._elapsed_seconds % 3600) // 60
|
||||
seconds = self._elapsed_seconds % 60
|
||||
self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
|
||||
|
||||
@Slot()
|
||||
def _stop_and_log(self):
|
||||
"""Stop the timer and emit signal to open time log."""
|
||||
if self._running:
|
||||
self._running = False
|
||||
self._timer.stop()
|
||||
|
||||
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
|
||||
self.accept()
|
||||
|
||||
|
||||
class PomodoroManager:
|
||||
"""Manages Pomodoro timers and integrates with time log."""
|
||||
|
||||
def __init__(self, db: DBManager, parent_window):
|
||||
self._db = db
|
||||
self._parent = parent_window
|
||||
self._active_timer: Optional[PomodoroTimer] = None
|
||||
|
||||
def start_timer_for_line(self, line_text: str, date_iso: str):
|
||||
"""Start a new timer for the given line of text."""
|
||||
# Stop any existing timer
|
||||
if self._active_timer and self._active_timer.isVisible():
|
||||
self._active_timer.close()
|
||||
|
||||
# Create new timer
|
||||
self._active_timer = PomodoroTimer(line_text, self._parent)
|
||||
self._active_timer.timerStopped.connect(
|
||||
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
|
||||
)
|
||||
self._active_timer.show()
|
||||
|
||||
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
|
||||
"""Handle timer stop - open time log dialog with pre-filled data."""
|
||||
# Convert seconds to decimal hours, rounded up
|
||||
hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour
|
||||
|
||||
# Ensure minimum of 0.25 hours
|
||||
if hours < 0.25:
|
||||
hours = 0.25
|
||||
|
||||
# Open time log dialog
|
||||
dlg = TimeLogDialog(self._db, date_iso, self._parent)
|
||||
|
||||
# Pre-fill the hours
|
||||
dlg.hours_spin.setValue(hours)
|
||||
|
||||
# Pre-fill the note with task text
|
||||
dlg.note.setText(task_text)
|
||||
|
||||
# Show the dialog
|
||||
dlg.exec()
|
||||
|
|
@ -1,639 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QFormLayout,
|
||||
QLineEdit,
|
||||
QComboBox,
|
||||
QTimeEdit,
|
||||
QPushButton,
|
||||
QFrame,
|
||||
QWidget,
|
||||
QToolButton,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QStyle,
|
||||
QSizePolicy,
|
||||
QMessageBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QAbstractItemView,
|
||||
QHeaderView,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
|
||||
|
||||
class ReminderType(Enum):
|
||||
ONCE = strings._("once")
|
||||
DAILY = strings._("daily")
|
||||
WEEKDAYS = strings._("weekdays") # Mon-Fri
|
||||
WEEKLY = strings._("weekly") # specific day of week
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reminder:
|
||||
id: Optional[int]
|
||||
text: str
|
||||
time_str: str # HH:MM
|
||||
reminder_type: ReminderType
|
||||
weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type)
|
||||
active: bool = True
|
||||
date_iso: Optional[str] = None # For ONCE type
|
||||
|
||||
|
||||
class ReminderDialog(QDialog):
|
||||
"""Dialog for creating/editing reminders with recurrence support."""
|
||||
|
||||
def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self._reminder = reminder
|
||||
|
||||
self.setWindowTitle(
|
||||
strings._("set_reminder") if not reminder else strings._("edit_reminder")
|
||||
)
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
|
||||
# Reminder text
|
||||
self.text_edit = QLineEdit()
|
||||
if reminder:
|
||||
self.text_edit.setText(reminder.text)
|
||||
form.addRow("&" + strings._("reminder") + ":", self.text_edit)
|
||||
|
||||
# Time
|
||||
self.time_edit = QTimeEdit()
|
||||
self.time_edit.setDisplayFormat("HH:mm")
|
||||
if reminder:
|
||||
parts = reminder.time_str.split(":")
|
||||
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
|
||||
else:
|
||||
self.time_edit.setTime(QTime.currentTime())
|
||||
form.addRow("&" + strings._("time") + ":", self.time_edit)
|
||||
|
||||
# Recurrence type
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
|
||||
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
|
||||
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
|
||||
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
|
||||
|
||||
if reminder:
|
||||
for i in range(self.type_combo.count()):
|
||||
if self.type_combo.itemData(i) == reminder.reminder_type:
|
||||
self.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_changed)
|
||||
form.addRow("&" + strings._("repeat") + ":", self.type_combo)
|
||||
|
||||
# Weekday selector (for weekly reminders)
|
||||
self.weekday_combo = QComboBox()
|
||||
days = [
|
||||
strings._("monday"),
|
||||
strings._("tuesday"),
|
||||
strings._("wednesday"),
|
||||
strings._("thursday"),
|
||||
strings._("friday"),
|
||||
strings._("saturday"),
|
||||
strings._("sunday"),
|
||||
]
|
||||
for i, day in enumerate(days):
|
||||
self.weekday_combo.addItem(day, i)
|
||||
|
||||
if reminder and reminder.weekday is not None:
|
||||
self.weekday_combo.setCurrentIndex(reminder.weekday)
|
||||
else:
|
||||
self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1)
|
||||
|
||||
form.addRow("&" + strings._("day") + ":", self.weekday_combo)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
save_btn = QPushButton("&" + strings._("save"))
|
||||
save_btn.clicked.connect(self.accept)
|
||||
save_btn.setDefault(True)
|
||||
btn_layout.addWidget(save_btn)
|
||||
|
||||
cancel_btn = QPushButton("&" + strings._("cancel"))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self._on_type_changed()
|
||||
|
||||
def _on_type_changed(self):
|
||||
"""Show/hide weekday selector based on reminder type."""
|
||||
reminder_type = self.type_combo.currentData()
|
||||
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
|
||||
|
||||
def get_reminder(self) -> Reminder:
|
||||
"""Get the configured reminder."""
|
||||
reminder_type = self.type_combo.currentData()
|
||||
time_obj = self.time_edit.time()
|
||||
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
|
||||
|
||||
weekday = None
|
||||
if reminder_type == ReminderType.WEEKLY:
|
||||
weekday = self.weekday_combo.currentData()
|
||||
|
||||
date_iso = None
|
||||
if reminder_type == ReminderType.ONCE:
|
||||
# Right now this just means "today at the chosen time".
|
||||
date_iso = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
|
||||
return Reminder(
|
||||
id=self._reminder.id if self._reminder else None,
|
||||
text=self.text_edit.text(),
|
||||
time_str=time_str,
|
||||
reminder_type=reminder_type,
|
||||
weekday=weekday,
|
||||
active=self._reminder.active if self._reminder else True,
|
||||
date_iso=date_iso,
|
||||
)
|
||||
|
||||
|
||||
class UpcomingRemindersWidget(QFrame):
|
||||
"""Collapsible widget showing upcoming reminders for today and next 7 days."""
|
||||
|
||||
reminderTriggered = Signal(str) # Emits reminder text
|
||||
|
||||
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
# Header with toggle button
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText("Upcoming Reminders")
|
||||
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
self.toggle_btn.setArrowType(Qt.RightArrow)
|
||||
self.toggle_btn.clicked.connect(self._on_toggle)
|
||||
|
||||
self.add_btn = QToolButton()
|
||||
self.add_btn.setText("⏰")
|
||||
self.add_btn.setToolTip("Add Reminder")
|
||||
self.add_btn.setAutoRaise(True)
|
||||
self.add_btn.clicked.connect(self._add_reminder)
|
||||
|
||||
self.manage_btn = QToolButton()
|
||||
self.manage_btn.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||
)
|
||||
self.manage_btn.setToolTip("Manage All Reminders")
|
||||
self.manage_btn.setAutoRaise(True)
|
||||
self.manage_btn.clicked.connect(self._manage_reminders)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.addWidget(self.toggle_btn)
|
||||
header.addStretch()
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.manage_btn)
|
||||
|
||||
# Body with reminder list
|
||||
self.body = QWidget()
|
||||
body_layout = QVBoxLayout(self.body)
|
||||
body_layout.setContentsMargins(0, 4, 0, 0)
|
||||
body_layout.setSpacing(2)
|
||||
|
||||
self.reminder_list = QListWidget()
|
||||
self.reminder_list.setMaximumHeight(200)
|
||||
self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.reminder_list.itemDoubleClicked.connect(self._edit_reminder)
|
||||
self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.reminder_list.customContextMenuRequested.connect(
|
||||
self._show_reminder_context_menu
|
||||
)
|
||||
body_layout.addWidget(self.reminder_list)
|
||||
|
||||
self.body.setVisible(False)
|
||||
|
||||
main = QVBoxLayout(self)
|
||||
main.setContentsMargins(0, 0, 0, 0)
|
||||
main.addLayout(header)
|
||||
main.addWidget(self.body)
|
||||
|
||||
# Timer to check and fire reminders
|
||||
# Start by syncing to the next minute boundary
|
||||
self._check_timer = QTimer(self)
|
||||
self._check_timer.timeout.connect(self._check_reminders)
|
||||
|
||||
# Calculate milliseconds until next minute (HH:MM:00)
|
||||
now = QDateTime.currentDateTime()
|
||||
current_second = now.time().second()
|
||||
current_msec = now.time().msec()
|
||||
|
||||
# Milliseconds until next minute
|
||||
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
|
||||
|
||||
# Start with a single-shot to sync to the minute
|
||||
self._sync_timer = QTimer(self)
|
||||
self._sync_timer.setSingleShot(True)
|
||||
self._sync_timer.timeout.connect(self._start_regular_timer)
|
||||
self._sync_timer.start(ms_until_next_minute)
|
||||
|
||||
# Also check immediately in case there are pending reminders
|
||||
QTimer.singleShot(1000, self._check_reminders)
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup timers when widget is destroyed."""
|
||||
try:
|
||||
if hasattr(self, "_check_timer") and self._check_timer:
|
||||
self._check_timer.stop()
|
||||
if hasattr(self, "_sync_timer") and self._sync_timer:
|
||||
self._sync_timer.stop()
|
||||
except:
|
||||
pass # Ignore any cleanup errors
|
||||
|
||||
def _start_regular_timer(self):
|
||||
"""Start the regular check timer after initial sync."""
|
||||
# Now we're at a minute boundary, check and start regular timer
|
||||
self._check_reminders()
|
||||
self._check_timer.start(60000) # Check every minute
|
||||
|
||||
def _on_toggle(self, checked: bool):
|
||||
"""Toggle visibility of reminder list."""
|
||||
self.body.setVisible(checked)
|
||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||
if checked:
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Reload and display upcoming reminders."""
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
self.reminder_list.clear()
|
||||
|
||||
reminders = self._db.get_all_reminders()
|
||||
now = QDateTime.currentDateTime()
|
||||
today = QDate.currentDate()
|
||||
|
||||
# Get reminders for the next 7 days
|
||||
upcoming = []
|
||||
for i in range(8): # Today + 7 days
|
||||
check_date = today.addDays(i)
|
||||
|
||||
for reminder in reminders:
|
||||
if not reminder.active:
|
||||
continue
|
||||
|
||||
if self._should_fire_on_date(reminder, check_date):
|
||||
# Parse time
|
||||
hour, minute = map(int, reminder.time_str.split(":"))
|
||||
dt = QDateTime(check_date, QTime(hour, minute))
|
||||
|
||||
# Skip past reminders
|
||||
if dt < now:
|
||||
continue
|
||||
|
||||
upcoming.append((dt, reminder))
|
||||
|
||||
# Sort by datetime
|
||||
upcoming.sort(key=lambda x: x[0])
|
||||
|
||||
# Display
|
||||
for dt, reminder in upcoming[:20]: # Show max 20
|
||||
date_str = dt.date().toString("ddd MMM d")
|
||||
time_str = dt.time().toString("HH:mm")
|
||||
|
||||
item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}")
|
||||
item.setData(Qt.UserRole, reminder)
|
||||
self.reminder_list.addItem(item)
|
||||
|
||||
if not upcoming:
|
||||
item = QListWidgetItem("No upcoming reminders")
|
||||
item.setFlags(Qt.NoItemFlags)
|
||||
self.reminder_list.addItem(item)
|
||||
|
||||
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
|
||||
"""Check if a reminder should fire on a given date."""
|
||||
if reminder.reminder_type == ReminderType.ONCE:
|
||||
if reminder.date_iso:
|
||||
return date.toString("yyyy-MM-dd") == reminder.date_iso
|
||||
return False
|
||||
elif reminder.reminder_type == ReminderType.DAILY:
|
||||
return True
|
||||
elif reminder.reminder_type == ReminderType.WEEKDAYS:
|
||||
# Monday=1, Sunday=7
|
||||
return 1 <= date.dayOfWeek() <= 5
|
||||
elif reminder.reminder_type == ReminderType.WEEKLY:
|
||||
# Qt: Monday=1, reminder: Monday=0
|
||||
return date.dayOfWeek() - 1 == reminder.weekday
|
||||
return False
|
||||
|
||||
def _check_reminders(self):
|
||||
"""Check if any reminders should fire now."""
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
now = QDateTime.currentDateTime()
|
||||
today = QDate.currentDate()
|
||||
|
||||
# Round current time to the minute (set seconds to 0)
|
||||
current_minute = QDateTime(
|
||||
today, QTime(now.time().hour(), now.time().minute(), 0)
|
||||
)
|
||||
|
||||
reminders = self._db.get_all_reminders()
|
||||
for reminder in reminders:
|
||||
if not reminder.active:
|
||||
continue
|
||||
|
||||
if not self._should_fire_on_date(reminder, today):
|
||||
continue
|
||||
|
||||
# Parse time
|
||||
hour, minute = map(int, reminder.time_str.split(":"))
|
||||
target = QDateTime(today, QTime(hour, minute, 0))
|
||||
|
||||
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
|
||||
seconds_diff = current_minute.secsTo(target)
|
||||
if -120 <= seconds_diff <= 0:
|
||||
# Check if we haven't already fired this one
|
||||
if not hasattr(self, "_fired_reminders"):
|
||||
self._fired_reminders = {}
|
||||
|
||||
reminder_key = (reminder.id, target.toString())
|
||||
|
||||
# Only fire once per reminder per target time
|
||||
if reminder_key not in self._fired_reminders:
|
||||
self._fired_reminders[reminder_key] = current_minute
|
||||
self.reminderTriggered.emit(reminder.text)
|
||||
|
||||
# For ONCE reminders, deactivate after firing
|
||||
if reminder.reminder_type == ReminderType.ONCE:
|
||||
self._db.update_reminder_active(reminder.id, False)
|
||||
self.refresh() # Refresh the list to show deactivated reminder
|
||||
|
||||
@Slot()
|
||||
def _add_reminder(self):
|
||||
"""Open dialog to add a new reminder."""
|
||||
dlg = ReminderDialog(self._db, self)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
reminder = dlg.get_reminder()
|
||||
self._db.save_reminder(reminder)
|
||||
self.refresh()
|
||||
|
||||
@Slot(QListWidgetItem)
|
||||
def _edit_reminder(self, item: QListWidgetItem):
|
||||
"""Edit an existing reminder."""
|
||||
reminder = item.data(Qt.UserRole)
|
||||
if not reminder:
|
||||
return
|
||||
|
||||
dlg = ReminderDialog(self._db, self, reminder)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
updated = dlg.get_reminder()
|
||||
self._db.save_reminder(updated)
|
||||
self.refresh()
|
||||
|
||||
@Slot()
|
||||
def _show_reminder_context_menu(self, pos):
|
||||
"""Show context menu for reminder list item(s)."""
|
||||
selected_items = self.reminder_list.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
from PySide6.QtWidgets import QMenu
|
||||
from PySide6.QtGui import QAction
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Only show Edit if single item selected
|
||||
if len(selected_items) == 1:
|
||||
reminder = selected_items[0].data(Qt.UserRole)
|
||||
if reminder:
|
||||
edit_action = QAction("Edit", self)
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_reminder(selected_items[0])
|
||||
)
|
||||
menu.addAction(edit_action)
|
||||
|
||||
# Delete option for any selection
|
||||
if len(selected_items) == 1:
|
||||
delete_text = "Delete"
|
||||
else:
|
||||
delete_text = f"Delete {len(selected_items)} Reminders"
|
||||
|
||||
delete_action = QAction(delete_text, self)
|
||||
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
|
||||
menu.addAction(delete_action)
|
||||
|
||||
menu.exec(self.reminder_list.mapToGlobal(pos))
|
||||
|
||||
def _delete_selected_reminders(self):
|
||||
"""Delete all selected reminders (handling duplicates)."""
|
||||
selected_items = self.reminder_list.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
# Collect unique reminder IDs
|
||||
unique_reminders = {}
|
||||
for item in selected_items:
|
||||
reminder = item.data(Qt.UserRole)
|
||||
if reminder and reminder.id not in unique_reminders:
|
||||
unique_reminders[reminder.id] = reminder
|
||||
|
||||
if not unique_reminders:
|
||||
return
|
||||
|
||||
# Confirmation message
|
||||
if len(unique_reminders) == 1:
|
||||
reminder = list(unique_reminders.values())[0]
|
||||
msg = f"Delete reminder '{reminder.text}'?"
|
||||
if reminder.reminder_type != ReminderType.ONCE:
|
||||
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
|
||||
else:
|
||||
msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Reminders",
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
for reminder_id in unique_reminders:
|
||||
self._db.delete_reminder(reminder_id)
|
||||
self.refresh()
|
||||
|
||||
def _delete_reminder(self, reminder):
|
||||
"""Delete a single reminder after confirmation."""
|
||||
msg = f"Delete reminder '{reminder.text}'?"
|
||||
if reminder.reminder_type != ReminderType.ONCE:
|
||||
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Reminder",
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._db.delete_reminder(reminder.id)
|
||||
self.refresh()
|
||||
|
||||
@Slot()
|
||||
def _manage_reminders(self):
|
||||
"""Open dialog to manage all reminders."""
|
||||
dlg = ManageRemindersDialog(self._db, self)
|
||||
dlg.exec()
|
||||
self.refresh()
|
||||
|
||||
|
||||
class ManageRemindersDialog(QDialog):
|
||||
"""Dialog for managing all reminders."""
|
||||
|
||||
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
|
||||
self.setWindowTitle("Manage Reminders")
|
||||
self.setMinimumSize(700, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Reminder list table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
["Text", "Time", "Type", "Active", "Actions"]
|
||||
)
|
||||
self.table.horizontalHeader().setStretchLastSection(False)
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
add_btn = QPushButton("Add Reminder")
|
||||
add_btn.clicked.connect(self._add_reminder)
|
||||
btn_layout.addWidget(add_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self._load_reminders()
|
||||
|
||||
def _load_reminders(self):
|
||||
"""Load all reminders into the table."""
|
||||
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
reminders = self._db.get_all_reminders()
|
||||
self.table.setRowCount(len(reminders))
|
||||
|
||||
for row, reminder in enumerate(reminders):
|
||||
# Text
|
||||
text_item = QTableWidgetItem(reminder.text)
|
||||
text_item.setData(Qt.UserRole, reminder)
|
||||
self.table.setItem(row, 0, text_item)
|
||||
|
||||
# Time
|
||||
time_item = QTableWidgetItem(reminder.time_str)
|
||||
self.table.setItem(row, 1, time_item)
|
||||
|
||||
# Type
|
||||
type_str = {
|
||||
ReminderType.ONCE: "Once",
|
||||
ReminderType.DAILY: "Daily",
|
||||
ReminderType.WEEKDAYS: "Weekdays",
|
||||
ReminderType.WEEKLY: "Weekly",
|
||||
}.get(reminder.reminder_type, "Unknown")
|
||||
|
||||
if (
|
||||
reminder.reminder_type == ReminderType.WEEKLY
|
||||
and reminder.weekday is not None
|
||||
):
|
||||
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
type_str += f" ({days[reminder.weekday]})"
|
||||
|
||||
type_item = QTableWidgetItem(type_str)
|
||||
self.table.setItem(row, 2, type_item)
|
||||
|
||||
# Active
|
||||
active_item = QTableWidgetItem("✓" if reminder.active else "✗")
|
||||
self.table.setItem(row, 3, active_item)
|
||||
|
||||
# Actions
|
||||
actions_widget = QWidget()
|
||||
actions_layout = QHBoxLayout(actions_widget)
|
||||
actions_layout.setContentsMargins(2, 2, 2, 2)
|
||||
|
||||
edit_btn = QPushButton("Edit")
|
||||
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
|
||||
actions_layout.addWidget(edit_btn)
|
||||
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.clicked.connect(
|
||||
lambda checked, r=reminder: self._delete_reminder(r)
|
||||
)
|
||||
actions_layout.addWidget(delete_btn)
|
||||
|
||||
self.table.setCellWidget(row, 4, actions_widget)
|
||||
|
||||
def _add_reminder(self):
|
||||
"""Add a new reminder."""
|
||||
dlg = ReminderDialog(self._db, self)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
reminder = dlg.get_reminder()
|
||||
self._db.save_reminder(reminder)
|
||||
self._load_reminders()
|
||||
|
||||
def _edit_reminder(self, reminder):
|
||||
"""Edit an existing reminder."""
|
||||
dlg = ReminderDialog(self._db, self, reminder)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
updated = dlg.get_reminder()
|
||||
self._db.save_reminder(updated)
|
||||
self._load_reminders()
|
||||
|
||||
def _delete_reminder(self, reminder):
|
||||
"""Delete a reminder."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Reminder",
|
||||
f"Delete reminder '{reminder.text}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._db.delete_reminder(reminder.id)
|
||||
self._load_reminders()
|
||||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
import datetime
|
||||
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
|
|
@ -11,36 +10,25 @@ from PySide6.QtWidgets import (
|
|||
QDialogButtonBox,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
class SaveDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Enter a name for this version",
|
||||
message: str = "Enter a name for this version?",
|
||||
):
|
||||
"""
|
||||
Used for explicitly saving a new version of a page.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(strings._("enter_a_name_for_this_version"))
|
||||
|
||||
self.setWindowTitle(title)
|
||||
v = QVBoxLayout(self)
|
||||
v.addWidget(QLabel(strings._("enter_a_name_for_this_version")))
|
||||
|
||||
v.addWidget(QLabel(message))
|
||||
self.note = QLineEdit()
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
text = strings._("new_version_i_saved_at") + f" {now}"
|
||||
self.note.setText(text)
|
||||
self.note.setText(f"New version I saved at {now}")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ from PySide6.QtWidgets import (
|
|||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
Row = Tuple[str, str]
|
||||
|
||||
|
||||
|
|
@ -32,7 +30,7 @@ class Search(QWidget):
|
|||
self._db = db
|
||||
|
||||
self.search = QLineEdit()
|
||||
self.search.setPlaceholderText(strings._("search_for_notes_here"))
|
||||
self.search.setPlaceholderText("Search for notes here")
|
||||
self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.search.textChanged.connect(self._search)
|
||||
|
||||
|
|
@ -68,7 +66,10 @@ class Search(QWidget):
|
|||
self.resultDatesChanged.emit([]) # clear highlights
|
||||
return
|
||||
|
||||
rows: Iterable[Row] = self._db.search_entries(q)
|
||||
try:
|
||||
rows: Iterable[Row] = self._db.search_entries(q)
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
self._populate_results(q, rows)
|
||||
|
||||
|
|
@ -85,7 +86,10 @@ class Search(QWidget):
|
|||
|
||||
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)
|
||||
frag_html, left_ell, right_ell = self._make_html_snippet(
|
||||
content, query, radius=30, maxlen=90
|
||||
)
|
||||
|
||||
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||
container = QWidget()
|
||||
outer = QVBoxLayout(container)
|
||||
|
|
@ -107,6 +111,11 @@ class Search(QWidget):
|
|||
h.setContentsMargins(0, 0, 0, 0)
|
||||
h.setSpacing(4)
|
||||
|
||||
if left_ell:
|
||||
left = QLabel("…")
|
||||
left.setStyleSheet("color:#888;")
|
||||
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
preview = QLabel()
|
||||
preview.setTextFormat(Qt.TextFormat.RichText)
|
||||
preview.setWordWrap(True)
|
||||
|
|
@ -118,6 +127,11 @@ class Search(QWidget):
|
|||
)
|
||||
h.addWidget(preview, 1)
|
||||
|
||||
if right_ell:
|
||||
right = QLabel("…")
|
||||
right.setStyleSheet("color:#888;")
|
||||
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
|
||||
|
||||
outer.addWidget(row)
|
||||
|
||||
line = QFrame()
|
||||
|
|
@ -134,7 +148,9 @@ class Search(QWidget):
|
|||
self.results.setItemWidget(item, container)
|
||||
|
||||
# --- Snippet/highlight helpers -----------------------------------------
|
||||
def _make_html_snippet(self, markdown_src: str, query: str, radius=60, maxlen=180):
|
||||
def _make_html_snippet(
|
||||
self, markdown_src: str, query: str, *, radius=60, maxlen=180
|
||||
):
|
||||
# For markdown, we can work directly with the text
|
||||
# Strip markdown formatting for display
|
||||
plain = self._strip_markdown(markdown_src)
|
||||
|
|
@ -179,7 +195,7 @@ class Search(QWidget):
|
|||
lambda m: f"<b>{m.group(0)}</b>", snippet_html
|
||||
)
|
||||
|
||||
return snippet_html
|
||||
return snippet_html, start > 0, end < L
|
||||
|
||||
def _strip_markdown(self, markdown: str) -> str:
|
||||
"""Strip markdown formatting for plain text display."""
|
||||
|
|
|
|||
|
|
@ -9,66 +9,31 @@ APP_ORG = "Bouquin"
|
|||
APP_NAME = "Bouquin"
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
|
||||
return base / "notebook.db"
|
||||
|
||||
|
||||
def get_settings() -> QSettings:
|
||||
return QSettings(APP_ORG, APP_NAME)
|
||||
|
||||
|
||||
def _default_db_location() -> Path:
|
||||
"""Where we put the notebook if nothing has been configured yet."""
|
||||
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / "notebook.db"
|
||||
|
||||
|
||||
def load_db_config() -> DBConfig:
|
||||
s = get_settings()
|
||||
|
||||
# --- 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 ------------------------------------------------
|
||||
path = Path(s.value("db/path", str(default_db_path())))
|
||||
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)
|
||||
tags = s.value("ui/tags", True, type=bool)
|
||||
time_log = s.value("ui/time_log", True, type=bool)
|
||||
reminders = s.value("ui/reminders", True, type=bool)
|
||||
locale = s.value("ui/locale", "en", type=str)
|
||||
font_size = s.value("ui/font_size", 11, type=int)
|
||||
return DBConfig(
|
||||
path=path,
|
||||
key=key,
|
||||
idle_minutes=idle,
|
||||
theme=theme,
|
||||
move_todos=move_todos,
|
||||
tags=tags,
|
||||
time_log=time_log,
|
||||
reminders=reminders,
|
||||
locale=locale,
|
||||
font_size=font_size,
|
||||
path=path, key=key, idle_minutes=idle, theme=theme, move_todos=move_todos
|
||||
)
|
||||
|
||||
|
||||
def save_db_config(cfg: DBConfig) -> None:
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(cfg.path))
|
||||
s.setValue("db/path", 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/tags", str(cfg.tags))
|
||||
s.setValue("ui/time_log", str(cfg.time_log))
|
||||
s.setValue("ui/reminders", str(cfg.reminders))
|
||||
s.setValue("ui/locale", str(cfg.locale))
|
||||
s.setValue("ui/font_size", str(cfg.font_size))
|
||||
|
|
|
|||
|
|
@ -4,21 +4,22 @@ from pathlib import Path
|
|||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QFileDialog,
|
||||
QDialogButtonBox,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QTabWidget,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
from PySide6.QtGui import QPalette
|
||||
|
|
@ -29,64 +30,32 @@ from .settings import load_db_config, save_db_config
|
|||
from .theme import Theme
|
||||
from .key_prompt import KeyPrompt
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(strings._("settings"))
|
||||
self.setWindowTitle("Settings")
|
||||
self._cfg = DBConfig(path=cfg.path, key="")
|
||||
self._db = db
|
||||
self.key = ""
|
||||
|
||||
self.current_settings = load_db_config()
|
||||
|
||||
self.setMinimumWidth(480)
|
||||
form = QFormLayout()
|
||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||
self.setMinimumWidth(560)
|
||||
self.setSizeGripEnabled(True)
|
||||
|
||||
# --- Tabs ----------------------------------------------------------
|
||||
tabs = QTabWidget()
|
||||
tabs.setTabPosition(QTabWidget.North)
|
||||
tabs.setDocumentMode(True)
|
||||
tabs.setMovable(False)
|
||||
current_settings = load_db_config()
|
||||
|
||||
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"))
|
||||
# Add theme selection
|
||||
theme_group = QGroupBox("Theme")
|
||||
theme_layout = QVBoxLayout(theme_group)
|
||||
|
||||
self.theme_system = QRadioButton(strings._("system"))
|
||||
self.theme_light = QRadioButton(strings._("light"))
|
||||
self.theme_dark = QRadioButton(strings._("dark"))
|
||||
self.theme_system = QRadioButton("System")
|
||||
self.theme_light = QRadioButton("Light")
|
||||
self.theme_dark = QRadioButton("Dark")
|
||||
|
||||
current_theme = self.current_settings.theme
|
||||
# Load current theme from settings
|
||||
current_theme = current_settings.theme
|
||||
if current_theme == Theme.DARK.value:
|
||||
self.theme_dark.setChecked(True)
|
||||
elif current_theme == Theme.LIGHT.value:
|
||||
|
|
@ -98,119 +67,63 @@ class SettingsDialog(QDialog):
|
|||
theme_layout.addWidget(self.theme_light)
|
||||
theme_layout.addWidget(self.theme_dark)
|
||||
|
||||
# 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)
|
||||
form.addRow(theme_group)
|
||||
|
||||
# explanation
|
||||
self.font_size_label = QLabel(strings._("font_size_explanation"))
|
||||
self.font_size_label.setWordWrap(True)
|
||||
self.font_size_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
pal = self.font_size_label.palette()
|
||||
self.font_size_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.font_size_label.setPalette(pal)
|
||||
|
||||
font_exp_row = QHBoxLayout()
|
||||
font_exp_row.setContentsMargins(24, 0, 0, 0)
|
||||
font_exp_row.addWidget(self.font_size_label)
|
||||
theme_layout.addLayout(font_exp_row)
|
||||
|
||||
layout.addWidget(theme_group)
|
||||
|
||||
# --- Locale group -------------------------------------------------
|
||||
locale_group = QGroupBox(strings._("locale"))
|
||||
locale_layout = QVBoxLayout(locale_group)
|
||||
|
||||
self.locale_combobox = QComboBox()
|
||||
self.locale_combobox.addItems(strings._AVAILABLE)
|
||||
self.locale_combobox.setCurrentText(self.current_settings.locale)
|
||||
locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft)
|
||||
|
||||
self.locale_label = QLabel(strings._("locale_restart"))
|
||||
self.locale_label.setWordWrap(True)
|
||||
self.locale_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
lpal = self.locale_label.palette()
|
||||
self.locale_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
self.locale_label.setPalette(lpal)
|
||||
loc_row = QHBoxLayout()
|
||||
loc_row.setContentsMargins(24, 0, 0, 0)
|
||||
loc_row.addWidget(self.locale_label)
|
||||
locale_layout.addLayout(loc_row)
|
||||
|
||||
layout.addWidget(locale_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
def _create_features_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
features_group = QGroupBox(strings._("features"))
|
||||
features_layout = QVBoxLayout(features_group)
|
||||
# Add Behaviour
|
||||
behaviour_group = QGroupBox("Behaviour")
|
||||
behaviour_layout = QVBoxLayout(behaviour_group)
|
||||
|
||||
self.move_todos = QCheckBox(
|
||||
strings._("move_unchecked_todos_to_today_on_startup")
|
||||
"Move yesterday's unchecked TODOs to today on startup"
|
||||
)
|
||||
self.move_todos.setChecked(self.current_settings.move_todos)
|
||||
self.move_todos.setChecked(current_settings.move_todos)
|
||||
self.move_todos.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.move_todos)
|
||||
|
||||
self.tags = QCheckBox(strings._("enable_tags_feature"))
|
||||
self.tags.setChecked(self.current_settings.tags)
|
||||
self.tags.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.tags)
|
||||
behaviour_layout.addWidget(self.move_todos)
|
||||
form.addRow(behaviour_group)
|
||||
|
||||
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.path_edit = QLineEdit(str(self._cfg.path))
|
||||
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
browse_btn = QPushButton("Browse…")
|
||||
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
browse_btn.clicked.connect(self._browse)
|
||||
path_row = QWidget()
|
||||
h = QHBoxLayout(path_row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
h.addWidget(self.path_edit, 1)
|
||||
h.addWidget(browse_btn, 0)
|
||||
h.setStretch(0, 1)
|
||||
h.setStretch(1, 0)
|
||||
form.addRow("Database path", path_row)
|
||||
|
||||
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
||||
self.reminders.setChecked(self.current_settings.reminders)
|
||||
self.reminders.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.reminders)
|
||||
|
||||
layout.addWidget(features_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
def _create_security_page(self, cfg: DBConfig) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# --- Encryption group ---------------------------------------------
|
||||
enc_group = QGroupBox(strings._("encryption"))
|
||||
# Encryption settings
|
||||
enc_group = QGroupBox("Encryption")
|
||||
enc = QVBoxLayout(enc_group)
|
||||
enc.setContentsMargins(12, 8, 12, 12)
|
||||
enc.setSpacing(6)
|
||||
|
||||
self.save_key_btn = QCheckBox(strings._("remember_key"))
|
||||
self.key = self.current_settings.key or ""
|
||||
# Checkbox to remember key
|
||||
self.save_key_btn = QCheckBox("Remember key")
|
||||
self.key = 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)
|
||||
|
||||
self.save_key_label = QLabel(strings._("save_key_warning"))
|
||||
# Explanation for remembering key
|
||||
self.save_key_label = QLabel(
|
||||
"If you don't want to be prompted for your encryption key, check this to remember it. "
|
||||
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
|
||||
)
|
||||
self.save_key_label.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)
|
||||
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
|
||||
self.save_key_label.setPalette(pal)
|
||||
|
||||
exp_row = QHBoxLayout()
|
||||
exp_row.setContentsMargins(24, 0, 0, 0)
|
||||
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
|
||||
exp_row.addWidget(self.save_key_label)
|
||||
enc.addLayout(exp_row)
|
||||
|
||||
|
|
@ -219,77 +132,102 @@ class SettingsDialog(QDialog):
|
|||
line.setFrameShadow(QFrame.Sunken)
|
||||
enc.addWidget(line)
|
||||
|
||||
self.rekey_btn = QPushButton(strings._("change_encryption_key"))
|
||||
# Change key button
|
||||
self.rekey_btn = QPushButton("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)
|
||||
|
||||
layout.addWidget(enc_group)
|
||||
form.addRow(enc_group)
|
||||
|
||||
# --- Idle lock group ----------------------------------------------
|
||||
priv_group = QGroupBox(strings._("lock_screen_when_idle"))
|
||||
# Privacy settings
|
||||
priv_group = QGroupBox("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("Never")
|
||||
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
|
||||
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
||||
|
||||
self.idle_spin_label = QLabel(strings._("autolock_explanation"))
|
||||
# Explanation for idle option (autolock)
|
||||
self.idle_spin_label = QLabel(
|
||||
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
|
||||
"Set to 0 (never) to never lock."
|
||||
)
|
||||
self.idle_spin_label.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)
|
||||
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
|
||||
self.idle_spin_label.setPalette(spal)
|
||||
|
||||
spin_row = QHBoxLayout()
|
||||
spin_row.setContentsMargins(24, 0, 0, 0)
|
||||
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
|
||||
spin_row.addWidget(self.idle_spin_label)
|
||||
priv.addLayout(spin_row)
|
||||
|
||||
layout.addWidget(priv_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
form.addRow(priv_group)
|
||||
|
||||
def _create_database_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
maint_group = QGroupBox(strings._("database_maintenance"))
|
||||
# Maintenance settings
|
||||
maint_group = QGroupBox("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 = QPushButton("Compact database")
|
||||
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)
|
||||
|
||||
self.compact_label = QLabel(strings._("database_compact_explanation"))
|
||||
# Explanation for compating button
|
||||
self.compact_label = QLabel(
|
||||
"Compacting runs VACUUM on the database. This can help reduce its size."
|
||||
)
|
||||
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)
|
||||
cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
|
||||
self.compact_label.setPalette(cpal)
|
||||
|
||||
maint_row = QHBoxLayout()
|
||||
maint_row.setContentsMargins(24, 0, 0, 0)
|
||||
maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
|
||||
maint_row.addWidget(self.compact_label)
|
||||
maint.addLayout(maint_row)
|
||||
|
||||
layout.addWidget(maint_group)
|
||||
layout.addStretch()
|
||||
return page
|
||||
form.addRow(maint_group)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Save settings
|
||||
# ------------------------------------------------------------------ #
|
||||
# Buttons
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self._save)
|
||||
bb.rejected.connect(self.reject)
|
||||
|
||||
# Root layout (adjust margins/spacing a bit)
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(12, 12, 12, 12)
|
||||
v.setSpacing(10)
|
||||
v.addLayout(form)
|
||||
v.addWidget(bb, 0, Qt.AlignRight)
|
||||
|
||||
def _browse(self):
|
||||
p, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Choose database file",
|
||||
self.path_edit.text(),
|
||||
"DB Files (*.db);;All Files (*)",
|
||||
)
|
||||
if p:
|
||||
self.path_edit.setText(p)
|
||||
|
||||
def _save(self):
|
||||
# Save the selected theme into QSettings
|
||||
if self.theme_dark.isChecked():
|
||||
selected_theme = Theme.DARK
|
||||
elif self.theme_light.isChecked():
|
||||
|
|
@ -300,16 +238,11 @@ class SettingsDialog(QDialog):
|
|||
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
||||
|
||||
self._cfg = DBConfig(
|
||||
path=Path(self.current_settings.path),
|
||||
path=Path(self.path_edit.text()),
|
||||
key=key_to_save,
|
||||
idle_minutes=self.idle_spin.value(),
|
||||
theme=selected_theme.value,
|
||||
move_todos=self.move_todos.isChecked(),
|
||||
tags=self.tags.isChecked(),
|
||||
time_log=self.time_log.isChecked(),
|
||||
reminders=self.reminders.isChecked(),
|
||||
locale=self.locale_combobox.currentText(),
|
||||
font_size=self.font_size.value(),
|
||||
)
|
||||
|
||||
save_db_config(self._cfg)
|
||||
|
|
@ -317,39 +250,27 @@ class SettingsDialog(QDialog):
|
|||
self.accept()
|
||||
|
||||
def _change_key(self):
|
||||
p1 = KeyPrompt(
|
||||
self,
|
||||
title=strings._("change_encryption_key"),
|
||||
message=strings._("enter_a_new_encryption_key"),
|
||||
)
|
||||
p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
|
||||
if p1.exec() != QDialog.Accepted:
|
||||
return
|
||||
new_key = p1.key()
|
||||
p2 = KeyPrompt(
|
||||
self,
|
||||
title=strings._("change_encryption_key"),
|
||||
message=strings._("reenter_the_new_key"),
|
||||
)
|
||||
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
|
||||
if p2.exec() != QDialog.Accepted:
|
||||
return
|
||||
if new_key != p2.key():
|
||||
QMessageBox.warning(
|
||||
self, strings._("key_mismatch"), strings._("key_mismatch_explanation")
|
||||
)
|
||||
QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
|
||||
return
|
||||
if not new_key:
|
||||
QMessageBox.warning(
|
||||
self, strings._("empty_key"), strings._("empty_key_explanation")
|
||||
)
|
||||
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
|
||||
return
|
||||
try:
|
||||
self.key = new_key
|
||||
self._db.rekey(new_key)
|
||||
QMessageBox.information(
|
||||
self, strings._("key_changed"), strings._("key_changed_explanation")
|
||||
self, "Key changed", "The notebook was re-encrypted with the new key!"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, strings._("error"), str(e))
|
||||
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
||||
|
||||
@Slot(bool)
|
||||
def _save_key_btn_clicked(self, checked: bool):
|
||||
|
|
@ -357,9 +278,7 @@ class SettingsDialog(QDialog):
|
|||
if checked:
|
||||
if not self.key:
|
||||
p1 = KeyPrompt(
|
||||
self,
|
||||
title=strings._("unlock_encrypted_notebook_explanation"),
|
||||
message=strings._("unlock_encrypted_notebook_explanation"),
|
||||
self, title="Enter your key", message="Enter the encryption key"
|
||||
)
|
||||
if p1.exec() != QDialog.Accepted:
|
||||
self.save_key_btn.blockSignals(True)
|
||||
|
|
@ -373,10 +292,10 @@ class SettingsDialog(QDialog):
|
|||
try:
|
||||
self._db.compact()
|
||||
QMessageBox.information(
|
||||
self, strings._("success"), strings._("database_compacted_successfully")
|
||||
self, "Compact complete", "Database compacted successfully!"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, strings._("error"), str(e))
|
||||
QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
|
||||
|
||||
@property
|
||||
def config(self) -> DBConfig:
|
||||
|
|
|
|||
|
|
@ -1,356 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Dict
|
||||
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QComboBox,
|
||||
QScrollArea,
|
||||
QWidget,
|
||||
QSizePolicy,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
|
||||
|
||||
# ---------- Activity heatmap ----------
|
||||
|
||||
|
||||
class DateHeatmap(QWidget):
|
||||
"""
|
||||
Small calendar heatmap for activity by date.
|
||||
|
||||
Data is a mapping: datetime.date -> integer value.
|
||||
"""
|
||||
|
||||
date_clicked = Signal(_dt.date)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._data: Dict[_dt.date, int] = {}
|
||||
self._start: _dt.date | None = None
|
||||
self._end: _dt.date | None = None
|
||||
self._max_value: int = 0
|
||||
|
||||
self._cell = 12
|
||||
self._gap = 3
|
||||
self._margin_left = 30
|
||||
self._margin_top = 10
|
||||
self._margin_bottom = 24
|
||||
self._margin_right = 10
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
def set_data(self, data: Dict[_dt.date, int]) -> None:
|
||||
"""Replace dataset and recompute layout."""
|
||||
self._data = {k: int(v) for k, v in (data or {}).items() if v is not None}
|
||||
if not self._data:
|
||||
self._start = self._end = None
|
||||
self._max_value = 0
|
||||
else:
|
||||
earliest = min(self._data.keys())
|
||||
latest = max(self._data.keys())
|
||||
self._start = earliest - _dt.timedelta(days=earliest.weekday())
|
||||
self._end = latest
|
||||
self._max_value = max(self._data.values()) if self._data else 0
|
||||
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
|
||||
# QWidget overrides ---------------------------------------------------
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
if not self._start or not self._end:
|
||||
height = (
|
||||
self._margin_top + self._margin_bottom + 7 * (self._cell + self._gap)
|
||||
)
|
||||
# some default width
|
||||
width = (
|
||||
self._margin_left + self._margin_right + 20 * (self._cell + self._gap)
|
||||
)
|
||||
return QSize(width, height)
|
||||
|
||||
day_count = (self._end - self._start).days + 1
|
||||
weeks = (day_count + 6) // 7 # ceil
|
||||
|
||||
width = (
|
||||
self._margin_left
|
||||
+ self._margin_right
|
||||
+ weeks * (self._cell + self._gap)
|
||||
+ self._gap
|
||||
)
|
||||
height = (
|
||||
self._margin_top
|
||||
+ self._margin_bottom
|
||||
+ 7 * (self._cell + self._gap)
|
||||
+ self._gap
|
||||
)
|
||||
return QSize(width, height)
|
||||
|
||||
def minimumSizeHint(self) -> QSize:
|
||||
sz = self.sizeHint()
|
||||
return QSize(min(380, sz.width()), sz.height())
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
|
||||
if not self._start or not self._end:
|
||||
return
|
||||
|
||||
palette = self.palette()
|
||||
bg_no_data = palette.base().color()
|
||||
active = palette.highlight().color()
|
||||
|
||||
painter.setPen(QPen(Qt.NoPen))
|
||||
|
||||
day_count = (self._end - self._start).days + 1
|
||||
weeks = (day_count + 6) // 7
|
||||
|
||||
for week in range(weeks):
|
||||
for dow in range(7):
|
||||
idx = week * 7 + dow
|
||||
date = self._start + _dt.timedelta(days=idx)
|
||||
if date > self._end:
|
||||
value = 0
|
||||
else:
|
||||
value = self._data.get(date, 0)
|
||||
|
||||
x = self._margin_left + week * (self._cell + self._gap)
|
||||
y = self._margin_top + dow * (self._cell + self._gap)
|
||||
|
||||
if value <= 0 or self._max_value <= 0:
|
||||
color = bg_no_data
|
||||
else:
|
||||
ratio = max(0.1, min(1.0, value / float(self._max_value)))
|
||||
color = QColor(active)
|
||||
# Lighter for low values, darker for high values
|
||||
lighten = 150 - int(50 * ratio) # 150 ≈ light, 100 ≈ original
|
||||
color = color.lighter(lighten)
|
||||
|
||||
painter.fillRect(
|
||||
x,
|
||||
y,
|
||||
self._cell,
|
||||
self._cell,
|
||||
QBrush(color),
|
||||
)
|
||||
|
||||
painter.setPen(palette.text().color())
|
||||
fm = painter.fontMetrics()
|
||||
|
||||
# --- weekday labels on left -------------------------------------
|
||||
# Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
|
||||
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
|
||||
|
||||
for dow in range(7):
|
||||
label = weekday_labels[dow]
|
||||
text_width = fm.horizontalAdvance(label)
|
||||
|
||||
# Center text vertically in the cell
|
||||
y_center = (
|
||||
self._margin_top + dow * (self._cell + self._gap) + self._cell / 2
|
||||
)
|
||||
baseline_y = int(y_center + fm.ascent() / 2 - fm.descent() / 2)
|
||||
|
||||
# Right-align text just to the left of the first column
|
||||
x = self._margin_left - self._gap - 2 - text_width
|
||||
|
||||
painter.drawText(x, baseline_y, label)
|
||||
|
||||
prev_month = None
|
||||
for week in range(weeks):
|
||||
date = self._start + _dt.timedelta(days=week * 7)
|
||||
if date > self._end: # pragma: no cover
|
||||
break
|
||||
|
||||
if prev_month == date.month:
|
||||
continue
|
||||
prev_month = date.month
|
||||
|
||||
label = date.strftime("%b")
|
||||
|
||||
x_center = (
|
||||
self._margin_left + week * (self._cell + self._gap) + self._cell / 2
|
||||
)
|
||||
y = self._margin_top + 7 * (self._cell + self._gap) + fm.ascent()
|
||||
|
||||
text_width = fm.horizontalAdvance(label)
|
||||
painter.drawText(
|
||||
int(x_center - text_width / 2),
|
||||
int(y),
|
||||
label,
|
||||
)
|
||||
|
||||
painter.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() != Qt.LeftButton:
|
||||
return super().mousePressEvent(event)
|
||||
|
||||
# No data = nothing to click
|
||||
if not self._start or not self._end:
|
||||
return
|
||||
|
||||
# Qt6: position(), older: pos()
|
||||
pos = event.position() if hasattr(event, "position") else event.pos()
|
||||
x = pos.x()
|
||||
y = pos.y()
|
||||
|
||||
# Outside grid area (left of weekday labels or above rows)
|
||||
if x < self._margin_left or y < self._margin_top:
|
||||
return
|
||||
|
||||
cell_span = self._cell + self._gap
|
||||
col = int((x - self._margin_left) // cell_span) # week index
|
||||
row = int((y - self._margin_top) // cell_span) # dow (0..6)
|
||||
|
||||
# Only 7 rows (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(600)
|
||||
self.setMinimumHeight(400)
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
page_most_revisions,
|
||||
page_most_revisions_count,
|
||||
words_by_date,
|
||||
total_words,
|
||||
unique_tags,
|
||||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
) = self._gather_stats()
|
||||
|
||||
# --- Numeric summary at the top ----------------------------------
|
||||
form = QFormLayout()
|
||||
root.addLayout(form)
|
||||
|
||||
form.addRow(
|
||||
strings._("stats_pages_with_content"),
|
||||
QLabel(str(pages_with_content)),
|
||||
)
|
||||
form.addRow(
|
||||
strings._("stats_total_revisions"),
|
||||
QLabel(str(total_revisions)),
|
||||
)
|
||||
|
||||
if page_most_revisions:
|
||||
form.addRow(
|
||||
strings._("stats_page_most_revisions"),
|
||||
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
|
||||
)
|
||||
else:
|
||||
form.addRow(strings._("stats_page_most_revisions"), QLabel("—"))
|
||||
|
||||
form.addRow(
|
||||
strings._("stats_total_words"),
|
||||
QLabel(str(total_words)),
|
||||
)
|
||||
|
||||
# Unique tag names
|
||||
form.addRow(
|
||||
strings._("stats_unique_tags"),
|
||||
QLabel(str(unique_tags)),
|
||||
)
|
||||
|
||||
if page_most_tags:
|
||||
form.addRow(
|
||||
strings._("stats_page_most_tags"),
|
||||
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
|
||||
)
|
||||
else:
|
||||
form.addRow(strings._("stats_page_most_tags"), QLabel("—"))
|
||||
|
||||
# --- Heatmap with switcher ---------------------------------------
|
||||
if words_by_date or revisions_by_date:
|
||||
group = QGroupBox(strings._("stats_activity_heatmap"))
|
||||
group_layout = QVBoxLayout(group)
|
||||
|
||||
# Metric selector
|
||||
combo_row = QHBoxLayout()
|
||||
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
|
||||
self.metric_combo = QComboBox()
|
||||
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
|
||||
self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
|
||||
combo_row.addWidget(self.metric_combo)
|
||||
combo_row.addStretch(1)
|
||||
group_layout.addLayout(combo_row)
|
||||
|
||||
self._heatmap = DateHeatmap()
|
||||
self._words_by_date = words_by_date
|
||||
self._revisions_by_date = revisions_by_date
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setWidget(self._heatmap)
|
||||
group_layout.addWidget(scroll)
|
||||
|
||||
root.addWidget(group)
|
||||
|
||||
# Default to "words"
|
||||
self._apply_metric("words")
|
||||
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
|
||||
else:
|
||||
root.addWidget(QLabel(strings._("stats_no_data")))
|
||||
|
||||
# ---------- internal helpers ----------
|
||||
|
||||
def _apply_metric(self, metric: str) -> None:
|
||||
if metric == "revisions":
|
||||
self._heatmap.set_data(self._revisions_by_date)
|
||||
else:
|
||||
self._heatmap.set_data(self._words_by_date)
|
||||
|
||||
def _on_metric_changed(self, index: int) -> None:
|
||||
metric = self.metric_combo.currentData()
|
||||
if metric:
|
||||
self._apply_metric(metric)
|
||||
|
||||
def _gather_stats(self):
|
||||
return self._db.gather_stats()
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
from importlib.resources import files
|
||||
import json
|
||||
|
||||
# Get list of locales
|
||||
root = files("bouquin") / "locales"
|
||||
_AVAILABLE = tuple(
|
||||
entry.stem
|
||||
for entry in root.iterdir()
|
||||
if entry.is_file() and entry.suffix == ".json"
|
||||
)
|
||||
|
||||
_DEFAULT = "en"
|
||||
|
||||
strings = {}
|
||||
translations = {}
|
||||
|
||||
|
||||
def load_strings(current_locale: str) -> None:
|
||||
global strings, translations
|
||||
translations = {}
|
||||
|
||||
# read in the locales json
|
||||
for loc in _AVAILABLE:
|
||||
data = (root / f"{loc}.json").read_text(encoding="utf-8")
|
||||
translations[loc] = json.loads(data)
|
||||
|
||||
if current_locale not in translations:
|
||||
current_locale = _DEFAULT
|
||||
|
||||
base = translations[_DEFAULT]
|
||||
cur = translations.get(current_locale, {})
|
||||
strings = {k: (cur.get(k) or base[k]) for k in base}
|
||||
|
||||
|
||||
def translated(k: str) -> str:
|
||||
return strings.get(k, k)
|
||||
|
||||
|
||||
_ = translated
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QColorDialog,
|
||||
QMessageBox,
|
||||
QInputDialog,
|
||||
)
|
||||
|
||||
from .db import DBManager
|
||||
from . import strings
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
|
||||
|
||||
class TagBrowserDialog(QDialog):
|
||||
openDateRequested = Signal(str)
|
||||
tagsModified = Signal()
|
||||
|
||||
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self.setWindowTitle(
|
||||
strings._("tag_browser_title") + " / " + strings._("manage_tags")
|
||||
)
|
||||
self.resize(600, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(strings._("tag_browser_instructions"))
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderLabels(
|
||||
[strings._("tag"), strings._("color_hex"), strings._("date")]
|
||||
)
|
||||
self.tree.setColumnWidth(0, 200)
|
||||
self.tree.setColumnWidth(1, 100)
|
||||
self.tree.itemActivated.connect(self._on_item_activated)
|
||||
self.tree.itemClicked.connect(self._on_item_clicked)
|
||||
self.tree.setSortingEnabled(True)
|
||||
self.tree.sortByColumn(0, Qt.AscendingOrder)
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
# Tag management buttons
|
||||
btn_row = QHBoxLayout()
|
||||
|
||||
self.add_tag_btn = QPushButton("&" + strings._("add_a_tag"))
|
||||
self.add_tag_btn.clicked.connect(self._add_a_tag)
|
||||
btn_row.addWidget(self.add_tag_btn)
|
||||
|
||||
self.edit_name_btn = QPushButton("&" + strings._("edit_tag_name"))
|
||||
self.edit_name_btn.clicked.connect(self._edit_tag_name)
|
||||
self.edit_name_btn.setEnabled(False)
|
||||
btn_row.addWidget(self.edit_name_btn)
|
||||
|
||||
self.change_color_btn = QPushButton("&" + strings._("change_color"))
|
||||
self.change_color_btn.clicked.connect(self._change_tag_color)
|
||||
self.change_color_btn.setEnabled(False)
|
||||
btn_row.addWidget(self.change_color_btn)
|
||||
|
||||
self.delete_btn = QPushButton("&" + strings._("delete_tag"))
|
||||
self.delete_btn.clicked.connect(self._delete_tag)
|
||||
self.delete_btn.setEnabled(False)
|
||||
btn_row.addWidget(self.delete_btn)
|
||||
|
||||
btn_row.addStretch(1)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# Close button
|
||||
close_row = QHBoxLayout()
|
||||
close_row.addStretch(1)
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
close_row.addWidget(close_btn)
|
||||
layout.addLayout(close_row)
|
||||
|
||||
self._populate(focus_tag)
|
||||
|
||||
def _populate(self, focus_tag: str | None):
|
||||
# Disable sorting during population for better performance
|
||||
was_sorting = self.tree.isSortingEnabled()
|
||||
self.tree.setSortingEnabled(False)
|
||||
self.tree.clear()
|
||||
tags = self._db.list_tags()
|
||||
focus_item = None
|
||||
|
||||
for tag_id, name, color in tags:
|
||||
# Create the tree item
|
||||
root = QTreeWidgetItem([name, "", ""])
|
||||
root.setData(
|
||||
0,
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"type": "tag", "id": tag_id, "name": name, "color": color},
|
||||
)
|
||||
|
||||
# Set background color for the second column to show the tag color
|
||||
bg_color = QColor(color)
|
||||
root.setBackground(1, bg_color)
|
||||
|
||||
# Calculate luminance and set contrasting text color
|
||||
# Using relative luminance formula (ITU-R BT.709)
|
||||
luminance = (
|
||||
0.2126 * bg_color.red()
|
||||
+ 0.7152 * bg_color.green()
|
||||
+ 0.0722 * bg_color.blue()
|
||||
) / 255.0
|
||||
text_color = QColor(0, 0, 0) if luminance > 0.5 else QColor(255, 255, 255)
|
||||
root.setForeground(1, text_color)
|
||||
root.setText(1, color) # Also show the hex code
|
||||
root.setTextAlignment(1, Qt.AlignCenter)
|
||||
|
||||
self.tree.addTopLevelItem(root)
|
||||
|
||||
pages = self._db.get_pages_for_tag(name)
|
||||
for date_iso, _content in pages:
|
||||
child = QTreeWidgetItem(["", "", date_iso])
|
||||
child.setData(
|
||||
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
|
||||
)
|
||||
root.addChild(child)
|
||||
|
||||
if focus_tag and name.lower() == focus_tag.lower():
|
||||
focus_item = root
|
||||
|
||||
if focus_item:
|
||||
self.tree.expandItem(focus_item)
|
||||
self.tree.setCurrentItem(focus_item)
|
||||
|
||||
# Re-enable sorting after population
|
||||
self.tree.setSortingEnabled(was_sorting)
|
||||
|
||||
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
|
||||
"""Enable/disable buttons based on selection"""
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(data, dict):
|
||||
if data.get("type") == "tag":
|
||||
self.edit_name_btn.setEnabled(True)
|
||||
self.change_color_btn.setEnabled(True)
|
||||
self.delete_btn.setEnabled(True)
|
||||
else:
|
||||
self.edit_name_btn.setEnabled(False)
|
||||
self.change_color_btn.setEnabled(False)
|
||||
self.delete_btn.setEnabled(False)
|
||||
|
||||
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(data, dict):
|
||||
if data.get("type") == "page":
|
||||
date_iso = data.get("date")
|
||||
if date_iso:
|
||||
self.openDateRequested.emit(date_iso)
|
||||
self.accept()
|
||||
|
||||
def _add_a_tag(self):
|
||||
"""Add a new tag"""
|
||||
|
||||
new_name, ok = QInputDialog.getText(
|
||||
self, strings._("add_a_tag"), strings._("new_tag_name"), text=""
|
||||
)
|
||||
|
||||
if ok and new_name:
|
||||
color = QColorDialog.getColor(QColor(), self)
|
||||
if color.isValid():
|
||||
try:
|
||||
self._db.add_tag(new_name, color.name())
|
||||
self._populate(None)
|
||||
self.tagsModified.emit()
|
||||
except IntegrityError as e:
|
||||
QMessageBox.critical(self, strings._("db_database_error"), str(e))
|
||||
|
||||
def _edit_tag_name(self):
|
||||
"""Edit the name of the selected tag"""
|
||||
item = self.tree.currentItem()
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict) or data.get("type") != "tag":
|
||||
return
|
||||
|
||||
tag_id = data["id"]
|
||||
old_name = data["name"]
|
||||
color = data["color"]
|
||||
|
||||
new_name, ok = QInputDialog.getText(
|
||||
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
|
||||
)
|
||||
|
||||
if ok and new_name and new_name != old_name:
|
||||
try:
|
||||
self._db.update_tag(tag_id, new_name, color)
|
||||
self._populate(None)
|
||||
self.tagsModified.emit()
|
||||
except IntegrityError as e:
|
||||
QMessageBox.critical(self, strings._("db_database_error"), str(e))
|
||||
|
||||
def _change_tag_color(self):
|
||||
"""Change the color of the selected tag"""
|
||||
item = self.tree.currentItem()
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict) or data.get("type") != "tag":
|
||||
return
|
||||
|
||||
tag_id = data["id"]
|
||||
name = data["name"]
|
||||
current_color = data["color"]
|
||||
|
||||
color = QColorDialog.getColor(QColor(current_color), self)
|
||||
if color.isValid():
|
||||
try:
|
||||
self._db.update_tag(tag_id, name, color.name())
|
||||
self._populate(None)
|
||||
self.tagsModified.emit()
|
||||
except IntegrityError as e:
|
||||
QMessageBox.critical(self, strings._("db_database_error"), str(e))
|
||||
|
||||
def _delete_tag(self):
|
||||
"""Delete the selected tag"""
|
||||
item = self.tree.currentItem()
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict) or data.get("type") != "tag":
|
||||
return
|
||||
|
||||
tag_id = data["id"]
|
||||
name = data["name"]
|
||||
|
||||
# Confirm deletion
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
strings._("delete_tag"),
|
||||
strings._("delete_tag_confirm").format(name=name),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._db.delete_tag(tag_id)
|
||||
self._populate(None)
|
||||
self.tagsModified.emit()
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QToolButton,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QStyle,
|
||||
QCompleter,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .flow_layout import FlowLayout
|
||||
|
||||
|
||||
class TagChip(QFrame):
|
||||
removeRequested = Signal(int) # tag_id
|
||||
clicked = Signal(str) # tag name
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag_id: int,
|
||||
name: str,
|
||||
color: str,
|
||||
parent: QWidget | None = None,
|
||||
show_remove: bool = True,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._id = tag_id
|
||||
self._name = name
|
||||
|
||||
self.setObjectName("TagChip")
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setFrameShadow(QFrame.Raised)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
layout.setSpacing(4)
|
||||
|
||||
color_lbl = QLabel()
|
||||
color_lbl.setFixedSize(10, 10)
|
||||
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;")
|
||||
layout.addWidget(color_lbl)
|
||||
|
||||
name_lbl = QLabel(name)
|
||||
layout.addWidget(name_lbl)
|
||||
|
||||
if show_remove:
|
||||
btn = QToolButton()
|
||||
btn.setText("×")
|
||||
btn.setAutoRaise(True)
|
||||
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
|
||||
layout.addWidget(btn)
|
||||
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
@property
|
||||
def tag_id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def mouseReleaseEvent(self, ev):
|
||||
if ev.button() == Qt.LeftButton:
|
||||
self.clicked.emit(self._name)
|
||||
try:
|
||||
super().mouseReleaseEvent(ev)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
class PageTagsWidget(QFrame):
|
||||
"""
|
||||
Collapsible per-page tag editor shown in the left sidebar.
|
||||
Now displays tag chips even when collapsed.
|
||||
"""
|
||||
|
||||
tagActivated = Signal(str) # tag name
|
||||
tagAdded = Signal() # emitted when a tag is added to trigger autosave
|
||||
|
||||
def __init__(self, db: DBManager, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self._current_date: Optional[str] = None
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
# Header (toggle + manage button)
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText(strings._("tags"))
|
||||
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
self.toggle_btn.setArrowType(Qt.RightArrow)
|
||||
self.toggle_btn.clicked.connect(self._on_toggle)
|
||||
|
||||
self.manage_btn = QToolButton()
|
||||
self.manage_btn.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||
)
|
||||
self.manage_btn.setToolTip(strings._("manage_tags"))
|
||||
self.manage_btn.setAutoRaise(True)
|
||||
self.manage_btn.clicked.connect(self._open_manager)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.addWidget(self.toggle_btn)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self.manage_btn)
|
||||
|
||||
# Body (chips + add line - only visible when expanded)
|
||||
self.body = QWidget()
|
||||
self.body_layout = QVBoxLayout(self.body)
|
||||
self.body_layout.setContentsMargins(0, 4, 0, 0)
|
||||
self.body_layout.setSpacing(4)
|
||||
|
||||
# Chips container
|
||||
self.chip_container = QWidget()
|
||||
self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4)
|
||||
self.body_layout.addWidget(self.chip_container)
|
||||
|
||||
self.add_edit = QLineEdit()
|
||||
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
|
||||
self.add_edit.returnPressed.connect(self._on_add_tag)
|
||||
|
||||
# Setup autocomplete
|
||||
self._setup_autocomplete()
|
||||
|
||||
self.body_layout.addWidget(self.add_edit)
|
||||
self.body.setVisible(False)
|
||||
|
||||
main = QVBoxLayout(self)
|
||||
main.setContentsMargins(0, 0, 0, 0)
|
||||
main.addLayout(header)
|
||||
main.addWidget(self.body)
|
||||
|
||||
# ----- external API ------------------------------------------------
|
||||
|
||||
def set_current_date(self, date_iso: str) -> None:
|
||||
self._current_date = date_iso
|
||||
# Only reload tags if expanded
|
||||
if self.toggle_btn.isChecked():
|
||||
self._reload_tags()
|
||||
else:
|
||||
self._clear_chips() # Clear chips when collapsed
|
||||
self._setup_autocomplete() # Update autocomplete with all available tags
|
||||
|
||||
# ----- internals ---------------------------------------------------
|
||||
|
||||
def _setup_autocomplete(self) -> None:
|
||||
"""Setup autocomplete for the tag input with all existing tags"""
|
||||
all_tags = [name for _, name, _ in self._db.list_tags()]
|
||||
completer = QCompleter(all_tags, self.add_edit)
|
||||
completer.setCaseSensitivity(Qt.CaseInsensitive)
|
||||
self.add_edit.setCompleter(completer)
|
||||
|
||||
def _on_toggle(self, checked: bool) -> None:
|
||||
self.body.setVisible(checked)
|
||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||
if checked:
|
||||
if self._current_date:
|
||||
self._reload_tags()
|
||||
self.add_edit.setFocus()
|
||||
|
||||
def _clear_chips(self) -> None:
|
||||
while self.chip_layout.count():
|
||||
item = self.chip_layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
def _reload_tags(self) -> None:
|
||||
if not self._current_date:
|
||||
self._clear_chips()
|
||||
return
|
||||
|
||||
self._clear_chips()
|
||||
tags = self._db.get_tags_for_page(self._current_date)
|
||||
for tag_id, name, color in tags:
|
||||
# Always show remove button since chips only visible when expanded
|
||||
chip = TagChip(tag_id, name, color, self, show_remove=True)
|
||||
chip.removeRequested.connect(self._remove_tag)
|
||||
chip.clicked.connect(self._on_chip_clicked)
|
||||
self.chip_layout.addWidget(chip)
|
||||
chip.show()
|
||||
chip.adjustSize()
|
||||
|
||||
# Force complete layout recalculation
|
||||
self.chip_layout.invalidate()
|
||||
self.chip_layout.activate()
|
||||
self.chip_container.updateGeometry()
|
||||
self.updateGeometry()
|
||||
# Process pending events to ensure layout is applied
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
|
||||
QCoreApplication.processEvents()
|
||||
|
||||
def _on_add_tag(self) -> None:
|
||||
if not self._current_date:
|
||||
return
|
||||
|
||||
# If the completer popup is visible and user pressed Enter,
|
||||
# the completer will handle it - don't process it again
|
||||
if self.add_edit.completer() and self.add_edit.completer().popup().isVisible():
|
||||
return
|
||||
|
||||
new_tag = self.add_edit.text().strip()
|
||||
if not new_tag:
|
||||
return
|
||||
|
||||
# Get existing tags for current page
|
||||
existing = [
|
||||
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
|
||||
]
|
||||
|
||||
# Check for duplicates (case-insensitive)
|
||||
if any(tag.lower() == new_tag.lower() for tag in existing):
|
||||
self.add_edit.clear()
|
||||
return
|
||||
|
||||
existing.append(new_tag)
|
||||
self._db.set_tags_for_page(self._current_date, existing)
|
||||
|
||||
self.add_edit.clear()
|
||||
self._reload_tags()
|
||||
self._setup_autocomplete() # Update autocomplete list
|
||||
|
||||
# Signal that a tag was added so main window can trigger autosave
|
||||
self.tagAdded.emit()
|
||||
|
||||
def _remove_tag(self, tag_id: int) -> None:
|
||||
if not self._current_date:
|
||||
return
|
||||
tags = self._db.get_tags_for_page(self._current_date)
|
||||
remaining = [name for (tid, name, _color) in tags if tid != tag_id]
|
||||
self._db.set_tags_for_page(self._current_date, remaining)
|
||||
self._reload_tags()
|
||||
|
||||
def _open_manager(self) -> None:
|
||||
from .tag_browser import TagBrowserDialog
|
||||
|
||||
dlg = TagBrowserDialog(self._db, self)
|
||||
dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso))
|
||||
if dlg.exec():
|
||||
# Reload tags after manager closes to pick up any changes
|
||||
if self._current_date:
|
||||
self._reload_tags()
|
||||
self._setup_autocomplete()
|
||||
|
||||
def _on_chip_clicked(self, name: str) -> None:
|
||||
self.tagActivated.emit(name)
|
||||
210
bouquin/theme.py
|
|
@ -2,9 +2,8 @@ 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.QtWidgets import QApplication
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from weakref import WeakSet
|
||||
|
||||
|
||||
class Theme(Enum):
|
||||
|
|
@ -27,9 +26,6 @@ class ThemeManager(QObject):
|
|||
super().__init__()
|
||||
self._app = app
|
||||
self._cfg = cfg
|
||||
self._current = None
|
||||
self._calendars: "WeakSet[QCalendarWidget]" = WeakSet()
|
||||
self._lock_overlays: "WeakSet[QWidget]" = WeakSet()
|
||||
|
||||
# Follow OS if supported (Qt 6+)
|
||||
hints = QGuiApplication.styleHints()
|
||||
|
|
@ -39,20 +35,6 @@ class ThemeManager(QObject):
|
|||
and self.apply(self._cfg.theme)
|
||||
)
|
||||
|
||||
def _is_system_dark(self) -> bool:
|
||||
pal = QGuiApplication.palette()
|
||||
# Heuristic: dark windows/backgrounds mean dark system theme
|
||||
return pal.color(QPalette.Window).lightness() < 128
|
||||
|
||||
def _restyle_registered(self) -> None:
|
||||
for cal in list(self._calendars):
|
||||
if cal is not None:
|
||||
self._apply_calendar_theme(cal)
|
||||
|
||||
for overlay in list(self._lock_overlays):
|
||||
if overlay is not None:
|
||||
self._apply_lock_overlay_theme(overlay)
|
||||
|
||||
def current(self) -> Theme:
|
||||
return self._cfg.theme
|
||||
|
||||
|
|
@ -61,34 +43,28 @@ class ThemeManager(QObject):
|
|||
self.apply(theme)
|
||||
|
||||
def apply(self, theme: Theme):
|
||||
# Resolve "system" into a concrete theme
|
||||
resolved = theme
|
||||
# Resolve "system"
|
||||
if theme == Theme.SYSTEM:
|
||||
resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT
|
||||
|
||||
if resolved == Theme.DARK:
|
||||
pal = self._dark_palette()
|
||||
else:
|
||||
pal = self._light_palette()
|
||||
hints = QGuiApplication.styleHints()
|
||||
scheme = getattr(hints, "colorScheme", None)
|
||||
if callable(scheme):
|
||||
scheme = hints.colorScheme()
|
||||
# 0=Light, 1=Dark; fall back to Light
|
||||
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
|
||||
|
||||
# Always use Fusion so palette applies consistently cross-platform
|
||||
QApplication.setStyle("Fusion")
|
||||
self._app.setStyle("Fusion")
|
||||
|
||||
self._app.setPalette(pal)
|
||||
self._current = resolved
|
||||
# Re-style any registered widgets
|
||||
self._restyle_registered()
|
||||
self.themeChanged.emit(self._current)
|
||||
if theme == Theme.DARK:
|
||||
pal = self._dark_palette()
|
||||
self._app.setPalette(pal)
|
||||
self._app.setStyleSheet("")
|
||||
else:
|
||||
pal = self._light_palette()
|
||||
self._app.setPalette(pal)
|
||||
self._app.setStyleSheet("")
|
||||
|
||||
def register_calendar(self, cal: QCalendarWidget) -> None:
|
||||
"""Start theming calendar and keep it in sync with theme changes."""
|
||||
self._calendars.add(cal)
|
||||
self._apply_calendar_theme(cal)
|
||||
|
||||
def register_lock_overlay(self, overlay: QWidget) -> None:
|
||||
"""Start theming lock overlay and keep it in sync with theme changes."""
|
||||
self._lock_overlays.add(overlay)
|
||||
self._apply_lock_overlay_theme(overlay)
|
||||
self.themeChanged.emit(theme)
|
||||
|
||||
# ----- Palettes -----
|
||||
def _dark_palette(self) -> QPalette:
|
||||
|
|
@ -99,24 +75,17 @@ class ThemeManager(QObject):
|
|||
disabled = QColor(127, 127, 127)
|
||||
focus = QColor(42, 130, 218)
|
||||
|
||||
# Base surfaces
|
||||
pal.setColor(QPalette.Window, window)
|
||||
pal.setColor(QPalette.WindowText, text)
|
||||
pal.setColor(QPalette.Base, base)
|
||||
pal.setColor(QPalette.AlternateBase, window)
|
||||
|
||||
# Text
|
||||
pal.setColor(QPalette.WindowText, text)
|
||||
pal.setColor(QPalette.ToolTipBase, window)
|
||||
pal.setColor(QPalette.ToolTipText, text)
|
||||
pal.setColor(QPalette.Text, text)
|
||||
pal.setColor(QPalette.PlaceholderText, disabled)
|
||||
pal.setColor(QPalette.ButtonText, text)
|
||||
|
||||
# Buttons/frames
|
||||
pal.setColor(QPalette.Button, window)
|
||||
pal.setColor(QPalette.ButtonText, text)
|
||||
pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
|
||||
|
||||
# Links / selection
|
||||
pal.setColor(QPalette.Highlight, focus)
|
||||
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
|
||||
pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
|
||||
|
|
@ -125,136 +94,11 @@ class ThemeManager(QObject):
|
|||
return pal
|
||||
|
||||
def _light_palette(self) -> QPalette:
|
||||
pal = QPalette()
|
||||
|
||||
# Base surfaces
|
||||
pal.setColor(QPalette.Window, QColor("#ffffff"))
|
||||
pal.setColor(QPalette.Base, QColor("#ffffff"))
|
||||
pal.setColor(QPalette.AlternateBase, QColor("#f5f5f5"))
|
||||
|
||||
# Text
|
||||
pal.setColor(QPalette.WindowText, QColor("#000000"))
|
||||
pal.setColor(QPalette.Text, QColor("#000000"))
|
||||
pal.setColor(QPalette.ButtonText, QColor("#000000"))
|
||||
|
||||
# Buttons/frames
|
||||
pal.setColor(QPalette.Button, QColor("#f0f0f0"))
|
||||
pal.setColor(QPalette.Mid, QColor("#9e9e9e"))
|
||||
|
||||
# Links / selection
|
||||
pal.setColor(QPalette.Highlight, QColor("#1a73e8"))
|
||||
pal.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
||||
pal.setColor(QPalette.Link, QColor("#1a73e8"))
|
||||
pal.setColor(QPalette.LinkVisited, QColor("#6b4ca5"))
|
||||
|
||||
# Let Qt provide its default light palette, but nudge a couple roles
|
||||
pal = self._app.style().standardPalette()
|
||||
pal.setColor(QPalette.Highlight, QColor(0, 120, 215))
|
||||
pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
|
||||
pal.setColor(
|
||||
QPalette.Link, QColor("#1a73e8")
|
||||
) # Light blue for links in light mode
|
||||
return pal
|
||||
|
||||
def _apply_calendar_theme(self, cal: QCalendarWidget) -> None:
|
||||
"""Use orange accents on the calendar in dark mode only."""
|
||||
app_pal = QApplication.instance().palette()
|
||||
is_dark = (self.current() == Theme.DARK) or (
|
||||
self.current() == Theme.SYSTEM and self._is_system_dark()
|
||||
)
|
||||
|
||||
if is_dark:
|
||||
highlight_css = Theme.ORANGE_ANCHOR.value
|
||||
highlight = QColor(highlight_css)
|
||||
black = QColor(0, 0, 0)
|
||||
|
||||
# Per-widget palette: selection color inside the date grid
|
||||
pal = cal.palette()
|
||||
pal.setColor(QPalette.Highlight, highlight)
|
||||
pal.setColor(QPalette.HighlightedText, black)
|
||||
cal.setPalette(pal)
|
||||
|
||||
# Stylesheet: nav bar + selected-day background
|
||||
cal.setStyleSheet(self._calendar_qss(highlight_css))
|
||||
else:
|
||||
# Back to app defaults in light/system-light
|
||||
cal.setPalette(app_pal)
|
||||
cal.setStyleSheet("")
|
||||
|
||||
cal.update()
|
||||
|
||||
def _calendar_qss(self, highlight_css: str) -> str:
|
||||
return f"""
|
||||
QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
|
||||
QCalendarWidget QToolButton {{ color: black; }}
|
||||
QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }}
|
||||
/* Selected day color in the table view */
|
||||
QCalendarWidget QTableView:enabled {{
|
||||
selection-background-color: {highlight_css};
|
||||
selection-color: black;
|
||||
}}
|
||||
/* Keep weekday header readable */
|
||||
QCalendarWidget QTableView QHeaderView::section {{
|
||||
background: transparent;
|
||||
color: palette(windowText);
|
||||
}}
|
||||
"""
|
||||
|
||||
def _apply_lock_overlay_theme(self, overlay: QWidget) -> None:
|
||||
"""
|
||||
Style the LockOverlay (objectName 'LockOverlay') using theme colors.
|
||||
Dark: opaque black bg, orange accent; Light: translucent scrim, palette-driven colors.
|
||||
"""
|
||||
pal = QApplication.instance().palette()
|
||||
is_dark = (self.current() == Theme.DARK) or (
|
||||
self.current() == Theme.SYSTEM and self._is_system_dark()
|
||||
)
|
||||
|
||||
if is_dark:
|
||||
# Use the link color as the accent
|
||||
accent = pal.color(QPalette.Link)
|
||||
r, g, b = accent.red(), accent.green(), accent.blue()
|
||||
accent_hex = accent.name()
|
||||
|
||||
qss = f"""
|
||||
#LockOverlay {{ background-color: rgb(0,0,0); }}
|
||||
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
||||
|
||||
#LockOverlay QPushButton#unlockButton {{
|
||||
color: {accent_hex};
|
||||
background-color: rgba({r},{g},{b},0.10);
|
||||
border: 1px solid {accent_hex};
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
#LockOverlay QPushButton#unlockButton:hover {{
|
||||
background-color: rgba({r},{g},{b},0.16);
|
||||
border-color: {accent_hex};
|
||||
}}
|
||||
#LockOverlay QPushButton#unlockButton:pressed {{
|
||||
background-color: rgba({r},{g},{b},0.24);
|
||||
}}
|
||||
#LockOverlay QPushButton#unlockButton:focus {{
|
||||
outline: none;
|
||||
border-color: {accent_hex};
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
qss = """
|
||||
#LockOverlay { background-color: rgba(0,0,0,120); }
|
||||
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
|
||||
|
||||
#LockOverlay QPushButton#unlockButton {
|
||||
color: palette(button-text);
|
||||
background-color: rgba(255,255,255,0.92);
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
#LockOverlay QPushButton#unlockButton:hover {
|
||||
background-color: rgba(255,255,255,1.0);
|
||||
border-color: rgba(0,0,0,0.35);
|
||||
}
|
||||
#LockOverlay QPushButton#unlockButton:pressed {
|
||||
background-color: rgba(245,245,245,1.0);
|
||||
}
|
||||
#LockOverlay QPushButton#unlockButton:focus {
|
||||
outline: none;
|
||||
border-color: palette(highlight);
|
||||
}
|
||||
"""
|
||||
overlay.setStyleSheet(qss)
|
||||
overlay.update()
|
||||
|
|
|
|||
1217
bouquin/time_log.py
|
|
@ -4,8 +4,6 @@ from PySide6.QtCore import Signal, Qt
|
|||
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
|
||||
from PySide6.QtWidgets import QToolBar
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
class ToolBar(QToolBar):
|
||||
boldRequested = Signal()
|
||||
|
|
@ -18,108 +16,83 @@ class ToolBar(QToolBar):
|
|||
checkboxesRequested = Signal()
|
||||
historyRequested = Signal()
|
||||
insertImageRequested = Signal()
|
||||
alarmRequested = Signal()
|
||||
timerRequested = Signal()
|
||||
fontSizeLargerRequested = Signal()
|
||||
fontSizeSmallerRequested = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(strings._("toolbar_format"), parent)
|
||||
self.setObjectName(strings._("toolbar_format"))
|
||||
super().__init__("Format", parent)
|
||||
self.setObjectName("Format")
|
||||
self.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
||||
self._build_actions()
|
||||
self._apply_toolbar_styles()
|
||||
|
||||
def _build_actions(self):
|
||||
self.actBold = QAction("B", self)
|
||||
self.actBold.setToolTip(strings._("toolbar_bold"))
|
||||
self.actBold.setToolTip("Bold")
|
||||
self.actBold.setCheckable(True)
|
||||
self.actBold.setShortcut(QKeySequence.Bold)
|
||||
self.actBold.triggered.connect(self.boldRequested)
|
||||
|
||||
self.actItalic = QAction("I", self)
|
||||
self.actItalic.setToolTip(strings._("toolbar_italic"))
|
||||
self.actItalic.setToolTip("Italic")
|
||||
self.actItalic.setCheckable(True)
|
||||
self.actItalic.setShortcut(QKeySequence.Italic)
|
||||
self.actItalic.triggered.connect(self.italicRequested)
|
||||
|
||||
self.actStrike = QAction("S", self)
|
||||
self.actStrike.setToolTip(strings._("toolbar_strikethrough"))
|
||||
self.actStrike.setToolTip("Strikethrough")
|
||||
self.actStrike.setCheckable(True)
|
||||
self.actStrike.setShortcut("Ctrl+-")
|
||||
self.actStrike.triggered.connect(self.strikeRequested)
|
||||
|
||||
self.actCode = QAction("</>", self)
|
||||
self.actCode.setToolTip(strings._("toolbar_code_block"))
|
||||
self.actCode.setToolTip("Code block")
|
||||
self.actCode.setShortcut("Ctrl+`")
|
||||
self.actCode.triggered.connect(self.codeRequested)
|
||||
|
||||
# Headings
|
||||
self.actH1 = QAction("H1", self)
|
||||
self.actH1.setToolTip(strings._("toolbar_heading") + " 1")
|
||||
self.actH1.setToolTip("Heading 1")
|
||||
self.actH1.setCheckable(True)
|
||||
self.actH1.setShortcut("Ctrl+1")
|
||||
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
|
||||
self.actH2 = QAction("H2", self)
|
||||
self.actH2.setToolTip(strings._("toolbar_heading") + " 2")
|
||||
self.actH2.setToolTip("Heading 2")
|
||||
self.actH2.setCheckable(True)
|
||||
self.actH2.setShortcut("Ctrl+2")
|
||||
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
|
||||
self.actH3 = QAction("H3", self)
|
||||
self.actH3.setToolTip(strings._("toolbar_heading") + " 3")
|
||||
self.actH3.setToolTip("Heading 3")
|
||||
self.actH3.setCheckable(True)
|
||||
self.actH3.setShortcut("Ctrl+3")
|
||||
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
|
||||
self.actNormal = QAction("P", self)
|
||||
self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text"))
|
||||
self.actNormal = QAction("N", self)
|
||||
self.actNormal.setToolTip("Normal paragraph text")
|
||||
self.actNormal.setCheckable(True)
|
||||
self.actNormal.setShortcut("Ctrl+.")
|
||||
self.actNormal.setShortcut("Ctrl+N")
|
||||
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"))
|
||||
self.actBullets.setToolTip("Bulleted list")
|
||||
self.actBullets.setCheckable(True)
|
||||
self.actBullets.triggered.connect(self.bulletsRequested)
|
||||
self.actNumbers = QAction("1.", self)
|
||||
self.actNumbers.setToolTip(strings._("toolbar_numbered_list"))
|
||||
self.actNumbers.setToolTip("Numbered list")
|
||||
self.actNumbers.setCheckable(True)
|
||||
self.actNumbers.triggered.connect(self.numbersRequested)
|
||||
self.actCheckboxes = QAction("☐", self)
|
||||
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
|
||||
self.actCheckboxes.setToolTip("Toggle checkboxes")
|
||||
self.actCheckboxes.triggered.connect(self.checkboxesRequested)
|
||||
|
||||
# Images
|
||||
self.actInsertImg = QAction("📸", self)
|
||||
self.actInsertImg.setToolTip(strings._("insert_images"))
|
||||
self.actInsertImg = QAction("Image", self)
|
||||
self.actInsertImg.setToolTip("Insert image")
|
||||
self.actInsertImg.setShortcut("Ctrl+Shift+I")
|
||||
self.actInsertImg.triggered.connect(self.insertImageRequested)
|
||||
|
||||
# History button
|
||||
self.actHistory = QAction("🔁", self)
|
||||
self.actHistory.setToolTip(strings._("history"))
|
||||
self.actHistory = QAction("History", self)
|
||||
self.actHistory.triggered.connect(self.historyRequested)
|
||||
|
||||
# Alarm / reminder
|
||||
self.actAlarm = QAction("⏰", self)
|
||||
self.actAlarm.setToolTip(strings._("toolbar_alarm"))
|
||||
self.actAlarm.triggered.connect(self.alarmRequested)
|
||||
|
||||
# Focus timer
|
||||
self.actTimer = QAction("⌛", self)
|
||||
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
|
||||
self.actTimer.triggered.connect(self.timerRequested)
|
||||
|
||||
# Set exclusive buttons in QActionGroups
|
||||
self.grpHeadings = QActionGroup(self)
|
||||
self.grpHeadings.setExclusive(True)
|
||||
|
|
@ -151,14 +124,10 @@ 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.actHistory,
|
||||
]
|
||||
)
|
||||
|
|
@ -175,19 +144,14 @@ 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, "P")
|
||||
self._style_letter_button(self.actFontSmaller, "P-")
|
||||
self._style_letter_button(self.actFontLarger, "P+")
|
||||
self._style_letter_button(self.actNormal, "N")
|
||||
|
||||
# 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, "⌛")
|
||||
|
||||
# History
|
||||
self._style_letter_button(self.actHistory, "🔁")
|
||||
self._style_letter_button(self.actHistory, "View History")
|
||||
|
||||
def _style_letter_button(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1,412 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from importlib.resources import files
|
||||
from PySide6.QtCore import QStandardPaths, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QProgressDialog,
|
||||
)
|
||||
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
|
||||
from .settings import APP_NAME
|
||||
from . import strings
|
||||
|
||||
|
||||
# Where to fetch the latest version string from
|
||||
VERSION_URL = "https://mig5.net/bouquin/version.txt"
|
||||
|
||||
# Name of the installed distribution according to pyproject.toml
|
||||
# (used with importlib.metadata.version)
|
||||
DIST_NAME = "bouquin"
|
||||
|
||||
# Base URL where AppImages are hosted
|
||||
APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download"
|
||||
|
||||
# Where we expect to find the bundled public key, relative to the *installed* package.
|
||||
GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc")
|
||||
|
||||
|
||||
class VersionChecker:
|
||||
"""
|
||||
Handles:
|
||||
* showing the version dialog
|
||||
* checking for updates
|
||||
* downloading & verifying a new AppImage
|
||||
|
||||
All dialogs use `parent` as their parent widget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
self._parent = parent
|
||||
|
||||
# ---------- Version helpers ---------- #
|
||||
|
||||
def _logo_pixmap(self, logical_size: int = 96) -> QPixmap:
|
||||
"""
|
||||
Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp.
|
||||
"""
|
||||
svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg"
|
||||
|
||||
# Logical size (what Qt layouts see)
|
||||
dpr = QGuiApplication.primaryScreen().devicePixelRatio()
|
||||
img_size = int(logical_size * dpr)
|
||||
|
||||
image = QImage(img_size, img_size, QImage.Format_ARGB32)
|
||||
image.fill(Qt.transparent)
|
||||
|
||||
renderer = QSvgRenderer(str(svg_path))
|
||||
painter = QPainter(image)
|
||||
renderer.render(painter)
|
||||
painter.end()
|
||||
|
||||
pixmap = QPixmap.fromImage(image)
|
||||
pixmap.setDevicePixelRatio(dpr)
|
||||
return pixmap
|
||||
|
||||
def current_version(self) -> str:
|
||||
"""
|
||||
Return the current app version as reported by importlib.metadata
|
||||
"""
|
||||
try:
|
||||
return importlib.metadata.version(DIST_NAME)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
# Fallback for editable installs / dev trees
|
||||
return "0.0.0"
|
||||
|
||||
@staticmethod
|
||||
def _parse_version(v: str) -> tuple[int, ...]:
|
||||
"""
|
||||
Very small helper to compare simple semantic versions like 1.2.3.
|
||||
Extracts numeric components and returns them as a tuple.
|
||||
"""
|
||||
parts = re.findall(r"\d+", v)
|
||||
if not parts:
|
||||
return (0,)
|
||||
return tuple(int(p) for p in parts)
|
||||
|
||||
def _is_newer_version(self, available: str, current: str) -> bool:
|
||||
"""
|
||||
True if `available` > `current` according to _parse_version.
|
||||
"""
|
||||
return self._parse_version(available) > self._parse_version(current)
|
||||
|
||||
# ---------- Public entrypoint for Help → Version ---------- #
|
||||
|
||||
def show_version_dialog(self) -> None:
|
||||
"""
|
||||
Show the Version dialog with a 'Check for updates' button.
|
||||
"""
|
||||
version = self.current_version()
|
||||
version_formatted = f"{APP_NAME} {version}"
|
||||
|
||||
box = QMessageBox(self._parent)
|
||||
box.setWindowTitle(strings._("version"))
|
||||
|
||||
box.setIconPixmap(self._logo_pixmap(96))
|
||||
|
||||
box.setText(version_formatted)
|
||||
|
||||
check_button = box.addButton(
|
||||
strings._("check_for_updates"), QMessageBox.ActionRole
|
||||
)
|
||||
box.addButton(QMessageBox.Close)
|
||||
|
||||
box.exec()
|
||||
|
||||
if box.clickedButton() is check_button:
|
||||
self.check_for_updates()
|
||||
|
||||
# ---------- Core update logic ---------- #
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""
|
||||
Fetch VERSION_URL, compare against the current version, and optionally
|
||||
download + verify a new AppImage.
|
||||
"""
|
||||
current = self.current_version()
|
||||
|
||||
try:
|
||||
resp = requests.get(VERSION_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
available_raw = resp.text.strip()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_check_for_updates") + str(e),
|
||||
)
|
||||
return
|
||||
|
||||
if not available_raw:
|
||||
QMessageBox.warning(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("update_server_returned_an_empty_version_string"),
|
||||
)
|
||||
return
|
||||
|
||||
if not self._is_newer_version(available_raw, current):
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("you_are_running_the_latest_version") + f"({current}).",
|
||||
)
|
||||
return
|
||||
|
||||
# Newer version is available
|
||||
reply = QMessageBox.question(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
(
|
||||
strings._("there_is_a_new_version_available")
|
||||
+ available_raw
|
||||
+ "\n\n"
|
||||
+ strings._("download_the_appimage")
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
self._download_and_verify_appimage(available_raw)
|
||||
|
||||
# ---------- Download + verification helpers ---------- #
|
||||
def _download_file(
|
||||
self,
|
||||
url: str,
|
||||
dest_path: Path,
|
||||
timeout: int = 30,
|
||||
progress: QProgressDialog | None = None,
|
||||
label: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Stream a URL to a local file, optionally updating a QProgressDialog.
|
||||
If the user cancels via the dialog, raises RuntimeError.
|
||||
"""
|
||||
resp = requests.get(url, timeout=timeout, stream=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total_bytes: int | None = None
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
total_bytes = int(content_length)
|
||||
except ValueError:
|
||||
total_bytes = None
|
||||
|
||||
if progress is not None:
|
||||
progress.setLabelText(
|
||||
label or strings._("downloading") + f" {dest_path.name}..."
|
||||
)
|
||||
# Unknown size → busy indicator; known size → real range
|
||||
if total_bytes is not None and total_bytes > 0:
|
||||
progress.setRange(0, total_bytes)
|
||||
else:
|
||||
progress.setRange(0, 0) # pragma: no cover
|
||||
progress.setValue(0)
|
||||
progress.show()
|
||||
QApplication.processEvents()
|
||||
|
||||
downloaded = 0
|
||||
with dest_path.open("wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue # pragma: no cover
|
||||
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if progress is not None:
|
||||
if total_bytes is not None and total_bytes > 0:
|
||||
progress.setValue(downloaded)
|
||||
else:
|
||||
# Just bump a little so the dialog looks alive
|
||||
progress.setValue(progress.value() + 1) # pragma: no cover
|
||||
QApplication.processEvents()
|
||||
|
||||
if progress.wasCanceled():
|
||||
raise RuntimeError(strings._("download_cancelled"))
|
||||
|
||||
if progress is not None and total_bytes is not None and total_bytes > 0:
|
||||
progress.setValue(total_bytes)
|
||||
QApplication.processEvents()
|
||||
|
||||
def _download_and_verify_appimage(self, version: str) -> None:
|
||||
"""
|
||||
Download the AppImage + its GPG signature to the user's Downloads dir,
|
||||
then verify it with a bundled public key.
|
||||
"""
|
||||
# Where to put the file
|
||||
download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
|
||||
if not download_dir:
|
||||
download_dir = os.path.expanduser("~/Downloads")
|
||||
download_dir = Path(download_dir)
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Construct AppImage filename and URLs
|
||||
appimage_path = download_dir / "Bouquin.AppImage"
|
||||
sig_path = Path(str(appimage_path) + ".asc")
|
||||
|
||||
appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage"
|
||||
sig_url = f"{appimage_url}.asc"
|
||||
|
||||
# Progress dialog covering both downloads
|
||||
progress = QProgressDialog(
|
||||
"Downloading update...",
|
||||
"Cancel",
|
||||
0,
|
||||
100,
|
||||
self._parent,
|
||||
)
|
||||
progress.setWindowTitle(strings._("update"))
|
||||
progress.setWindowModality(Qt.WindowModal)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
|
||||
try:
|
||||
# AppImage download
|
||||
self._download_file(
|
||||
appimage_url,
|
||||
appimage_path,
|
||||
progress=progress,
|
||||
label=strings._("downloading") + " Bouquin.AppImage...",
|
||||
)
|
||||
# Signature download (usually tiny, but we still show it)
|
||||
self._download_file(
|
||||
sig_url,
|
||||
sig_path,
|
||||
progress=progress,
|
||||
label=strings._("downloading") + " signature...",
|
||||
)
|
||||
except RuntimeError:
|
||||
# User cancelled
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
progress.close()
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("download_cancelled"),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
# Other error
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
progress.close()
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("failed_to_download_update") + str(e),
|
||||
)
|
||||
return
|
||||
|
||||
progress.close()
|
||||
|
||||
# Load the bundled public key
|
||||
try:
|
||||
pkg, *rel = GPG_PUBKEY_RESOURCE
|
||||
pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes()
|
||||
except Exception as e: # pragma: no cover
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_read_bundled_gpg_public_key") + str(e),
|
||||
)
|
||||
# On failure, delete the downloaded files for safety
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
return
|
||||
|
||||
# Use a temporary GNUPGHOME so we don't touch the user's main keyring
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as gnupg_home:
|
||||
pubkey_path = Path(gnupg_home) / "pubkey.asc"
|
||||
pubkey_path.write_bytes(pubkey_bytes)
|
||||
|
||||
# Import the key
|
||||
subprocess.run(
|
||||
["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
) # nosec
|
||||
|
||||
# Verify the signature
|
||||
subprocess.run(
|
||||
[
|
||||
"gpg",
|
||||
"--homedir",
|
||||
gnupg_home,
|
||||
"--verify",
|
||||
str(sig_path),
|
||||
str(appimage_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
) # nosec
|
||||
except FileNotFoundError:
|
||||
# gpg not installed / not on PATH
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_find_gpg_executable"),
|
||||
)
|
||||
return
|
||||
except subprocess.CalledProcessError as e:
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink() # pragma: no cover
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("gpg_signature_verification_failed")
|
||||
+ e.stderr.decode(errors="ignore"),
|
||||
)
|
||||
return
|
||||
|
||||
# Success
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
|
||||
)
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
#!/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()
|
||||
444
poetry.lock
generated
|
|
@ -1,138 +1,5 @@
|
|||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
|
||||
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
|
||||
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
|
||||
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
|
|
@ -146,103 +13,115 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.12.0"
|
||||
version = "7.10.7"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
|
||||
{file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
|
||||
{file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
|
||||
{file = "coverage-7.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"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -251,20 +130,6 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
|
|||
[package.extras]
|
||||
toml = ["tomli"]
|
||||
|
||||
[[package]]
|
||||
name = "desktop-entry-lib"
|
||||
version = "5.0"
|
||||
description = "A library for working with .desktop files"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"},
|
||||
{file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
xdg-desktop-portal = ["jeepney"]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
|
|
@ -283,45 +148,16 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
|
|||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "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"
|
||||
|
|
@ -362,22 +198,6 @@ files = [
|
|||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyproject-appimage"
|
||||
version = "4.2"
|
||||
description = "Generate AppImages from your Python projects"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"},
|
||||
{file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
desktop-entry-lib = "*"
|
||||
requests = "*"
|
||||
tomli = {version = "*", markers = "python_version < \"3.11\""}
|
||||
|
||||
[[package]]
|
||||
name = "pyside6"
|
||||
version = "6.10.0"
|
||||
|
|
@ -511,27 +331,6 @@ typing_extensions = "*"
|
|||
dev = ["pre-commit", "tox"]
|
||||
doc = ["sphinx", "sphinx_rtd_theme"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.10.0"
|
||||
|
|
@ -739,24 +538,7 @@ files = [
|
|||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.10,<3.14"
|
||||
content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df"
|
||||
python-versions = ">=3.9,<3.14"
|
||||
content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4"
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
[tool.poetry]
|
||||
name = "bouquin"
|
||||
version = "0.5.2"
|
||||
version = "0.2.0"
|
||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://git.mig5.net/mig5/bouquin"
|
||||
packages = [{ include = "bouquin" }]
|
||||
include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/NotoSansSymbols2-Regular.ttf", "bouquin/fonts/OFL.txt"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<3.14"
|
||||
python = ">=3.9,<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"
|
||||
|
|
@ -25,20 +21,6 @@ pytest-qt = "^4.5.0"
|
|||
pytest-mock = "^3.15.1"
|
||||
pytest-cov = "^7.0.0"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pyproject-appimage = "^4.2"
|
||||
|
||||
[tool.pyproject-appimage]
|
||||
script = "bouquin"
|
||||
output = "Bouquin.AppImage"
|
||||
icon = "bouquin/icons/bouquin.svg"
|
||||
rename-icon = "bouquin.png"
|
||||
desktop-entry = "bouquin.desktop"
|
||||
|
||||
[tool.vulture]
|
||||
paths = ["bouquin", "vulture_ignorelist.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
|
|
|||
11
release.sh
|
|
@ -1,19 +1,10 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
set -e
|
||||
|
||||
rm -rf dist
|
||||
|
||||
# Publish to Pypi
|
||||
poetry build
|
||||
poetry publish
|
||||
|
||||
# Make AppImage
|
||||
sudo apt-get install libfuse-dev
|
||||
poetry run pyproject-appimage
|
||||
mv Bouquin.AppImage dist/
|
||||
|
||||
# Sign packages
|
||||
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
||||
|
||||
echo "Don't forget to update version string on remote server."
|
||||
|
|
|
|||
BIN
screenshot.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
screenshot_dark.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
|
@ -33,19 +33,10 @@ def isolate_qsettings(tmp_path_factory):
|
|||
def tmp_db_cfg(tmp_path):
|
||||
from bouquin.db import DBConfig
|
||||
|
||||
default_db = tmp_path / "notebook.db"
|
||||
db_path = tmp_path / "notebook.db"
|
||||
key = "test-secret-key"
|
||||
return DBConfig(
|
||||
path=default_db,
|
||||
key=key,
|
||||
idle_minutes=0,
|
||||
theme="light",
|
||||
move_todos=True,
|
||||
tags=True,
|
||||
time_log=True,
|
||||
reminders=True,
|
||||
locale="en",
|
||||
font_size=11,
|
||||
path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
import bouquin.bug_report_dialog as bugmod
|
||||
from bouquin.bug_report_dialog import BugReportDialog
|
||||
from bouquin import strings
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
|
||||
def test_bug_report_truncates_text_to_max_chars(qtbot):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
max_chars = getattr(dlg, "MAX_CHARS", 5000)
|
||||
|
||||
# Make a string longer than the allowed maximum
|
||||
long_text = "x" * (max_chars + 50)
|
||||
|
||||
# Setting the text should trigger textChanged -> _enforce_max_length
|
||||
dlg.text_edit.setPlainText(long_text)
|
||||
|
||||
# Let Qt process the signal/slot if needed
|
||||
qtbot.wait(10)
|
||||
|
||||
current = dlg.text_edit.toPlainText()
|
||||
assert len(current) == max_chars
|
||||
assert current == long_text[:max_chars]
|
||||
|
||||
|
||||
def test_bug_report_allows_up_to_max_chars_unchanged(qtbot):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
max_chars = getattr(dlg, "MAX_CHARS", 5000)
|
||||
exact_text = "y" * max_chars
|
||||
|
||||
dlg.text_edit.setPlainText(exact_text)
|
||||
qtbot.wait(10)
|
||||
|
||||
current = dlg.text_edit.toPlainText()
|
||||
# Should not be trimmed if it's exactly the limit
|
||||
assert len(current) == max_chars
|
||||
assert current == exact_text
|
||||
|
||||
|
||||
def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Non-empty message so we don't hit the "empty" warning branch
|
||||
dlg.text_edit.setPlainText("Hello, something broke.")
|
||||
qtbot.wait(10)
|
||||
|
||||
# Make version() deterministic
|
||||
def fake_version(pkg_name):
|
||||
assert pkg_name == "bouquin"
|
||||
return "1.2.3"
|
||||
|
||||
monkeypatch.setattr(
|
||||
bugmod.importlib.metadata, "version", fake_version, raising=True
|
||||
)
|
||||
|
||||
# Capture the POST call and fake a 201 Created response
|
||||
calls = {}
|
||||
|
||||
class DummyResp:
|
||||
status_code = 201
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls["url"] = url
|
||||
calls["json"] = json
|
||||
calls["timeout"] = timeout
|
||||
return DummyResp()
|
||||
|
||||
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
|
||||
|
||||
# Capture information / critical message boxes
|
||||
info_called = {}
|
||||
crit_called = {}
|
||||
|
||||
def fake_info(parent, title, text, *a, **k):
|
||||
info_called["title"] = title
|
||||
info_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
def fake_critical(parent, title, text, *a, **k):
|
||||
crit_called["title"] = title
|
||||
crit_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
|
||||
)
|
||||
|
||||
# Don't actually close the dialog in the test; just record that accept() was called
|
||||
accepted = {}
|
||||
|
||||
def fake_accept():
|
||||
accepted["called"] = True
|
||||
|
||||
dlg.accept = fake_accept
|
||||
|
||||
# Call the send logic directly
|
||||
dlg._send()
|
||||
|
||||
# --- Assertions ---------------------------------------------------------
|
||||
|
||||
# POST was called with the expected URL and JSON payload
|
||||
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
|
||||
assert calls["json"]["message"] == "Hello, something broke."
|
||||
assert calls["json"]["version"] == "1.2.3"
|
||||
# No attachment fields expected any more
|
||||
|
||||
# Success path: information dialog shown, critical not shown
|
||||
assert "title" in info_called
|
||||
assert "text" in info_called
|
||||
assert crit_called == {}
|
||||
|
||||
# Dialog accepted
|
||||
assert accepted.get("called") is True
|
||||
|
||||
|
||||
def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted(
|
||||
qtbot, monkeypatch
|
||||
):
|
||||
dlg = BugReportDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
dlg.text_edit.setPlainText("Broken again.")
|
||||
qtbot.wait(10)
|
||||
|
||||
# Stub version() again
|
||||
monkeypatch.setattr(
|
||||
bugmod.importlib.metadata,
|
||||
"version",
|
||||
lambda name: "9.9.9",
|
||||
raising=True,
|
||||
)
|
||||
|
||||
# Fake a non-201 response (e.g. 500)
|
||||
calls = {}
|
||||
|
||||
class DummyResp:
|
||||
status_code = 500
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls["url"] = url
|
||||
calls["json"] = json
|
||||
calls["timeout"] = timeout
|
||||
return DummyResp()
|
||||
|
||||
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
|
||||
|
||||
info_called = {}
|
||||
crit_called = {}
|
||||
|
||||
def fake_info(parent, title, text, *a, **k):
|
||||
info_called["title"] = title
|
||||
info_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
def fake_critical(parent, title, text, *a, **k):
|
||||
crit_called["title"] = title
|
||||
crit_called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
|
||||
)
|
||||
|
||||
accepted = {}
|
||||
|
||||
def fake_accept():
|
||||
accepted["called"] = True
|
||||
|
||||
dlg.accept = fake_accept
|
||||
|
||||
dlg._send()
|
||||
|
||||
# POST still called with JSON payload
|
||||
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
|
||||
assert calls["json"]["message"] == "Broken again."
|
||||
assert calls["json"]["version"] == "9.9.9"
|
||||
|
||||
# Failure path: critical dialog shown, information not shown
|
||||
assert crit_called # non-empty
|
||||
assert info_called == {}
|
||||
|
||||
# Dialog should NOT be accepted on failure
|
||||
assert accepted.get("called") is not True
|
||||
|
||||
|
||||
def test_bug_report_dialog_text_limit_clamps_cursor(qtbot):
|
||||
"""Test that cursor position is clamped when text exceeds limit."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Set text that exceeds MAX_CHARS
|
||||
max_chars = dialog.MAX_CHARS
|
||||
long_text = "A" * (max_chars + 100)
|
||||
|
||||
# Set text and move cursor to end
|
||||
dialog.text_edit.setPlainText(long_text)
|
||||
dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
# Text should be truncated
|
||||
assert len(dialog.text_edit.toPlainText()) == max_chars
|
||||
|
||||
# Cursor should be clamped to max position
|
||||
final_cursor = dialog.text_edit.textCursor()
|
||||
assert final_cursor.position() <= max_chars
|
||||
|
||||
|
||||
def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch):
|
||||
"""Test that sending empty report shows warning."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Clear any text
|
||||
dialog.text_edit.clear()
|
||||
|
||||
warning_shown = {"shown": False}
|
||||
|
||||
def mock_warning(*args):
|
||||
warning_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||
|
||||
# Try to send empty report
|
||||
dialog._send()
|
||||
|
||||
assert warning_shown["shown"]
|
||||
|
||||
|
||||
def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch):
|
||||
"""Test that sending whitespace-only report shows warning."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Set whitespace only
|
||||
dialog.text_edit.setPlainText(" \n\n \t\t ")
|
||||
|
||||
warning_shown = {"shown": False}
|
||||
|
||||
def mock_warning(*args):
|
||||
warning_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||
|
||||
dialog._send()
|
||||
|
||||
assert warning_shown["shown"]
|
||||
|
||||
|
||||
def test_bug_report_dialog_network_error(qtbot, monkeypatch):
|
||||
"""Test handling network error during send."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
dialog.text_edit.setPlainText("Test bug report")
|
||||
|
||||
# Mock requests.post to raise exception
|
||||
import requests
|
||||
|
||||
def mock_post(*args, **kwargs):
|
||||
raise requests.exceptions.ConnectionError("Network error")
|
||||
|
||||
monkeypatch.setattr(requests, "post", mock_post)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
dialog._send()
|
||||
|
||||
assert critical_shown["shown"]
|
||||
|
||||
|
||||
def test_bug_report_dialog_timeout_error(qtbot, monkeypatch):
|
||||
"""Test handling timeout error during send."""
|
||||
strings.load_strings("en")
|
||||
dialog = BugReportDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
dialog.text_edit.setPlainText("Test bug report")
|
||||
|
||||
# Mock requests.post to raise timeout
|
||||
import requests
|
||||
|
||||
def mock_post(*args, **kwargs):
|
||||
raise requests.exceptions.Timeout("Request timed out")
|
||||
|
||||
monkeypatch.setattr(requests, "post", mock_post)
|
||||
|
||||
critical_shown = {"shown": False}
|
||||
|
||||
def mock_critical(*args):
|
||||
critical_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||
|
||||
dialog._send()
|
||||
|
||||
assert critical_shown["shown"]
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
|
||||
from PySide6.QtGui import QTextCharFormat, QFont
|
||||
|
||||
|
||||
def test_get_language_patterns_python(app):
|
||||
"""Test getting highlighting patterns for Python."""
|
||||
patterns = CodeHighlighter.get_language_patterns("python")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have comment pattern
|
||||
assert any("#" in p[0] for p in patterns)
|
||||
# Should have string patterns
|
||||
assert any('"' in p[0] for p in patterns)
|
||||
# Should have keyword patterns
|
||||
assert any("keyword" == p[1] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_javascript(app):
|
||||
"""Test getting highlighting patterns for JavaScript."""
|
||||
patterns = CodeHighlighter.get_language_patterns("javascript")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have // comment pattern
|
||||
assert any("//" in p[0] for p in patterns)
|
||||
# Should have /* */ comment pattern (with escaped asterisks in regex)
|
||||
assert any(r"/\*" in p[0] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_php(app):
|
||||
"""Test getting highlighting patterns for PHP."""
|
||||
patterns = CodeHighlighter.get_language_patterns("php")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have # comment pattern
|
||||
assert any("#" in p[0] for p in patterns)
|
||||
# Should have // comment pattern
|
||||
assert any("//" in p[0] for p in patterns)
|
||||
# Should have /* */ comment pattern (with escaped asterisks in regex)
|
||||
assert any(r"/\*" in p[0] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_bash(app):
|
||||
"""Test getting highlighting patterns for Bash."""
|
||||
patterns = CodeHighlighter.get_language_patterns("bash")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have # comment pattern
|
||||
assert any("#" in p[0] for p in patterns)
|
||||
# Should have bash keywords
|
||||
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
|
||||
assert len(keyword_patterns) > 0
|
||||
|
||||
|
||||
def test_get_language_patterns_html(app):
|
||||
"""Test getting highlighting patterns for HTML."""
|
||||
patterns = CodeHighlighter.get_language_patterns("html")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have tag pattern
|
||||
assert any("tag" == p[1] for p in patterns)
|
||||
# Should have HTML comment pattern
|
||||
assert any("<!--" in p[0] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_css(app):
|
||||
"""Test getting highlighting patterns for CSS."""
|
||||
patterns = CodeHighlighter.get_language_patterns("css")
|
||||
|
||||
assert len(patterns) > 0
|
||||
# Should have // comment pattern
|
||||
assert any("//" in p[0] for p in patterns)
|
||||
# Should have CSS properties as keywords
|
||||
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
|
||||
assert len(keyword_patterns) > 0
|
||||
|
||||
|
||||
def test_get_language_patterns_unknown_language(app):
|
||||
"""Test getting patterns for an unknown language."""
|
||||
patterns = CodeHighlighter.get_language_patterns("unknown-lang")
|
||||
|
||||
# Should still return basic patterns (strings, numbers)
|
||||
assert len(patterns) > 0
|
||||
assert any("string" == p[1] for p in patterns)
|
||||
assert any("number" == p[1] for p in patterns)
|
||||
|
||||
|
||||
def test_get_language_patterns_case_insensitive(app):
|
||||
"""Test that language matching is case insensitive."""
|
||||
patterns_lower = CodeHighlighter.get_language_patterns("python")
|
||||
patterns_upper = CodeHighlighter.get_language_patterns("PYTHON")
|
||||
patterns_mixed = CodeHighlighter.get_language_patterns("PyThOn")
|
||||
|
||||
assert len(patterns_lower) == len(patterns_upper)
|
||||
assert len(patterns_lower) == len(patterns_mixed)
|
||||
|
||||
|
||||
def test_get_format_for_type_keyword(app):
|
||||
"""Test getting format for keyword type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
|
||||
|
||||
assert fmt.fontWeight() == QFont.Weight.Bold
|
||||
assert fmt.foreground().color().blue() > 0 # Should have blue-ish color
|
||||
|
||||
|
||||
def test_get_format_for_type_string(app):
|
||||
"""Test getting format for string type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("string", base_format)
|
||||
|
||||
# Should have orangish color
|
||||
color = fmt.foreground().color()
|
||||
assert color.red() > 100
|
||||
|
||||
|
||||
def test_get_format_for_type_comment(app):
|
||||
"""Test getting format for comment type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("comment", base_format)
|
||||
|
||||
assert fmt.fontItalic() is True
|
||||
# Should have greenish color
|
||||
color = fmt.foreground().color()
|
||||
assert color.green() > 0
|
||||
|
||||
|
||||
def test_get_format_for_type_number(app):
|
||||
"""Test getting format for number type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("number", base_format)
|
||||
|
||||
# Should have some color
|
||||
color = fmt.foreground().color()
|
||||
assert color.isValid()
|
||||
|
||||
|
||||
def test_get_format_for_type_tag(app):
|
||||
"""Test getting format for HTML tag type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("tag", base_format)
|
||||
|
||||
# Should have cyan-ish color
|
||||
color = fmt.foreground().color()
|
||||
assert color.green() > 0
|
||||
assert color.blue() > 0
|
||||
|
||||
|
||||
def test_get_format_for_type_unknown(app):
|
||||
"""Test getting format for unknown type."""
|
||||
base_format = QTextCharFormat()
|
||||
fmt = CodeHighlighter.get_format_for_type("unknown", base_format)
|
||||
|
||||
# Should return a valid format (based on base_format)
|
||||
assert fmt is not None
|
||||
|
||||
|
||||
def test_code_block_metadata_init(app):
|
||||
"""Test CodeBlockMetadata initialization."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
assert len(metadata._block_languages) == 0
|
||||
|
||||
|
||||
def test_code_block_metadata_set_get_language(app):
|
||||
"""Test setting and getting language for a block."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
metadata.set_language(0, "python")
|
||||
metadata.set_language(5, "javascript")
|
||||
|
||||
assert metadata.get_language(0) == "python"
|
||||
assert metadata.get_language(5) == "javascript"
|
||||
assert metadata.get_language(10) is None
|
||||
|
||||
|
||||
def test_code_block_metadata_set_language_case_normalization(app):
|
||||
"""Test that language is normalized to lowercase."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
metadata.set_language(0, "PYTHON")
|
||||
metadata.set_language(1, "JavaScript")
|
||||
|
||||
assert metadata.get_language(0) == "python"
|
||||
assert metadata.get_language(1) == "javascript"
|
||||
|
||||
|
||||
def test_code_block_metadata_serialize_empty(app):
|
||||
"""Test serializing empty metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
result = metadata.serialize()
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_code_block_metadata_serialize(app):
|
||||
"""Test serializing metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
metadata.set_language(0, "python")
|
||||
metadata.set_language(3, "javascript")
|
||||
|
||||
result = metadata.serialize()
|
||||
|
||||
assert "<!-- code-langs:" in result
|
||||
assert "0:python" in result
|
||||
assert "3:javascript" in result
|
||||
assert "-->" in result
|
||||
|
||||
|
||||
def test_code_block_metadata_serialize_sorted(app):
|
||||
"""Test that serialized metadata is sorted by block number."""
|
||||
metadata = CodeBlockMetadata()
|
||||
metadata.set_language(5, "python")
|
||||
metadata.set_language(2, "javascript")
|
||||
metadata.set_language(8, "bash")
|
||||
|
||||
result = metadata.serialize()
|
||||
|
||||
# Find positions in string
|
||||
pos_2 = result.find("2:")
|
||||
pos_5 = result.find("5:")
|
||||
pos_8 = result.find("8:")
|
||||
|
||||
# Should be in order
|
||||
assert pos_2 < pos_5 < pos_8
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize(app):
|
||||
"""Test deserializing metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
text = (
|
||||
"Some content\n<!-- code-langs: 0:python,3:javascript,5:bash -->\nMore content"
|
||||
)
|
||||
|
||||
metadata.deserialize(text)
|
||||
|
||||
assert metadata.get_language(0) == "python"
|
||||
assert metadata.get_language(3) == "javascript"
|
||||
assert metadata.get_language(5) == "bash"
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize_empty(app):
|
||||
"""Test deserializing from text without metadata."""
|
||||
metadata = CodeBlockMetadata()
|
||||
metadata.set_language(0, "python") # Set some initial data
|
||||
|
||||
text = "Just some regular text with no metadata"
|
||||
metadata.deserialize(text)
|
||||
|
||||
# Should clear existing data
|
||||
assert len(metadata._block_languages) == 0
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize_invalid_format(app):
|
||||
"""Test deserializing with invalid format."""
|
||||
metadata = CodeBlockMetadata()
|
||||
text = "<!-- code-langs: invalid,format,here -->"
|
||||
|
||||
metadata.deserialize(text)
|
||||
|
||||
# Should handle gracefully, resulting in empty or minimal data
|
||||
# Pairs without ':' should be skipped
|
||||
assert len(metadata._block_languages) == 0
|
||||
|
||||
|
||||
def test_code_block_metadata_deserialize_invalid_block_number(app):
|
||||
"""Test deserializing with invalid block number."""
|
||||
metadata = CodeBlockMetadata()
|
||||
text = "<!-- code-langs: abc:python,3:javascript -->"
|
||||
|
||||
metadata.deserialize(text)
|
||||
|
||||
# Should skip invalid block number 'abc'
|
||||
assert metadata.get_language(3) == "javascript"
|
||||
assert "abc" not in str(metadata._block_languages)
|
||||
|
||||
|
||||
def test_code_block_metadata_round_trip(app):
|
||||
"""Test serializing and deserializing preserves data."""
|
||||
metadata1 = CodeBlockMetadata()
|
||||
metadata1.set_language(0, "python")
|
||||
metadata1.set_language(2, "javascript")
|
||||
metadata1.set_language(7, "bash")
|
||||
|
||||
serialized = metadata1.serialize()
|
||||
|
||||
metadata2 = CodeBlockMetadata()
|
||||
metadata2.deserialize(serialized)
|
||||
|
||||
assert metadata2.get_language(0) == "python"
|
||||
assert metadata2.get_language(2) == "javascript"
|
||||
assert metadata2.get_language(7) == "bash"
|
||||
|
||||
|
||||
def test_python_keywords_present(app):
|
||||
"""Test that Python keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("python", [])
|
||||
|
||||
assert "def" in keywords
|
||||
assert "class" in keywords
|
||||
assert "if" in keywords
|
||||
assert "for" in keywords
|
||||
assert "import" in keywords
|
||||
|
||||
|
||||
def test_javascript_keywords_present(app):
|
||||
"""Test that JavaScript keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("javascript", [])
|
||||
|
||||
assert "function" in keywords
|
||||
assert "const" in keywords
|
||||
assert "let" in keywords
|
||||
assert "var" in keywords
|
||||
assert "class" in keywords
|
||||
|
||||
|
||||
def test_php_keywords_present(app):
|
||||
"""Test that PHP keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("php", [])
|
||||
|
||||
assert "function" in keywords
|
||||
assert "class" in keywords
|
||||
assert "echo" in keywords
|
||||
assert "require" in keywords
|
||||
|
||||
|
||||
def test_bash_keywords_present(app):
|
||||
"""Test that Bash keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("bash", [])
|
||||
|
||||
assert "if" in keywords
|
||||
assert "then" in keywords
|
||||
assert "fi" in keywords
|
||||
assert "for" in keywords
|
||||
|
||||
|
||||
def test_html_keywords_present(app):
|
||||
"""Test that HTML keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("html", [])
|
||||
|
||||
assert "div" in keywords
|
||||
assert "span" in keywords
|
||||
assert "body" in keywords
|
||||
assert "html" in keywords
|
||||
|
||||
|
||||
def test_css_keywords_present(app):
|
||||
"""Test that CSS keywords are defined."""
|
||||
keywords = CodeHighlighter.KEYWORDS.get("css", [])
|
||||
|
||||
assert "color" in keywords
|
||||
assert "background" in keywords
|
||||
assert "margin" in keywords
|
||||
assert "padding" in keywords
|
||||
|
||||
|
||||
def test_all_patterns_have_string_and_number(app):
|
||||
"""Test that all languages have string and number patterns."""
|
||||
languages = ["python", "javascript", "php", "bash", "html", "css"]
|
||||
|
||||
for lang in languages:
|
||||
patterns = CodeHighlighter.get_language_patterns(lang)
|
||||
pattern_types = [p[1] for p in patterns]
|
||||
|
||||
assert "string" in pattern_types, f"{lang} should have string pattern"
|
||||
assert "number" in pattern_types, f"{lang} should have number pattern"
|
||||
|
||||
|
||||
def test_patterns_have_regex_format(app):
|
||||
"""Test that patterns are in regex format."""
|
||||
patterns = CodeHighlighter.get_language_patterns("python")
|
||||
|
||||
for pattern, pattern_type in patterns:
|
||||
# Each pattern should be a string (regex pattern)
|
||||
assert isinstance(pattern, str)
|
||||
# Each type should be a string
|
||||
assert isinstance(pattern_type, str)
|
||||
|
||||
|
||||
def test_code_block_metadata_update_language(app):
|
||||
"""Test updating language for existing block."""
|
||||
metadata = CodeBlockMetadata()
|
||||
|
||||
metadata.set_language(0, "python")
|
||||
assert metadata.get_language(0) == "python"
|
||||
|
||||
metadata.set_language(0, "javascript")
|
||||
assert metadata.get_language(0) == "javascript"
|
||||
|
||||
|
||||
def test_get_format_preserves_base_format_properties(app):
|
||||
"""Test that get_format_for_type preserves base format properties."""
|
||||
base_format = QTextCharFormat()
|
||||
base_format.setFontPointSize(14)
|
||||
|
||||
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
|
||||
|
||||
# Should be based on the base_format
|
||||
assert isinstance(fmt, QTextCharFormat)
|
||||
509
tests/test_db.py
|
|
@ -1,9 +1,5 @@
|
|||
import pytest
|
||||
import json, csv
|
||||
import datetime as dt
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
from bouquin.db import DBManager
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def _today():
|
||||
|
|
@ -18,10 +14,6 @@ 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"
|
||||
|
||||
|
|
@ -65,7 +57,7 @@ def test_dates_with_content_and_search(fresh_db):
|
|||
assert any(d == _tomorrow() for d, _ in hits)
|
||||
|
||||
|
||||
def test_get_all_entries_and_export(fresh_db, tmp_path):
|
||||
def test_get_all_entries_and_export_by_extension(fresh_db, tmp_path):
|
||||
for i in range(3):
|
||||
d = (dt.date.today() - dt.timedelta(days=i)).isoformat()
|
||||
fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}")
|
||||
|
|
@ -80,6 +72,10 @@ 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()
|
||||
|
|
@ -97,12 +93,19 @@ def test_get_all_entries_and_export(fresh_db, tmp_path):
|
|||
fresh_db.export_sqlcipher(str(sqlc_path))
|
||||
assert sqlc_path.exists() and sqlc_path.read_bytes()
|
||||
|
||||
for path in [json_path, csv_path, txt_path, md_path, html_path, sql_path]:
|
||||
path.unlink(missing_ok=True)
|
||||
fresh_db.export_by_extension(str(path))
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
|
||||
fresh_db.save_new_version(_today(), _entry("secure"), "before rekey")
|
||||
fresh_db.rekey("new-key-123")
|
||||
fresh_db.close()
|
||||
|
||||
from bouquin.db import DBManager
|
||||
|
||||
tmp_db_cfg.key = "new-key-123"
|
||||
db2 = DBManager(tmp_db_cfg)
|
||||
assert db2.connect()
|
||||
|
|
@ -115,490 +118,10 @@ def test_compact_and_close_dont_crash(fresh_db):
|
|||
fresh_db.close()
|
||||
|
||||
|
||||
def test_connect_integrity_failure(monkeypatch, tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
# simulate cursor() ok, but integrity check raising
|
||||
called = {"ok": False}
|
||||
|
||||
def bad_integrity(self):
|
||||
called["ok"] = True
|
||||
raise sqlite.Error("bad cipher")
|
||||
|
||||
monkeypatch.setattr(DBManager, "_integrity_ok", bad_integrity, raising=True)
|
||||
ok = db.connect()
|
||||
assert not ok and called["ok"]
|
||||
assert db.conn is None
|
||||
import pytest
|
||||
|
||||
|
||||
def test_rekey_reopen_failure(monkeypatch, tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
assert db.connect()
|
||||
|
||||
# Monkeypatch connect() on the instance so the reconnect attempt fails
|
||||
def fail_connect():
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(db, "connect", fail_connect, raising=False)
|
||||
with pytest.raises(sqlite.Error):
|
||||
db.rekey("newkey")
|
||||
|
||||
|
||||
def test_revert_wrong_date_raises(fresh_db):
|
||||
d1, d2 = "2024-01-01", "2024-01-02"
|
||||
v1_id, _ = fresh_db.save_new_version(d1, "one", "seed")
|
||||
fresh_db.save_new_version(d2, "two", "seed")
|
||||
def test_export_by_extension_unsupported(fresh_db, tmp_path):
|
||||
p = tmp_path / "export.xyz"
|
||||
with pytest.raises(ValueError):
|
||||
fresh_db.revert_to_version(d2, version_id=v1_id)
|
||||
|
||||
|
||||
def test_compact_error_path(monkeypatch, tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
assert db.connect()
|
||||
|
||||
# Replace cursor.execute to raise to hit except branch
|
||||
class BadCur:
|
||||
def execute(self, *a, **k):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
class BadConn:
|
||||
def cursor(self):
|
||||
return BadCur()
|
||||
|
||||
db.conn = BadConn()
|
||||
# Should not raise; just print error
|
||||
db.compact()
|
||||
|
||||
|
||||
class _Cur:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def execute(self, *a, **k):
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def cursor(self):
|
||||
return _Cur(self._rows)
|
||||
|
||||
|
||||
def test_integrity_check_raises_with_details(tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
assert db.connect()
|
||||
# Force the integrity check to report problems with text details
|
||||
db.conn = _Conn([("bad page checksum",), (None,)])
|
||||
with pytest.raises(sqlite.IntegrityError) as ei:
|
||||
db._integrity_ok()
|
||||
# Message should contain the detail string
|
||||
assert "bad page checksum" in str(ei.value)
|
||||
|
||||
|
||||
def test_integrity_check_raises_without_details(tmp_db_cfg):
|
||||
db = DBManager(tmp_db_cfg)
|
||||
assert db.connect()
|
||||
# Force the integrity check to report problems but without textual details
|
||||
db.conn = _Conn([(None,), (None,)])
|
||||
with pytest.raises(sqlite.IntegrityError):
|
||||
db._integrity_ok()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DB _strip_markdown and _count_words Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_db_strip_markdown_empty_text(fresh_db):
|
||||
"""Test strip_markdown with empty text."""
|
||||
result = fresh_db._strip_markdown("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_db_strip_markdown_none_text(fresh_db):
|
||||
"""Test strip_markdown with None."""
|
||||
result = fresh_db._strip_markdown(None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_db_strip_markdown_fenced_code_blocks(fresh_db):
|
||||
"""Test stripping fenced code blocks."""
|
||||
text = """
|
||||
Some text here
|
||||
```python
|
||||
def hello():
|
||||
print("world")
|
||||
```
|
||||
More text after
|
||||
"""
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "def hello" not in result
|
||||
assert "Some text" in result
|
||||
assert "More text" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_inline_code(fresh_db):
|
||||
"""Test stripping inline code."""
|
||||
text = "Here is some `inline code` in text"
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "`" not in result
|
||||
assert "inline code" not in result
|
||||
assert "Here is some" in result
|
||||
assert "in text" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_links(fresh_db):
|
||||
"""Test converting markdown links to plain text."""
|
||||
text = "Check out [this link](https://example.com) for more info"
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "this link" in result
|
||||
assert "https://example.com" not in result
|
||||
assert "[" not in result
|
||||
assert "]" not in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_emphasis_and_headers(fresh_db):
|
||||
"""Test stripping emphasis markers and headers."""
|
||||
text = """
|
||||
# Header 1
|
||||
## Header 2
|
||||
**bold text** and *italic text*
|
||||
> blockquote
|
||||
_underline_
|
||||
"""
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "#" not in result
|
||||
assert "*" not in result
|
||||
assert "_" not in result
|
||||
assert ">" not in result
|
||||
assert "bold text" in result
|
||||
assert "italic text" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_html_tags(fresh_db):
|
||||
"""Test stripping HTML tags."""
|
||||
text = "Some <b>bold</b> and <i>italic</i> text with <div>divs</div>"
|
||||
result = fresh_db._strip_markdown(text)
|
||||
# The regex replaces tags with spaces, may leave some angle brackets from malformed HTML
|
||||
# The important thing is that the words are preserved
|
||||
assert "bold" in result
|
||||
assert "italic" in result
|
||||
assert "divs" in result
|
||||
|
||||
|
||||
def test_db_strip_markdown_complex_document(fresh_db):
|
||||
"""Test stripping complex markdown document."""
|
||||
text = """
|
||||
# My Document
|
||||
|
||||
This is a paragraph with **bold** and *italic* text.
|
||||
|
||||
```javascript
|
||||
const x = 10;
|
||||
console.log(x);
|
||||
```
|
||||
|
||||
Here's a [link](https://example.com) and some `code`.
|
||||
|
||||
> A blockquote
|
||||
|
||||
<p>HTML paragraph</p>
|
||||
"""
|
||||
result = fresh_db._strip_markdown(text)
|
||||
assert "My Document" in result
|
||||
assert "paragraph" in result
|
||||
assert "const x" not in result
|
||||
assert "https://example.com" not in result
|
||||
assert "<p>" not in result
|
||||
|
||||
|
||||
def test_db_count_words_simple(fresh_db):
|
||||
"""Test word counting on simple text."""
|
||||
text = "This is a simple test with seven words"
|
||||
count = fresh_db._count_words(text)
|
||||
assert count == 8
|
||||
|
||||
|
||||
def test_db_count_words_empty(fresh_db):
|
||||
"""Test word counting on empty text."""
|
||||
count = fresh_db._count_words("")
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_db_count_words_with_markdown(fresh_db):
|
||||
"""Test word counting strips markdown first."""
|
||||
text = "**Bold** and *italic* and `code` words"
|
||||
count = fresh_db._count_words(text)
|
||||
# Should count: Bold, and, italic, and, words (5 words, code is in backticks so stripped)
|
||||
assert count == 5
|
||||
|
||||
|
||||
def test_db_count_words_with_unicode(fresh_db):
|
||||
"""Test word counting with unicode characters."""
|
||||
text = "Hello 世界 café naïve résumé"
|
||||
count = fresh_db._count_words(text)
|
||||
# Should count all words including unicode
|
||||
assert count >= 5
|
||||
|
||||
|
||||
def test_db_count_words_with_numbers(fresh_db):
|
||||
"""Test word counting includes numbers."""
|
||||
text = "There are 123 apples and 456 oranges"
|
||||
count = fresh_db._count_words(text)
|
||||
assert count == 7
|
||||
|
||||
|
||||
def test_db_count_words_with_punctuation(fresh_db):
|
||||
"""Test word counting handles punctuation correctly."""
|
||||
text = "Hello, world! How are you? I'm fine, thanks."
|
||||
count = fresh_db._count_words(text)
|
||||
# Hello, world, How, are, you, I, m, fine, thanks = 9 words
|
||||
assert count == 9
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DB gather_stats Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_db_gather_stats_empty_database(fresh_db):
|
||||
"""Test gather_stats on empty database."""
|
||||
stats = fresh_db.gather_stats()
|
||||
|
||||
assert len(stats) == 10
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
page_most_revisions,
|
||||
page_most_revisions_count,
|
||||
words_by_date,
|
||||
total_words,
|
||||
unique_tags,
|
||||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
) = stats
|
||||
|
||||
assert pages_with_content == 0
|
||||
assert total_revisions == 0
|
||||
assert page_most_revisions is None
|
||||
assert page_most_revisions_count == 0
|
||||
assert len(words_by_date) == 0
|
||||
assert total_words == 0
|
||||
assert unique_tags == 0
|
||||
assert page_most_tags is None
|
||||
assert page_most_tags_count == 0
|
||||
assert len(revisions_by_date) == 0
|
||||
|
||||
|
||||
def test_db_gather_stats_with_content(fresh_db):
|
||||
"""Test gather_stats with actual content."""
|
||||
# Add multiple pages with different content
|
||||
fresh_db.save_new_version("2024-01-01", "Hello world this is a test", "v1")
|
||||
fresh_db.save_new_version(
|
||||
"2024-01-01", "Hello world this is version two", "v2"
|
||||
) # 2nd revision
|
||||
fresh_db.save_new_version("2024-01-02", "Another page with more words here", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
page_most_revisions,
|
||||
page_most_revisions_count,
|
||||
words_by_date,
|
||||
total_words,
|
||||
unique_tags,
|
||||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
) = stats
|
||||
|
||||
assert pages_with_content == 2
|
||||
assert total_revisions == 3
|
||||
assert page_most_revisions == "2024-01-01"
|
||||
assert page_most_revisions_count == 2
|
||||
assert total_words > 0
|
||||
assert len(words_by_date) == 2
|
||||
|
||||
|
||||
def test_db_gather_stats_word_counting(fresh_db):
|
||||
"""Test that gather_stats counts words correctly."""
|
||||
# Add page with known word count
|
||||
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
|
||||
|
||||
assert total_words == 5
|
||||
|
||||
test_date = date(2024, 1, 1)
|
||||
assert test_date in words_by_date
|
||||
assert words_by_date[test_date] == 5
|
||||
|
||||
|
||||
def test_db_gather_stats_with_tags(fresh_db):
|
||||
"""Test gather_stats with tags."""
|
||||
# Add tags
|
||||
fresh_db.add_tag("tag1", "#ff0000")
|
||||
fresh_db.add_tag("tag2", "#00ff00")
|
||||
fresh_db.add_tag("tag3", "#0000ff")
|
||||
|
||||
# Add pages with tags
|
||||
fresh_db.save_new_version("2024-01-01", "Page 1", "test")
|
||||
fresh_db.save_new_version("2024-01-02", "Page 2", "test")
|
||||
|
||||
fresh_db.set_tags_for_page(
|
||||
"2024-01-01", ["tag1", "tag2", "tag3"]
|
||||
) # Page 1 has 3 tags
|
||||
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
|
||||
|
||||
assert unique_tags == 3
|
||||
assert page_most_tags == "2024-01-01"
|
||||
assert page_most_tags_count == 3
|
||||
|
||||
|
||||
def test_db_gather_stats_revisions_by_date(fresh_db):
|
||||
"""Test revisions_by_date tracking."""
|
||||
# Add multiple revisions on different dates
|
||||
fresh_db.save_new_version("2024-01-01", "First", "v1")
|
||||
fresh_db.save_new_version("2024-01-01", "Second", "v2")
|
||||
fresh_db.save_new_version("2024-01-01", "Third", "v3")
|
||||
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
|
||||
|
||||
assert date(2024, 1, 1) in revisions_by_date
|
||||
assert revisions_by_date[date(2024, 1, 1)] == 3
|
||||
assert date(2024, 1, 2) in revisions_by_date
|
||||
assert revisions_by_date[date(2024, 1, 2)] == 1
|
||||
|
||||
|
||||
def test_db_gather_stats_handles_malformed_dates(fresh_db):
|
||||
"""Test that gather_stats handles malformed dates gracefully."""
|
||||
# This is hard to test directly since the DB enforces date format
|
||||
# But we can test that normal dates work
|
||||
fresh_db.save_new_version("2024-01-15", "Test", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
|
||||
|
||||
# Should have parsed the date correctly
|
||||
assert date(2024, 1, 15) in revisions_by_date
|
||||
|
||||
|
||||
def test_db_gather_stats_current_version_only(fresh_db):
|
||||
"""Test that word counts use current version only, not all revisions."""
|
||||
# Add multiple revisions
|
||||
fresh_db.save_new_version("2024-01-01", "one two three", "v1")
|
||||
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
|
||||
|
||||
# Should count words from current version (5 words), not old version
|
||||
assert total_words == 5
|
||||
assert words_by_date[date(2024, 1, 1)] == 5
|
||||
|
||||
|
||||
def test_db_gather_stats_no_tags(fresh_db):
|
||||
"""Test gather_stats when there are no tags."""
|
||||
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
|
||||
|
||||
assert unique_tags == 0
|
||||
assert page_most_tags is None
|
||||
assert page_most_tags_count == 0
|
||||
|
||||
|
||||
def test_db_gather_stats_exception_in_dates_with_content(fresh_db, monkeypatch):
|
||||
"""Test that gather_stats handles exception in dates_with_content."""
|
||||
|
||||
def bad_dates():
|
||||
raise RuntimeError("Simulated error")
|
||||
|
||||
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates)
|
||||
|
||||
# Should still return stats without crashing
|
||||
stats = fresh_db.gather_stats()
|
||||
pages_with_content = stats[0]
|
||||
|
||||
# Should default to 0 when exception occurs
|
||||
assert pages_with_content == 0
|
||||
|
||||
|
||||
def test_delete_version(fresh_db):
|
||||
"""Test deleting a specific version by version_id."""
|
||||
d = date.today().isoformat()
|
||||
|
||||
# Create multiple versions
|
||||
vid1, _ = fresh_db.save_new_version(d, "version 1", "note1")
|
||||
vid2, _ = fresh_db.save_new_version(d, "version 2", "note2")
|
||||
vid3, _ = fresh_db.save_new_version(d, "version 3", "note3")
|
||||
|
||||
# Verify all versions exist
|
||||
versions = fresh_db.list_versions(d)
|
||||
assert len(versions) == 3
|
||||
|
||||
# Delete the second version
|
||||
fresh_db.delete_version(version_id=vid2)
|
||||
|
||||
# Verify it's deleted
|
||||
versions_after = fresh_db.list_versions(d)
|
||||
assert len(versions_after) == 2
|
||||
|
||||
# Make sure the deleted version is not in the list
|
||||
version_ids = [v["id"] for v in versions_after]
|
||||
assert vid2 not in version_ids
|
||||
assert vid1 in version_ids
|
||||
assert vid3 in version_ids
|
||||
|
||||
|
||||
def test_update_reminder_active(fresh_db):
|
||||
"""Test updating the active status of a reminder."""
|
||||
from bouquin.reminders import Reminder, ReminderType
|
||||
|
||||
# Create a reminder object
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Test reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str="14:30",
|
||||
date_iso=date.today().isoformat(),
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Save it
|
||||
reminder_id = fresh_db.save_reminder(reminder)
|
||||
|
||||
# Verify it's active
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
active_reminder = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert active_reminder.active is True
|
||||
|
||||
# Deactivate it
|
||||
fresh_db.update_reminder_active(reminder_id, False)
|
||||
|
||||
# Verify it's inactive
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
inactive_reminder = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert inactive_reminder.active is False
|
||||
|
||||
# Reactivate it
|
||||
fresh_db.update_reminder_active(reminder_id, True)
|
||||
|
||||
# Verify it's active again
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
reactivated_reminder = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert reactivated_reminder.active is True
|
||||
fresh_db.export_by_extension(str(p))
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import pytest
|
||||
|
||||
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
|
||||
|
|
@ -16,6 +14,10 @@ def editor(app, qtbot):
|
|||
return ed
|
||||
|
||||
|
||||
from bouquin.find_bar import FindBar
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_findbar_basic_navigation(qtbot, editor):
|
||||
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
|
||||
editor.moveCursor(QTextCursor.Start)
|
||||
|
|
@ -40,6 +42,7 @@ def test_findbar_basic_navigation(qtbot, editor):
|
|||
|
||||
|
||||
def test_show_bar_seeds_selection(qtbot, editor):
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
editor.from_markdown("alpha beta")
|
||||
c = editor.textCursor()
|
||||
|
|
@ -52,120 +55,3 @@ def test_show_bar_seeds_selection(qtbot, editor):
|
|||
fb.show_bar()
|
||||
assert fb.edit.text().lower() == "alpha"
|
||||
fb.hide_bar()
|
||||
|
||||
|
||||
def test_show_bar_no_editor(qtbot, app):
|
||||
fb = FindBar(lambda: None)
|
||||
qtbot.addWidget(fb)
|
||||
fb.show_bar() # should early return without crashing and not become visible
|
||||
assert not fb.isVisible()
|
||||
|
||||
|
||||
def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor):
|
||||
editor.from_markdown("alpha\n\nbeta")
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
# Select across the paragraph separator U+2029 equivalent – select more than one block
|
||||
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(c)
|
||||
fb = FindBar(lambda: editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.show_bar()
|
||||
assert fb.edit.text() == "" # should not seed with multi-paragraph
|
||||
fb.hide_bar()
|
||||
|
||||
|
||||
def test_find_wraps_and_bumps_caret(qtbot, editor):
|
||||
editor.from_markdown("alpha alpha alpha")
|
||||
fb = FindBar(lambda: editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.edit.setText("alpha")
|
||||
|
||||
# Select the first occurrence so caret bumping path triggers
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(c)
|
||||
|
||||
fb.find_next() # should bump to after current selection then find next
|
||||
sel = editor.textCursor().selectedText()
|
||||
assert sel.lower() == "alpha"
|
||||
|
||||
# Force wrap to start by moving cursor to end then searching next
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(c)
|
||||
fb.find_next() # triggers wrap-to-start path
|
||||
assert editor.textCursor().hasSelection()
|
||||
|
||||
|
||||
def test_update_highlight_clear_when_empty(qtbot, editor):
|
||||
editor.from_markdown("find me find me")
|
||||
fb = FindBar(lambda: editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.edit.setText("find")
|
||||
fb._update_highlight()
|
||||
assert editor.extraSelections() # some highlights present
|
||||
|
||||
fb.edit.setText("")
|
||||
fb._update_highlight() # should clear
|
||||
assert not editor.extraSelections()
|
||||
|
||||
|
||||
def test_maybe_hide_and_wrap_prev(qtbot, editor):
|
||||
editor.setPlainText("a a a")
|
||||
fb = FindBar(editor=editor, shortcut_parent=editor)
|
||||
qtbot.addWidget(editor)
|
||||
qtbot.addWidget(fb)
|
||||
editor.show()
|
||||
fb.show()
|
||||
|
||||
fb.edit.setText("a")
|
||||
fb._update_highlight()
|
||||
|
||||
assert fb.isVisible()
|
||||
fb._maybe_hide()
|
||||
assert not fb.isVisible()
|
||||
|
||||
fb.show_bar()
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(c)
|
||||
fb.find_prev()
|
||||
|
||||
|
||||
def _make_fb(editor, qtbot):
|
||||
"""Create a FindBar with a live parent kept until teardown."""
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
fb = FindBar(editor=editor, parent=parent)
|
||||
qtbot.addWidget(fb)
|
||||
parent.show()
|
||||
fb.show()
|
||||
return fb, parent
|
||||
|
||||
|
||||
def test_find_next_early_returns_no_editor(qtbot):
|
||||
# No editor: should early return and not crash
|
||||
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
|
||||
fb.find_next()
|
||||
|
||||
|
||||
def test_find_next_early_returns_empty_text(qtbot):
|
||||
ed = QTextEdit()
|
||||
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
|
||||
fb.edit.setText("") # empty -> early return
|
||||
fb.find_next()
|
||||
|
||||
|
||||
def test_find_prev_early_returns_empty_text(qtbot):
|
||||
ed = QTextEdit()
|
||||
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
|
||||
fb.edit.setText("") # empty -> early return
|
||||
fb.find_prev()
|
||||
|
||||
|
||||
def test_update_highlight_early_returns_no_editor(qtbot):
|
||||
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
|
||||
fb.edit.setText("abc")
|
||||
fb._update_highlight() # should return without error
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
|
@ -17,295 +17,3 @@ def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
|||
dlg.list.setCurrentRow(1)
|
||||
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
|
||||
assert fresh_db.get_entry(d) == "v1"
|
||||
|
||||
|
||||
def test_history_dialog_no_selection_clears(qtbot, fresh_db):
|
||||
d = "2001-01-01"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Clear selection (no current item) and call slot
|
||||
dlg.list.setCurrentItem(None)
|
||||
dlg._on_select()
|
||||
assert dlg.preview.toPlainText() == ""
|
||||
assert dlg.diff.toPlainText() == ""
|
||||
assert not dlg.btn_revert.isEnabled()
|
||||
|
||||
|
||||
def test_history_dialog_revert_same_version_noop(qtbot, fresh_db):
|
||||
d = "2001-01-01"
|
||||
# Only one version; that's the current
|
||||
vid, _ = fresh_db.save_new_version(d, "seed", "note")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Pick the only item (current)
|
||||
dlg.list.setCurrentRow(0)
|
||||
# Clicking revert should simply return (no change)
|
||||
before = fresh_db.get_entry(d)
|
||||
dlg._revert()
|
||||
after = fresh_db.get_entry(d)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
|
||||
d = "2001-01-02"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Select the row
|
||||
dlg.list.setCurrentRow(0)
|
||||
|
||||
# Monkeypatch db to raise inside revert_to_version to hit except path
|
||||
def boom(date_iso, version_id):
|
||||
raise RuntimeError("nope")
|
||||
|
||||
dlg._db.revert_to_version = boom
|
||||
|
||||
# Auto-accept any QMessageBox that appears
|
||||
def _pump():
|
||||
for m in QMessageBox.instances():
|
||||
m.accept()
|
||||
|
||||
t = QTimer()
|
||||
t.setInterval(10)
|
||||
t.timeout.connect(_pump)
|
||||
t.start()
|
||||
try:
|
||||
dlg._revert()
|
||||
finally:
|
||||
t.stop()
|
||||
|
||||
|
||||
def test_revert_returns_when_no_item_selected(qtbot, fresh_db):
|
||||
d = "2000-01-01"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
# No selection at all -> early return
|
||||
dlg.list.clearSelection()
|
||||
dlg._revert() # should not raise
|
||||
|
||||
|
||||
def test_revert_returns_when_current_selected(qtbot, fresh_db):
|
||||
d = "2000-01-02"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
# Create a second version so there is a 'current'
|
||||
fresh_db.save_new_version(d, "v2", "second")
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
# Select the current item -> early return
|
||||
for i in range(dlg.list.count()):
|
||||
item = dlg.list.item(i)
|
||||
if item.data(Qt.UserRole) == dlg._current_id:
|
||||
dlg.list.setCurrentItem(item)
|
||||
break
|
||||
dlg._revert() # no-op
|
||||
|
||||
|
||||
def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
|
||||
"""
|
||||
Trigger the exception path in _revert() and auto-accept the modal
|
||||
QMessageBox that HistoryDialog pops so the test doesn't hang.
|
||||
"""
|
||||
d = "2000-01-03"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
fresh_db.save_new_version(d, "v2", "second")
|
||||
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Select a non-current item
|
||||
for i in range(dlg.list.count()):
|
||||
item = dlg.list.item(i)
|
||||
if item.data(Qt.UserRole) != dlg._current_id:
|
||||
dlg.list.setCurrentItem(item)
|
||||
break
|
||||
|
||||
# Make revert raise to hit the except/critical message path.
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError("nope")
|
||||
|
||||
monkeypatch.setattr(dlg._db, "revert_to_version", boom)
|
||||
|
||||
# Prepare a small helper that keeps trying to close an active modal box,
|
||||
# but gives up after a bounded number of attempts.
|
||||
def make_closer(max_tries=50, interval_ms=10):
|
||||
tries = {"n": 0}
|
||||
|
||||
def closer():
|
||||
tries["n"] += 1
|
||||
w = QApplication.activeModalWidget()
|
||||
if isinstance(w, QMessageBox):
|
||||
# Prefer clicking the OK button if present; otherwise accept().
|
||||
ok = w.button(QMessageBox.Ok)
|
||||
if ok is not None:
|
||||
ok.click()
|
||||
else:
|
||||
w.accept()
|
||||
elif tries["n"] < max_tries:
|
||||
QTimer.singleShot(interval_ms, closer)
|
||||
|
||||
return closer
|
||||
|
||||
# Schedule auto-close right before we trigger the modal dialog.
|
||||
QTimer.singleShot(0, make_closer())
|
||||
|
||||
# Should show the critical box, which our timer will accept; _revert returns.
|
||||
dlg._revert()
|
||||
|
||||
|
||||
def test_delete_version_from_history(qtbot, fresh_db):
|
||||
"""Test deleting a version through the history dialog."""
|
||||
d = "2001-01-01"
|
||||
|
||||
# Create multiple versions
|
||||
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
|
||||
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
|
||||
vid3, _ = fresh_db.save_new_version(d, "v3", "third")
|
||||
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Verify we have 3 versions
|
||||
assert dlg.list.count() == 3
|
||||
|
||||
# Select the first version (oldest, not current)
|
||||
dlg.list.setCurrentRow(2) # Last row is oldest version
|
||||
|
||||
# Call _delete
|
||||
dlg._delete()
|
||||
|
||||
# Verify the version was deleted
|
||||
assert dlg.list.count() == 2
|
||||
|
||||
# Verify from DB
|
||||
versions = fresh_db.list_versions(d)
|
||||
assert len(versions) == 2
|
||||
|
||||
|
||||
def test_delete_current_version_returns_early(qtbot, fresh_db):
|
||||
"""Test that deleting the current version returns early without deleting."""
|
||||
d = "2001-01-02"
|
||||
|
||||
# Create versions
|
||||
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
|
||||
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
|
||||
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Find and select the current version
|
||||
for i in range(dlg.list.count()):
|
||||
item = dlg.list.item(i)
|
||||
if item.data(Qt.UserRole) == dlg._current_id:
|
||||
dlg.list.setCurrentItem(item)
|
||||
break
|
||||
|
||||
# Try to delete - should return early
|
||||
dlg._delete()
|
||||
|
||||
# Verify nothing was deleted
|
||||
versions = fresh_db.list_versions(d)
|
||||
assert len(versions) == 2
|
||||
|
||||
|
||||
def test_delete_version_with_error(qtbot, fresh_db, monkeypatch):
|
||||
"""Test that delete version error shows a message box."""
|
||||
d = "2001-01-03"
|
||||
|
||||
# Create versions
|
||||
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
|
||||
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
|
||||
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Select a non-current version
|
||||
for i in range(dlg.list.count()):
|
||||
item = dlg.list.item(i)
|
||||
if item.data(Qt.UserRole) != dlg._current_id:
|
||||
dlg.list.setCurrentItem(item)
|
||||
break
|
||||
|
||||
# Make delete_version raise an error
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError("Delete failed")
|
||||
|
||||
monkeypatch.setattr(dlg._db, "delete_version", boom)
|
||||
|
||||
# Set up auto-closer for message box
|
||||
def make_closer(max_tries=50, interval_ms=10):
|
||||
tries = {"n": 0}
|
||||
|
||||
def closer():
|
||||
tries["n"] += 1
|
||||
w = QApplication.activeModalWidget()
|
||||
if isinstance(w, QMessageBox):
|
||||
ok = w.button(QMessageBox.Ok)
|
||||
if ok is not None:
|
||||
ok.click()
|
||||
else:
|
||||
w.accept()
|
||||
elif tries["n"] < max_tries:
|
||||
QTimer.singleShot(interval_ms, closer)
|
||||
|
||||
return closer
|
||||
|
||||
QTimer.singleShot(0, make_closer())
|
||||
|
||||
# Call delete - should show error message
|
||||
dlg._delete()
|
||||
|
||||
|
||||
def test_delete_multiple_versions(qtbot, fresh_db):
|
||||
"""Test deleting multiple versions at once."""
|
||||
d = "2001-01-04"
|
||||
|
||||
# Create multiple versions
|
||||
vid1, _ = fresh_db.save_new_version(d, "v1", "first")
|
||||
vid2, _ = fresh_db.save_new_version(d, "v2", "second")
|
||||
vid3, _ = fresh_db.save_new_version(d, "v3", "third")
|
||||
vid4, _ = fresh_db.save_new_version(d, "v4", "fourth")
|
||||
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Select multiple non-current items
|
||||
selected_count = 0
|
||||
for i in range(dlg.list.count()):
|
||||
item = dlg.list.item(i)
|
||||
if item.data(Qt.UserRole) != dlg._current_id:
|
||||
item.setSelected(True)
|
||||
selected_count += 1
|
||||
if selected_count >= 2: # Select 2 items
|
||||
break
|
||||
|
||||
# Delete them
|
||||
dlg._delete()
|
||||
|
||||
# Verify versions were deleted (should have current + 1 remaining)
|
||||
versions = fresh_db.list_versions(d)
|
||||
assert len(versions) == 2 # Current + 1 that wasn't deleted
|
||||
|
|
|
|||
|
|
@ -1,205 +1,9 @@
|
|||
from bouquin.key_prompt import KeyPrompt
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QFileDialog, QLineEdit
|
||||
|
||||
|
||||
def test_key_prompt_roundtrip(qtbot):
|
||||
kp = KeyPrompt()
|
||||
qtbot.addWidget(kp)
|
||||
kp.show()
|
||||
kp.key_entry.setText("swordfish")
|
||||
kp.edit.setText("swordfish")
|
||||
assert kp.key() == "swordfish"
|
||||
|
||||
|
||||
def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test KeyPrompt with DB path selection - covers lines 57-67"""
|
||||
test_db = tmp_path / "test.db"
|
||||
test_db.touch()
|
||||
|
||||
# Create prompt with show_db_change=True
|
||||
prompt = KeyPrompt(show_db_change=True)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Mock the file dialog to return a file
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
return str(test_db), "SQLCipher DB (*.db)"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
||||
# Simulate clicking the browse button
|
||||
# Find the browse button by looking through the widget's children
|
||||
browse_btn = None
|
||||
for child in prompt.findChildren(object):
|
||||
if hasattr(child, "clicked") and hasattr(child, "text"):
|
||||
if (
|
||||
"select" in str(child.text()).lower()
|
||||
or "browse" in str(child.text()).lower()
|
||||
):
|
||||
browse_btn = child
|
||||
break
|
||||
|
||||
if browse_btn:
|
||||
browse_btn.click()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Verify the path was set
|
||||
assert prompt.path_edit is not None
|
||||
assert str(test_db) in prompt.path_edit.text()
|
||||
|
||||
|
||||
def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition"""
|
||||
# Create prompt with show_db_change=True
|
||||
prompt = KeyPrompt(show_db_change=True)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Mock the file dialog to return empty string (user cancelled)
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
return "", ""
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
||||
# Store original path text
|
||||
original_text = prompt.path_edit.text() if prompt.path_edit else ""
|
||||
|
||||
# Simulate clicking the browse button
|
||||
browse_btn = None
|
||||
for child in prompt.findChildren(object):
|
||||
if hasattr(child, "clicked") and hasattr(child, "text"):
|
||||
if (
|
||||
"select" in str(child.text()).lower()
|
||||
or "browse" in str(child.text()).lower()
|
||||
):
|
||||
browse_btn = child
|
||||
break
|
||||
|
||||
if browse_btn:
|
||||
browse_btn.click()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Path should not have changed since no file was selected
|
||||
if prompt.path_edit:
|
||||
assert prompt.path_edit.text() == original_text
|
||||
|
||||
|
||||
def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
|
||||
"""Test KeyPrompt with existing DB path provided"""
|
||||
test_db = tmp_path / "existing.db"
|
||||
test_db.touch()
|
||||
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Verify the path is pre-filled
|
||||
assert prompt.path_edit is not None
|
||||
assert str(test_db) in prompt.path_edit.text()
|
||||
|
||||
|
||||
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
|
||||
"""Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Path edit should exist but be empty
|
||||
assert prompt.path_edit is not None
|
||||
assert prompt.path_edit.text() == ""
|
||||
|
||||
|
||||
def test_key_prompt_accept_with_valid_key(qtbot, app):
|
||||
"""Test accepting prompt with valid key"""
|
||||
prompt = KeyPrompt()
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Enter a key
|
||||
prompt.key_entry.setText("test-key-123")
|
||||
|
||||
# Accept
|
||||
QTimer.singleShot(0, prompt.accept)
|
||||
qtbot.wait(50)
|
||||
|
||||
assert prompt.key_entry.text() == "test-key-123"
|
||||
|
||||
|
||||
def test_key_prompt_without_db_change(qtbot, app):
|
||||
"""Test KeyPrompt without show_db_change"""
|
||||
prompt = KeyPrompt(show_db_change=False)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Path edit should not exist
|
||||
assert prompt.path_edit is None
|
||||
|
||||
|
||||
def test_key_prompt_password_visibility(qtbot, app):
|
||||
"""Test password entry mode"""
|
||||
prompt = KeyPrompt()
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Initially should be password mode
|
||||
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
|
||||
|
||||
# Enter some text
|
||||
prompt.key_entry.setText("secret")
|
||||
|
||||
# The text should be obscured
|
||||
assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
|
||||
|
||||
|
||||
def test_key_prompt_key_method(qtbot, app):
|
||||
"""Test the key() method returns entered text"""
|
||||
prompt = KeyPrompt()
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
prompt.key_entry.setText("my-secret-key")
|
||||
|
||||
assert prompt.key() == "my-secret-key"
|
||||
|
||||
|
||||
def test_key_prompt_db_path_method(qtbot, app, tmp_path):
|
||||
"""Test the db_path() method returns selected path"""
|
||||
test_db = tmp_path / "test.db"
|
||||
test_db.touch()
|
||||
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Should return the db_path
|
||||
assert prompt.db_path() == test_db
|
||||
|
||||
|
||||
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test browsing when initial_db_path is set - covers line 57 with non-None path"""
|
||||
initial_db = tmp_path / "initial.db"
|
||||
initial_db.touch()
|
||||
|
||||
new_db = tmp_path / "new.db"
|
||||
new_db.touch()
|
||||
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
# Mock the file dialog to return a different file
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
# Verify that start_dir was passed correctly (line 57)
|
||||
return str(new_db), "SQLCipher DB (*.db)"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
||||
# Find and click browse button
|
||||
browse_btn = None
|
||||
for child in prompt.findChildren(object):
|
||||
if hasattr(child, "clicked") and hasattr(child, "text"):
|
||||
if (
|
||||
"select" in str(child.text()).lower()
|
||||
or "browse" in str(child.text()).lower()
|
||||
):
|
||||
browse_btn = child
|
||||
break
|
||||
|
||||
if browse_btn:
|
||||
browse_btn.click()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Verify new path was set
|
||||
assert str(new_db) in prompt.path_edit.text()
|
||||
assert prompt.db_path() == new_db
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import pytest
|
||||
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):
|
||||
@pytest.mark.gui
|
||||
def test_lock_overlay_reacts_to_theme(qtbot):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
host.show()
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ol = LockOverlay(host, on_unlock=lambda: None, themes=themes)
|
||||
|
||||
ol = LockOverlay(host, on_unlock=lambda: None)
|
||||
qtbot.addWidget(ol)
|
||||
ol.show()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import importlib
|
||||
import runpy
|
||||
import pytest
|
||||
|
||||
|
||||
def test_main_module_has_main():
|
||||
|
|
@ -11,87 +9,3 @@ def test_main_module_has_main():
|
|||
def test_dunder_main_imports_main():
|
||||
m = importlib.import_module("bouquin.__main__")
|
||||
assert hasattr(m, "main")
|
||||
|
||||
|
||||
def test_dunder_main_calls_main(monkeypatch):
|
||||
called = {"ok": False}
|
||||
|
||||
def fake_main():
|
||||
called["ok"] = True
|
||||
|
||||
# Replace real main with a stub to avoid launching Qt event loop
|
||||
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
|
||||
# Ensure that when __main__ imports from .main it gets our stub
|
||||
import bouquin.main as real_main
|
||||
|
||||
monkeypatch.setattr(real_main, "main", fake_main, raising=True)
|
||||
# Execute the module as a script
|
||||
runpy.run_module("bouquin.__main__", run_name="__main__")
|
||||
assert called["ok"]
|
||||
|
||||
|
||||
def test_main_creates_and_shows(monkeypatch):
|
||||
# Create a fake QApplication with the minimal API
|
||||
class FakeApp:
|
||||
def __init__(self, argv):
|
||||
self.ok = True
|
||||
|
||||
def setApplicationName(self, *_):
|
||||
pass
|
||||
|
||||
def setOrganizationName(self, *_):
|
||||
pass
|
||||
|
||||
def setWindowIcon(self, *_):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return 0
|
||||
|
||||
class FakeWin:
|
||||
def __init__(self, themes=None):
|
||||
self.shown = False
|
||||
|
||||
def show(self):
|
||||
self.shown = True
|
||||
|
||||
class FakeSettings:
|
||||
def value(self, k, default=None):
|
||||
return "light" if k == "ui/theme" else default
|
||||
|
||||
# Patch imports inside bouquin.main
|
||||
import bouquin.main as m
|
||||
|
||||
monkeypatch.setattr(m, "QApplication", FakeApp, raising=True)
|
||||
monkeypatch.setattr(m, "MainWindow", FakeWin, raising=True)
|
||||
|
||||
# Theme classes
|
||||
class FakeTM:
|
||||
def __init__(self, app, cfg):
|
||||
pass
|
||||
|
||||
def apply(self, theme):
|
||||
pass
|
||||
|
||||
class FakeTheme:
|
||||
def __init__(self, s):
|
||||
pass
|
||||
|
||||
class FakeCfg:
|
||||
def __init__(self, theme):
|
||||
self.theme = theme
|
||||
|
||||
monkeypatch.setattr(m, "ThemeManager", FakeTM, raising=True)
|
||||
monkeypatch.setattr(m, "Theme", FakeTheme, raising=True)
|
||||
monkeypatch.setattr(m, "ThemeConfig", FakeCfg, raising=True)
|
||||
|
||||
# get_settings() used inside main()
|
||||
def fake_get_settings():
|
||||
return FakeSettings()
|
||||
|
||||
monkeypatch.setattr(m, "get_settings", fake_get_settings, raising=True)
|
||||
|
||||
# Run
|
||||
with pytest.raises(SystemExit) as e:
|
||||
m.main()
|
||||
assert e.value.code == 0
|
||||
|
|
|
|||
|
|
@ -1,354 +0,0 @@
|
|||
from unittest.mock import Mock, patch
|
||||
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
|
||||
|
||||
|
||||
def test_pomodoro_timer_init(qtbot, app, fresh_db):
|
||||
"""Test PomodoroTimer initialization."""
|
||||
task_text = "Write unit tests"
|
||||
timer = PomodoroTimer(task_text)
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
assert timer._task_text == task_text
|
||||
assert timer._elapsed_seconds == 0
|
||||
assert timer._running is False
|
||||
assert timer.time_label.text() == "00:00:00"
|
||||
assert timer.stop_btn.isEnabled() is False
|
||||
|
||||
|
||||
def test_pomodoro_timer_start(qtbot, app):
|
||||
"""Test starting the timer."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
timer._toggle_timer()
|
||||
|
||||
assert timer._running is True
|
||||
assert timer.stop_btn.isEnabled() is True
|
||||
|
||||
|
||||
def test_pomodoro_timer_pause(qtbot, app):
|
||||
"""Test pausing the timer."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Start the timer
|
||||
timer._toggle_timer()
|
||||
assert timer._running is True
|
||||
|
||||
# Pause the timer
|
||||
timer._toggle_timer()
|
||||
assert timer._running is False
|
||||
|
||||
|
||||
def test_pomodoro_timer_resume(qtbot, app):
|
||||
"""Test resuming the timer after pause."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Start, pause, then resume
|
||||
timer._toggle_timer() # Start
|
||||
timer._toggle_timer() # Pause
|
||||
timer._toggle_timer() # Resume
|
||||
|
||||
assert timer._running is True
|
||||
|
||||
|
||||
def test_pomodoro_timer_tick(qtbot, app):
|
||||
"""Test timer tick increments elapsed time."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
initial_time = timer._elapsed_seconds
|
||||
timer._tick()
|
||||
|
||||
assert timer._elapsed_seconds == initial_time + 1
|
||||
|
||||
|
||||
def test_pomodoro_timer_display_update(qtbot, app):
|
||||
"""Test display updates with various elapsed times."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Test 0 seconds
|
||||
timer._elapsed_seconds = 0
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "00:00:00"
|
||||
|
||||
# Test 65 seconds (1 min 5 sec)
|
||||
timer._elapsed_seconds = 65
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "00:01:05"
|
||||
|
||||
# Test 3665 seconds (1 hour 1 min 5 sec)
|
||||
timer._elapsed_seconds = 3665
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "01:01:05"
|
||||
|
||||
# Test 3600 seconds (1 hour exactly)
|
||||
timer._elapsed_seconds = 3600
|
||||
timer._update_display()
|
||||
assert timer.time_label.text() == "01:00:00"
|
||||
|
||||
|
||||
def test_pomodoro_timer_stop_and_log_running(qtbot, app):
|
||||
"""Test stopping the timer while it's running."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Start the timer
|
||||
timer._toggle_timer()
|
||||
timer._elapsed_seconds = 100
|
||||
|
||||
# Connect a mock to the signal
|
||||
signal_received = []
|
||||
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
|
||||
|
||||
timer._stop_and_log()
|
||||
|
||||
assert timer._running is False
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == 100 # elapsed seconds
|
||||
assert signal_received[0][1] == "Test task"
|
||||
|
||||
|
||||
def test_pomodoro_timer_stop_and_log_paused(qtbot, app):
|
||||
"""Test stopping the timer when it's paused."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
timer._elapsed_seconds = 50
|
||||
|
||||
signal_received = []
|
||||
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
|
||||
|
||||
timer._stop_and_log()
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == 50
|
||||
|
||||
|
||||
def test_pomodoro_timer_multiple_ticks(qtbot, app):
|
||||
"""Test multiple timer ticks."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
for i in range(10):
|
||||
timer._tick()
|
||||
|
||||
assert timer._elapsed_seconds == 10
|
||||
assert "00:00:10" in timer.time_label.text()
|
||||
|
||||
|
||||
def test_pomodoro_timer_modal_state(qtbot, app):
|
||||
"""Test that timer is non-modal."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
assert timer.isModal() is False
|
||||
|
||||
|
||||
def test_pomodoro_timer_window_title(qtbot, app):
|
||||
"""Test timer window title."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Window title should contain some reference to timer/pomodoro
|
||||
assert len(timer.windowTitle()) > 0
|
||||
|
||||
|
||||
def test_pomodoro_manager_init(app, fresh_db):
|
||||
"""Test PomodoroManager initialization."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
assert manager._db is fresh_db
|
||||
assert manager._parent is parent
|
||||
assert manager._active_timer is None
|
||||
|
||||
|
||||
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
|
||||
"""Test starting a timer through the manager."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
line_text = "Important task"
|
||||
date_iso = "2024-01-15"
|
||||
|
||||
manager.start_timer_for_line(line_text, date_iso)
|
||||
|
||||
assert manager._active_timer is not None
|
||||
assert manager._active_timer._task_text == line_text
|
||||
qtbot.addWidget(manager._active_timer)
|
||||
|
||||
|
||||
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
|
||||
"""Test that starting a new timer closes the previous one."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Start first timer
|
||||
manager.start_timer_for_line("Task 1", "2024-01-15")
|
||||
first_timer = manager._active_timer
|
||||
qtbot.addWidget(first_timer)
|
||||
first_timer.show()
|
||||
|
||||
# Start second timer
|
||||
manager.start_timer_for_line("Task 2", "2024-01-16")
|
||||
second_timer = manager._active_timer
|
||||
qtbot.addWidget(second_timer)
|
||||
|
||||
assert first_timer is not second_timer
|
||||
assert second_timer._task_text == "Task 2"
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that timer stopped with very short time logs minimum hours."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Mock TimeLogDialog to avoid actually showing it
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
manager._on_timer_stopped(10, "Quick task", "2024-01-15")
|
||||
|
||||
# Should set minimum of 0.25 hours
|
||||
mock_dialog.hours_spin.setValue.assert_called_once()
|
||||
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
|
||||
assert hours_set >= 0.25
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
|
||||
"""Test that elapsed time is properly rounded to decimal hours."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
# Test with 1800 seconds (30 minutes)
|
||||
manager._on_timer_stopped(1800, "Task", "2024-01-15")
|
||||
|
||||
mock_dialog.hours_spin.setValue.assert_called_once()
|
||||
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
|
||||
# Should round up and be a multiple of 0.25
|
||||
assert hours_set > 0
|
||||
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_prefills_note(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that timer stopped pre-fills the note in time log dialog."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
task_text = "Write documentation"
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
manager._on_timer_stopped(3600, task_text, "2024-01-15")
|
||||
|
||||
mock_dialog.note.setText.assert_called_once_with(task_text)
|
||||
|
||||
|
||||
def test_pomodoro_manager_timer_stopped_signal_connection(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that timer stopped signal is properly connected."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Mock TimeLogDialog
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
manager.start_timer_for_line("Task", "2024-01-15")
|
||||
timer = manager._active_timer
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Simulate timer stopped
|
||||
timer._elapsed_seconds = 1000
|
||||
timer._stop_and_log()
|
||||
|
||||
# TimeLogDialog should have been created
|
||||
assert mock_dialog.exec.called
|
||||
|
||||
|
||||
def test_pomodoro_timer_accepts_parent(qtbot, app):
|
||||
"""Test that timer accepts a parent widget."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
timer = PomodoroTimer("Task", parent)
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
assert timer.parent() is parent
|
||||
|
||||
|
||||
def test_pomodoro_manager_no_active_timer_initially(app, fresh_db):
|
||||
"""Test that manager starts with no active timer."""
|
||||
parent = Mock()
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
assert manager._active_timer is None
|
||||
|
||||
|
||||
def test_pomodoro_timer_start_stop_cycle(qtbot, app):
|
||||
"""Test a complete start-stop cycle."""
|
||||
timer = PomodoroTimer("Complete cycle")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
signal_received = []
|
||||
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
|
||||
|
||||
# Start
|
||||
timer._toggle_timer()
|
||||
assert timer._running is True
|
||||
|
||||
# Simulate some ticks
|
||||
for _ in range(5):
|
||||
timer._tick()
|
||||
|
||||
# Stop
|
||||
timer._stop_and_log()
|
||||
assert timer._running is False
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == 5
|
||||
|
||||
|
||||
def test_pomodoro_timer_long_elapsed_time(qtbot, app):
|
||||
"""Test display with very long elapsed time."""
|
||||
timer = PomodoroTimer("Long task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Set to 2 hours, 34 minutes, 56 seconds
|
||||
timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56
|
||||
timer._update_display()
|
||||
|
||||
assert timer.time_label.text() == "02:34:56"
|
||||
|
|
@ -1,801 +0,0 @@
|
|||
from unittest.mock import patch
|
||||
from bouquin.reminders import (
|
||||
Reminder,
|
||||
ReminderType,
|
||||
ReminderDialog,
|
||||
UpcomingRemindersWidget,
|
||||
ManageRemindersDialog,
|
||||
)
|
||||
from PySide6.QtCore import QDate, QTime
|
||||
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def test_reminder_type_enum(app):
|
||||
"""Test ReminderType enum values."""
|
||||
assert ReminderType.ONCE is not None
|
||||
assert ReminderType.DAILY is not None
|
||||
assert ReminderType.WEEKDAYS is not None
|
||||
assert ReminderType.WEEKLY is not None
|
||||
|
||||
|
||||
def test_reminder_dataclass_creation(app):
|
||||
"""Test creating a Reminder instance."""
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test reminder",
|
||||
time_str="10:30",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
weekday=None,
|
||||
active=True,
|
||||
date_iso=None,
|
||||
)
|
||||
|
||||
assert reminder.id == 1
|
||||
assert reminder.text == "Test reminder"
|
||||
assert reminder.time_str == "10:30"
|
||||
assert reminder.reminder_type == ReminderType.DAILY
|
||||
assert reminder.active is True
|
||||
|
||||
|
||||
def test_reminder_dialog_init_new(qtbot, app, fresh_db):
|
||||
"""Test ReminderDialog initialization for new reminder."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog._db is fresh_db
|
||||
assert dialog._reminder is None
|
||||
assert dialog.text_edit.text() == ""
|
||||
|
||||
|
||||
def test_reminder_dialog_init_existing(qtbot, app, fresh_db):
|
||||
"""Test ReminderDialog initialization with existing reminder."""
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Existing reminder",
|
||||
time_str="14:30",
|
||||
reminder_type=ReminderType.WEEKLY,
|
||||
weekday=2,
|
||||
active=True,
|
||||
)
|
||||
|
||||
dialog = ReminderDialog(fresh_db, reminder=reminder)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.text_edit.text() == "Existing reminder"
|
||||
assert dialog.time_edit.time().hour() == 14
|
||||
assert dialog.time_edit.time().minute() == 30
|
||||
|
||||
|
||||
def test_reminder_dialog_type_changed(qtbot, app, fresh_db):
|
||||
"""Test that weekday combo visibility changes with type."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show() # Show the dialog so child widgets can be visible
|
||||
|
||||
# Find weekly type in combo
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
qtbot.wait(10) # Wait for Qt event processing
|
||||
assert dialog.weekday_combo.isVisible() is True
|
||||
|
||||
# Switch to daily
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.DAILY:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
qtbot.wait(10) # Wait for Qt event processing
|
||||
assert dialog.weekday_combo.isVisible() is False
|
||||
|
||||
|
||||
def test_reminder_dialog_get_reminder_once(qtbot, app, fresh_db):
|
||||
"""Test getting reminder with ONCE type."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.text_edit.setText("Test task")
|
||||
dialog.time_edit.setTime(QTime(10, 30))
|
||||
|
||||
# Set to ONCE type
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.ONCE:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
reminder = dialog.get_reminder()
|
||||
|
||||
assert reminder.text == "Test task"
|
||||
assert reminder.time_str == "10:30"
|
||||
assert reminder.reminder_type == ReminderType.ONCE
|
||||
assert reminder.date_iso is not None
|
||||
|
||||
|
||||
def test_reminder_dialog_get_reminder_weekly(qtbot, app, fresh_db):
|
||||
"""Test getting reminder with WEEKLY type."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.text_edit.setText("Weekly meeting")
|
||||
dialog.time_edit.setTime(QTime(15, 0))
|
||||
|
||||
# Set to WEEKLY type
|
||||
for i in range(dialog.type_combo.count()):
|
||||
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
|
||||
dialog.type_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
dialog.weekday_combo.setCurrentIndex(1) # Tuesday
|
||||
|
||||
reminder = dialog.get_reminder()
|
||||
|
||||
assert reminder.text == "Weekly meeting"
|
||||
assert reminder.reminder_type == ReminderType.WEEKLY
|
||||
assert reminder.weekday == 1
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_init(qtbot, app, fresh_db):
|
||||
"""Test UpcomingRemindersWidget initialization."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._db is fresh_db
|
||||
assert widget.body.isVisible() is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_toggle(qtbot, app, fresh_db):
|
||||
"""Test toggling reminder list visibility."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show() # Show the widget so child widgets can be visible
|
||||
|
||||
# Initially hidden
|
||||
assert widget.body.isVisible() is False
|
||||
|
||||
# Click toggle
|
||||
widget.toggle_btn.click()
|
||||
qtbot.wait(10) # Wait for Qt event processing
|
||||
|
||||
assert widget.body.isVisible() is True
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_once(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for ONCE type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
date_iso="2024-01-15",
|
||||
)
|
||||
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_daily(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for DAILY type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
# Should fire every day
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is True
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_weekdays(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for WEEKDAYS type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.WEEKDAYS,
|
||||
)
|
||||
|
||||
# Monday (dayOfWeek = 1)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
|
||||
# Friday (dayOfWeek = 5)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 19)) is True
|
||||
# Saturday (dayOfWeek = 6)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 20)) is False
|
||||
# Sunday (dayOfWeek = 7)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 21)) is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_should_fire_on_date_weekly(qtbot, app, fresh_db):
|
||||
"""Test should_fire_on_date for WEEKLY type."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Fire on Wednesday (weekday = 2)
|
||||
reminder = Reminder(
|
||||
id=1,
|
||||
text="Test",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.WEEKLY,
|
||||
weekday=2,
|
||||
)
|
||||
|
||||
# Wednesday (dayOfWeek = 3, so weekday = 2)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 17)) is True
|
||||
# Thursday (dayOfWeek = 4, so weekday = 3)
|
||||
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 18)) is False
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_refresh_no_db(qtbot, app):
|
||||
"""Test refresh with no database connection."""
|
||||
widget = UpcomingRemindersWidget(None)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not crash
|
||||
widget.refresh()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_refresh_with_reminders(qtbot, app, fresh_db):
|
||||
"""Test refresh displays reminders."""
|
||||
# Add a reminder to the database
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Test reminder",
|
||||
time_str="23:59", # Late time so it's in the future
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.refresh()
|
||||
|
||||
# Should have at least one item (or "No upcoming reminders")
|
||||
assert widget.reminder_list.count() > 0
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
|
||||
"""Test adding a reminder through the widget."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
mock_get.return_value = Reminder(
|
||||
id=None,
|
||||
text="New reminder",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
widget._add_reminder()
|
||||
|
||||
# Reminder should be saved
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
assert len(reminders) > 0
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
|
||||
"""Test editing a reminder through the widget."""
|
||||
# Add a reminder first
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Original",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
# Get the list item
|
||||
if widget.reminder_list.count() > 0:
|
||||
item = widget.reminder_list.item(0)
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
updated = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
mock_get.return_value = updated
|
||||
|
||||
widget._edit_reminder(item)
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db):
|
||||
"""Test deleting a single selected reminder."""
|
||||
# Add a reminder
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="To delete",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
if widget.reminder_list.count() > 0:
|
||||
widget.reminder_list.setCurrentRow(0)
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
widget._delete_selected_reminders()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_delete_selected_multiple(qtbot, app, fresh_db):
|
||||
"""Test deleting multiple selected reminders."""
|
||||
# Add multiple reminders
|
||||
for i in range(3):
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text=f"Reminder {i}",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
# Select all items
|
||||
for i in range(widget.reminder_list.count()):
|
||||
widget.reminder_list.item(i).setSelected(True)
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
widget._delete_selected_reminders()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
|
||||
"""Test check_reminders with no database."""
|
||||
widget = UpcomingRemindersWidget(None)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not crash
|
||||
widget._check_reminders()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
|
||||
"""Test starting the regular check timer."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget._start_regular_timer()
|
||||
|
||||
# Timer should be running
|
||||
assert widget._check_timer.isActive()
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
|
||||
"""Test ManageRemindersDialog initialization."""
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog._db is fresh_db
|
||||
assert dialog.table is not None
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_load_reminders(qtbot, app, fresh_db):
|
||||
"""Test loading reminders into the table."""
|
||||
# Add some reminders
|
||||
for i in range(3):
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text=f"Reminder {i}",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.table.rowCount() == 3
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_load_reminders_no_db(qtbot, app):
|
||||
"""Test loading reminders with no database."""
|
||||
dialog = ManageRemindersDialog(None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should not crash
|
||||
dialog._load_reminders()
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
|
||||
"""Test adding a reminder through the manage dialog."""
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
mock_get.return_value = Reminder(
|
||||
id=None,
|
||||
text="New",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
dialog._add_reminder()
|
||||
|
||||
# Table should have one more row
|
||||
assert dialog.table.rowCount() == initial_count + 1
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
|
||||
"""Test editing a reminder through the manage dialog."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Original",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
mock_get.return_value = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
dialog._edit_reminder(reminder)
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db):
|
||||
"""Test deleting a reminder through the manage dialog."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="To delete",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
saved_reminders = fresh_db.get_all_reminders()
|
||||
reminder_to_delete = saved_reminders[0]
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
||||
dialog._delete_reminder(reminder_to_delete)
|
||||
|
||||
# Table should have one fewer row
|
||||
assert dialog.table.rowCount() == initial_count - 1
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_delete_reminder_declined(qtbot, app, fresh_db):
|
||||
"""Test declining to delete a reminder."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Keep me",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
saved_reminders = fresh_db.get_all_reminders()
|
||||
reminder_to_keep = saved_reminders[0]
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
|
||||
dialog._delete_reminder(reminder_to_keep)
|
||||
|
||||
# Table should have same number of rows
|
||||
assert dialog.table.rowCount() == initial_count
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
|
||||
"""Test that weekly reminders display the day name."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Weekly",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.WEEKLY,
|
||||
weekday=2, # Wednesday
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Check that the type column shows the day
|
||||
type_item = dialog.table.item(0, 2)
|
||||
assert "Wed" in type_item.text()
|
||||
|
||||
|
||||
def test_reminder_dialog_accept(qtbot, app, fresh_db):
|
||||
"""Test accepting the reminder dialog."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.text_edit.setText("Test")
|
||||
dialog.accept()
|
||||
|
||||
|
||||
def test_reminder_dialog_reject(qtbot, app, fresh_db):
|
||||
"""Test rejecting the reminder dialog."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.reject()
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_signal_emitted(qtbot, app, fresh_db):
|
||||
"""Test that reminderTriggered signal is emitted."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
signal_received = []
|
||||
widget.reminderTriggered.connect(lambda text: signal_received.append(text))
|
||||
|
||||
# Manually emit for testing
|
||||
widget.reminderTriggered.emit("Test reminder")
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == "Test reminder"
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_no_upcoming_message(qtbot, app, fresh_db):
|
||||
"""Test that 'No upcoming reminders' message is shown when appropriate."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.refresh()
|
||||
|
||||
# Should show message when no reminders
|
||||
if widget.reminder_list.count() > 0:
|
||||
item = widget.reminder_list.item(0)
|
||||
if "No upcoming" in item.text():
|
||||
assert True
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
|
||||
"""Test clicking the manage button."""
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
with patch.object(ManageRemindersDialog, "exec"):
|
||||
widget._manage_reminders()
|
||||
|
||||
|
||||
def test_reminder_dialog_time_format(qtbot, app, fresh_db):
|
||||
"""Test that time is formatted correctly."""
|
||||
dialog = ReminderDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.time_edit.setTime(QTime(9, 5))
|
||||
reminder = dialog.get_reminder()
|
||||
|
||||
assert reminder.time_str == "09:05"
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_past_reminders_filtered(qtbot, app, fresh_db):
|
||||
"""Test that past reminders are not shown in upcoming list."""
|
||||
# Create a reminder that's in the past
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Past reminder",
|
||||
time_str="00:01", # Very early morning
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Current time should be past 00:01
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
if QTime.currentTime().hour() > 0:
|
||||
widget.refresh()
|
||||
# The past reminder for today should be filtered out
|
||||
# but tomorrow's occurrence should be shown
|
||||
|
||||
|
||||
def test_reminder_with_inactive_status(qtbot, app, fresh_db):
|
||||
"""Test that inactive reminders are not displayed."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Inactive",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=False,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
widget.refresh()
|
||||
|
||||
# Should not show inactive reminder
|
||||
for i in range(widget.reminder_list.count()):
|
||||
item = widget.reminder_list.item(i)
|
||||
assert "Inactive" not in item.text() or "No upcoming" in item.text()
|
||||
|
||||
|
||||
def test_reminder_triggers_and_deactivates(qtbot, fresh_db):
|
||||
"""Test that ONCE reminders deactivate after firing."""
|
||||
# Add a ONCE reminder for right now
|
||||
now = QTime.currentTime()
|
||||
hour = now.hour()
|
||||
minute = now.minute()
|
||||
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Test once reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str=f"{hour:02d}:{minute:02d}",
|
||||
date_iso=date.today().isoformat(),
|
||||
active=True,
|
||||
)
|
||||
reminder_id = fresh_db.save_reminder(reminder)
|
||||
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Set up signal spy
|
||||
triggered_texts = []
|
||||
reminders_widget.reminderTriggered.connect(
|
||||
lambda text: triggered_texts.append(text)
|
||||
)
|
||||
|
||||
# Trigger the check
|
||||
reminders_widget._check_reminders()
|
||||
|
||||
# Verify reminder was triggered
|
||||
assert len(triggered_texts) > 0
|
||||
assert "Test once reminder" in triggered_texts
|
||||
|
||||
# Verify reminder was deactivated
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
deactivated = [r for r in reminders if r.id == reminder_id][0]
|
||||
assert deactivated.active is False
|
||||
|
||||
|
||||
def test_reminder_not_active_skipped(qtbot, fresh_db):
|
||||
"""Test that inactive reminders are not triggered."""
|
||||
now = QTime.currentTime()
|
||||
hour = now.hour()
|
||||
minute = now.minute()
|
||||
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Inactive reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str=f"{hour:02d}:{minute:02d}",
|
||||
date_iso=date.today().isoformat(),
|
||||
active=False, # Not active
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Set up signal spy
|
||||
triggered_texts = []
|
||||
reminders_widget.reminderTriggered.connect(
|
||||
lambda text: triggered_texts.append(text)
|
||||
)
|
||||
|
||||
# Trigger the check
|
||||
reminders_widget._check_reminders()
|
||||
|
||||
# Should not trigger inactive reminder
|
||||
assert len(triggered_texts) == 0
|
||||
|
||||
|
||||
def test_reminder_not_today_skipped(qtbot, fresh_db):
|
||||
"""Test that reminders not scheduled for today are skipped."""
|
||||
now = QTime.currentTime()
|
||||
hour = now.hour()
|
||||
minute = now.minute()
|
||||
|
||||
# Schedule for tomorrow
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Tomorrow's reminder",
|
||||
reminder_type=ReminderType.ONCE,
|
||||
time_str=f"{hour:02d}:{minute:02d}",
|
||||
date_iso=tomorrow.isoformat(),
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Set up signal spy
|
||||
triggered_texts = []
|
||||
reminders_widget.reminderTriggered.connect(
|
||||
lambda text: triggered_texts.append(text)
|
||||
)
|
||||
|
||||
# Trigger the check
|
||||
reminders_widget._check_reminders()
|
||||
|
||||
# Should not trigger tomorrow's reminder
|
||||
assert len(triggered_texts) == 0
|
||||
|
||||
|
||||
def test_reminder_context_menu_no_selection(qtbot, fresh_db):
|
||||
"""Test context menu with no selection returns early."""
|
||||
reminders_widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(reminders_widget)
|
||||
|
||||
# Clear selection
|
||||
reminders_widget.reminder_list.clearSelection()
|
||||
|
||||
# Show context menu - should return early
|
||||
reminders_widget._show_reminder_context_menu(reminders_widget.reminder_list.pos())
|
||||
|
||||
|
||||
def test_edit_reminder_dialog(qtbot, fresh_db):
|
||||
"""Test editing a reminder through the dialog."""
|
||||
reminder = Reminder(
|
||||
id=None,
|
||||
text="Original text",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
time_str="14:30",
|
||||
date_iso=None,
|
||||
active=True,
|
||||
)
|
||||
fresh_db.save_reminder(reminder)
|
||||
|
||||
widget = QWidget()
|
||||
|
||||
# Create edit dialog
|
||||
reminder_obj = fresh_db.get_all_reminders()[0]
|
||||
dlg = ReminderDialog(fresh_db, widget, reminder=reminder_obj)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Verify fields are populated
|
||||
assert dlg.text_edit.text() == "Original text"
|
||||
assert dlg.time_edit.time().toString("HH:mm") == "14:30"
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
from bouquin.search import Search
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
|
||||
def test_search_widget_populates_results(qtbot, fresh_db):
|
||||
|
|
@ -21,80 +20,3 @@ def test_search_widget_populates_results(qtbot, fresh_db):
|
|||
s.search.setText("")
|
||||
qtbot.wait(50)
|
||||
assert s.results.isHidden()
|
||||
|
||||
|
||||
def test_open_selected_with_data(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
seen = []
|
||||
s.openDateRequested.connect(lambda d: seen.append(d))
|
||||
it = QListWidgetItem("dummy")
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
|
||||
s.results.addItem(it)
|
||||
s._open_selected(it)
|
||||
assert seen == ["1999-12-31"]
|
||||
|
||||
|
||||
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
long = (
|
||||
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||
)
|
||||
frag = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||
assert "alpha" in frag
|
||||
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||
|
||||
|
||||
def test_open_selected_ignores_no_data(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
seen = []
|
||||
s.openDateRequested.connect(lambda d: seen.append(d))
|
||||
it = QListWidgetItem("dummy")
|
||||
# No UserRole data set -> should not emit
|
||||
s._open_selected(it)
|
||||
assert not seen
|
||||
|
||||
|
||||
def test_make_html_snippet_variants(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
|
||||
src = " ".join(["word"] * 200)
|
||||
frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
|
||||
assert frag
|
||||
|
||||
# Case: multiple tokens highlighted
|
||||
src = "Alpha bravo charlie delta echo"
|
||||
frag = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
|
||||
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
|
||||
assert "<b>delta</b>" in frag
|
||||
|
||||
|
||||
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
s.search.setText("alpha")
|
||||
|
||||
frag, left, right = s._make_html_snippet("", "alpha", radius=10, maxlen=40)
|
||||
assert frag == "" and not left and not right
|
||||
|
||||
|
||||
def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
long = "X" * 40 + "alpha" + "Y" * 40
|
||||
rows = [("2000-01-01", long)]
|
||||
s._populate_results("alpha", rows)
|
||||
assert s.results.count() >= 1
|
||||
|
|
|
|||
11
tests/test_search_helpers.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from bouquin.search import Search
|
||||
|
||||
|
||||
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
long = (
|
||||
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||
)
|
||||
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||
assert "alpha" in frag
|
||||
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
from pathlib import Path
|
||||
from bouquin.settings import (
|
||||
default_db_path,
|
||||
get_settings,
|
||||
load_db_config,
|
||||
save_db_config,
|
||||
|
|
@ -6,26 +8,16 @@ from bouquin.settings import (
|
|||
from bouquin.db import DBConfig
|
||||
|
||||
|
||||
def _clear_db_settings():
|
||||
s = get_settings()
|
||||
for k in [
|
||||
"db/default_db",
|
||||
"db/path", # legacy key
|
||||
"db/key",
|
||||
"ui/idle_minutes",
|
||||
"ui/theme",
|
||||
"ui/move_todos",
|
||||
"ui/tags",
|
||||
"ui/time_log",
|
||||
"ui/reminders",
|
||||
"ui/locale",
|
||||
"ui/font_size",
|
||||
]:
|
||||
s.remove(k)
|
||||
def test_default_db_path_returns_writable_path(app, tmp_path):
|
||||
p = default_db_path()
|
||||
assert isinstance(p, Path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
||||
_clear_db_settings()
|
||||
s = get_settings()
|
||||
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]:
|
||||
s.remove(k)
|
||||
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "notes.db",
|
||||
|
|
@ -33,11 +25,6 @@ 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)
|
||||
|
||||
|
|
@ -47,25 +34,3 @@ 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,12 @@
|
|||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
import bouquin.settings_dialog as sd
|
||||
import pytest
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.settings import get_settings
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
||||
@pytest.mark.gui
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
|
||||
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
|
||||
app = QApplication.instance()
|
||||
parent = QWidget()
|
||||
|
|
@ -17,12 +15,10 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
|||
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():
|
||||
|
|
@ -36,15 +32,18 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
|||
|
||||
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")
|
||||
|
||||
|
||||
def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
|
|
@ -60,7 +59,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.key_entry.setText("supersecret")
|
||||
w.edit.setText("supersecret")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
|
@ -82,6 +81,12 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
|
|||
|
||||
|
||||
def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "iso.db",
|
||||
key="oldkey",
|
||||
|
|
@ -104,7 +109,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.key_entry.setText(keys.pop(0) if keys else "zzz")
|
||||
w.edit.setText(keys.pop(0) if keys else "zzz")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
|
@ -124,6 +129,12 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
|
|||
|
||||
|
||||
def test_change_key_success(qtbot, tmp_path, app):
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "iso2.db",
|
||||
key="oldkey",
|
||||
|
|
@ -146,7 +157,7 @@ def test_change_key_success(qtbot, tmp_path, app):
|
|||
def _pump():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.key_entry.setText(keys.pop(0) if keys else "newkey")
|
||||
w.edit.setText(keys.pop(0) if keys else "newkey")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
|
@ -167,246 +178,3 @@ def test_change_key_success(qtbot, tmp_path, app):
|
|||
assert db2.connect()
|
||||
assert "seed" in db2.get_entry("2001-01-01")
|
||||
db2.close()
|
||||
|
||||
|
||||
def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
|
||||
# Parent with ThemeManager (dialog uses parent().themes.set(...))
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Monkeypatch db.compact to raise
|
||||
def boom():
|
||||
raise RuntimeError("nope")
|
||||
|
||||
dlg._db.compact = boom # type: ignore
|
||||
|
||||
called = {"critical": False, "title": None, "text": None}
|
||||
|
||||
class DummyMB:
|
||||
@staticmethod
|
||||
def information(*args, **kwargs):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def critical(parent, title, text, *rest):
|
||||
called["critical"] = True
|
||||
called["title"] = title
|
||||
called["text"] = str(text)
|
||||
return 0
|
||||
|
||||
# Swap QMessageBox used inside the dialog module so signature mismatch can't occur
|
||||
monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True)
|
||||
|
||||
# Invoke
|
||||
dlg._compact_btn_clicked()
|
||||
|
||||
assert called["critical"]
|
||||
assert called["title"]
|
||||
assert called["text"]
|
||||
|
||||
|
||||
class _Host(QWidget):
|
||||
def __init__(self, themes):
|
||||
super().__init__()
|
||||
self.themes = themes
|
||||
|
||||
|
||||
def _make_host_and_dialog(tmp_db_cfg, fresh_db):
|
||||
# Create a real ThemeManager so we don't have to fake anything here
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
themes = ThemeManager(QApplication.instance(), ThemeConfig(theme=Theme.SYSTEM))
|
||||
host = _Host(themes)
|
||||
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=host)
|
||||
return host, dlg
|
||||
|
||||
|
||||
def _clear_qsettings_theme_to_system():
|
||||
"""Make the radio-button default deterministic across the full suite."""
|
||||
s = get_settings()
|
||||
s.clear()
|
||||
s.setValue("ui/theme", "system")
|
||||
|
||||
|
||||
def test_default_theme_radio_is_system_checked(qtbot, tmp_db_cfg, fresh_db):
|
||||
# Ensure no stray theme value from previous tests
|
||||
_clear_qsettings_theme_to_system()
|
||||
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(host)
|
||||
qtbot.addWidget(dlg)
|
||||
# With fresh settings (system), the 'system' radio should be selected
|
||||
assert dlg.theme_system.isChecked()
|
||||
|
||||
|
||||
def test_save_selects_system_when_no_explicit_choice(
|
||||
qtbot, tmp_db_cfg, fresh_db, monkeypatch
|
||||
):
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(dlg)
|
||||
# Ensure neither dark nor light is checked so SYSTEM path is taken
|
||||
dlg.theme_dark.setChecked(False)
|
||||
dlg.theme_light.setChecked(False)
|
||||
# This should not raise
|
||||
dlg._save()
|
||||
|
||||
|
||||
def test_save_with_dark_selected(qtbot, tmp_db_cfg, fresh_db):
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.theme_dark.setChecked(True)
|
||||
dlg._save()
|
||||
|
||||
|
||||
def test_change_key_cancel_first_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
class P1:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Rejected
|
||||
|
||||
def key(self):
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
|
||||
dlg._change_key() # returns early
|
||||
|
||||
|
||||
def test_change_key_cancel_second_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
class P1:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Accepted
|
||||
|
||||
def key(self):
|
||||
return "abc"
|
||||
|
||||
class P2:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Rejected
|
||||
|
||||
def key(self):
|
||||
return "abc"
|
||||
|
||||
# First call yields P1, second yields P2
|
||||
seq = [P1, P2]
|
||||
|
||||
def _factory(*a, **k):
|
||||
cls = seq.pop(0)
|
||||
return cls(*a, **k)
|
||||
|
||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
|
||||
dlg._change_key() # returns early
|
||||
|
||||
|
||||
def test_change_key_empty_and_exception_paths(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Timer that auto-accepts any modal QMessageBox so we don't hang.
|
||||
def _pump_boxes():
|
||||
# Try both the active modal and the general top-level enumeration
|
||||
m = QApplication.activeModalWidget()
|
||||
if isinstance(m, QMessageBox):
|
||||
m.accept()
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
||||
timer = QTimer()
|
||||
timer.setInterval(10)
|
||||
timer.timeout.connect(_pump_boxes)
|
||||
timer.start()
|
||||
|
||||
try:
|
||||
|
||||
class P1:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Accepted
|
||||
|
||||
def key(self):
|
||||
return ""
|
||||
|
||||
class P2:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Accepted
|
||||
|
||||
def key(self):
|
||||
return ""
|
||||
|
||||
seq = [P1, P2, P1, P2]
|
||||
|
||||
def _factory(*a, **k):
|
||||
cls = seq.pop(0)
|
||||
return cls(*a, **k)
|
||||
|
||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
|
||||
# First run triggers empty-key warning path and return (auto-closed)
|
||||
dlg._change_key()
|
||||
|
||||
# Now make rekey() raise to hit the except block (critical dialog)
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("nope")
|
||||
|
||||
dlg._db.rekey = boom
|
||||
|
||||
# Return a non-empty matching key twice
|
||||
class P3:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Accepted
|
||||
|
||||
def key(self):
|
||||
return "secret"
|
||||
|
||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: P3())
|
||||
dlg._change_key()
|
||||
finally:
|
||||
timer.stop()
|
||||
|
||||
|
||||
def test_save_key_btn_clicked_cancel_flow(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Make sure we start with no key saved so it will prompt
|
||||
dlg.key = ""
|
||||
|
||||
class P1:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return QDialog.Rejected
|
||||
|
||||
def key(self):
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
|
||||
|
||||
dlg.save_key_btn.setChecked(True) # toggles and calls handler
|
||||
# Handler should have undone the checkbox back to False
|
||||
assert not dlg.save_key_btn.isChecked()
|
||||
|
|
|
|||
|
|
@ -1,636 +0,0 @@
|
|||
import datetime as _dt
|
||||
from datetime import datetime, timedelta, date
|
||||
|
||||
from bouquin import strings
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QDate
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
|
||||
|
||||
|
||||
class FakeStatsDB:
|
||||
"""Minimal stub that returns a fixed stats payload."""
|
||||
|
||||
def __init__(self):
|
||||
d1 = _dt.date(2024, 1, 1)
|
||||
d2 = _dt.date(2024, 1, 2)
|
||||
self.stats = (
|
||||
2, # pages_with_content
|
||||
5, # total_revisions
|
||||
"2024-01-02", # page_most_revisions
|
||||
3, # page_most_revisions_count
|
||||
{d1: 10, d2: 20}, # words_by_date
|
||||
30, # total_words
|
||||
4, # unique_tags
|
||||
"2024-01-02", # page_most_tags
|
||||
2, # page_most_tags_count
|
||||
{d1: 1, d2: 2}, # revisions_by_date
|
||||
)
|
||||
self.called = False
|
||||
|
||||
def gather_stats(self):
|
||||
self.called = True
|
||||
return self.stats
|
||||
|
||||
|
||||
def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
|
||||
# Make sure we have a known language for label texts
|
||||
strings.load_strings("en")
|
||||
|
||||
db = FakeStatsDB()
|
||||
dlg = StatisticsDialog(db)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Stats were actually requested from the DB
|
||||
assert db.called
|
||||
|
||||
# Window title comes from translations
|
||||
assert dlg.windowTitle() == strings._("statistics")
|
||||
|
||||
# Grab all label texts for simple content checks
|
||||
label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)}
|
||||
|
||||
# Page with most revisions / tags are rendered as "DATE (COUNT)"
|
||||
assert "2024-01-02 (3)" in label_texts
|
||||
assert "2024-01-02 (2)" in label_texts
|
||||
|
||||
# Heatmap is created and uses "words" by default
|
||||
words_by_date = db.stats[4]
|
||||
revisions_by_date = db.stats[-1]
|
||||
|
||||
assert hasattr(dlg, "_heatmap")
|
||||
assert dlg._heatmap._data == words_by_date
|
||||
|
||||
# Switching the metric to "revisions" should swap the dataset
|
||||
dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions
|
||||
qtbot.wait(10)
|
||||
assert dlg._heatmap._data == revisions_by_date
|
||||
|
||||
|
||||
class EmptyStatsDB:
|
||||
"""Stub that returns a 'no data yet' stats payload."""
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def gather_stats(self):
|
||||
self.called = True
|
||||
return (
|
||||
0, # pages_with_content
|
||||
0, # total_revisions
|
||||
None, # page_most_revisions
|
||||
0,
|
||||
{}, # words_by_date
|
||||
0, # total_words
|
||||
0, # unique_tags
|
||||
None, # page_most_tags
|
||||
0,
|
||||
{}, # revisions_by_date
|
||||
)
|
||||
|
||||
|
||||
def test_statistics_dialog_no_data_shows_placeholder(qtbot):
|
||||
strings.load_strings("en")
|
||||
|
||||
db = EmptyStatsDB()
|
||||
dlg = StatisticsDialog(db)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
assert db.called
|
||||
|
||||
label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)]
|
||||
assert strings._("stats_no_data") in label_texts
|
||||
|
||||
# When there's no data, the heatmap and metric combo shouldn't exist
|
||||
assert not hasattr(dlg, "metric_combo")
|
||||
assert not hasattr(dlg, "_heatmap")
|
||||
|
||||
|
||||
def _date(year, month, day):
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DateHeatmapTests - Missing Coverage
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_activity_heatmap_empty_data(qtbot):
|
||||
"""Test heatmap with empty data dict."""
|
||||
strings.load_strings("en")
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set empty data
|
||||
heatmap.set_data({})
|
||||
|
||||
# Should handle empty data gracefully
|
||||
assert heatmap._start is None
|
||||
assert heatmap._end is None
|
||||
assert heatmap._max_value == 0
|
||||
|
||||
# Size hint should return default dimensions
|
||||
size = heatmap.sizeHint()
|
||||
assert size.width() > 0
|
||||
assert size.height() > 0
|
||||
|
||||
# Paint should not crash
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_none_data(qtbot):
|
||||
"""Test heatmap with None data."""
|
||||
strings.load_strings("en")
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set None data
|
||||
heatmap.set_data(None)
|
||||
|
||||
assert heatmap._start is None
|
||||
assert heatmap._end is None
|
||||
|
||||
# Paint event should return early
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_click_when_no_data(qtbot):
|
||||
"""Test clicking heatmap when there's no data."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
heatmap.set_data({})
|
||||
|
||||
# Simulate click - should not crash or emit signal
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click in the middle of widget
|
||||
pos = QPoint(100, 100)
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
# Should not have clicked any date
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_click_outside_grid(qtbot):
|
||||
"""Test clicking outside the grid area."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set some data
|
||||
data = {
|
||||
date(2024, 1, 1): 5,
|
||||
date(2024, 1, 2): 10,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click in top-left margin (before grid starts)
|
||||
pos = QPoint(5, 5)
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_click_beyond_end_date(qtbot):
|
||||
"""Test clicking on trailing empty cells beyond the last date."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set data that doesn't fill a complete week
|
||||
data = {
|
||||
date(2024, 1, 1): 5, # Monday
|
||||
date(2024, 1, 2): 10, # Tuesday
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Try clicking far to the right (beyond end date)
|
||||
# This is tricky to target precisely, but we can simulate
|
||||
pos = QPoint(1000, 50) # Far right
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
# Should either not click or only click valid dates
|
||||
# If it did click, it should be a valid date within range
|
||||
if clicked_dates:
|
||||
assert clicked_dates[0] <= date(2024, 1, 2)
|
||||
|
||||
|
||||
def test_activity_heatmap_click_invalid_row(qtbot):
|
||||
"""Test clicking below the 7 weekday rows."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {
|
||||
date(2024, 1, 1): 5,
|
||||
date(2024, 1, 8): 10,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click below the grid (row 8 or higher)
|
||||
pos = QPoint(100, 500) # Very low Y
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_right_click_ignored(qtbot):
|
||||
"""Test that right-click is ignored."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {date(2024, 1, 1): 5}
|
||||
heatmap.set_data(data)
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Right click should be ignored
|
||||
pos = QPoint(100, 100)
|
||||
QTest.mouseClick(heatmap, Qt.RightButton, pos=pos)
|
||||
|
||||
assert len(clicked_dates) == 0
|
||||
|
||||
|
||||
def test_activity_heatmap_month_label_rendering(qtbot):
|
||||
"""Test heatmap spanning multiple months renders month labels."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Data spanning multiple months
|
||||
data = {
|
||||
date(2024, 1, 1): 5,
|
||||
date(2024, 1, 15): 10,
|
||||
date(2024, 2, 1): 8,
|
||||
date(2024, 2, 15): 12,
|
||||
date(2024, 3, 1): 6,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Should calculate proper size
|
||||
size = heatmap.sizeHint()
|
||||
assert size.width() > 0
|
||||
assert size.height() > 0
|
||||
|
||||
# Paint should work without crashing
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_same_month_continues(qtbot):
|
||||
"""Test that month labels skip weeks in the same month."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Multiple dates in same month
|
||||
data = {}
|
||||
for day in range(1, 29): # January 1-28
|
||||
data[date(2024, 1, day)] = day
|
||||
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Should render without issues
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_data_with_zero_values(qtbot):
|
||||
"""Test heatmap with zero values in data."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {
|
||||
date(2024, 1, 1): 0,
|
||||
date(2024, 1, 2): 5,
|
||||
date(2024, 1, 3): 0,
|
||||
}
|
||||
heatmap.set_data(data)
|
||||
|
||||
assert heatmap._max_value == 5
|
||||
|
||||
heatmap.update()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_activity_heatmap_single_day(qtbot):
|
||||
"""Test heatmap with just one day of data."""
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
data = {date(2024, 1, 15): 10}
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Should handle single day
|
||||
assert heatmap._start is not None
|
||||
assert heatmap._end is not None
|
||||
|
||||
clicked_dates = []
|
||||
heatmap.date_clicked.connect(clicked_dates.append)
|
||||
|
||||
# Click should work
|
||||
pos = QPoint(100, 100)
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# StatisticsDialog Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_statistics_dialog_with_empty_database(qtbot, fresh_db):
|
||||
"""Test statistics dialog with an empty database."""
|
||||
strings.load_strings("en")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Should handle empty database gracefully
|
||||
assert dialog.isVisible()
|
||||
|
||||
# Heatmap should be empty
|
||||
heatmap = dialog.findChild(DateHeatmap)
|
||||
if heatmap:
|
||||
# No crash when displaying empty heatmap
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_statistics_dialog_with_data(qtbot, fresh_db):
|
||||
"""Test statistics dialog with actual data."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Add some content
|
||||
fresh_db.save_new_version("2024-01-01", "Hello world", "test")
|
||||
fresh_db.save_new_version("2024-01-02", "More content here", "test")
|
||||
fresh_db.save_new_version("2024-01-03", "Even more text", "test")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Should display statistics
|
||||
assert dialog.isVisible()
|
||||
qtbot.wait(10)
|
||||
|
||||
|
||||
def test_statistics_dialog_gather_stats_exception_handling(
|
||||
qtbot, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that gather_stats handles exceptions gracefully."""
|
||||
strings.load_strings("en")
|
||||
|
||||
# Make dates_with_content raise an exception
|
||||
def bad_dates_with_content():
|
||||
raise RuntimeError("Simulated DB error")
|
||||
|
||||
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content)
|
||||
|
||||
# Should still create dialog without crashing
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
|
||||
# Should handle error gracefully
|
||||
assert dialog.isVisible()
|
||||
|
||||
|
||||
def test_statistics_dialog_with_sparse_data(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test statistics dialog with sparse data"""
|
||||
# Add some entries on non-consecutive days
|
||||
dates = ["2024-01-01", "2024-01-05", "2024-01-10", "2024-01-20"]
|
||||
for _date in dates:
|
||||
content = "Word " * 100 # 100 words
|
||||
fresh_db.save_new_version(_date, content, "note")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should create without crashing
|
||||
assert dialog is not None
|
||||
|
||||
|
||||
def test_statistics_dialog_with_empty_data(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test statistics dialog with no data"""
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should handle empty data gracefully
|
||||
assert dialog is not None
|
||||
|
||||
|
||||
def test_statistics_dialog_date_range_selection(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test changing metric in statistics dialog"""
|
||||
# Add some test data
|
||||
for i in range(10):
|
||||
date = QDate.currentDate().addDays(-i).toString("yyyy-MM-dd")
|
||||
fresh_db.save_new_version(date, f"Content for day {i}", "note")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Change metric to revisions
|
||||
idx = dialog.metric_combo.findData("revisions")
|
||||
if idx >= 0:
|
||||
dialog.metric_combo.setCurrentIndex(idx)
|
||||
qtbot.wait(50)
|
||||
|
||||
# Change back to words
|
||||
idx = dialog.metric_combo.findData("words")
|
||||
if idx >= 0:
|
||||
dialog.metric_combo.setCurrentIndex(idx)
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_heatmap_with_varying_word_counts(qtbot):
|
||||
"""Test heatmap color scaling with varying word counts"""
|
||||
today = datetime.now().date()
|
||||
start = today - timedelta(days=30)
|
||||
|
||||
entries = {}
|
||||
# Create entries with varying word counts
|
||||
for i in range(31):
|
||||
date = start + timedelta(days=i)
|
||||
entries[date] = i * 50 # Increasing word counts
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
heatmap.set_data(entries)
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Should paint without errors
|
||||
assert heatmap.isVisible()
|
||||
|
||||
|
||||
def test_heatmap_single_day(qtbot):
|
||||
"""Test heatmap with single day of data"""
|
||||
today = datetime.now().date()
|
||||
entries = {today: 500}
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
heatmap.set_data(entries)
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
assert heatmap.isVisible()
|
||||
|
||||
|
||||
def test_statistics_dialog_metric_changes(qtbot, tmp_db_cfg, fresh_db):
|
||||
"""Test various metric selections"""
|
||||
# Add data spanning multiple months
|
||||
base_date = QDate.currentDate().addDays(-90)
|
||||
for i in range(90):
|
||||
date = base_date.addDays(i).toString("yyyy-MM-dd")
|
||||
fresh_db.save_new_version(date, f"Day {i} content with many words", "note")
|
||||
|
||||
dialog = StatisticsDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Test each metric option
|
||||
for i in range(dialog.metric_combo.count()):
|
||||
dialog.metric_combo.setCurrentIndex(i)
|
||||
qtbot.wait(50)
|
||||
|
||||
|
||||
def test_heatmap_date_beyond_end(qtbot, fresh_db):
|
||||
"""Test clicking on a date beyond the end date in heatmap."""
|
||||
# Create entries spanning a range
|
||||
today = date.today()
|
||||
start = today - timedelta(days=30)
|
||||
|
||||
data = {}
|
||||
for i in range(20):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
|
||||
# Set data
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Try to click beyond the end date - should return early
|
||||
# Calculate a position that would be beyond the end
|
||||
if heatmap._start and heatmap._end:
|
||||
cell_span = heatmap._cell + heatmap._gap
|
||||
weeks = ((heatmap._end - heatmap._start).days + 6) // 7
|
||||
|
||||
# Click beyond the last week
|
||||
x = heatmap._margin_left + (weeks + 1) * cell_span + 5
|
||||
y = heatmap._margin_top + 3 * cell_span + 5
|
||||
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
|
||||
|
||||
|
||||
def test_heatmap_click_outside_grid(qtbot, fresh_db):
|
||||
"""Test clicking outside the heatmap grid area."""
|
||||
today = date.today()
|
||||
start = today - timedelta(days=7)
|
||||
|
||||
data = {}
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Click in the margin (outside grid)
|
||||
x = heatmap._margin_left - 10 # Before the grid
|
||||
y = heatmap._margin_top - 10 # Above the grid
|
||||
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
|
||||
|
||||
# Should not crash, just return early
|
||||
|
||||
|
||||
def test_heatmap_click_invalid_row(qtbot, fresh_db):
|
||||
"""Test clicking on an invalid row (>= 7)."""
|
||||
today = date.today()
|
||||
start = today - timedelta(days=7)
|
||||
|
||||
data = {}
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Click below row 6 (day of week > Sunday)
|
||||
cell_span = heatmap._cell + heatmap._gap
|
||||
x = heatmap._margin_left + 5
|
||||
y = heatmap._margin_top + 7 * cell_span + 5 # Row 7, which is invalid
|
||||
|
||||
QTest.mouseClick(heatmap, Qt.LeftButton, Qt.NoModifier, QPoint(int(x), int(y)))
|
||||
|
||||
# Should return early, not crash
|
||||
|
||||
|
||||
def test_heatmap_month_label_continuation(qtbot, fresh_db):
|
||||
"""Test that month labels don't repeat when continuing in same month."""
|
||||
# Create a date range that spans multiple weeks within the same month
|
||||
today = date.today()
|
||||
# Use a date that's guaranteed to be mid-month
|
||||
start = date(today.year, today.month, 1)
|
||||
|
||||
data = {}
|
||||
for i in range(21):
|
||||
d = start + timedelta(days=i)
|
||||
fresh_db.save_new_version(d.isoformat(), f"Entry {i}", f"note {i}")
|
||||
data[d] = 1
|
||||
|
||||
w = QWidget()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
heatmap = DateHeatmap()
|
||||
qtbot.addWidget(heatmap)
|
||||
heatmap.show()
|
||||
heatmap.set_data(data)
|
||||
|
||||
# Force a repaint to execute paintEvent
|
||||
heatmap.repaint()
|
||||
|
||||
# The month continuation logic (line 175) should prevent duplicate labels
|
||||
# We can't easily test the visual output, but we ensure no crash
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
from bouquin import strings
|
||||
|
||||
|
||||
def test_load_strings_uses_system_locale_and_fallback():
|
||||
# pass a bogus locale to trigger fallback-to-default
|
||||
strings.load_strings("zz")
|
||||
assert strings._("next") # key exists in base translations
|
||||
|
||||
|
||||
def test_load_strings_french():
|
||||
strings.load_strings("fr")
|
||||
assert strings._("today") == "Aujourd'hui" # translation exists in French
|
||||
|
||||
|
||||
def test_load_strings_italian():
|
||||
strings.load_strings("it")
|
||||
assert strings._("today") == "Oggi" # translation exists in Italian
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import types
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
# point to the temp encrypted DB
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# first tab is today's date
|
||||
date1 = w.calendar.selectedDate()
|
||||
initial_count = w.tab_widget.count()
|
||||
|
||||
# opening the same date should NOT create a new tab
|
||||
w._open_date_in_tab(date1)
|
||||
assert w.tab_widget.count() == initial_count
|
||||
assert w.tab_widget.currentWidget().current_date == date1
|
||||
|
||||
# opening a different date should create exactly one new tab
|
||||
date2 = date1.addDays(1)
|
||||
w._open_date_in_tab(date2)
|
||||
assert w.tab_widget.count() == initial_count + 1
|
||||
assert w.tab_widget.currentWidget().current_date == date2
|
||||
|
||||
# jumping back to date1 just focuses the existing tab
|
||||
w._open_date_in_tab(date1)
|
||||
assert w.tab_widget.count() == initial_count + 1
|
||||
assert w.tab_widget.currentWidget().current_date == date1
|
||||
|
||||
|
||||
def test_toolbar_signals_dispatch_once_per_click(
|
||||
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
|
||||
):
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
tb = w.toolBar
|
||||
|
||||
# Spy on the first tab's editor
|
||||
calls1 = {
|
||||
"bold": 0,
|
||||
"italic": 0,
|
||||
"strike": 0,
|
||||
"code": 0,
|
||||
"heading": 0,
|
||||
"bullets": 0,
|
||||
"numbers": 0,
|
||||
"checkboxes": 0,
|
||||
}
|
||||
|
||||
def mk(key):
|
||||
def _spy(self, *a, **k):
|
||||
calls1[key] += 1
|
||||
|
||||
return _spy
|
||||
|
||||
w.editor.apply_weight = types.MethodType(mk("bold"), w.editor)
|
||||
w.editor.apply_italic = types.MethodType(mk("italic"), w.editor)
|
||||
w.editor.apply_strikethrough = types.MethodType(mk("strike"), w.editor)
|
||||
w.editor.apply_code = types.MethodType(mk("code"), w.editor)
|
||||
w.editor.apply_heading = types.MethodType(mk("heading"), w.editor)
|
||||
w.editor.toggle_bullets = types.MethodType(mk("bullets"), w.editor)
|
||||
w.editor.toggle_numbers = types.MethodType(mk("numbers"), w.editor)
|
||||
w.editor.toggle_checkboxes = types.MethodType(mk("checkboxes"), w.editor)
|
||||
|
||||
# Click all the things once
|
||||
tb.boldRequested.emit()
|
||||
tb.italicRequested.emit()
|
||||
tb.strikeRequested.emit()
|
||||
tb.codeRequested.emit()
|
||||
tb.headingRequested.emit(24)
|
||||
tb.bulletsRequested.emit()
|
||||
tb.numbersRequested.emit()
|
||||
tb.checkboxesRequested.emit()
|
||||
|
||||
assert all(v == 1 for v in calls1.values()) # fired once each
|
||||
|
||||
# Switch to a new tab and make sure clicks go ONLY to the active editor
|
||||
date2 = w.calendar.selectedDate().addDays(1)
|
||||
w._open_date_in_tab(date2)
|
||||
calls2 = {"bold": 0}
|
||||
w.editor.apply_weight = types.MethodType(
|
||||
lambda self: calls2.__setitem__("bold", calls2["bold"] + 1), w.editor
|
||||
)
|
||||
|
||||
tb.boldRequested.emit()
|
||||
assert calls1["bold"] == 1
|
||||
assert calls2["bold"] == 1
|
||||
|
||||
w._open_date_in_tab(date2.addDays(-1)) # back to first tab
|
||||
tb.boldRequested.emit()
|
||||
assert calls1["bold"] == 2
|
||||
assert calls2["bold"] == 1
|
||||
|
||||
|
||||
def test_history_and_insert_image_not_duplicated(
|
||||
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
|
||||
):
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# History dialog opens exactly once
|
||||
opened = {"count": 0}
|
||||
|
||||
def fake_exec(self):
|
||||
opened["count"] += 1
|
||||
return 0 # Rejected
|
||||
|
||||
monkeypatch.setattr(HistoryDialog, "exec", fake_exec, raising=False)
|
||||
w.toolBar.historyRequested.emit()
|
||||
assert opened["count"] == 1
|
||||
|
||||
# Insert image: simulate user selecting one file, and ensure it's inserted once
|
||||
dummy = tmp_path / "x.png"
|
||||
dummy.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
inserted = {"count": 0}
|
||||
|
||||
def fake_insert(self, p):
|
||||
inserted["count"] += 1
|
||||
|
||||
w.editor.insert_image_from_path = types.MethodType(fake_insert, w.editor)
|
||||
monkeypatch.setattr(
|
||||
QFileDialog,
|
||||
"getOpenFileNames",
|
||||
lambda *a, **k: ([str(dummy)], "Images (*.png)"),
|
||||
raising=False,
|
||||
)
|
||||
w.toolBar.insertImageRequested.emit()
|
||||
assert inserted["count"] == 1
|
||||
|
||||
|
||||
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
w.editor.from_markdown("**bold**\n- [ ] task\n~~strike~~")
|
||||
assert w.editor.highlighter is not None
|
||||
assert w.editor.highlighter.document() is w.editor.document()
|
||||
|
||||
|
||||
def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
# Tab 1 content
|
||||
w.editor.from_markdown("alpha bravo charlie")
|
||||
w.findBar.show_bar()
|
||||
w.findBar.edit.setText("bravo")
|
||||
w.findBar.find_next()
|
||||
assert w.editor.textCursor().selectedText() == "bravo"
|
||||
|
||||
# Tab 2 content (contains the query too)
|
||||
date2 = w.calendar.selectedDate().addDays(1)
|
||||
w._open_date_in_tab(date2)
|
||||
w.editor.from_markdown("x bravo y bravo z")
|
||||
w.editor.moveCursor(QTextCursor.Start)
|
||||
w.findBar.find_next()
|
||||
assert w.editor.textCursor().selectedText() == "bravo"
|
||||
2319
tests/test_tags.py
|
|
@ -1,5 +1,5 @@
|
|||
import pytest
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
|
||||
|
|
@ -14,39 +14,8 @@ def test_theme_manager_apply_light_and_dark(app):
|
|||
assert isinstance(app.palette(), QPalette)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_theme_manager_system_roundtrip(app, qtbot):
|
||||
cfg = ThemeConfig(theme=Theme.SYSTEM)
|
||||
mgr = ThemeManager(app, cfg)
|
||||
mgr.apply(cfg.theme)
|
||||
|
||||
|
||||
def _make_themes(theme):
|
||||
app = QApplication.instance()
|
||||
return ThemeManager(app, ThemeConfig(theme=theme))
|
||||
|
||||
|
||||
def test_register_and_restyle_calendar_and_overlay(qtbot):
|
||||
themes = _make_themes(Theme.DARK)
|
||||
cal = QCalendarWidget()
|
||||
ov = QWidget()
|
||||
ov.setObjectName("LockOverlay")
|
||||
qtbot.addWidget(cal)
|
||||
qtbot.addWidget(ov)
|
||||
|
||||
themes.register_calendar(cal)
|
||||
themes.register_lock_overlay(ov)
|
||||
|
||||
# Force a restyle pass (covers the "is not None" branches)
|
||||
themes._restyle_registered()
|
||||
|
||||
|
||||
def test_apply_dark_styles_cover_css_paths(qtbot):
|
||||
themes = _make_themes(Theme.DARK)
|
||||
cal = QCalendarWidget()
|
||||
ov = QWidget()
|
||||
ov.setObjectName("LockOverlay")
|
||||
qtbot.addWidget(cal)
|
||||
qtbot.addWidget(ov)
|
||||
|
||||
themes.register_calendar(cal) # drives _apply_calendar_theme (dark path)
|
||||
themes.register_lock_overlay(ov) # drives _apply_lock_overlay_theme (dark path)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import pytest
|
|||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.toolbar import ToolBar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -14,6 +13,10 @@ def editor(app, qtbot):
|
|||
return ed
|
||||
|
||||
|
||||
from bouquin.toolbar import ToolBar
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_toolbar_signals_and_styling(qtbot, editor):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
|
|
@ -39,29 +42,3 @@ def test_toolbar_signals_and_styling(qtbot, editor):
|
|||
tb.strikeRequested.emit()
|
||||
tb.headingRequested.emit(24)
|
||||
assert editor.to_markdown()
|
||||
|
||||
|
||||
def test_style_letter_button_paths(app, qtbot):
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
# Create toolbar
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
tb = ToolBar(parent)
|
||||
qtbot.addWidget(tb)
|
||||
|
||||
# Action not added to toolbar -> no widget, early return
|
||||
from PySide6.QtGui import QAction
|
||||
|
||||
stray = QAction("Stray", tb)
|
||||
tb._style_letter_button(stray, "Z") # should not raise
|
||||
|
||||
# Now add an action to toolbar and style with tooltip
|
||||
act = tb.addAction("Temp")
|
||||
tb._style_letter_button(act, "T", tooltip="Tip here")
|
||||
btn = tb.widgetForAction(act)
|
||||
assert btn.toolTip() == "Tip here"
|
||||
assert btn.accessibleName() == "Tip here"
|
||||
|
|
|
|||
|
|
@ -1,534 +0,0 @@
|
|||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import subprocess
|
||||
from bouquin.version_check import VersionChecker
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
|
||||
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,26 +0,0 @@
|
|||
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.statistics_dialog import DateHeatMap
|
||||
|
||||
DBManager.row_factory
|
||||
|
||||
DateHeatMap.minimumSizeHint
|
||||
|
||||
FlowLayout.itemAt
|
||||
FlowLayout.expandingDirections
|
||||
FlowLayout.hasHeightForWidth
|
||||
FlowLayout.heightForWidth
|
||||
|
||||
MarkdownEditor.apply_weight
|
||||
MarkdownEditor.apply_italic
|
||||
MarkdownEditor.apply_strikethrough
|
||||
MarkdownEditor.apply_code
|
||||
MarkdownEditor.apply_heading
|
||||
MarkdownEditor.contextMenuEvent
|
||||
MarkdownEditor.toggle_bullets
|
||||
MarkdownEditor.toggle_numbers
|
||||
MarkdownEditor.toggle_checkboxes
|
||||
|
||||
MarkdownHighlighter.highlightBlock
|
||||