Compare commits

..

31 commits
0.7.5 ... main

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

View file

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

View file

@ -23,7 +23,7 @@ jobs:
- name: Run trivy - name: Run trivy
run: | run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry --skip-version-check --exit-code 1 .
# Notify if any previous step in this job failed # Notify if any previous step in this job failed
- name: Notify on failure - name: Notify on failure

View file

@ -1,3 +1,29 @@
# 0.8.3
* Update urllib3 dependency to resolve CVE-2026-21441
* Fix carrying over data to next day from over-capturing data belonging to next header section
* Other dependency updates
# 0.8.2
* Add ability to delete an invoice via 'Manage Invoices' dialog
# 0.8.1
* Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
* Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
# 0.8.0
* Add .desktop file for Debian
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter.
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
* Add ability to collapse/expand sections of text.
* Add 'Last Month' date range for timesheet reports
* Add missing strings (for English and French)
* Don't offer to download latest AppImage unless we are running as an AppImage already
# 0.7.5 # 0.7.5
* Fix import of sqlcipher4 * Fix import of sqlcipher4

110
Dockerfile.rpmbuild Normal file
View file

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

View file

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

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import re
from PySide6.QtCore import QRect, QSize, Qt from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
@ -32,6 +34,12 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
# Allow Tab to insert indentation (not move focus between widgets)
self.setTabChangesFocus(False)
# Track whether we just auto-inserted indentation on Enter
self._last_enter_was_empty_indent = False
self._line_number_area = _LineNumberArea(self) self._line_number_area = _LineNumberArea(self)
self.blockCountChanged.connect(self._update_line_number_area_width) self.blockCountChanged.connect(self._update_line_number_area_width)
@ -140,6 +148,48 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
bottom = top + self.blockBoundingRect(block).height() bottom = top + self.blockBoundingRect(block).height()
block_number += 1 block_number += 1
def keyPressEvent(self, event): # type: ignore[override]
"""Auto-retain indentation on newlines (Tab/space) like the markdown editor.
Rules:
- If the current line is indented, Enter inserts a newline + the same indent.
- If the current line contains only indentation, a *second* Enter clears the indent
and starts an unindented line (similar to exiting bullets/checkboxes).
"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
cursor = self.textCursor()
block_text = cursor.block().text()
indent = re.match(r"[ \t]*", block_text).group(0) # type: ignore[union-attr]
if indent:
rest = block_text[len(indent) :]
indent_only = rest.strip() == ""
if indent_only and self._last_enter_was_empty_indent:
# Second Enter on an indentation-only line: remove that line and
# start a fresh, unindented line.
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
cursor.removeSelectedText()
cursor.insertText("\n")
self.setTextCursor(cursor)
self._last_enter_was_empty_indent = False
return
# First Enter: keep indentation
super().keyPressEvent(event)
self.textCursor().insertText(indent)
self._last_enter_was_empty_indent = True
return
# No indent -> normal Enter
self._last_enter_was_empty_indent = False
super().keyPressEvent(event)
return
# Any other key resets the empty-indent-enter flag
self._last_enter_was_empty_indent = False
super().keyPressEvent(event)
class CodeBlockEditorDialog(QDialog): class CodeBlockEditorDialog(QDialog):
def __init__( def __init__(

View file

@ -2392,6 +2392,18 @@ class DBManager:
(document_id, invoice_id), (document_id, invoice_id),
) )
def delete_invoice(self, invoice_id: int) -> None:
"""Delete an invoice.
Related invoice line items and invoice time log links are removed via
ON DELETE CASCADE.
"""
with self.conn:
self.conn.execute(
"DELETE FROM invoices WHERE id = ?",
(invoice_id,),
)
def time_logs_for_range( def time_logs_for_range(
self, self,
project_id: int, project_id: int,

View file

@ -1065,6 +1065,10 @@ class InvoicesDialog(QDialog):
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch(1) btn_row.addStretch(1)
delete_btn = QPushButton(strings._("delete"))
delete_btn.clicked.connect(self._on_delete_clicked)
btn_row.addWidget(delete_btn)
close_btn = QPushButton(strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn) btn_row.addWidget(close_btn)
@ -1073,6 +1077,68 @@ class InvoicesDialog(QDialog):
self._reload_invoices() self._reload_invoices()
# ----------------------------------------------------------------- deletion
def _on_delete_clicked(self) -> None:
"""Delete the currently selected invoice."""
row = self.table.currentRow()
if row < 0:
sel = self.table.selectionModel().selectedRows()
if sel:
row = sel[0].row()
if row < 0:
QMessageBox.information(
self,
strings._("delete"),
strings._("invoice_required"),
)
return
base_item = self.table.item(row, self.COL_NUMBER)
if base_item is None:
return
inv_id = base_item.data(Qt.ItemDataRole.UserRole)
if not inv_id:
return
invoice_number = (base_item.text() or "").strip() or "?"
proj_item = self.table.item(row, self.COL_PROJECT)
project_name = (proj_item.text() if proj_item is not None else "").strip()
label = strings._("delete")
prompt = (
f"{label} '{invoice_number}'"
+ (f" ({project_name})" if project_name else "")
+ "?"
)
resp = QMessageBox.question(
self,
label,
prompt,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if resp != QMessageBox.StandardButton.Yes:
return
# Remove any automatically created due-date reminder.
if self.cfg.reminders:
self._remove_invoice_due_reminder(row, int(inv_id))
try:
self._db.delete_invoice(int(inv_id))
except Exception as e:
QMessageBox.warning(
self,
strings._("error"),
f"Failed to delete invoice: {e}",
)
return
self._reload_invoices()
# ------------------------------------------------------------------ helpers # ------------------------------------------------------------------ helpers
def _reload_projects(self) -> None: def _reload_projects(self) -> None:

View file

@ -217,6 +217,7 @@
"date_range": "Date range", "date_range": "Date range",
"custom_range": "Custom", "custom_range": "Custom",
"last_week": "Last week", "last_week": "Last week",
"last_month": "Last month",
"this_week": "This week", "this_week": "This week",
"this_month": "This month", "this_month": "This month",
"this_year": "This year", "this_year": "This year",
@ -302,6 +303,10 @@
"cut": "Cut", "cut": "Cut",
"copy": "Copy", "copy": "Copy",
"paste": "Paste", "paste": "Paste",
"collapse": "Collapse",
"expand": "Expand",
"remove_collapse": "Remove collapse",
"collapse_selection": "Collapse selection",
"start": "Start", "start": "Start",
"pause": "Pause", "pause": "Pause",
"resume": "Resume", "resume": "Resume",
@ -363,21 +368,19 @@
"documents_col_file": "File", "documents_col_file": "File",
"documents_col_description": "Description", "documents_col_description": "Description",
"documents_col_added": "Added", "documents_col_added": "Added",
"documents_col_path": "Path",
"documents_col_tags": "Tags", "documents_col_tags": "Tags",
"documents_col_size": "Size", "documents_col_size": "Size",
"documents_add": "&Add", "documents_add": "&Add",
"documents_add_document": "Add a document",
"documents_open": "&Open", "documents_open": "&Open",
"documents_delete": "&Delete", "documents_delete": "&Delete",
"documents_no_project_selected": "Please choose a project first.", "documents_no_project_selected": "Please choose a project first.",
"documents_file_filter_all": "All files (*)", "documents_file_filter_all": "All files (*)",
"documents_add_failed": "Could not add document: {error}", "documents_add_failed": "Could not add document: {error}",
"documents_open_failed": "Could not open document: {error}", "documents_open_failed": "Could not open document: {error}",
"documents_missing_file": "The file does not exist:\n{path}",
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)", "documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
"documents_search_label": "Search", "documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)", "documents_search_placeholder": "Type to search documents (all projects)",
"documents_invalid_date_format": "Invalid date format",
"todays_documents": "Documents from this day", "todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.", "todays_documents_none": "No documents yet.",
"manage_invoices": "Manage Invoices", "manage_invoices": "Manage Invoices",
@ -428,5 +431,11 @@
"invoice_company_logo_choose": "Choose logo", "invoice_company_logo_choose": "Choose logo",
"invoice_company_logo_set": "Logo has been set", "invoice_company_logo_set": "Logo has been set",
"invoice_company_logo_not_set": "Logo not set", "invoice_company_logo_not_set": "Logo not set",
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists." "invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
"invoice_invalid_amount": "The amount is invalid",
"invoice_invalid_date_format": "Invalid date format",
"invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
} }

View file

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

View file

@ -28,7 +28,6 @@ from PySide6.QtGui import (
QGuiApplication, QGuiApplication,
QKeySequence, QKeySequence,
QTextCursor, QTextCursor,
QTextListFormat,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
@ -871,6 +870,13 @@ class MainWindow(QMainWindow):
into the rollover target date (today, or next Monday if today into the rollover target date (today, or next Monday if today
is a weekend). is a weekend).
In addition to moving the unchecked checkbox *line* itself, this also
moves any subsequent lines that belong to that unchecked item, stopping
at the next *checked* checkbox line **or** the next markdown heading.
This allows code fences, collapsed blocks, and notes under a todo to
travel with it without accidentally pulling in the next section.
Returns True if any items were moved, False otherwise. Returns True if any items were moved, False otherwise.
""" """
if not getattr(self.cfg, "move_todos", False): if not getattr(self.cfg, "move_todos", False):
@ -885,7 +891,9 @@ class MainWindow(QMainWindow):
# Regexes for markdown headings and checkboxes # Regexes for markdown headings and checkboxes
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$") heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+") unchecked_re = re.compile(r"^(\s*)-\s*\[[\s☐]\]\s+(.*)$")
checked_re = re.compile(r"^(\s*)-\s*\[[xX☑]\]\s+(.*)$")
fence_re = re.compile(r"^\s*(`{3,}|~{3,})")
def _normalize_heading(text: str) -> str: def _normalize_heading(text: str) -> str:
""" """
@ -896,13 +904,47 @@ class MainWindow(QMainWindow):
text = re.sub(r"\s+#+\s*$", "", text) text = re.sub(r"\s+#+\s*$", "", text)
return text.strip() return text.strip()
def _insert_todos_under_heading( def _update_fence_state(
line: str, in_fence: bool, fence_marker: str | None
) -> tuple[bool, str | None]:
"""
Track fenced code blocks (``` / ~~~). We ignore checkbox markers inside
fences so we don't accidentally split/move based on "- [x]" that appears
in code.
"""
m = fence_re.match(line)
if not m:
return in_fence, fence_marker
marker = m.group(1)
if not in_fence:
return True, marker
# Close only when we see a fence of the same char and >= length
if (
fence_marker
and marker[0] == fence_marker[0]
and len(marker) >= len(fence_marker)
):
return False, None
return in_fence, fence_marker
def _is_list_item(line: str) -> bool:
s = line.lstrip()
return bool(
re.match(r"^([-*+]\s+|\d+\.\s+)", s)
or unchecked_re.match(line)
or checked_re.match(line)
)
def _insert_blocks_under_heading(
target_lines: list[str], target_lines: list[str],
heading_level: int, heading_level: int,
heading_text: str, heading_text: str,
todos: list[str], blocks: list[list[str]],
) -> list[str]: ) -> list[str]:
"""Ensure a heading exists and append todos to the end of its section.""" """Ensure a heading exists and append blocks to the end of its section."""
normalized = _normalize_heading(heading_text) normalized = _normalize_heading(heading_text)
# 1) Find existing heading with same text (any level) # 1) Find existing heading with same text (any level)
@ -942,15 +984,137 @@ class MainWindow(QMainWindow):
): ):
insert_at -= 1 insert_at -= 1
for todo in todos: # Insert blocks (preserve internal blank lines)
target_lines.insert(insert_at, todo) for block in blocks:
insert_at += 1 if not block:
continue
# Avoid gluing a paragraph to the new block unless both look like list items
if (
insert_at > start_idx + 1
and target_lines[insert_at - 1].strip() != ""
and block[0].strip() != ""
and not (
_is_list_item(target_lines[insert_at - 1])
and _is_list_item(block[0])
)
):
target_lines.insert(insert_at, "")
insert_at += 1
for line in block:
target_lines.insert(insert_at, line)
insert_at += 1
return target_lines return target_lines
# Collect moved todos as (heading_info, item_text) def _prune_empty_headings(src_lines: list[str]) -> list[str]:
"""Remove markdown headings whose section became empty.
The rollover logic removes unchecked todo *blocks* but intentionally keeps
headings on the source day so we can re-create the same section on the
target day. If a heading ends up with no remaining content (including
empty subheadings), we should remove it from the source day too.
Headings inside fenced code blocks are ignored.
"""
# Identify headings (outside fences) and their levels
heading_levels: dict[int, int] = {}
heading_indices: list[int] = []
in_f = False
f_mark: str | None = None
for idx, ln in enumerate(src_lines):
if not in_f:
m = heading_re.match(ln)
if m:
heading_indices.append(idx)
heading_levels[idx] = len(m.group(1))
in_f, f_mark = _update_fence_state(ln, in_f, f_mark)
if not heading_indices:
return src_lines
# Compute each heading's section boundary: next heading with level <= current
boundary: dict[int, int] = {}
stack: list[int] = []
for idx in heading_indices:
lvl = heading_levels[idx]
while stack and lvl <= heading_levels[stack[-1]]:
boundary[stack.pop()] = idx
stack.append(idx)
for idx in stack:
boundary[idx] = len(src_lines)
# Build parent/children relationships based on heading levels
children: dict[int, list[int]] = {}
parent_stack: list[int] = []
for idx in heading_indices:
lvl = heading_levels[idx]
while parent_stack and lvl <= heading_levels[parent_stack[-1]]:
parent_stack.pop()
if parent_stack:
children.setdefault(parent_stack[-1], []).append(idx)
parent_stack.append(idx)
# Determine whether each heading has any non-heading, non-blank content in its span
has_body: dict[int, bool] = {}
for h_idx in heading_indices:
end = boundary[h_idx]
body = False
in_f = False
f_mark = None
for j in range(h_idx + 1, end):
ln = src_lines[j]
if not in_f:
if ln.strip() and not heading_re.match(ln):
body = True
break
in_f, f_mark = _update_fence_state(ln, in_f, f_mark)
has_body[h_idx] = body
# Bottom-up: keep headings that have body content or any kept child headings
keep: dict[int, bool] = {}
for h_idx in reversed(heading_indices):
keep_child = any(keep.get(ch, False) for ch in children.get(h_idx, []))
keep[h_idx] = has_body[h_idx] or keep_child
remove_set = {idx for idx, k in keep.items() if not k}
if not remove_set:
return src_lines
# Remove empty headings and any immediate blank lines following them
out: list[str] = []
i = 0
while i < len(src_lines):
if i in remove_set:
i += 1
while i < len(src_lines) and src_lines[i].strip() == "":
i += 1
continue
out.append(src_lines[i])
i += 1
# Normalize excessive blank lines created by removals
cleaned: list[str] = []
prev_blank = False
for ln in out:
blank = ln.strip() == ""
if blank and prev_blank:
continue
cleaned.append(ln)
prev_blank = blank
while cleaned and cleaned[0].strip() == "":
cleaned.pop(0)
while cleaned and cleaned[-1].strip() == "":
cleaned.pop()
return cleaned
# Collect moved blocks as (heading_info, block_lines)
# heading_info is either None or (level, heading_text) # heading_info is either None or (level, heading_text)
moved_items: list[tuple[tuple[int, str] | None, str]] = [] moved_blocks: list[tuple[tuple[int, str] | None, list[str]]] = []
any_moved = False any_moved = False
# Look back N days (yesterday = 1, up to `days_back`) # Look back N days (yesterday = 1, up to `days_back`)
@ -966,28 +1130,87 @@ class MainWindow(QMainWindow):
moved_from_this_day = False moved_from_this_day = False
current_heading: tuple[int, str] | None = None current_heading: tuple[int, str] | None = None
for line in lines: in_fence = False
# Track the last seen heading (# / ## / ###) fence_marker: str | None = None
m_head = heading_re.match(line)
if m_head:
level = len(m_head.group(1))
heading_text = _normalize_heading(m_head.group(2))
if level <= 3:
current_heading = (level, heading_text)
# Keep headings in the original day
remaining_lines.append(line)
continue
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] " i = 0
if unchecked_re.match(line): while i < len(lines):
item_text = unchecked_re.sub("", line) line = lines[i]
moved_items.append((current_heading, item_text))
moved_from_this_day = True # If we're not in a fenced code block, we can interpret headings/checkboxes
any_moved = True if not in_fence:
else: # Track the last seen heading (# / ## / ###)
remaining_lines.append(line) m_head = heading_re.match(line)
if m_head:
level = len(m_head.group(1))
heading_text = _normalize_heading(m_head.group(2))
if level <= 3:
current_heading = (level, heading_text)
# Keep headings in the original day (only headings ABOVE a moved block are "carried")
remaining_lines.append(line)
in_fence, fence_marker = _update_fence_state(
line, in_fence, fence_marker
)
i += 1
continue
# Start of an unchecked checkbox block
m_unchecked = unchecked_re.match(line)
if m_unchecked:
indent = m_unchecked.group(1) or ""
item_text = m_unchecked.group(2)
block: list[str] = [f"{indent}- [ ] {item_text}"]
i += 1
# Consume subsequent lines until the next *checked* checkbox
# (ignoring any "- [x]" that appear inside fenced code blocks)
block_in_fence = in_fence
block_fence_marker = fence_marker
while i < len(lines):
nxt = lines[i]
# If we're not inside a fence, a checked checkbox ends the block,
# otherwise a new heading does as well.
if not block_in_fence and (
checked_re.match(nxt) or heading_re.match(nxt)
):
break
# Normalize any unchecked checkbox lines inside the block
m_inner_unchecked = (
unchecked_re.match(nxt) if not block_in_fence else None
)
if m_inner_unchecked:
inner_indent = m_inner_unchecked.group(1) or ""
inner_text = m_inner_unchecked.group(2)
block.append(f"{inner_indent}- [ ] {inner_text}")
else:
block.append(nxt)
# Update fence state after consuming the line
block_in_fence, block_fence_marker = _update_fence_state(
nxt, block_in_fence, block_fence_marker
)
i += 1
# Carry the last heading *above* the unchecked checkbox
moved_blocks.append((current_heading, block))
moved_from_this_day = True
any_moved = True
# We consumed the block; keep scanning from the checked checkbox (or EOF)
continue
# Default: keep the line on the original day
remaining_lines.append(line)
in_fence, fence_marker = _update_fence_state(
line, in_fence, fence_marker
)
i += 1
if moved_from_this_day: if moved_from_this_day:
remaining_lines = _prune_empty_headings(remaining_lines)
modified_text = "\n".join(remaining_lines) modified_text = "\n".join(remaining_lines)
# Save the cleaned-up source day # Save the cleaned-up source day
self.db.save_new_version( self.db.save_new_version(
@ -999,33 +1222,52 @@ class MainWindow(QMainWindow):
if not any_moved: if not any_moved:
return False return False
# --- Merge all moved items into the *target* date --- # --- Merge all moved blocks into the *target* date ---
target_text = self.db.get_entry(target_iso) or "" target_text = self.db.get_entry(target_iso) or ""
target_lines = target_text.split("\n") if target_text else [] # Treat a whitespace-only target note as truly empty; otherwise we can
# end up appending the new heading *after* leading blank lines (e.g. if
# a newly-created empty day was previously saved as just "\n").
if not target_text.strip():
target_lines = []
else:
target_lines = target_text.split("\n")
by_heading: dict[tuple[int, str], list[str]] = {} by_heading: dict[tuple[int, str], list[list[str]]] = {}
plain_items: list[str] = [] plain_blocks: list[list[str]] = []
for heading_info, item_text in moved_items: for heading_info, block in moved_blocks:
todo_line = f"- [ ] {item_text}"
if heading_info is None: if heading_info is None:
# No heading above this checkbox in the source: behave as before plain_blocks.append(block)
plain_items.append(todo_line)
else: else:
by_heading.setdefault(heading_info, []).append(todo_line) by_heading.setdefault(heading_info, []).append(block)
# First insert all items that have headings # First insert all blocks that have headings
for (level, heading_text), todos in by_heading.items(): for (level, heading_text), blocks in by_heading.items():
target_lines = _insert_todos_under_heading( target_lines = _insert_blocks_under_heading(
target_lines, level, heading_text, todos target_lines, level, heading_text, blocks
) )
# Then append all items without headings at the end, like before # Then append all blocks without headings at the end, like before
if plain_items: if plain_blocks:
if target_lines and target_lines[-1].strip(): if target_lines and target_lines[-1].strip():
target_lines.append("") # one blank line before the "unsectioned" todos target_lines.append("") # one blank line before the "unsectioned" todos
target_lines.extend(plain_items) first = True
for block in plain_blocks:
if not block:
continue
if (
not first
and target_lines
and target_lines[-1].strip() != ""
and block[0].strip() != ""
and not (
_is_list_item(target_lines[-1]) and _is_list_item(block[0])
)
):
target_lines.append("")
target_lines.extend(block)
first = False
new_target_text = "\n".join(target_lines) new_target_text = "\n".join(target_lines)
if not new_target_text.endswith("\n"): if not new_target_text.endswith("\n"):
@ -1241,46 +1483,58 @@ class MainWindow(QMainWindow):
self._toolbar_bound = True self._toolbar_bound = True
def _sync_toolbar(self): def _sync_toolbar(self):
fmt = self.editor.currentCharFormat() """
Keep the toolbar "sticky" by reflecting the markdown state at the current caret/selection.
"""
c = self.editor.textCursor() c = self.editor.textCursor()
line = c.block().text()
# Inline styles (markdown-aware)
bold_on = bool(getattr(self.editor, "is_markdown_bold_active", lambda: False)())
italic_on = bool(
getattr(self.editor, "is_markdown_italic_active", lambda: False)()
)
strike_on = bool(
getattr(self.editor, "is_markdown_strike_active", lambda: False)()
)
# Block signals so setChecked() doesn't re-trigger actions # Block signals so setChecked() doesn't re-trigger actions
QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actBold)
QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actItalic)
QSignalBlocker(self.toolBar.actStrike) QSignalBlocker(self.toolBar.actStrike)
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actBold.setChecked(bold_on)
self.toolBar.actItalic.setChecked(fmt.fontItalic()) self.toolBar.actItalic.setChecked(italic_on)
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) self.toolBar.actStrike.setChecked(strike_on)
# Headings: decide which to check by current point size # Headings: infer from leading markdown markers
def _approx(a, b, eps=0.5): # small float tolerance heading_level = 0
return abs(float(a) - float(b)) <= eps m = re.match(r"^\s*(#{1,3})\s+", line)
if m:
cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() heading_level = len(m.group(1))
bH1 = _approx(cur_size, 24)
bH2 = _approx(cur_size, 18)
bH3 = _approx(cur_size, 14)
QSignalBlocker(self.toolBar.actH1) QSignalBlocker(self.toolBar.actH1)
QSignalBlocker(self.toolBar.actH2) QSignalBlocker(self.toolBar.actH2)
QSignalBlocker(self.toolBar.actH3) QSignalBlocker(self.toolBar.actH3)
QSignalBlocker(self.toolBar.actNormal) QSignalBlocker(self.toolBar.actNormal)
self.toolBar.actH1.setChecked(bH1) self.toolBar.actH1.setChecked(heading_level == 1)
self.toolBar.actH2.setChecked(bH2) self.toolBar.actH2.setChecked(heading_level == 2)
self.toolBar.actH3.setChecked(bH3) self.toolBar.actH3.setChecked(heading_level == 3)
self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) self.toolBar.actNormal.setChecked(heading_level == 0)
# Lists: infer from leading markers on the current line
bullets_on = bool(re.match(r"^\s*(?:•|-|\*)\s+", line))
numbers_on = bool(re.match(r"^\s*\d+\.\s+", line))
checkboxes_on = bool(re.match(r"^\s*[☐☑]\s+", line))
# Lists
lst = c.currentList()
bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
QSignalBlocker(self.toolBar.actBullets) QSignalBlocker(self.toolBar.actBullets)
QSignalBlocker(self.toolBar.actNumbers) QSignalBlocker(self.toolBar.actNumbers)
self.toolBar.actBullets.setChecked(bool(bullets_on)) QSignalBlocker(self.toolBar.actCheckboxes)
self.toolBar.actNumbers.setChecked(bool(numbers_on))
self.toolBar.actBullets.setChecked(bullets_on)
self.toolBar.actNumbers.setChecked(numbers_on)
self.toolBar.actCheckboxes.setChecked(checkboxes_on)
def _change_font_size(self, delta: int) -> None: def _change_font_size(self, delta: int) -> None:
"""Change font size for all editor tabs and save the setting.""" """Change font size for all editor tabs and save the setting."""

File diff suppressed because it is too large Load diff

View file

@ -111,6 +111,25 @@ class PomodoroManager:
self._parent = parent_window self._parent = parent_window
self._active_timer: Optional[PomodoroTimer] = None self._active_timer: Optional[PomodoroTimer] = None
@staticmethod
def _seconds_to_logged_hours(elapsed_seconds: int) -> float:
"""Convert elapsed seconds to decimal hours for logging.
Rules:
- For very short runs (< 15 minutes), always round up to 0.25h (15 minutes).
- Otherwise, round to the closest 0.25h (15-minute) increment.
Halfway cases (e.g., 22.5 minutes) round up.
"""
if elapsed_seconds < 0:
elapsed_seconds = 0
# 15 minutes = 900 seconds
if elapsed_seconds < 900:
return 0.25
quarters = int(math.floor((elapsed_seconds / 900.0) + 0.5))
return quarters * 0.25
def start_timer_for_line(self, line_text: str, date_iso: str): def start_timer_for_line(self, line_text: str, date_iso: str):
""" """
Start a new timer for the given line of text and embed it into the Start a new timer for the given line of text and embed it into the
@ -156,9 +175,8 @@ class PomodoroManager:
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): 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.""" """Handle timer stop - open time log dialog with pre-filled data."""
# Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes) # Convert seconds to decimal hours, and handle rounding up or down
quarter_hours = math.ceil(elapsed_seconds / 900) hours = self._seconds_to_logged_hours(elapsed_seconds)
hours = quarter_hours * 0.25
# Ensure minimum of 0.25 hours # Ensure minimum of 0.25 hours
if hours < 0.25: if hours < 0.25:

View file

@ -532,7 +532,7 @@ class SettingsDialog(QDialog):
def _on_choose_logo(self) -> None: def _on_choose_logo(self) -> None:
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, self,
strings._("company_logo_choose"), strings._("invoice_company_logo_choose"),
"", "",
"Images (*.png *.jpg *.jpeg *.bmp)", "Images (*.png *.jpg *.jpeg *.bmp)",
) )

View file

@ -1055,6 +1055,7 @@ class TimeReportDialog(QDialog):
self.range_preset.addItem(strings._("today"), "today") self.range_preset.addItem(strings._("today"), "today")
self.range_preset.addItem(strings._("last_week"), "last_week") self.range_preset.addItem(strings._("last_week"), "last_week")
self.range_preset.addItem(strings._("this_week"), "this_week") self.range_preset.addItem(strings._("this_week"), "this_week")
self.range_preset.addItem(strings._("last_month"), "last_month")
self.range_preset.addItem(strings._("this_month"), "this_month") self.range_preset.addItem(strings._("this_month"), "this_month")
self.range_preset.addItem(strings._("this_year"), "this_year") self.range_preset.addItem(strings._("this_year"), "this_year")
self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed) self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed)
@ -1214,6 +1215,12 @@ class TimeReportDialog(QDialog):
start = start_of_this_week.addDays(-7) # last week's Monday start = start_of_this_week.addDays(-7) # last week's Monday
end = start_of_this_week.addDays(-1) # last week's Sunday end = start_of_this_week.addDays(-1) # last week's Sunday
elif preset == "last_month":
# Previous calendar month (full month)
start_of_this_month = QDate(today.year(), today.month(), 1)
start = start_of_this_month.addMonths(-1)
end = start_of_this_month.addDays(-1)
elif preset == "this_month": elif preset == "this_month":
start = QDate(today.year(), today.month(), 1) start = QDate(today.year(), today.month(), 1)
end = today end = today

View file

@ -98,6 +98,7 @@ class ToolBar(QToolBar):
self.actNumbers.triggered.connect(self.numbersRequested) self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self) self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
self.actCheckboxes.setCheckable(True)
self.actCheckboxes.triggered.connect(self.checkboxesRequested) self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images # Images
@ -126,22 +127,14 @@ class ToolBar(QToolBar):
self.actDocuments = QAction("📁", self) self.actDocuments = QAction("📁", self)
self.actDocuments.setToolTip(strings._("toolbar_documents")) self.actDocuments.setToolTip(strings._("toolbar_documents"))
self.actDocuments.triggered.connect(self.documentsRequested) self.actDocuments.triggered.connect(self.documentsRequested)
# Headings are mutually exclusive (like radio buttons)
# Set exclusive buttons in QActionGroups
self.grpHeadings = QActionGroup(self) self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True) self.grpHeadings.setExclusive(True)
for a in ( for a in (self.actH1, self.actH2, self.actH3, self.actNormal):
self.actBold,
self.actItalic,
self.actStrike,
self.actH1,
self.actH2,
self.actH3,
self.actNormal,
):
a.setCheckable(True) a.setCheckable(True)
a.setActionGroup(self.grpHeadings) a.setActionGroup(self.grpHeadings)
# List types are mutually exclusive
self.grpLists = QActionGroup(self) self.grpLists = QActionGroup(self)
self.grpLists.setExclusive(True) self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes): for a in (self.actBullets, self.actNumbers, self.actCheckboxes):

View file

@ -95,6 +95,9 @@ class VersionChecker:
""" """
return self._parse_version(available) > self._parse_version(current) return self._parse_version(available) > self._parse_version(current)
def _running_in_appimage(self) -> bool:
return "APPIMAGE" in os.environ
# ---------- Public entrypoint for Help → Version ---------- # # ---------- Public entrypoint for Help → Version ---------- #
def show_version_dialog(self) -> None: def show_version_dialog(self) -> None:
@ -114,8 +117,8 @@ class VersionChecker:
check_button = box.addButton( check_button = box.addButton(
strings._("check_for_updates"), QMessageBox.ActionRole strings._("check_for_updates"), QMessageBox.ActionRole
) )
box.addButton(QMessageBox.Close)
box.addButton(QMessageBox.Close)
box.exec() box.exec()
if box.clickedButton() is check_button: if box.clickedButton() is check_button:
@ -159,21 +162,32 @@ class VersionChecker:
return return
# Newer version is available # Newer version is available
reply = QMessageBox.question(
self._parent,
strings._("update"),
(
strings._("there_is_a_new_version_available")
+ available_raw
+ "\n\n"
+ strings._("download_the_appimage")
),
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._download_and_verify_appimage(available_raw) if self._running_in_appimage():
# If running in an AppImage, offer to download the new AppImage
reply = QMessageBox.question(
self._parent,
strings._("update"),
(
strings._("there_is_a_new_version_available")
+ available_raw
+ "\n\n"
+ strings._("download_the_appimage")
),
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._download_and_verify_appimage(available_raw)
else:
# If not running in an AppImage, just report that there's a new version.
QMessageBox.information(
self._parent,
strings._("update"),
(strings._("there_is_a_new_version_available") + available_raw),
)
return
# ---------- Download + verification helpers ---------- # # ---------- Download + verification helpers ---------- #
def _download_file( def _download_file(

13
debian/bouquin.desktop vendored Normal file
View file

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

2
debian/bouquin.install vendored Normal file
View file

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

34
debian/changelog vendored
View file

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

305
poetry.lock generated
View file

@ -14,13 +14,13 @@ files = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12" version = "2026.1.4"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
] ]
[[package]] [[package]]
@ -158,103 +158,103 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.0" version = "7.13.2"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, {file = "coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, {file = "coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"},
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, {file = "coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, {file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, {file = "coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, {file = "coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"},
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, {file = "coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, {file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, {file = "coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, {file = "coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, {file = "coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, {file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, {file = "coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, {file = "coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"},
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, {file = "coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, {file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"},
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"},
] ]
[package.dependencies] [package.dependencies]
@ -321,28 +321,28 @@ files = [
[[package]] [[package]]
name = "markdown" name = "markdown"
version = "3.10" version = "3.10.1"
description = "Python implementation of John Gruber's Markdown." description = "Python implementation of John Gruber's Markdown."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, {file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"},
{file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, {file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"},
] ]
[package.extras] [package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"]
testing = ["coverage", "pyyaml"] testing = ["coverage", "pyyaml"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "26.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
] ]
[[package]] [[package]]
@ -560,53 +560,58 @@ files = [
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.3.0" version = "2.4.0"
description = "A lil' TOML parser" description = "A lil' TOML parser"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"},
{file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"},
{file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"},
{file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"},
{file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"},
{file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"},
] ]
[[package]] [[package]]
@ -622,13 +627,13 @@ files = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.2" version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
] ]
[package.extras] [package.extras]
@ -640,4 +645,4 @@ zstd = ["backports-zstd (>=1.0.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "0241cd7378c45e79da728a23b89defa18f776ada9af1e60f2a19b0d90f3a2c19" content-hash = "0ce61476b8c35f864d395ab3a4ee4645a1ad2e16aa800cba5e77e5bfee23d6a6"

View file

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

View file

@ -69,4 +69,50 @@ for dist in ${DISTS[@]}; do
reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}" reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}"
done done
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
# RPM
sudo apt-get -y install createrepo-c rpm
BUILD_OUTPUT="${HOME}/git/bouquin/dist"
KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
REPO_ROOT="${HOME}/git/repo_rpm"
REMOTE="letessier.mig5.net:/opt/repo_rpm"
DISTS=(
fedora:42
)
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
REPO_RELEASE_ROOT="${REPO_ROOT}/${release}"
RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64"
mkdir -p "$RPM_REPO"
docker build \
--no-cache \
-f Dockerfile.rpmbuild \
-t bouquin-rpm:${release} \
--progress=plain \
--build-arg BASE_IMAGE=${dist} \
.
docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:${release}
sudo chown -R "${USER}" "$PWD/dist"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file"
done
cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/"
createrepo_c "$RPM_REPO"
echo "==> Signing repomd.xml..."
qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
done
echo "==> Syncing repo to server..."
rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/"
echo "Done!"
ssh lupin.mig5.net "echo ${VERSION} | tee /var/www/bouquin/version.txt"

95
rpm/bouquin.spec Normal file
View file

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

View file

@ -3,8 +3,8 @@ from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog, CodeBlockEditorDialog,
CodeEditorWithLineNumbers, CodeEditorWithLineNumbers,
) )
from PySide6.QtCore import QRect, QSize from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QFont, QPaintEvent from PySide6.QtGui import QFont, QPaintEvent, QTextCursor
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
@ -323,3 +323,42 @@ def test_code_editor_viewport_margins(qtbot, app):
assert margins.top() == 0 assert margins.top() == 0
assert margins.right() == 0 assert margins.right() == 0
assert margins.bottom() == 0 assert margins.bottom() == 0
def test_code_editor_retains_indentation_on_enter(qtbot, app):
"""Pressing Enter on an indented line retains indentation in code editor."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("\tfoo")
editor.show()
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
def test_code_editor_double_enter_on_empty_indent_resets(qtbot, app):
"""Second Enter on an indentation-only line clears the indent in code editor."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("\tfoo")
editor.show()
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.End)
editor.setTextCursor(cursor)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\n")
assert editor.textCursor().block().text() == ""

View file

@ -1,6 +1,7 @@
import base64 import base64
import pytest import pytest
import re
from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.theme import Theme, ThemeConfig, ThemeManager
@ -72,8 +73,13 @@ def test_apply_styles_and_headings(editor, qtbot):
editor.apply_italic() editor.apply_italic()
editor.apply_strikethrough() editor.apply_strikethrough()
editor.apply_heading(24) editor.apply_heading(24)
md = editor.to_markdown() md = editor.to_markdown().strip()
assert "**" in md and "*~~~~*" in md
assert md.startswith("# ")
assert "~~hello world~~" in md
assert re.search(
r"\*{2,3}~~hello world~~\*{2,3}", md
) # bold or bold+italic wrapping strike
def test_toggle_lists_and_checkboxes(editor): def test_toggle_lists_and_checkboxes(editor):
@ -150,6 +156,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
assert "\n\u2022 " in txt assert "\n\u2022 " in txt
def test_tab_indentation_is_retained_on_newline(editor, qtbot):
"""Pressing Enter on an indented line should retain the indentation."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("\tfoo")
editor.moveCursor(QTextCursor.End)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
def test_double_enter_on_empty_indented_line_resets_indent(editor, qtbot):
"""A second Enter on an indentation-only line should reset to column 0."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("\tfoo")
editor.moveCursor(QTextCursor.End)
# First Enter inserts a new indented line
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\t")
# Second Enter on the now-empty indented line removes the indent
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert editor.toPlainText().endswith("\tfoo\n\n")
# Cursor should be on a fresh unindented blank line
assert editor.textCursor().block().text() == ""
def test_nested_list_continuation_preserves_indentation(editor, qtbot):
"""Enter on an indented bullet should keep indent + bullet prefix."""
qtbot.addWidget(editor)
editor.show()
editor.from_markdown("\t- item")
editor.moveCursor(QTextCursor.End)
qtbot.keyPress(editor, Qt.Key_Return)
qtbot.wait(0)
assert "\n\t\u2022 " in editor.toPlainText()
def test_enter_on_empty_list_marks_empty(qtbot, editor): def test_enter_on_empty_list_marks_empty(qtbot, editor):
qtbot.addWidget(editor) qtbot.addWidget(editor)
editor.show() editor.show()
@ -181,6 +234,116 @@ def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor,
assert t == "" assert t == ""
def _find_first_block(doc, predicate):
b = doc.begin()
while b.isValid():
if predicate(b):
return b
b = b.next()
return None
def test_collapse_selection_wraps_and_hides_blocks(editor, qtbot):
"""Collapsing a selection should insert header/end marker and hide content."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("a\nb\nc\n")
doc = editor.document()
# Select lines b..c
b_block = doc.findBlockByNumber(1)
c_block = doc.findBlockByNumber(2)
cur = editor.textCursor()
cur.setPosition(b_block.position())
cur.setPosition(c_block.position() + c_block.length() - 1, QTextCursor.KeepAnchor)
editor.setTextCursor(cur)
editor.collapse_selection()
qtbot.wait(0)
# Header and end marker should exist as their own blocks
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith(""))
assert header is not None
assert "" in header.text() and "expand" in header.text()
end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text())
assert end_marker is not None
# Inner blocks should be hidden; end marker always hidden
inner1 = header.next()
inner2 = inner1.next()
assert inner1.text() == "b"
assert inner2.text() == "c"
assert inner1.isVisible() is False
assert inner2.isVisible() is False
assert end_marker.isVisible() is False
def test_toggle_collapse_expands_and_updates_header(editor, qtbot):
"""Toggling a collapse header should reveal hidden blocks and flip label."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("a\nb\nc\n")
doc = editor.document()
# Select b..c and collapse
b_block = doc.findBlockByNumber(1)
c_block = doc.findBlockByNumber(2)
cur = editor.textCursor()
cur.setPosition(b_block.position(), QTextCursor.MoveMode.MoveAnchor)
cur.setPosition(
c_block.position() + c_block.length() - 1, QTextCursor.MoveMode.KeepAnchor
)
editor.setTextCursor(cur)
editor.collapse_selection()
qtbot.wait(0)
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith(""))
assert header is not None
# Toggle to expand
editor._toggle_collapse_at_block(header)
qtbot.wait(0)
header2 = doc.findBlock(header.position())
assert "" in header2.text() and "collapse" in header2.text()
assert header2.next().isVisible() is True
assert header2.next().next().isVisible() is True
def test_collapse_selection_without_trailing_newline_keeps_marker_on_own_line(
editor, qtbot
):
"""Selections reaching EOF without a trailing newline should still fold correctly."""
qtbot.addWidget(editor)
editor.show()
editor.setPlainText("a\nb\nc") # no trailing newline
doc = editor.document()
# Bottom-up selection of last two lines (c..b)
b_block = doc.findBlockByNumber(1)
c_block = doc.findBlockByNumber(2)
cur = editor.textCursor()
cur.setPosition(c_block.position() + len(c_block.text()))
cur.setPosition(b_block.position(), QTextCursor.KeepAnchor)
editor.setTextCursor(cur)
editor.collapse_selection()
qtbot.wait(0)
end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text())
assert end_marker is not None
# End marker is its own block, and remains hidden
assert end_marker.text().strip() == "<!-- bouquin:collapse:end -->"
assert end_marker.isVisible() is False
# The last content line should be hidden (folded)
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith(""))
assert header is not None
assert header.next().isVisible() is False
def test_down_escapes_from_last_code_line(editor, qtbot): def test_down_escapes_from_last_code_line(editor, qtbot):
editor.from_markdown("```\nLINE\n```\n") editor.from_markdown("```\nLINE\n```\n")
# Put caret at end of "LINE" # Put caret at end of "LINE"

View file

@ -276,7 +276,7 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours(
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
"""Elapsed time should be rounded up to the nearest 0.25 hours.""" """Elapsed time should be rounded to a 0.25-hour increment."""
parent = DummyMainWindow(app) parent = DummyMainWindow(app)
qtbot.addWidget(parent) qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log) qtbot.addWidget(parent.time_log)
@ -300,6 +300,31 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey
assert hours_set * 4 == int(hours_set * 4) assert hours_set * 4 == int(hours_set * 4)
def test_seconds_to_logged_hours_nearest_quarter_rounding():
"""Seconds -> hours uses nearest-quarter rounding with a 15-min minimum."""
# Import the pure conversion helper directly (no Qt required)
from bouquin.pomodoro_timer import PomodoroManager
# <15 minutes always rounds up to 0.25
assert PomodoroManager._seconds_to_logged_hours(1) == 0.25
assert PomodoroManager._seconds_to_logged_hours(899) == 0.25
# 15 minutes exact
assert PomodoroManager._seconds_to_logged_hours(900) == 0.25
# Examples from the spec: closest quarter-hour
# 33 minutes -> closer to 0.50 than 0.75
assert PomodoroManager._seconds_to_logged_hours(33 * 60) == 0.50
# 40 minutes -> closer to 0.75 than 0.50
assert PomodoroManager._seconds_to_logged_hours(40 * 60) == 0.75
# Halfway case: 22.5 min is exactly between 0.25 and 0.50 -> round up
assert PomodoroManager._seconds_to_logged_hours(int(22.5 * 60)) == 0.50
# Sanity: 1 hour stays 1.0
assert PomodoroManager._seconds_to_logged_hours(60 * 60) == 1.00
def test_pomodoro_manager_on_timer_stopped_prefills_note( def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):

View file

@ -1211,6 +1211,26 @@ def test_time_report_dialog_default_date_range(qtbot, fresh_db):
assert dialog.to_date.date() == today assert dialog.to_date.date() == today
def test_time_report_dialog_last_month_preset_sets_full_previous_month(qtbot, fresh_db):
"""Selecting 'Last month' sets the date range to the previous calendar month."""
strings.load_strings("en")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
idx = dialog.range_preset.findData("last_month")
assert idx != -1
today = QDate.currentDate()
start_of_this_month = QDate(today.year(), today.month(), 1)
expected_start = start_of_this_month.addMonths(-1)
expected_end = start_of_this_month.addDays(-1)
dialog.range_preset.setCurrentIndex(idx)
assert dialog.from_date.date() == expected_start
assert dialog.to_date.date() == expected_end
def test_time_report_dialog_run_report(qtbot, fresh_db): def test_time_report_dialog_run_report(qtbot, fresh_db):
"""Run a time report.""" """Run a time report."""
strings.load_strings("en") strings.load_strings("en")

View file

@ -173,45 +173,6 @@ def test_check_for_updates_already_latest(qtbot, app):
assert mock_info.called 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): def test_download_file_success(qtbot, app, tmp_path):
"""Test downloading a file successfully.""" """Test downloading a file successfully."""
checker = VersionChecker() checker = VersionChecker()