diff --git a/.forgejo/workflows/build-deb.yml b/.forgejo/workflows/build-deb.yml new file mode 100644 index 0000000..0f44886 --- /dev/null +++ b/.forgejo/workflows/build-deb.yml @@ -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" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 87b67ff..8c2fb8d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -35,3 +35,16 @@ jobs: run: | ./tests.sh + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml index 5bb3794..fbe5a7e 100644 --- a/.forgejo/workflows/lint.yml +++ b/.forgejo/workflows/lint.yml @@ -25,3 +25,17 @@ jobs: pyflakes3 tests/* vulture bandit -s B110 -r bouquin/ + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml index 18ced32..d5585f4 100644 --- a/.forgejo/workflows/trivy.yml +++ b/.forgejo/workflows/trivy.yml @@ -23,4 +23,18 @@ jobs: - name: Run trivy 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 + - 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" diff --git a/.gitignore b/.gitignore index 851b242..07c956d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__ dist .coverage *.db +*.pdf +*.csv +*.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6281daa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + args: ["--select=F"] + types: [python] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.11.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.2 + hooks: + - id: bandit + files: ^bouquin/ + args: ["-s", "B110"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7839225..01266cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,119 @@ +# 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 + + * Fix import of sqlcipher4 + +# 0.7.4 + + * Depend on my own bouquin-sqlcipher4 package (upgraded to latest SQLCipher 4.12.0) + * Package a deb for Debian Trixie + +# 0.7.3 + + * Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday. + * Add 'group by activity' in timesheet/invoice reports, rather than just by time period. + +# 0.7.2 + + * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) + +# 0.7.1 + + * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter) + * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present + * Invoicing should not be enabled by default + * Fix Reminders to fire right on the minute after adding them during runtime + * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings. + * Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability + +# 0.7.0 + + * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. + * Add 'Last week' to Time Report dialog range option + * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs) + +# 0.6.4 + + * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. + * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case) + * Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date + +# 0.6.3 + + * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month. + * Allow 'All Projects' for timesheet reports. + * Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed) + * Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday) + +# 0.6.2 + + * Ensure that adding a document whilst on an older date page, uses that date as its upload date + * Add 'Created at' to time log table. + * Show total hours for the day in the time log table (not just in the widget in sidebar) + * Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way + * Indent tabs by 4 spaces in code block editor dialog + +# 0.6.1 + + * Consolidate some code related to opening documents using the Documents feature. + * Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time. + * More code coverage + +# 0.6.0 + + * Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature. + * Close time log dialog if opened via the + button from sidebar widget + * Only show tags in Statistics widget if tags are enabled + * Fix rounding up/down in Pomodoro timer to the closest 15 min interval + +# 0.5.5 + + * Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option) + * Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text. + * Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager) + * Ensure time log reports have an extension + +# 0.5.4 + + * Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it) + +# 0.5.3 + + * Prevent triple-click select from selecting the list item (e.g checkbox, bullet) + * Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues. + * Change History icon (again) + * Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it) + * Prevent double-click of checkbox leading to selecting/highlighting it + * Slightly fade the text of a checkbox line if the checkbox is checked. + * Fix weekend date colours being incorrect on theme change while app is running + * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops + * Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks. + # 0.5.2 * Update icon again to remove background diff --git a/Dockerfile.debbuild b/Dockerfile.debbuild new file mode 100644 index 0000000..4c22e05 --- /dev/null +++ b/Dockerfile.debbuild @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 +ARG BASE_IMAGE=debian:bookworm +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive + +# Build deps +RUN set -eux; \ + . /etc/os-release; \ + apt-get update; \ + 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; \ + 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 \ + ; \ + rm -rf /var/lib/apt/lists/* + +# Build runner script +RUN set -eux; \ + cat > /usr/local/bin/build-deb <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +SRC="${SRC:-/src}" +WORKROOT="${WORKROOT:-/work}" +WORK="${WORKROOT}/src" +OUT="${OUT:-/out}" + +mkdir -p "$WORK" "$OUT" + +rsync -a --delete \ + --exclude '.git' \ + --exclude '.venv' \ + --exclude 'dist' \ + --exclude 'build' \ + --exclude '__pycache__' \ + --exclude '.pytest_cache' \ + --exclude '.mypy_cache' \ + "${SRC}/" "${WORK}/" + +cd "${WORK}" +if [ -n "${SUITE:-}" ]; then + export DEBEMAIL="mig@mig5.net" + export DEBFULLNAME="Miguel Jacq" + + dch --distribution "$SUITE" --local "~${SUITE}" "CI build for $SUITE" +fi +dpkg-buildpackage -us -uc -b + +shopt -s nullglob +cp -v "${WORKROOT}"/*.deb \ + "${WORKROOT}"/*.changes \ + "${WORKROOT}"/*.buildinfo \ + "${WORKROOT}"/*.dsc \ + "${WORKROOT}"/*.tar.* \ + "${OUT}/" || true + +echo "Artifacts copied to ${OUT}" +EOF +RUN chmod +x /usr/local/bin/build-deb + +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/build-deb"] diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild new file mode 100644 index 0000000..9013b6e --- /dev/null +++ b/Dockerfile.rpmbuild @@ -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 :/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"] diff --git a/README.md b/README.md index 5cf77e5..30f7ce1 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,14 @@ 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 also how long we spent on them. -It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement -for SQLite3. This means that the underlying database for the notebook is encrypted at rest. +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. + +This means that the underlying database for the notebook is encrypted at rest. To increase security, the SQLCipher key is requested when the app is opened, and is not written to disk unless the user configures it to be in the settings. @@ -51,16 +57,18 @@ report from within the app, or optionally to check for new versions to upgrade t -## Some of the features +## Features * Data is encrypted at rest * Encryption key is prompted for and never stored, unless user chooses to via Settings * All changes are version controlled, with ability to view/diff versions, revert or delete revisions - * Automatic rendering of basic Markdown syntax * Tabs are supported - right-click on a date from the calendar to open it in a new tab. + * 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 * Search all pages, or find text on current page - * Add and manage tags * Automatic periodic saving (or explicitly save) * Automatic locking of the app after a period of inactivity (default 15 min) * Rekey the database (change the password) @@ -68,19 +76,49 @@ 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) * Dark and light theme support * 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 - * Ability to set reminder alarms (which will be flashed as the reminder) - * Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports + * Ability to 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, 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. + * Add and manage tags on pages and documents ## 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: -It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes. + * 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). -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') + +```bash +sudo mkdir -p /usr/share/keyrings +curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg +echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mig5.list +sudo apt update +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 @@ -97,13 +135,4 @@ If downloading from my Forgejo's Releases page, you may wish to verify the GPG s * Run `poetry install` to install dependencies * Run `poetry run bouquin` to start the application. -### From the releases page - - * 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` +Alternatively, you can download the source code and wheels from Releases as well. diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py index 9cc727c..0743985 100644 --- a/bouquin/bug_report_dialog.py +++ b/bouquin/bug_report_dialog.py @@ -3,19 +3,17 @@ from __future__ import annotations import importlib.metadata import requests - from PySide6.QtWidgets import ( QDialog, - QVBoxLayout, - QLabel, - QTextEdit, QDialogButtonBox, + QLabel, QMessageBox, + QTextEdit, + QVBoxLayout, ) from . import strings - BUG_REPORT_HOST = "https://nr.mig5.net" ROUTE = "forms/bouquin/bugs" diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py new file mode 100644 index 0000000..64bb46b --- /dev/null +++ b/bouquin/code_block_editor_dialog.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import re + +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QLabel, + QPlainTextEdit, + QVBoxLayout, + QWidget, +) + +from . import strings + + +class _LineNumberArea(QWidget): + def __init__(self, editor: "CodeEditorWithLineNumbers"): + super().__init__(editor) + self._editor = editor + + def sizeHint(self) -> QSize: # type: ignore[override] + return QSize(self._editor.line_number_area_width(), 0) + + def paintEvent(self, event): # type: ignore[override] + self._editor.line_number_area_paint_event(event) + + +class CodeEditorWithLineNumbers(QPlainTextEdit): + """QPlainTextEdit with a non-selectable line-number gutter on the left.""" + + def __init__(self, parent=None): + super().__init__(parent) + # 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.blockCountChanged.connect(self._update_line_number_area_width) + self.updateRequest.connect(self._update_line_number_area) + self.cursorPositionChanged.connect(self._line_number_area.update) + + self._update_line_number_area_width() + self._update_tab_stop_width() + + # ---- layout / sizing ------------------------------------------------- + + def setFont(self, font: QFont) -> None: # type: ignore[override] + """Ensure tab width stays at 4 spaces when the font changes.""" + super().setFont(font) + self._update_tab_stop_width() + + def _update_tab_stop_width(self) -> None: + """Set tab width to 4 spaces.""" + metrics = QFontMetrics(self.font()) + # Tab width = width of 4 space characters + self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4) + + def line_number_area_width(self) -> int: + # Enough digits for large-ish code blocks. + digits = max(2, len(str(max(1, self.blockCount())))) + fm = QFontMetrics(self._line_number_font()) + return fm.horizontalAdvance("9" * digits) + 8 + + def _line_number_font(self) -> QFont: + """Font to use for line numbers (slightly smaller than main text).""" + font = self.font() + if font.pointSize() > 0: + font.setPointSize(font.pointSize() - 1) + else: + # fallback for pixel-sized fonts + font.setPointSizeF(font.pointSizeF() * 0.9) + return font + + def _update_line_number_area_width(self) -> None: + margin = self.line_number_area_width() + self.setViewportMargins(margin, 0, 0, 0) + + def resizeEvent(self, event): # type: ignore[override] + super().resizeEvent(event) + cr = self.contentsRect() + self._line_number_area.setGeometry( + QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()) + ) + + def _update_line_number_area(self, rect, dy) -> None: + if dy: + self._line_number_area.scroll(0, dy) + else: + self._line_number_area.update( + 0, rect.y(), self._line_number_area.width(), rect.height() + ) + + if rect.contains(self.viewport().rect()): + self._update_line_number_area_width() + + # ---- painting -------------------------------------------------------- + + def line_number_area_paint_event(self, event) -> None: + painter = QPainter(self._line_number_area) + painter.fillRect(event.rect(), self.palette().base()) + + # Use a slightly smaller font for numbers + painter.setFont(self._line_number_font()) + + # Faded colour: same blend used for completed-task text in + # MarkdownHighlighter (text colour towards background). + pal = self.palette() + text_fg = pal.color(QPalette.Text) + text_bg = pal.color(QPalette.Base) + t = 0.55 # same factor as completed_task_format + faded = QColor( + int(text_fg.red() * (1.0 - t) + text_bg.red() * t), + int(text_fg.green() * (1.0 - t) + text_bg.green() * t), + int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t), + ) + painter.setPen(faded) + + block = self.firstVisibleBlock() + block_number = block.blockNumber() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + fm = self.fontMetrics() + line_height = fm.height() + right_margin = self._line_number_area.width() - 4 + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + number = str(block_number + 1) + painter.setPen(self.palette().text().color()) + painter.drawText( + 0, + int(top), + right_margin, + line_height, + Qt.AlignRight | Qt.AlignVCenter, + number, + ) + + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + block_number += 1 + + 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): + def __init__( + self, code: str, language: str | None, parent=None, allow_delete: bool = False + ): + super().__init__(parent) + self.setWindowTitle(strings._("edit_code_block")) + + self.setMinimumSize(650, 650) + self._code_edit = CodeEditorWithLineNumbers(self) + self._code_edit.setPlainText(code) + + # Track whether the user clicked "Delete" + self._delete_requested = False + + # Language selector (optional) + self._lang_combo = QComboBox(self) + languages = [ + "", + "bash", + "css", + "html", + "javascript", + "php", + "python", + ] + self._lang_combo.addItems(languages) + if language and language in languages: + self._lang_combo.setCurrentText(language) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + if allow_delete: + delete_btn = buttons.addButton( + strings._("delete_code_block"), + QDialogButtonBox.ButtonRole.DestructiveRole, + ) + delete_btn.clicked.connect(self._on_delete_clicked) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(strings._("locale") + ":", self)) + layout.addWidget(self._lang_combo) + layout.addWidget(self._code_edit) + layout.addWidget(buttons) + + def _on_delete_clicked(self) -> None: + """Mark this dialog as 'delete requested' and close as Accepted.""" + self._delete_requested = True + self.accept() + + def was_deleted(self) -> bool: + """Return True if the user chose to delete the code block.""" + return self._delete_requested + + def code(self) -> str: + return self._code_edit.toPlainText() + + def language(self) -> str | None: + text = self._lang_combo.currentText().strip() + return text or None diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py index e462574..74ef6d4 100644 --- a/bouquin/code_highlighter.py +++ b/bouquin/code_highlighter.py @@ -1,9 +1,9 @@ from __future__ import annotations import re -from typing import Optional, Dict +from typing import Dict, Optional -from PySide6.QtGui import QColor, QTextCharFormat, QFont +from PySide6.QtGui import QColor, QFont, QTextCharFormat class CodeHighlighter: @@ -41,6 +41,7 @@ class CodeHighlighter: "not", "or", "pass", + "pprint", "print", "raise", "return", @@ -180,6 +181,7 @@ class CodeHighlighter: "unset", "use", "var", + "var_dump", "while", "xor", "yield", @@ -348,7 +350,7 @@ class CodeBlockMetadata: return "" items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())] - return "" + return "\n" def deserialize(self, text: str): """Deserialize metadata from text.""" @@ -365,3 +367,7 @@ class CodeBlockMetadata: self._block_languages[int(block_num)] = lang except ValueError: pass + + def clear_language(self, block_number: int): + """Remove any stored language for a given block, if present.""" + self._block_languages.pop(block_number, None) diff --git a/bouquin/db.py b/bouquin/db.py index ba6b6ce..157aae8 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -5,14 +5,15 @@ import datetime as _dt import hashlib import html import json -import markdown +import mimetypes import re - from dataclasses import dataclass from pathlib import Path -from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple, Dict +from typing import Dict, List, Sequence, Tuple +import markdown +from sqlcipher4 import Binary +from sqlcipher4 import dbapi2 as sqlite from . import strings @@ -30,6 +31,35 @@ TimeLogRow = Tuple[ int, # minutes str | None, # note ] +DocumentRow = Tuple[ + int, # id + int, # project_id + str, # project_name + str, # file_name + str | None, # description + int, # size_bytes + str, # uploaded_at (ISO) +] +ProjectBillingRow = Tuple[ + int, # project_id + int, # hourly_rate_cents + str, # currency + str | None, # tax_label + float | None, # tax_rate_percent + str | None, # client_name + str | None, # client_company + str | None, # client_address + str | None, # client_email +] +CompanyProfileRow = Tuple[ + str | None, # name + str | None, # address + str | None, # phone + str | None, # email + str | None, # tax_id + str | None, # payment_details + bytes | None, # logo +] _TAG_COLORS = [ "#FFB3BA", # soft red @@ -62,14 +92,38 @@ class DBConfig: idle_minutes: int = 15 # 0 = never lock theme: str = "system" move_todos: bool = False + move_todos_include_weekends: bool = False tags: bool = True time_log: bool = True reminders: bool = True + reminders_webhook_url: str = (None,) + reminders_webhook_secret: str = (None,) + documents: bool = True + invoicing: bool = False locale: str = "en" font_size: int = 11 class DBManager: + # Allow list of invoice columns allowed for dynamic field helpers + _INVOICE_COLUMN_ALLOWLIST = frozenset( + { + "invoice_number", + "issue_date", + "due_date", + "currency", + "tax_label", + "tax_rate_percent", + "subtotal_cents", + "tax_cents", + "total_cents", + "detail_mode", + "paid_at", + "payment_note", + "document_id", + } + ) + def __init__(self, cfg: DBConfig): self.cfg = cfg self.conn: sqlite.Connection | None = None @@ -211,6 +265,105 @@ class DBManager: CREATE INDEX IF NOT EXISTS ix_reminders_active ON reminders(active); + + CREATE TABLE IF NOT EXISTS project_documents ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL, -- FK to projects.id + file_name TEXT NOT NULL, -- original filename + mime_type TEXT, -- optional + description TEXT, + size_bytes INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT ( + strftime('%Y-%m-%d','now') + ), + data BLOB NOT NULL, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE RESTRICT + ); + + CREATE INDEX IF NOT EXISTS ix_project_documents_project + ON project_documents(project_id); + + -- New: tags attached to documents (like page_tags, but for docs) + CREATE TABLE IF NOT EXISTS document_tags ( + document_id INTEGER NOT NULL, -- FK to project_documents.id + tag_id INTEGER NOT NULL, -- FK to tags.id + PRIMARY KEY (document_id, tag_id), + FOREIGN KEY(document_id) REFERENCES project_documents(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id + ON document_tags(tag_id); + + CREATE TABLE IF NOT EXISTS project_billing ( + project_id INTEGER PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + hourly_rate_cents INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'AUD', + tax_label TEXT, + tax_rate_percent REAL, + client_name TEXT, -- contact person + client_company TEXT, -- business name + client_address TEXT, + client_email TEXT + ); + + CREATE TABLE IF NOT EXISTS company_profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT, + address TEXT, + phone TEXT, + email TEXT, + tax_id TEXT, + payment_details TEXT, + logo BLOB + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL + REFERENCES projects(id) ON DELETE RESTRICT, + invoice_number TEXT NOT NULL, + issue_date TEXT NOT NULL, -- yyyy-MM-dd + due_date TEXT, + currency TEXT NOT NULL, + tax_label TEXT, + tax_rate_percent REAL, + subtotal_cents INTEGER NOT NULL, + tax_cents INTEGER NOT NULL, + total_cents INTEGER NOT NULL, + detail_mode TEXT NOT NULL, -- 'detailed' | 'summary' + paid_at TEXT, + payment_note TEXT, + document_id INTEGER, + FOREIGN KEY(document_id) REFERENCES project_documents(id) + ON DELETE SET NULL, + UNIQUE(project_id, invoice_number) + ); + + CREATE INDEX IF NOT EXISTS ix_invoices_project + ON invoices(project_id); + + CREATE TABLE IF NOT EXISTS invoice_line_items ( + id INTEGER PRIMARY KEY, + invoice_id INTEGER NOT NULL + REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + hours REAL NOT NULL, + rate_cents INTEGER NOT NULL, + amount_cents INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_invoice_line_items_invoice + ON invoice_line_items(invoice_id); + + CREATE TABLE IF NOT EXISTS invoice_time_log ( + invoice_id INTEGER NOT NULL + REFERENCES invoices(id) ON DELETE CASCADE, + time_log_id INTEGER NOT NULL + REFERENCES time_log(id) ON DELETE RESTRICT, + PRIMARY KEY (invoice_id, time_log_id) + ); """ ) self.conn.commit() @@ -248,25 +401,37 @@ class DBManager: ).fetchone() return row[0] if row else "" - def search_entries(self, text: str) -> list[str]: + def search_entries(self, text: str) -> list[tuple[str, str, str, str, str | None]]: """ Search for entries by term or tag name. - This only works against the latest version of the page. + Returns both pages and documents. + + kind = "page" or "document" + key = date_iso (page) or str(doc_id) (document) + title = heading for the result ("YYYY-MM-DD" or "Document") + text = source text for the snippet + aux = extra info (file_name for documents, else None) """ cur = self.conn.cursor() q = text.strip() + if not q: + return [] + pattern = f"%{q.lower()}%" - rows = cur.execute( + results: list[tuple[str, str, str, str, str | None]] = [] + + # --- Pages: content or tag matches --------------------------------- + page_rows = cur.execute( """ - SELECT DISTINCT p.date, v.content + SELECT DISTINCT p.date AS date_iso, v.content FROM pages AS p JOIN versions AS v ON v.id = p.current_version_id LEFT JOIN page_tags pt ON pt.page_date = p.date LEFT JOIN tags t - ON t.id = pt.tag_id + ON t.id = pt.tag_id WHERE TRIM(v.content) <> '' AND ( LOWER(v.content) LIKE ? @@ -276,7 +441,54 @@ class DBManager: """, (pattern, pattern), ).fetchall() - return [(r[0], r[1]) for r in rows] + + for r in page_rows: + date_iso = r["date_iso"] + content = r["content"] + results.append(("page", date_iso, date_iso, content, None)) + + # --- Documents: file name, description, or tag matches ------------- + doc_rows = cur.execute( + """ + SELECT DISTINCT + d.id AS doc_id, + d.file_name AS file_name, + d.uploaded_at AS uploaded_at, + COALESCE(d.description, '') AS description, + COALESCE(t.name, '') AS tag_name + FROM project_documents AS d + LEFT JOIN document_tags AS dt + ON dt.document_id = d.id + LEFT JOIN tags AS t + ON t.id = dt.tag_id + WHERE + LOWER(d.file_name) LIKE ? + OR LOWER(COALESCE(d.description, '')) LIKE ? + OR LOWER(COALESCE(t.name, '')) LIKE ? + ORDER BY LOWER(d.file_name); + """, + (pattern, pattern, pattern), + ).fetchall() + + for r in doc_rows: + doc_id = r["doc_id"] + file_name = r["file_name"] + description = r["description"] or "" + uploaded_at = r["uploaded_at"] + # Simple snippet source: file name + description + text_src = f"{file_name}\n{description}".strip() + + results.append( + ( + "document", + str(doc_id), + strings._("search_result_heading_document") + f" ({uploaded_at})", + text_src, + file_name, + ) + ) + + return results def dates_with_content(self) -> list[str]: """ @@ -691,11 +903,12 @@ class DBManager: def delete_tag(self, tag_id: int) -> None: """ - Delete a tag entirely (removes it from all pages). + Delete a tag entirely (removes it from all pages and documents). """ with self.conn: cur = self.conn.cursor() cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) + cur.execute("DELETE FROM document_tags WHERE tag_id=?;", (tag_id,)) cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) def get_pages_for_tag(self, tag_name: str) -> list[Entry]: @@ -761,7 +974,7 @@ class DBManager: # 2 & 3) total revisions + page with most revisions + per-date counts total_revisions = 0 - page_most_revisions = None + page_most_revisions: str | None = None page_most_revisions_count = 0 revisions_by_date: Dict[_dt.date, int] = {} @@ -798,7 +1011,6 @@ class DBManager: words_by_date[d] = wc # tags + page with most tags - rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall() unique_tags = int(rows[0]["total_unique"]) if rows else 0 @@ -819,6 +1031,119 @@ class DBManager: page_most_tags = None page_most_tags_count = 0 + # 5) Time logging stats (minutes / hours) + time_minutes_by_date: Dict[_dt.date, int] = {} + total_time_minutes = 0 + day_most_time: str | None = None + day_most_time_minutes = 0 + + try: + rows = cur.execute( + """ + SELECT page_date, SUM(minutes) AS total_minutes + FROM time_log + GROUP BY page_date + ORDER BY page_date; + """ + ).fetchall() + except Exception: + rows = [] + + for r in rows: + date_iso = r["page_date"] + if not date_iso: + continue + m = int(r["total_minutes"] or 0) + total_time_minutes += m + if m > day_most_time_minutes: + day_most_time_minutes = m + day_most_time = date_iso + try: + d = _dt.date.fromisoformat(date_iso) + except Exception: # nosec B112 + continue + time_minutes_by_date[d] = m + + # Project with most logged time + project_most_minutes_name: str | None = None + project_most_minutes = 0 + + try: + rows = cur.execute( + """ + SELECT p.name AS project_name, + SUM(t.minutes) AS total_minutes + FROM time_log t + JOIN projects p ON p.id = t.project_id + GROUP BY t.project_id, p.name + ORDER BY total_minutes DESC, LOWER(project_name) ASC + LIMIT 1; + """ + ).fetchall() + except Exception: + rows = [] + + if rows: + project_most_minutes_name = rows[0]["project_name"] + project_most_minutes = int(rows[0]["total_minutes"] or 0) + + # Activity with most logged time + activity_most_minutes_name: str | None = None + activity_most_minutes = 0 + + try: + rows = cur.execute( + """ + SELECT a.name AS activity_name, + SUM(t.minutes) AS total_minutes + FROM time_log t + JOIN activities a ON a.id = t.activity_id + GROUP BY t.activity_id, a.name + ORDER BY total_minutes DESC, LOWER(activity_name) ASC + LIMIT 1; + """ + ).fetchall() + except Exception: + rows = [] + + if rows: + activity_most_minutes_name = rows[0]["activity_name"] + activity_most_minutes = int(rows[0]["total_minutes"] or 0) + + # 6) Reminder stats + reminders_by_date: Dict[_dt.date, int] = {} + total_reminders = 0 + day_most_reminders: str | None = None + day_most_reminders_count = 0 + + try: + rows = cur.execute( + """ + SELECT substr(created_at, 1, 10) AS date_iso, + COUNT(*) AS c + FROM reminders + GROUP BY date_iso + ORDER BY date_iso; + """ + ).fetchall() + except Exception: + rows = [] + + for r in rows: + date_iso = r["date_iso"] + if not date_iso: + continue + c = int(r["c"] or 0) + total_reminders += c + if c > day_most_reminders_count: + day_most_reminders_count = c + day_most_reminders = date_iso + try: + d = _dt.date.fromisoformat(date_iso) + except Exception: # nosec B112 + continue + reminders_by_date[d] = c + return ( pages_with_content, total_revisions, @@ -830,6 +1155,18 @@ class DBManager: page_most_tags, page_most_tags_count, revisions_by_date, + time_minutes_by_date, + total_time_minutes, + day_most_time, + day_most_time_minutes, + project_most_minutes_name, + project_most_minutes, + activity_most_minutes_name, + activity_most_minutes, + reminders_by_date, + total_reminders, + day_most_reminders, + day_most_reminders_count, ) # -------- Time logging: projects & activities --------------------- @@ -841,6 +1178,14 @@ class DBManager: ).fetchall() return [(r["id"], r["name"]) for r in rows] + def list_projects_by_id(self, project_id: int) -> str: + cur = self.conn.cursor() + row = cur.execute( + "SELECT name FROM projects WHERE id = ?;", + (project_id,), + ).fetchone() + return row["name"] if row else "" + def add_project(self, name: str) -> int: name = name.strip() if not name: @@ -974,7 +1319,8 @@ class DBManager: t.activity_id, a.name AS activity_name, t.minutes, - t.note + t.note, + t.created_at AS created_at FROM time_log t JOIN projects p ON p.id = t.project_id JOIN activities a ON a.id = t.activity_id @@ -996,6 +1342,7 @@ class DBManager: r["activity_name"], r["minutes"], r["note"], + r["created_at"], ) ) return result @@ -1005,8 +1352,8 @@ class DBManager: project_id: int, start_date_iso: str, end_date_iso: str, - granularity: str = "day", # 'day' | 'week' | 'month' - ) -> list[tuple[str, str, int]]: + granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none' + ) -> list[tuple[str, str, str, int]]: """ Return (time_period, activity_name, total_minutes) tuples between start and end for a project, grouped by period and activity. @@ -1014,7 +1361,54 @@ class DBManager: - 'YYYY-MM-DD' for day - 'YYYY-WW' for week - 'YYYY-MM' for month + For 'activity' granularity, results are grouped by activity only (no time bucket). + For 'none' granularity, each individual time log entry becomes a row. """ + cur = self.conn.cursor() + + if granularity == "none": + # No grouping: one row per entry + rows = cur.execute( + """ + SELECT + t.page_date AS period, + a.name AS activity_name, + t.note AS note, + t.minutes AS total_minutes + FROM time_log t + JOIN activities a ON a.id = t.activity_id + WHERE t.project_id = ? + AND t.page_date BETWEEN ? AND ? + ORDER BY period, LOWER(a.name), t.id; + """, + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + return [ + (r["period"], r["activity_name"], r["note"], r["total_minutes"]) + for r in rows + ] + + if granularity == "activity": + rows = cur.execute( + """ + SELECT + a.name AS activity_name, + SUM(t.minutes) AS total_minutes + FROM time_log t + JOIN activities a ON a.id = t.activity_id + WHERE t.project_id = ? + AND t.page_date BETWEEN ? AND ? + GROUP BY activity_name + ORDER BY LOWER(activity_name); + """, + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + # period column is unused for activity grouping in the UI, but we keep + # the tuple shape consistent. + return [("", r["activity_name"], "", r["total_minutes"]) for r in rows] + if granularity == "day": bucket_expr = "page_date" elif granularity == "week": @@ -1023,13 +1417,11 @@ class DBManager: else: # month bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM - cur = self.conn.cursor() rows = cur.execute( f""" SELECT {bucket_expr} AS bucket, a.name AS activity_name, - t.note AS note, SUM(t.minutes) AS total_minutes FROM time_log t JOIN activities a ON a.id = t.activity_id @@ -1041,8 +1433,113 @@ class DBManager: (project_id, start_date_iso, end_date_iso), ).fetchall() + return [(r["bucket"], r["activity_name"], "", r["total_minutes"]) for r in rows] + + def time_report_all( + self, + start_date_iso: str, + end_date_iso: str, + granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none' + ) -> list[tuple[str, str, str, str, int]]: + """ + Return (project_name, time_period, activity_name, note, total_minutes) + across *all* projects between start and end. + - For 'day'/'week'/'month', grouped by project + period + activity. + - For 'activity', grouped by project + activity. + - For 'none', one row per time_log entry. + """ + cur = self.conn.cursor() + + if granularity == "none": + # No grouping - one row per time_log record + rows = cur.execute( + """ + SELECT + p.name AS project_name, + t.page_date AS period, + a.name AS activity_name, + t.note AS note, + t.minutes AS total_minutes + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.page_date BETWEEN ? AND ? + ORDER BY LOWER(p.name), period, LOWER(activity_name), t.id; + """, + (start_date_iso, end_date_iso), + ).fetchall() + + return [ + ( + r["project_name"], + r["period"], + r["activity_name"], + r["note"], + r["total_minutes"], + ) + for r in rows + ] + + if granularity == "activity": + rows = cur.execute( + """ + SELECT + p.name AS project_name, + a.name AS activity_name, + SUM(t.minutes) AS total_minutes + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.page_date BETWEEN ? AND ? + GROUP BY p.id, activity_name + ORDER BY LOWER(p.name), LOWER(activity_name); + """, + (start_date_iso, end_date_iso), + ).fetchall() + + return [ + ( + r["project_name"], + "", + r["activity_name"], + "", + r["total_minutes"], + ) + for r in rows + ] + + if granularity == "day": + bucket_expr = "page_date" + elif granularity == "week": + bucket_expr = "strftime('%Y-%W', page_date)" + else: # month + bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM + + rows = cur.execute( + f""" + SELECT + p.name AS project_name, + {bucket_expr} AS bucket, + a.name AS activity_name, + SUM(t.minutes) AS total_minutes + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.page_date BETWEEN ? AND ? + GROUP BY p.id, bucket, activity_name + ORDER BY LOWER(p.name), bucket, LOWER(activity_name); + """, # nosec + (start_date_iso, end_date_iso), + ).fetchall() + return [ - (r["bucket"], r["activity_name"], r["note"], r["total_minutes"]) + ( + r["project_name"], + r["bucket"], + r["activity_name"], + "", + r["total_minutes"], + ) for r in rows ] @@ -1137,3 +1634,823 @@ class DBManager: cur = self.conn.cursor() cur.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,)) self.conn.commit() + + # ------------------------- Documents logic here ------------------------# + + def documents_for_project(self, project_id: int) -> list[DocumentRow]: + """ + Return metadata for all documents attached to a given project. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT + d.id, + d.project_id, + p.name AS project_name, + d.file_name, + d.description, + d.size_bytes, + d.uploaded_at + FROM project_documents AS d + JOIN projects AS p ON p.id = d.project_id + WHERE d.project_id = ? + ORDER BY d.uploaded_at DESC, LOWER(d.file_name); + """, + (project_id,), + ).fetchall() + + result: list[DocumentRow] = [] + for r in rows: + result.append( + ( + r["id"], + r["project_id"], + r["project_name"], + r["file_name"], + r["description"], + r["size_bytes"], + r["uploaded_at"], + ) + ) + return result + + def search_documents(self, query: str) -> list[DocumentRow]: + """Search documents across all projects. + + The search is case-insensitive and matches against: + - file name + - description + - project name + - tag names associated with the document + """ + pattern = f"%{query.lower()}%" + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT DISTINCT + d.id, + d.project_id, + p.name AS project_name, + d.file_name, + d.description, + d.size_bytes, + d.uploaded_at + FROM project_documents AS d + LEFT JOIN projects AS p ON p.id = d.project_id + LEFT JOIN document_tags AS dt ON dt.document_id = d.id + LEFT JOIN tags AS t ON t.id = dt.tag_id + WHERE LOWER(d.file_name) LIKE :pat + OR LOWER(COALESCE(d.description, '')) LIKE :pat + OR LOWER(COALESCE(p.name, '')) LIKE :pat + OR LOWER(COALESCE(t.name, '')) LIKE :pat + ORDER BY d.uploaded_at DESC, LOWER(d.file_name); + """, + {"pat": pattern}, + ).fetchall() + + result: list[DocumentRow] = [] + for r in rows: + result.append( + ( + r["id"], + r["project_id"], + r["project_name"], + r["file_name"], + r["description"], + r["size_bytes"], + r["uploaded_at"], + ) + ) + return result + + def add_document_from_path( + self, + project_id: int, + file_path: str, + description: str | None = None, + uploaded_at: str | None = None, + ) -> int: + """ + Read a file from disk and store it as a BLOB in project_documents. + + Args: + project_id: The project to attach the document to + file_path: Path to the file to upload + description: Optional description + uploaded_at: Optional date in YYYY-MM-DD format. If None, uses current date. + """ + path = Path(file_path) + if not path.is_file(): + raise ValueError(f"File does not exist: {file_path}") + + data = path.read_bytes() + size_bytes = len(data) + file_name = path.name + mime_type, _ = mimetypes.guess_type(str(path)) + mime_type = mime_type or None + + with self.conn: + cur = self.conn.cursor() + if uploaded_at is not None: + # Use explicit date + cur.execute( + """ + INSERT INTO project_documents + (project_id, file_name, mime_type, + description, size_bytes, uploaded_at, data) + VALUES (?, ?, ?, ?, ?, ?, ?); + """, + ( + project_id, + file_name, + mime_type, + description, + size_bytes, + uploaded_at, + Binary(data), + ), + ) + else: + # Let DB default to current date + cur.execute( + """ + INSERT INTO project_documents + (project_id, file_name, mime_type, + description, size_bytes, data) + VALUES (?, ?, ?, ?, ?, ?); + """, + ( + project_id, + file_name, + mime_type, + description, + size_bytes, + Binary(data), + ), + ) + doc_id = cur.lastrowid or 0 + + return int(doc_id) + + def update_document_description(self, doc_id: int, description: str | None) -> None: + with self.conn: + self.conn.execute( + "UPDATE project_documents SET description = ? WHERE id = ?;", + (description, doc_id), + ) + + def update_document_uploaded_at(self, doc_id: int, uploaded_at: str) -> None: + """ + Update the uploaded_at date for a document. + + Args: + doc_id: Document ID + uploaded_at: Date in YYYY-MM-DD format + """ + with self.conn: + self.conn.execute( + "UPDATE project_documents SET uploaded_at = ? WHERE id = ?;", + (uploaded_at, doc_id), + ) + + def delete_document(self, doc_id: int) -> None: + with self.conn: + self.conn.execute("DELETE FROM project_documents WHERE id = ?;", (doc_id,)) + + def document_data(self, doc_id: int) -> bytes: + """ + Return just the raw bytes for a document. + """ + cur = self.conn.cursor() + row = cur.execute( + "SELECT data FROM project_documents WHERE id = ?;", + (doc_id,), + ).fetchone() + if row is None: + raise KeyError(f"Unknown document id {doc_id}") + return bytes(row["data"]) + + def get_tags_for_document(self, document_id: int) -> list[TagRow]: + """ + Return (id, name, color) for all tags attached to this document. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT t.id, t.name, t.color + FROM document_tags dt + JOIN tags t ON t.id = dt.tag_id + WHERE dt.document_id = ? + ORDER BY LOWER(t.name); + """, + (document_id,), + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def set_tags_for_document(self, document_id: int, tag_names: Sequence[str]) -> None: + """ + Replace the tag set for a document with the given names. + Behaviour mirrors set_tags_for_page. + """ + # Normalise + dedupe (case-insensitive) + clean_names: list[str] = [] + seen: set[str] = set() + for name in tag_names: + name = name.strip() + if not name: + continue + key = name.lower() + if key in seen: + continue + seen.add(key) + clean_names.append(name) + + with self.conn: + cur = self.conn.cursor() + + # Ensure the document exists + exists = cur.execute( + "SELECT 1 FROM project_documents WHERE id = ?;", (document_id,) + ).fetchone() + if not exists: + raise sqlite.IntegrityError(f"Unknown document id {document_id}") + + if not clean_names: + cur.execute( + "DELETE FROM document_tags WHERE document_id = ?;", + (document_id,), + ) + return + + # For each tag name, reuse existing tag (case-insensitive) or create new + final_tag_names: list[str] = [] + for name in clean_names: + existing = cur.execute( + "SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,) + ).fetchone() + if existing: + final_tag_names.append(existing["name"]) + else: + cur.execute( + """ + INSERT OR IGNORE INTO tags(name, color) + VALUES (?, ?); + """, + (name, self._default_tag_colour(name)), + ) + final_tag_names.append(name) + + # Lookup ids for the final tag names + placeholders = ",".join("?" for _ in final_tag_names) + rows = cur.execute( + f""" + SELECT id, name + FROM tags + WHERE name IN ({placeholders}); + """, # nosec + tuple(final_tag_names), + ).fetchall() + ids_by_name = {r["name"]: r["id"] for r in rows} + + # Reset document_tags for this document + cur.execute( + "DELETE FROM document_tags WHERE document_id = ?;", + (document_id,), + ) + for name in final_tag_names: + tag_id = ids_by_name.get(name) + if tag_id is not None: + cur.execute( + """ + INSERT OR IGNORE INTO document_tags(document_id, tag_id) + VALUES (?, ?); + """, + (document_id, tag_id), + ) + + def documents_by_date(self) -> Dict[_dt.date, int]: + """ + Return a mapping of date -> number of documents uploaded on that date. + + The keys are datetime.date objects derived from the + project_documents.uploaded_at column, which is stored as a + YYYY-MM-DD ISO date string (or a timestamp whose leading part + is that date). + """ + cur = self.conn.cursor() + try: + rows = cur.execute( + """ + SELECT uploaded_at AS date_iso, + COUNT(*) AS c + FROM project_documents + WHERE uploaded_at IS NOT NULL + AND uploaded_at != '' + GROUP BY uploaded_at + ORDER BY uploaded_at; + """ + ).fetchall() + except Exception: + # Older DBs without project_documents/uploaded_at → no document stats + return {} + + result: Dict[_dt.date, int] = {} + for r in rows: + date_iso = r["date_iso"] + if not date_iso: + continue + + # If uploaded_at ever contains a full timestamp, only use + # the leading date portion. + date_part = str(date_iso).split(" ", 1)[0][:10] + try: + d = _dt.date.fromisoformat(date_part) + except Exception: # nosec B112 + continue + + result[d] = int(r["c"]) + + return result + + def todays_documents(self, date_iso: str) -> list[tuple[int, str, str | None, str]]: + """ + Return today's documents as + (doc_id, file_name, project_name, uploaded_at). + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT d.id AS doc_id, + d.file_name AS file_name, + p.name AS project_name + FROM project_documents AS d + LEFT JOIN projects AS p ON p.id = d.project_id + WHERE d.uploaded_at LIKE ? + ORDER BY d.uploaded_at DESC, LOWER(d.file_name); + """, + (f"%{date_iso}%",), + ).fetchall() + + return [(r["doc_id"], r["file_name"], r["project_name"]) for r in rows] + + def get_documents_for_tag(self, tag_name: str) -> list[tuple[int, str, str]]: + """ + Return (document_id, project_name, file_name) for documents with a given tag. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT d.id AS doc_id, + p.name AS project_name, + d.file_name + FROM project_documents AS d + JOIN document_tags AS dt ON dt.document_id = d.id + JOIN tags AS t ON t.id = dt.tag_id + LEFT JOIN projects AS p ON p.id = d.project_id + WHERE LOWER(t.name) = LOWER(?) + ORDER BY LOWER(d.file_name); + """, + (tag_name,), + ).fetchall() + return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows] + + # ------------------------- Billing settings ------------------------# + + def get_project_billing(self, project_id: int) -> ProjectBillingRow | None: + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email + FROM project_billing + WHERE project_id = ? + """, + (project_id,), + ).fetchone() + if not row: + return None + return ( + row["project_id"], + row["hourly_rate_cents"], + row["currency"], + row["tax_label"], + row["tax_rate_percent"], + row["client_name"], + row["client_company"], + row["client_address"], + row["client_email"], + ) + + def upsert_project_billing( + self, + project_id: int, + hourly_rate_cents: int, + currency: str, + tax_label: str | None, + tax_rate_percent: float | None, + client_name: str | None, + client_company: str | None, + client_address: str | None, + client_email: str | None, + ) -> None: + with self.conn: + self.conn.execute( + """ + INSERT INTO project_billing ( + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + hourly_rate_cents = excluded.hourly_rate_cents, + currency = excluded.currency, + tax_label = excluded.tax_label, + tax_rate_percent = excluded.tax_rate_percent, + client_name = excluded.client_name, + client_company = excluded.client_company, + client_address = excluded.client_address, + client_email = excluded.client_email; + """, + ( + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email, + ), + ) + + def list_client_companies(self) -> list[str]: + """Return distinct client display names from project_billing.""" + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT DISTINCT client_company + FROM project_billing + WHERE client_company IS NOT NULL + AND TRIM(client_company) <> '' + ORDER BY LOWER(client_company); + """ + ).fetchall() + return [r["client_company"] for r in rows] + + def get_client_by_company( + self, client_company: str + ) -> tuple[str | None, str | None, str | None, str | None] | None: + """ + Return (contact_name, client_display_name, address, email) + for a given client display name, based on the most recent project using it. + """ + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT client_name, client_company, client_address, client_email + FROM project_billing + WHERE client_company = ? + AND client_company IS NOT NULL + AND TRIM(client_company) <> '' + ORDER BY project_id DESC + LIMIT 1 + """, + (client_company,), + ).fetchone() + if not row: + return None + return ( + row["client_name"], + row["client_company"], + row["client_address"], + row["client_email"], + ) + + # ------------------------- Company profile ------------------------# + + def get_company_profile(self) -> CompanyProfileRow | None: + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT name, address, phone, email, tax_id, payment_details, logo + FROM company_profile + WHERE id = 1 + """ + ).fetchone() + if not row: + return None + return ( + row["name"], + row["address"], + row["phone"], + row["email"], + row["tax_id"], + row["payment_details"], + row["logo"], + ) + + def save_company_profile( + self, + name: str | None, + address: str | None, + phone: str | None, + email: str | None, + tax_id: str | None, + payment_details: str | None, + logo: bytes | None, + ) -> None: + with self.conn: + self.conn.execute( + """ + INSERT INTO company_profile (id, name, address, phone, email, tax_id, payment_details, logo) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + address = excluded.address, + phone = excluded.phone, + email = excluded.email, + tax_id = excluded.tax_id, + payment_details = excluded.payment_details, + logo = excluded.logo; + """, + ( + name, + address, + phone, + email, + tax_id, + payment_details, + Binary(logo) if logo else None, + ), + ) + + # ------------------------- Invoices -------------------------------# + + def create_invoice( + self, + project_id: int, + invoice_number: str, + issue_date: str, + due_date: str | None, + currency: str, + tax_label: str | None, + tax_rate_percent: float | None, + detail_mode: str, # 'detailed' or 'summary' + line_items: list[tuple[str, float, int]], # (description, hours, rate_cents) + time_log_ids: list[int], + ) -> int: + """ + Create invoice + line items + link time logs. + Returns invoice ID. + """ + if line_items: + first_rate_cents = line_items[0][2] + else: + first_rate_cents = 0 + + total_hours = sum(hours for _desc, hours, _rate in line_items) + subtotal_cents = int(round(total_hours * first_rate_cents)) + tax_cents = int(round(subtotal_cents * (tax_rate_percent or 0) / 100.0)) + total_cents = subtotal_cents + tax_cents + + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + INSERT INTO invoices ( + project_id, + invoice_number, + issue_date, + due_date, + currency, + tax_label, + tax_rate_percent, + subtotal_cents, + tax_cents, + total_cents, + detail_mode + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + invoice_number, + issue_date, + due_date, + currency, + tax_label, + tax_rate_percent, + subtotal_cents, + tax_cents, + total_cents, + detail_mode, + ), + ) + invoice_id = cur.lastrowid + + # Line items + for desc, hours, rate_cents in line_items: + amount_cents = int(round(hours * rate_cents)) + cur.execute( + """ + INSERT INTO invoice_line_items ( + invoice_id, description, hours, rate_cents, amount_cents + ) + VALUES (?, ?, ?, ?, ?) + """, + (invoice_id, desc, hours, rate_cents, amount_cents), + ) + + # Link time logs + for tl_id in time_log_ids: + cur.execute( + "INSERT INTO invoice_time_log (invoice_id, time_log_id) VALUES (?, ?)", + (invoice_id, tl_id), + ) + + return invoice_id + + def get_invoice_count_by_project_id_and_year( + self, project_id: int, year: str + ) -> None: + with self.conn: + row = self.conn.execute( + "SELECT COUNT(*) AS c FROM invoices WHERE project_id = ? AND issue_date LIKE ?", + (project_id, year), + ).fetchone() + return row["c"] + + def get_all_invoices(self, project_id: int | None = None) -> None: + with self.conn: + if project_id is None: + rows = self.conn.execute( + """ + SELECT + i.id, + i.project_id, + p.name AS project_name, + i.invoice_number, + i.issue_date, + i.due_date, + i.currency, + i.tax_label, + i.tax_rate_percent, + i.subtotal_cents, + i.tax_cents, + i.total_cents, + i.paid_at, + i.payment_note + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """ + ).fetchall() + else: + rows = self.conn.execute( + """ + SELECT + i.id, + i.project_id, + p.name AS project_name, + i.invoice_number, + i.issue_date, + i.due_date, + i.currency, + i.tax_label, + i.tax_rate_percent, + i.subtotal_cents, + i.tax_cents, + i.total_cents, + i.paid_at, + i.payment_note + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + WHERE i.project_id = ? + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """, + (project_id,), + ).fetchall() + return rows + + def _validate_invoice_field(self, field: str) -> str: + if field not in self._INVOICE_COLUMN_ALLOWLIST: + raise ValueError(f"Invalid invoice field name: {field!r}") + return field + + def get_invoice_field_by_id(self, invoice_id: int, field: str) -> None: + field = self._validate_invoice_field(field) + + with self.conn: + row = self.conn.execute( + f"SELECT {field} FROM invoices WHERE id = ?", # nosec B608 + (invoice_id,), + ).fetchone() + return row + + def set_invoice_field_by_id( + self, invoice_id: int, field: str, value: str | None = None + ) -> None: + field = self._validate_invoice_field(field) + + with self.conn: + self.conn.execute( + f"UPDATE invoices SET {field} = ? WHERE id = ?", # nosec B608 + ( + value, + invoice_id, + ), + ) + + def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None: + with self.conn: + self.conn.execute( + "UPDATE invoices SET invoice_number = ? WHERE id = ?", + (invoice_number, invoice_id), + ) + + def set_invoice_document(self, invoice_id: int, document_id: int) -> None: + with self.conn: + self.conn.execute( + "UPDATE invoices SET document_id = ? WHERE 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( + self, + project_id: int, + start_date_iso: str, + end_date_iso: str, + ) -> list[TimeLogRow]: + """ + Return raw time log rows for a project/date range. + + Shape matches time_log_for_date: TimeLogRow. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT + t.id, + t.page_date, + t.project_id, + p.name AS project_name, + t.activity_id, + a.name AS activity_name, + t.minutes, + t.note, + t.created_at AS created_at + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.project_id = ? + AND t.page_date BETWEEN ? AND ? + ORDER BY t.page_date, LOWER(a.name), t.id; + """, + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + result: list[TimeLogRow] = [] + for r in rows: + result.append( + ( + r["id"], + r["page_date"], + r["project_id"], + r["project_name"], + r["activity_id"], + r["activity_name"], + r["minutes"], + r["note"], + r["created_at"], + ) + ) + return result diff --git a/bouquin/document_utils.py b/bouquin/document_utils.py new file mode 100644 index 0000000..fd7313e --- /dev/null +++ b/bouquin/document_utils.py @@ -0,0 +1,64 @@ +""" +Utility functions for document operations. + +This module provides shared functionality for document handling across +different widgets (TodaysDocumentsWidget, DocumentsDialog, SearchResultsDialog, +and TagBrowserDialog). +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QMessageBox, QWidget + +from . import strings + +if TYPE_CHECKING: + from .db import DBManager + + +def open_document_from_db( + db: DBManager, doc_id: int, file_name: str, parent_widget: Optional[QWidget] = None +) -> bool: + """ + Open a document by fetching it from the database and opening with system default app. + """ + # Fetch document data from database + try: + data = db.document_data(doc_id) + except Exception as e: + # Show error dialog if parent widget is provided + if parent_widget: + QMessageBox.warning( + parent_widget, + strings._("project_documents_title"), + strings._("documents_open_failed").format(error=str(e)), + ) + return False + + # Extract file extension + suffix = Path(file_name).suffix or "" + + # Create temporary file with same extension + tmp = tempfile.NamedTemporaryFile( + prefix="bouquin_doc_", + suffix=suffix, + delete=False, + ) + + # Write data to temp file + try: + tmp.write(data) + tmp.flush() + finally: + tmp.close() + + # Open with system default application + success = QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name)) + + return success diff --git a/bouquin/documents.py b/bouquin/documents.py new file mode 100644 index 0000000..9f5a40f --- /dev/null +++ b/bouquin/documents.py @@ -0,0 +1,601 @@ +from __future__ import annotations + +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QDialog, + QFileDialog, + QFormLayout, + QFrame, + QHBoxLayout, + QHeaderView, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSizePolicy, + QStyle, + QTableWidget, + QTableWidgetItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +from . import strings +from .db import DBManager, DocumentRow +from .settings import load_db_config +from .time_log import TimeCodeManagerDialog + + +class TodaysDocumentsWidget(QFrame): + """ + Collapsible sidebar widget showing today's documents. + """ + + def __init__( + self, db: DBManager, date_iso: str, parent: QWidget | None = None + ) -> None: + super().__init__(parent) + self._db = db + self._current_date = date_iso + + self.setFrameShape(QFrame.StyledPanel) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + + # Header (toggle + open-documents button) + self.toggle_btn = QToolButton() + self.toggle_btn.setText(strings._("todays_documents")) + self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.toggle_btn.setCheckable(True) + self.toggle_btn.setChecked(False) + self.toggle_btn.setArrowType(Qt.RightArrow) + self.toggle_btn.clicked.connect(self._on_toggle) + + self.open_btn = QToolButton() + self.open_btn.setIcon( + self.style().standardIcon(QStyle.SP_FileDialogDetailedView) + ) + self.open_btn.setToolTip(strings._("project_documents_title")) + self.open_btn.setAutoRaise(True) + self.open_btn.clicked.connect(self._open_documents_dialog) + + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.addWidget(self.toggle_btn) + header.addStretch(1) + header.addWidget(self.open_btn) + + # Body: list of today's documents + self.body = QWidget() + body_layout = QVBoxLayout(self.body) + body_layout.setContentsMargins(0, 4, 0, 0) + body_layout.setSpacing(2) + + self.list = QListWidget() + self.list.setSelectionMode(QAbstractItemView.SingleSelection) + self.list.setMaximumHeight(160) + self.list.itemDoubleClicked.connect(self._open_selected_document) + body_layout.addWidget(self.list) + + self.body.setVisible(False) + + main = QVBoxLayout(self) + main.setContentsMargins(0, 0, 0, 0) + main.addLayout(header) + main.addWidget(self.body) + + # Initial fill + self.reload() + + # ----- public API --------------------------------------------------- + + def reload(self) -> None: + """Refresh the list of today's documents.""" + self.list.clear() + + rows = self._db.todays_documents(self._current_date) + if not rows: + item = QListWidgetItem(strings._("todays_documents_none")) + item.setFlags(item.flags() & ~Qt.ItemIsEnabled) + self.list.addItem(item) + return + + for doc_id, file_name, project_name in rows: + label = file_name + extra_parts = [] + if project_name: + extra_parts.append(project_name) + if extra_parts: + label = f"{file_name} - " + " · ".join(extra_parts) + + item = QListWidgetItem(label) + item.setData( + Qt.ItemDataRole.UserRole, + {"doc_id": doc_id, "file_name": file_name}, + ) + self.list.addItem(item) + + # ----- internals ---------------------------------------------------- + + def set_current_date(self, date_iso: str) -> None: + self._current_date = date_iso + self.reload() + + def _on_toggle(self, checked: bool) -> None: + self.body.setVisible(checked) + self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) + if checked: + self.reload() + + def _open_selected_document(self, item: QListWidgetItem) -> None: + data = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(data, dict): + return + doc_id = data.get("doc_id") + file_name = data.get("file_name") or "" + if doc_id is None or not file_name: + return + self._open_document(int(doc_id), file_name) + + def _open_document(self, doc_id: int, file_name: str) -> None: + """Open a document from the list.""" + from .document_utils import open_document_from_db + + open_document_from_db(self._db, doc_id, file_name, parent_widget=self) + + def _open_documents_dialog(self) -> None: + """Open the full DocumentsDialog.""" + dlg = DocumentsDialog(self._db, self, current_date=self._current_date) + dlg.exec() + # Refresh after any changes + self.reload() + + +class DocumentsDialog(QDialog): + """ + Per-project document manager. + + - Choose a project + - See list of attached documents + - Add (from file), open (via temp file), delete + - Inline-edit description + - Inline-edit tags (comma-separated), using the global tags table + """ + + FILE_COL = 0 + TAGS_COL = 1 + DESC_COL = 2 + ADDED_COL = 3 + SIZE_COL = 4 + + def __init__( + self, + db: DBManager, + parent: QWidget | None = None, + initial_project_id: Optional[int] = None, + current_date: Optional[str] = None, + ) -> None: + super().__init__(parent) + self._db = db + self.cfg = load_db_config() + self._reloading_docs = False + self._search_text: str = "" + self._current_date = current_date # Store the current date for document uploads + + self.setWindowTitle(strings._("project_documents_title")) + self.resize(900, 450) + + root = QVBoxLayout(self) + + # --- Project selector ------------------------------------------------- + form = QFormLayout() + proj_row = QHBoxLayout() + self.project_combo = QComboBox() + self.manage_projects_btn = QPushButton(strings._("manage_projects")) + self.manage_projects_btn.clicked.connect(self._manage_projects) + proj_row.addWidget(self.project_combo, 1) + proj_row.addWidget(self.manage_projects_btn) + form.addRow(strings._("project"), proj_row) + + # --- Search box (all projects) ---------------------------------------- + self.search_edit = QLineEdit() + self.search_edit.setClearButtonEnabled(True) + self.search_edit.setPlaceholderText(strings._("documents_search_placeholder")) + self.search_edit.textChanged.connect(self._on_search_text_changed) + form.addRow(strings._("documents_search_label"), self.search_edit) + + root.addLayout(form) + + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + + # --- Table of documents ---------------------------------------------- + self.table = QTableWidget() + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels( + [ + strings._("documents_col_file"), # FILE_COL + strings._("documents_col_tags"), # TAGS_COL + strings._("documents_col_description"), # DESC_COL + strings._("documents_col_added"), # ADDED_COL + strings._("documents_col_size"), # SIZE_COL + ] + ) + + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.FILE_COL, QHeaderView.Stretch) + header.setSectionResizeMode(self.TAGS_COL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.DESC_COL, QHeaderView.Stretch) + header.setSectionResizeMode(self.ADDED_COL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.SIZE_COL, QHeaderView.ResizeToContents) + + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + # Editable: tags + description + self.table.setEditTriggers( + QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked + ) + + self.table.itemChanged.connect(self._on_item_changed) + self.table.itemDoubleClicked.connect(self._on_open_clicked) + + root.addWidget(self.table, 1) + + # --- Buttons --------------------------------------------------------- + btn_row = QHBoxLayout() + btn_row.addStretch(1) + + self.add_btn = QPushButton(strings._("documents_add")) + self.add_btn.clicked.connect(self._on_add_clicked) + btn_row.addWidget(self.add_btn) + + self.open_btn = QPushButton(strings._("documents_open")) + self.open_btn.clicked.connect(self._on_open_clicked) + btn_row.addWidget(self.open_btn) + + self.delete_btn = QPushButton(strings._("documents_delete")) + self.delete_btn.clicked.connect(self._on_delete_clicked) + btn_row.addWidget(self.delete_btn) + + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + + root.addLayout(btn_row) + + # Separator at bottom (purely cosmetic) + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + root.addWidget(line) + + # Init data + self._reload_projects() + self._select_initial_project(initial_project_id) + self._reload_documents() + + # --- Helpers ------------------------------------------------------------- + + def _reload_projects(self) -> None: + self.project_combo.blockSignals(True) + try: + self.project_combo.clear() + for proj_id, name in self._db.list_projects(): + self.project_combo.addItem(name, proj_id) + finally: + self.project_combo.blockSignals(False) + + def _select_initial_project(self, project_id: Optional[int]) -> None: + if project_id is None: + if self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + return + + idx = self.project_combo.findData(project_id) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + elif self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + + def _current_project(self) -> Optional[int]: + idx = self.project_combo.currentIndex() + if idx < 0: + return None + proj_id = self.project_combo.itemData(idx) + return int(proj_id) if proj_id is not None else None + + def _manage_projects(self) -> None: + dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self) + dlg.exec() + self._reload_projects() + self._reload_documents() + + def _on_search_text_changed(self, text: str) -> None: + """Update the in-memory search text and reload the table.""" + self._search_text = text + self._reload_documents() + + def _reload_documents(self) -> None: + + search = (self._search_text or "").strip() + + self._reloading_docs = True + try: + self.table.setRowCount(0) + + if search: + # Global search across all projects + rows: list[DocumentRow] = self._db.search_documents(search) + + else: + proj_id = self._current_project() + if proj_id is None: + return + + rows = self._db.documents_for_project(proj_id) + + self.table.setRowCount(len(rows)) + + for row_idx, r in enumerate(rows): + ( + doc_id, + _project_id, + project_name, + file_name, + description, + size_bytes, + uploaded_at, + ) = r + + # Col 0: File + file_item = QTableWidgetItem(file_name) + file_item.setData(Qt.ItemDataRole.UserRole, doc_id) + file_item.setFlags(file_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row_idx, self.FILE_COL, file_item) + + # Col 1: Tags (comma-separated) + tags = self._db.get_tags_for_document(doc_id) + tag_names = [name for (_tid, name, _color) in tags] + tags_text = ", ".join(tag_names) + tags_item = QTableWidgetItem(tags_text) + + # If there is at least one tag, colour the cell using the first tag's colour + if tags: + first_color = tags[0][2] + if first_color: + col = QColor(first_color) + tags_item.setBackground(col) + # Choose a readable text color + if col.lightness() < 128: + tags_item.setForeground(QColor("#ffffff")) + else: + tags_item.setForeground(QColor("#000000")) + + self.table.setItem(row_idx, self.TAGS_COL, tags_item) + if not self.cfg.tags: + self.table.hideColumn(self.TAGS_COL) + + # Col 2: Description (editable) + desc_item = QTableWidgetItem(description or "") + self.table.setItem(row_idx, self.DESC_COL, desc_item) + + # Col 3: Added at (editable) + added_label = uploaded_at + added_item = QTableWidgetItem(added_label) + self.table.setItem(row_idx, self.ADDED_COL, added_item) + + # Col 4: Size (not editable) + size_item = QTableWidgetItem(self._format_size(size_bytes)) + size_item.setFlags(size_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row_idx, self.SIZE_COL, size_item) + finally: + self._reloading_docs = False + + # --- Signals ------------------------------------------------------------- + + def _on_project_changed(self, idx: int) -> None: + _ = idx + self._reload_documents() + + def _on_add_clicked(self) -> None: + proj_id = self._current_project() + if proj_id is None: + QMessageBox.warning( + self, + strings._("project_documents_title"), + strings._("documents_no_project_selected"), + ) + return + + paths, _ = QFileDialog.getOpenFileNames( + self, + strings._("documents_add"), + "", + strings._("documents_file_filter_all"), + ) + if not paths: + return + + for path in paths: + try: + self._db.add_document_from_path( + proj_id, path, uploaded_at=self._current_date + ) + except Exception as e: # pragma: no cover + QMessageBox.warning( + self, + strings._("project_documents_title"), + strings._("documents_add_failed").format(error=str(e)), + ) + + self._reload_documents() + + def _selected_doc_meta(self) -> tuple[Optional[int], Optional[str]]: + row = self.table.currentRow() + if row < 0: + return None, None + + file_item = self.table.item(row, self.FILE_COL) + if file_item is None: + return None, None + + doc_id = file_item.data(Qt.ItemDataRole.UserRole) + file_name = file_item.text() + return (int(doc_id) if doc_id is not None else None, file_name) + + def _on_open_clicked(self, *args) -> None: + doc_id, file_name = self._selected_doc_meta() + if doc_id is None or not file_name: + return + self._open_document(doc_id, file_name) + + def _on_delete_clicked(self) -> None: + doc_id, _file_name = self._selected_doc_meta() + if doc_id is None: + return + + resp = QMessageBox.question( + self, + strings._("project_documents_title"), + strings._("documents_confirm_delete"), + ) + if resp != QMessageBox.StandardButton.Yes: + return + + self._db.delete_document(doc_id) + self._reload_documents() + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + """ + Handle inline edits to Description, Tags, and Added date. + """ + if self._reloading_docs or item is None: + return + + row = item.row() + col = item.column() + + file_item = self.table.item(row, self.FILE_COL) + if file_item is None: + return + + doc_id = file_item.data(Qt.ItemDataRole.UserRole) + if doc_id is None: + return + + doc_id = int(doc_id) + + # Description column + if col == self.DESC_COL: + desc = item.text().strip() or None + self._db.update_document_description(doc_id, desc) + return + + # Tags column + if col == self.TAGS_COL: + raw = item.text() + # split on commas, strip, drop empties + names = [p.strip() for p in raw.split(",") if p.strip()] + self._db.set_tags_for_document(doc_id, names) + + # Re-normalise text to the canonical tag names stored in DB + tags = self._db.get_tags_for_document(doc_id) + tag_names = [name for (_tid, name, _color) in tags] + tags_text = ", ".join(tag_names) + + self._reloading_docs = True + try: + item.setText(tags_text) + # Reset / apply background based on first tag colour + if tags: + first_color = tags[0][2] + if first_color: + col = QColor(first_color) + item.setBackground(col) + if col.lightness() < 128: + item.setForeground(QColor("#ffffff")) + else: + item.setForeground(QColor("#000000")) + else: + # No tags: clear background / foreground to defaults + item.setBackground(QColor()) + item.setForeground(QColor()) + finally: + self._reloading_docs = False + return + + # Added date column + if col == self.ADDED_COL: + date_str = item.text().strip() + + # Validate date format (YYYY-MM-DD) + if not self._validate_date_format(date_str): + QMessageBox.warning( + self, + strings._("project_documents_title"), + ( + strings._("documents_invalid_date_format") + if hasattr(strings, "_") + and callable(getattr(strings, "_")) + and "documents_invalid_date_format" in dir(strings) + else f"Invalid date format. Please use YYYY-MM-DD format.\nExample: {date_str[:4]}-01-15" + ), + ) + # Reload to reset the cell to its original value + self._reload_documents() + return + + # Update the database + self._db.update_document_uploaded_at(doc_id, date_str) + return + + # --- utils ------------------------------------------------------------- + + def _validate_date_format(self, date_str: str) -> bool: + """ + Validate that a date string is in YYYY-MM-DD format. + + Returns True if valid, False otherwise. + """ + import re + from datetime import datetime + + # Check basic format with regex + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + return False + + # Validate it's a real date + try: + datetime.strptime(date_str, "%Y-%m-%d") + return True + except ValueError: + return False + + def _open_document(self, doc_id: int, file_name: str) -> None: + """ + Fetch BLOB from DB, write to a temporary file, and open with default app. + """ + from .document_utils import open_document_from_db + + open_document_from_db(self._db, doc_id, file_name, parent_widget=self) + + @staticmethod + def _format_size(size_bytes: int) -> str: + """ + Human-readable file size. + """ + if size_bytes < 1024: + return f"{size_bytes} B" + kb = size_bytes / 1024.0 + if kb < 1024: + return f"{kb:.1f} KB" + mb = kb / 1024.0 + if mb < 1024: + return f"{mb:.1f} MB" + gb = mb / 1024.0 + return f"{gb:.1f} GB" diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py index ae0206b..99a1fcd 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -1,20 +1,15 @@ from __future__ import annotations from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import ( - QShortcut, - QTextCursor, - QTextCharFormat, - QTextDocument, -) +from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument from PySide6.QtWidgets import ( - QWidget, - QHBoxLayout, - QLineEdit, - QLabel, - QPushButton, QCheckBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, QTextEdit, + QWidget, ) from . import strings diff --git a/bouquin/fonts/DejaVu.license b/bouquin/fonts/DejaVu.license new file mode 100644 index 0000000..8d71958 --- /dev/null +++ b/bouquin/fonts/DejaVu.license @@ -0,0 +1,187 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +TeX Gyre DJV Math +----------------- +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. + +Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski +(on behalf of TeX users groups) are in public domain. + +Letters imported from Euler Fraktur from AMSfonts are (c) American +Mathematical Society (see below). +Bitstream Vera Fonts Copyright +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera +is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license (“Fonts”) and associated +documentation +files (the “Font Software”), to reproduce and distribute the Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, +and/or sell copies of the Font Software, and to permit persons to whom +the Font Software is furnished to do so, subject to the following +conditions: + +The above copyright and trademark notices and this permission notice +shall be +included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional +glyphs or characters may be added to the Fonts, only if the fonts are +renamed +to names not containing either the words “Bitstream” or the word “Vera”. + +This License becomes null and void to the extent applicable to Fonts or +Font Software +that has been modified and is distributed under the “Bitstream Vera” +names. + +The Font Software may be sold as part of a larger software package but +no copy +of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, +SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN +ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR +INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. +Except as contained in this notice, the names of GNOME, the GNOME +Foundation, +and Bitstream Inc., shall not be used in advertising or otherwise to promote +the sale, use or other dealings in this Font Software without prior written +authorization from the GNOME Foundation or Bitstream Inc., respectively. +For further information, contact: fonts at gnome dot org. + +AMSFonts (v. 2.2) copyright + +The PostScript Type 1 implementation of the AMSFonts produced by and +previously distributed by Blue Sky Research and Y&Y, Inc. are now freely +available for general use. This has been accomplished through the +cooperation +of a consortium of scientific publishers with Blue Sky Research and Y&Y. +Members of this consortium include: + +Elsevier Science IBM Corporation Society for Industrial and Applied +Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) + +In order to assure the authenticity of these fonts, copyright will be +held by +the American Mathematical Society. This is not meant to restrict in any way +the legitimate use of the fonts, such as (but not limited to) electronic +distribution of documents containing these fonts, inclusion of these fonts +into other public domain or commercial font collections or computer +applications, use of the outline data to create derivative fonts and/or +faces, etc. However, the AMS does require that the AMS copyright notice be +removed from any derivative versions of the fonts which have been altered in +any way. In addition, to ensure the fidelity of TeX documents using Computer +Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, +has requested that any alterations which yield different font metrics be +given a different name. + +$Id$ diff --git a/bouquin/fonts/DejaVuSans.ttf b/bouquin/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..e5f7eec Binary files /dev/null and b/bouquin/fonts/DejaVuSans.ttf differ diff --git a/bouquin/fonts/OFL.txt b/bouquin/fonts/Noto.license similarity index 97% rename from bouquin/fonts/OFL.txt rename to bouquin/fonts/Noto.license index 106e5d8..c37cc47 100644 --- a/bouquin/fonts/OFL.txt +++ b/bouquin/fonts/Noto.license @@ -18,7 +18,7 @@ with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, +fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The diff --git a/bouquin/fonts/NotoSans-Regular.ttf b/bouquin/fonts/NotoSans-Regular.ttf deleted file mode 100644 index 4bac02f..0000000 Binary files a/bouquin/fonts/NotoSans-Regular.ttf and /dev/null differ diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index f2cdc1c..c145cce 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -1,22 +1,29 @@ from __future__ import annotations -import difflib, re, html as _html +import difflib +import html as _html +import re from datetime import datetime -from PySide6.QtCore import Qt, Slot + +from PySide6.QtCore import QDate, Qt, Slot from PySide6.QtWidgets import ( + QAbstractItemView, + QCalendarWidget, QDialog, - QVBoxLayout, + QDialogButtonBox, QHBoxLayout, + QLabel, QListWidget, QListWidgetItem, - QPushButton, QMessageBox, - QTextBrowser, + QPushButton, QTabWidget, - QAbstractItemView, + QTextBrowser, + QVBoxLayout, ) from . import strings +from .theme import ThemeManager def _markdown_to_text(s: str) -> str: @@ -70,16 +77,29 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str: class HistoryDialog(QDialog): """Show versions for a date, preview, diff, and allow revert.""" - def __init__(self, db, date_iso: str, parent=None): + def __init__( + self, db, date_iso: str, parent=None, themes: ThemeManager | None = None + ): super().__init__(parent) self.setWindowTitle(f"{strings._('history')} — {date_iso}") self._db = db self._date = date_iso + self._themes = themes self._versions = [] # list[dict] from DB self._current_id = None # id of current root = QVBoxLayout(self) + # --- Top: date label + change-date button + date_row = QHBoxLayout() + self.date_label = QLabel(strings._("date_label").format(date=date_iso)) + date_row.addWidget(self.date_label) + date_row.addStretch(1) + self.change_date_btn = QPushButton(strings._("change_date")) + self.change_date_btn.clicked.connect(self._on_change_date_clicked) + date_row.addWidget(self.change_date_btn) + root.addLayout(date_row) + # Top: list of versions top = QHBoxLayout() self.list = QListWidget() @@ -117,6 +137,53 @@ class HistoryDialog(QDialog): self._load_versions() + @Slot() + def _on_change_date_clicked(self) -> None: + """Let the user choose a different date and reload entries.""" + + # Start from current dialog date; fall back to today if invalid + current_qdate = QDate.fromString(self._date, Qt.ISODate) + if not current_qdate.isValid(): + current_qdate = QDate.currentDate() + + dlg = QDialog(self) + dlg.setWindowTitle(strings._("select_date_title")) + + layout = QVBoxLayout(dlg) + + calendar = QCalendarWidget(dlg) + calendar.setSelectedDate(current_qdate) + layout.addWidget(calendar) + # Apply the same theming as the main sidebar calendar + if self._themes is not None: + self._themes.register_calendar(calendar) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg + ) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + layout.addWidget(buttons) + + if dlg.exec() != QDialog.Accepted: + return + + new_qdate = calendar.selectedDate() + new_iso = new_qdate.toString(Qt.ISODate) + if new_iso == self._date: + # No change + return + + # Update state + self._date = new_iso + + # Update window title and header label + self.setWindowTitle(strings._("for").format(date=new_iso)) + self.date_label.setText(strings._("date_label").format(date=new_iso)) + + # Reload entries for the newly selected date + self._load_versions() + # --- Data/UX helpers --- def _load_versions(self): # [{id,version_no,created_at,note,is_current}] diff --git a/bouquin/invoices.py b/bouquin/invoices.py new file mode 100644 index 0000000..fde6a92 --- /dev/null +++ b/bouquin/invoices.py @@ -0,0 +1,1511 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from PySide6.QtCore import QDate, Qt, QUrl, Signal +from PySide6.QtGui import QDesktopServices, QImage, QPageLayout, QTextDocument +from PySide6.QtPrintSupport import QPrinter +from PySide6.QtWidgets import ( + QAbstractItemView, + QButtonGroup, + QCheckBox, + QComboBox, + QDateEdit, + QDialog, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QRadioButton, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QVBoxLayout, + QWidget, +) +from sqlcipher4 import dbapi2 as sqlite3 + +from . import strings +from .db import DBManager, TimeLogRow +from .reminders import Reminder, ReminderType +from .settings import load_db_config + + +class InvoiceDetailMode(str, Enum): + DETAILED = "detailed" + SUMMARY = "summary" + + +@dataclass +class InvoiceLineItem: + description: str + hours: float + rate_cents: int + amount_cents: int + + +# Default time of day for automatically created invoice reminders (HH:MM) +_INVOICE_REMINDER_TIME = "09:00" + + +def _invoice_due_reminder_text(project_name: str, invoice_number: str) -> str: + """Build the human-readable text for an invoice due-date reminder. + + Using a single helper keeps the text consistent between creation and + removal of reminders. + """ + project = project_name.strip() or "(no project)" + number = invoice_number.strip() or "?" + return f"Invoice {number} for {project} is due" + + +class InvoiceDialog(QDialog): + """ + Create an invoice for a project + date range from time logs. + """ + + COL_INCLUDE = 0 + COL_DATE = 1 + COL_ACTIVITY = 2 + COL_NOTE = 3 + COL_HOURS = 4 + COL_AMOUNT = 5 + + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + project_id: int, + start_date_iso: str, + end_date_iso: str, + time_rows: list[TimeLogRow] | None = None, + parent=None, + ): + super().__init__(parent) + self._db = db + self._project_id = project_id + self._start = start_date_iso + self._end = end_date_iso + + self.cfg = load_db_config() + + if time_rows is not None: + self._time_rows = time_rows + else: + # Fallback if dialog is ever used standalone + self._time_rows = db.time_logs_for_range( + project_id, start_date_iso, end_date_iso + ) + + self.setWindowTitle(strings._("invoice_dialog_title")) + + layout = QVBoxLayout(self) + + # -------- Header / metadata -------- + form = QFormLayout() + + # Project label + proj_name = self._project_name() + self.project_label = QLabel(proj_name) + form.addRow(strings._("project") + ":", self.project_label) + + # Invoice number + self.invoice_number_edit = QLineEdit(self._suggest_invoice_number()) + form.addRow(strings._("invoice_number") + ":", self.invoice_number_edit) + + # Issue + due dates + today = QDate.currentDate() + self.issue_date_edit = QDateEdit(today) + self.issue_date_edit.setDisplayFormat("yyyy-MM-dd") + self.issue_date_edit.setCalendarPopup(True) + form.addRow(strings._("invoice_issue_date") + ":", self.issue_date_edit) + + self.due_date_edit = QDateEdit(today.addDays(14)) + self.due_date_edit.setDisplayFormat("yyyy-MM-dd") + self.due_date_edit.setCalendarPopup(True) + form.addRow(strings._("invoice_due_date") + ":", self.due_date_edit) + + # Billing defaults from project_billing + pb = db.get_project_billing(project_id) + if pb: + ( + _pid, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email, + ) = pb + else: + hourly_rate_cents = 0 + currency = "AUD" + tax_label = "GST" + tax_rate_percent = None + client_name = client_company = client_address = client_email = "" + + # Currency + self.currency_edit = QLineEdit(currency) + form.addRow(strings._("invoice_currency") + ":", self.currency_edit) + + # Hourly rate + self.rate_spin = QDoubleSpinBox() + self.rate_spin.setRange(0, 1_000_000) + self.rate_spin.setDecimals(2) + self.rate_spin.setValue(hourly_rate_cents / 100.0) + self.rate_spin.valueChanged.connect(self._recalc_amounts) + form.addRow(strings._("invoice_hourly_rate") + ":", self.rate_spin) + + # Tax + self.tax_checkbox = QCheckBox(strings._("invoice_apply_tax")) + self.tax_label = QLabel(strings._("invoice_tax_label") + ":") + self.tax_label_edit = QLineEdit(tax_label or "") + + self.tax_rate_label = QLabel(strings._("invoice_tax_rate") + " %:") + self.tax_rate_spin = QDoubleSpinBox() + self.tax_rate_spin.setRange(0, 100) + self.tax_rate_spin.setDecimals(2) + + tax_row = QHBoxLayout() + tax_row.addWidget(self.tax_checkbox) + tax_row.addWidget(self.tax_label) + tax_row.addWidget(self.tax_label_edit) + tax_row.addWidget(self.tax_rate_label) + tax_row.addWidget(self.tax_rate_spin) + form.addRow(strings._("invoice_tax") + ":", tax_row) + + if tax_rate_percent is None: + self.tax_rate_spin.setValue(10.0) + self.tax_checkbox.setChecked(False) + self.tax_label.hide() + self.tax_label_edit.hide() + self.tax_rate_label.hide() + self.tax_rate_spin.hide() + else: + self.tax_rate_spin.setValue(tax_rate_percent) + self.tax_checkbox.setChecked(True) + self.tax_label.show() + self.tax_label_edit.show() + self.tax_rate_label.show() + self.tax_rate_spin.show() + + # When tax settings change, recalc totals + self.tax_checkbox.toggled.connect(self._on_tax_toggled) + self.tax_rate_spin.valueChanged.connect(self._recalc_totals) + + # Client info + self.client_name_edit = QLineEdit(client_name or "") + + # Client company as an editable combo box with existing clients + self.client_company_combo = QComboBox() + self.client_company_combo.setEditable(True) + + companies = self._db.list_client_companies() + # Add existing companies + for comp in companies: + if comp: + self.client_company_combo.addItem(comp) + + # If this project already has a client_company, select it or set as text + if client_company: + idx = self.client_company_combo.findText( + client_company, Qt.MatchFixedString + ) + if idx >= 0: + self.client_company_combo.setCurrentIndex(idx) + else: + self.client_company_combo.setEditText(client_company) + + # When the company text changes (selection or typed), try autofill + self.client_company_combo.currentTextChanged.connect( + self._on_client_company_changed + ) + + self.client_addr_edit = QTextEdit() + self.client_addr_edit.setPlainText(client_address or "") + self.client_email_edit = QLineEdit(client_email or "") + + form.addRow(strings._("invoice_client_name") + ":", self.client_name_edit) + form.addRow( + strings._("invoice_client_company") + ":", self.client_company_combo + ) + form.addRow(strings._("invoice_client_address") + ":", self.client_addr_edit) + form.addRow(strings._("invoice_client_email") + ":", self.client_email_edit) + + layout.addLayout(form) + + # -------- Detail mode + table -------- + mode_row = QHBoxLayout() + self.rb_detailed = QRadioButton(strings._("invoice_mode_detailed")) + self.rb_summary = QRadioButton(strings._("invoice_mode_summary")) + self.rb_detailed.setChecked(True) + self.mode_group = QButtonGroup(self) + self.mode_group.addButton(self.rb_detailed) + self.mode_group.addButton(self.rb_summary) + self.rb_detailed.toggled.connect(self._update_mode_enabled) + mode_row.addWidget(self.rb_detailed) + mode_row.addWidget(self.rb_summary) + mode_row.addStretch() + layout.addLayout(mode_row) + + # Detailed table (time entries) + self.table = QTableWidget() + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels( + [ + "", # include checkbox + strings._("date"), + strings._("activity"), + strings._("note"), + strings._("invoice_hours"), + strings._("invoice_amount"), + ] + ) + self.table.setSelectionMode(QAbstractItemView.NoSelection) + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_INCLUDE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_ACTIVITY, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_NOTE, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_HOURS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_AMOUNT, QHeaderView.ResizeToContents) + layout.addWidget(self.table) + + self._populate_detailed_rows(hourly_rate_cents) + self.table.itemChanged.connect(self._on_table_item_changed) + + # Summary line + self.summary_desc_label = QLabel(strings._("invoice_summary_desc") + ":") + self.summary_desc_edit = QLineEdit(strings._("invoice_summary_default_desc")) + self.summary_hours_label = QLabel(strings._("invoice_summary_hours") + ":") + self.summary_hours_spin = QDoubleSpinBox() + self.summary_hours_spin.setRange(0, 10_000) + self.summary_hours_spin.setDecimals(2) + self.summary_hours_spin.setValue(self._total_hours_from_table()) + self.summary_hours_spin.valueChanged.connect(self._recalc_totals) + + summary_row = QHBoxLayout() + summary_row.addWidget(self.summary_desc_label) + summary_row.addWidget(self.summary_desc_edit) + summary_row.addWidget(self.summary_hours_label) + summary_row.addWidget(self.summary_hours_spin) + layout.addLayout(summary_row) + + # -------- Totals -------- + totals_row = QHBoxLayout() + self.subtotal_label = QLabel("0.00") + self.tax_label_total = QLabel("0.00") + self.total_label = QLabel("0.00") + totals_row.addStretch() + totals_row.addWidget(QLabel(strings._("invoice_subtotal") + ":")) + totals_row.addWidget(self.subtotal_label) + totals_row.addWidget(QLabel(strings._("invoice_tax_total") + ":")) + totals_row.addWidget(self.tax_label_total) + totals_row.addWidget(QLabel(strings._("invoice_total") + ":")) + totals_row.addWidget(self.total_label) + layout.addLayout(totals_row) + + # -------- Buttons -------- + btn_row = QHBoxLayout() + btn_row.addStretch() + self.btn_save = QPushButton(strings._("invoice_save_and_export")) + self.btn_save.clicked.connect(self._on_save_clicked) + btn_row.addWidget(self.btn_save) + + cancel_btn = QPushButton(strings._("cancel")) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + self._update_mode_enabled() + self._recalc_totals() + + def _project_name(self) -> str: + # relies on TimeLogRow including project_name + if self._time_rows: + return self._time_rows[0][3] + # fallback: query projects table + return self._db.list_projects_by_id(self._project_id) + + def _suggest_invoice_number(self) -> str: + # Very simple example: YYYY-XXX based on count + today = QDate.currentDate() + year = today.toString("yyyy") + last = self._db.get_invoice_count_by_project_id_and_year( + self._project_id, f"{year}-%" + ) + seq = int(last) + 1 + return f"{year}-{seq:03d}" + + def _create_due_date_reminder( + self, invoice_id: int, invoice_number: str, due_date_iso: str + ) -> None: + """Create a one-off reminder on the invoice's due date. + + The reminder is purely informational and is keyed by its text so + that it can be found and deleted later when the invoice is paid. + """ + # No due date, nothing to remind about. + if not due_date_iso: + return + + # Build consistent text and create a Reminder dataclass instance. + project_name = self._project_name() + text = _invoice_due_reminder_text(project_name, invoice_number) + + reminder = Reminder( + id=None, + text=text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + weekday=None, + active=True, + date_iso=due_date_iso, + ) + + try: + # Save without failing the invoice flow if something goes wrong. + self._db.save_reminder(reminder) + self.remindersChanged.emit() + except Exception: + pass + + def _populate_detailed_rows(self, hourly_rate_cents: int) -> None: + self.table.blockSignals(True) + try: + self.table.setRowCount(len(self._time_rows)) + rate = hourly_rate_cents / 100.0 if hourly_rate_cents else 0.0 + + for row_idx, tl in enumerate(self._time_rows): + ( + tl_id, + page_date, + _proj_id, + _proj_name, + _act_id, + activity_name, + minutes, + note, + _created_at, + ) = tl + + # include checkbox + chk_item = QTableWidgetItem() + chk_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + chk_item.setCheckState(Qt.Checked) + chk_item.setData(Qt.UserRole, tl_id) + self.table.setItem(row_idx, self.COL_INCLUDE, chk_item) + + self.table.setItem(row_idx, self.COL_DATE, QTableWidgetItem(page_date)) + self.table.setItem( + row_idx, self.COL_ACTIVITY, QTableWidgetItem(activity_name) + ) + self.table.setItem(row_idx, self.COL_NOTE, QTableWidgetItem(note or "")) + + hours = minutes / 60.0 + + # Hours - editable via spin box (override allowed) + hours_spin = QDoubleSpinBox() + hours_spin.setRange(0, 24) + hours_spin.setDecimals(2) + hours_spin.setValue(hours) + hours_spin.valueChanged.connect(self._recalc_totals) + self.table.setCellWidget(row_idx, self.COL_HOURS, hours_spin) + + amount = hours * rate + amount_item = QTableWidgetItem(f"{amount:.2f}") + amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + amount_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row_idx, self.COL_AMOUNT, amount_item) + finally: + self.table.blockSignals(False) + + def _total_hours_from_table(self) -> float: + total = 0.0 + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + if isinstance(hours_widget, QDoubleSpinBox): + total += hours_widget.value() + return total + + def _detail_line_items(self) -> list[InvoiceLineItem]: + rate_cents = int(round(self.rate_spin.value() * 100)) + items: list[InvoiceLineItem] = [] + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + date_str = self.table.item(r, self.COL_DATE).text() + activity = self.table.item(r, self.COL_ACTIVITY).text() + note = self.table.item(r, self.COL_NOTE).text() + + descr_parts = [date_str, activity] + if note: + descr_parts.append(note) + descr = " - ".join(descr_parts) + + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + hours = ( + hours_widget.value() + if isinstance(hours_widget, QDoubleSpinBox) + else 0.0 + ) + amount_cents = int(round(hours * rate_cents)) + items.append( + InvoiceLineItem( + description=descr, + hours=hours, + rate_cents=rate_cents, + amount_cents=amount_cents, + ) + ) + return items + + def _summary_line_items(self) -> list[InvoiceLineItem]: + rate_cents = int(round(self.rate_spin.value() * 100)) + hours = self.summary_hours_spin.value() + amount_cents = int(round(hours * rate_cents)) + return [ + InvoiceLineItem( + description=self.summary_desc_edit.text().strip() or "Services", + hours=hours, + rate_cents=rate_cents, + amount_cents=amount_cents, + ) + ] + + def _update_mode_enabled(self) -> None: + detailed = self.rb_detailed.isChecked() + self.table.setEnabled(detailed) + if not detailed: + self.summary_desc_label.show() + self.summary_desc_edit.show() + self.summary_hours_label.show() + self.summary_hours_spin.show() + else: + self.summary_desc_label.hide() + self.summary_desc_edit.hide() + self.summary_hours_label.hide() + self.summary_hours_spin.hide() + self.resize(self.sizeHint().width(), self.sizeHint().height()) + self._recalc_totals() + + def _recalc_amounts(self) -> None: + # Called when rate changes + rate = self.rate_spin.value() + for r in range(self.table.rowCount()): + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + if isinstance(hours_widget, QDoubleSpinBox): + hours = hours_widget.value() + amount = hours * rate + amount_item = self.table.item(r, self.COL_AMOUNT) + if amount_item: + amount_item.setText(f"{amount:.2f}") + self._recalc_totals() + + def _recalc_totals(self) -> None: + if self.rb_detailed.isChecked(): + items = self._detail_line_items() + else: + items = self._summary_line_items() + + rate_cents = int(round(self.rate_spin.value() * 100)) + total_hours = sum(li.hours for li in items) + subtotal_cents = int(round(total_hours * rate_cents)) + + tax_rate = self.tax_rate_spin.value() if self.tax_checkbox.isChecked() else 0.0 + tax_cents = int(round(subtotal_cents * (tax_rate / 100.0))) + total_cents = subtotal_cents + tax_cents + + self.subtotal_label.setText(f"{subtotal_cents / 100.0:.2f}") + self.tax_label_total.setText(f"{tax_cents / 100.0:.2f}") + self.total_label.setText(f"{total_cents / 100.0:.2f}") + + def _on_table_item_changed(self, item: QTableWidgetItem) -> None: + """Handle changes to table items, particularly checkbox toggles.""" + if item and item.column() == self.COL_INCLUDE: + self._recalc_totals() + + def _on_tax_toggled(self, checked: bool) -> None: + # if on, show the other tax fields + if checked: + self.tax_label.show() + self.tax_label_edit.show() + self.tax_rate_label.show() + self.tax_rate_spin.show() + else: + self.tax_label.hide() + self.tax_label_edit.hide() + self.tax_rate_label.hide() + self.tax_rate_spin.hide() + + # If user just turned tax ON and the rate is 0, give a sensible default + if checked and self.tax_rate_spin.value() == 0.0: + self.tax_rate_spin.setValue(10.0) + self.resize(self.sizeHint().width(), self.sizeHint().height()) + self._recalc_totals() + + def _on_client_company_changed(self, text: str) -> None: + text = text.strip() + if not text: + return + + details = self._db.get_client_by_company(text) + if not details: + # New client - leave other fields as-is + return + + # We don't touch the company combo text - user already chose/typed it. + client_name, client_company, client_address, client_email = details + if client_name: + self.client_name_edit.setText(client_name) + if client_address: + self.client_addr_edit.setPlainText(client_address) + if client_email: + self.client_email_edit.setText(client_email) + + def _on_save_clicked(self) -> None: + invoice_number = self.invoice_number_edit.text().strip() + if not invoice_number: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_required"), + ) + return + + issue_date = self.issue_date_edit.date() + due_date = self.due_date_edit.date() + issue_date_iso = issue_date.toString("yyyy-MM-dd") + due_date_iso = due_date.toString("yyyy-MM-dd") + + # Guard against due date before issue date + if due_date.isValid() and issue_date.isValid() and due_date < issue_date: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_due_before_issue"), + ) + return + + detail_mode = ( + InvoiceDetailMode.DETAILED + if self.rb_detailed.isChecked() + else InvoiceDetailMode.SUMMARY + ) + + # Build line items & collect time_log_ids + if detail_mode == InvoiceDetailMode.DETAILED: + items = self._detail_line_items() + time_log_ids: list[int] = [] + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + tl_id = int(include_item.data(Qt.UserRole)) + time_log_ids.append(tl_id) + else: + items = self._summary_line_items() + # In summary mode we still link all rows used for the report + time_log_ids = [tl[0] for tl in self._time_rows] + + if not items: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_no_items"), + ) + return + + # Rate & tax info + rate_cents = int(round(self.rate_spin.value() * 100)) + currency = self.currency_edit.text().strip() + tax_label = self.tax_label_edit.text().strip() or None + tax_rate_percent = ( + self.tax_rate_spin.value() if self.tax_checkbox.isChecked() else None + ) + + # Persist billing settings for this project (fills project_billing) + self._db.upsert_project_billing( + project_id=self._project_id, + hourly_rate_cents=rate_cents, + currency=currency, + tax_label=tax_label, + tax_rate_percent=tax_rate_percent, + client_name=self.client_name_edit.text().strip() or None, + client_company=self.client_company_combo.currentText().strip() or None, + client_address=self.client_addr_edit.toPlainText().strip() or None, + client_email=self.client_email_edit.text().strip() or None, + ) + + try: + # Create invoice in DB + invoice_id = self._db.create_invoice( + project_id=self._project_id, + invoice_number=invoice_number, + issue_date=issue_date_iso, + due_date=due_date_iso, + currency=currency, + tax_label=tax_label, + tax_rate_percent=tax_rate_percent, + detail_mode=detail_mode.value, + line_items=[(li.description, li.hours, li.rate_cents) for li in items], + time_log_ids=time_log_ids, + ) + + # Automatically create a reminder for the invoice due date + if self.cfg.reminders: + self._create_due_date_reminder(invoice_id, invoice_number, due_date_iso) + + except sqlite3.IntegrityError: + # (project_id, invoice_number) must be unique + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_unique"), + ) + return + + # Generate PDF + pdf_path = self._export_pdf(invoice_id, items) + # Save to Documents if the Documents feature is enabled + if pdf_path and self.cfg.documents: + doc_id = self._db.add_document_from_path( + self._project_id, + pdf_path, + description=f"Invoice {invoice_number}", + ) + self._db.set_invoice_document(invoice_id, doc_id) + + self.accept() + + def _export_pdf(self, invoice_id: int, items: list[InvoiceLineItem]) -> str | None: + proj_name = self._project_name() + safe_proj = proj_name.replace(" ", "_") or "project" + invoice_number = self.invoice_number_edit.text().strip() + filename = f"{safe_proj}_invoice_{invoice_number}.pdf" + + path, _ = QFileDialog.getSaveFileName( + self, + strings._("invoice_save_pdf_title"), + filename, + "PDF (*.pdf)", + ) + if not path: + return None + + printer = QPrinter(QPrinter.HighResolution) + printer.setOutputFormat(QPrinter.PdfFormat) + printer.setOutputFileName(path) + printer.setPageOrientation(QPageLayout.Portrait) + + doc = QTextDocument() + + # Load company profile before building HTML + profile = self._db.get_company_profile() + self._company_profile = None + if profile: + name, address, phone, email, tax_id, payment_details, logo_bytes = profile + self._company_profile = { + "name": name, + "address": address, + "phone": phone, + "email": email, + "tax_id": tax_id, + "payment_details": payment_details, + } + if logo_bytes: + img = QImage.fromData(logo_bytes) + if not img.isNull(): + doc.addResource( + QTextDocument.ImageResource, QUrl("company_logo"), img + ) + + html = self._build_invoice_html(items) + doc.setHtml(html) + doc.print_(printer) + + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + return path + + def _build_invoice_html(self, items: list[InvoiceLineItem]) -> str: + # Monetary values based on current labels (these are kept in sync by _recalc_totals) + try: + subtotal = float(self.subtotal_label.text()) + except ValueError: + subtotal = 0.0 + try: + tax_total = float(self.tax_label_total.text()) + except ValueError: + tax_total = 0.0 + total = subtotal + tax_total + + currency = self.currency_edit.text().strip() + issue = self.issue_date_edit.date().toString("yyyy-MM-dd") + due = self.due_date_edit.date().toString("yyyy-MM-dd") + inv_no = self.invoice_number_edit.text().strip() or "-" + proj = self._project_name() + + # --- Client block (Bill to) ------------------------------------- + client_lines = [ + self.client_company_combo.currentText().strip(), + self.client_name_edit.text().strip(), + self.client_addr_edit.toPlainText().strip(), + self.client_email_edit.text().strip(), + ] + client_lines = [ln for ln in client_lines if ln] + client_block = "
".join( + line.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + for line in client_lines + ) + + # --- Company block (From) --------------------------------------- + company_html = "" + if self._company_profile: + cp = self._company_profile + lines = [ + cp.get("name"), + cp.get("address"), + cp.get("phone"), + cp.get("email"), + "Tax ID/Business No: " + cp.get("tax_id"), + ] + lines = [ln for ln in lines if ln] + company_html = "
".join( + line.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + for line in lines + ) + + logo_html = "" + if self._company_profile: + # "company_logo" resource is registered in _export_pdf + logo_html = ( + '' + ) + + # --- Items table ------------------------------------------------- + item_rows_html = "" + for idx, li in enumerate(items, start=1): + desc = li.description or "" + desc = ( + desc.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + ) + hours_str = f"{li.hours:.2f}".rstrip("0").rstrip(".") + price = li.rate_cents / 100.0 + amount = li.amount_cents / 100.0 + item_rows_html += f""" + + + {desc} + + + {hours_str} + + + {price:,.2f} {currency} + + + {amount:,.2f} {currency} + + + """ + + if not item_rows_html: + item_rows_html = """ + + + (No items) + + + """ + + # --- Tax summary line ------------------------------------------- + if tax_total > 0.0: + tax_label = self.tax_label_edit.text().strip() or "Tax" + tax_summary_text = f"{tax_label} has been added." + tax_line_label = tax_label + invoice_title = "TAX INVOICE" + else: + tax_summary_text = "No tax has been charged." + tax_line_label = "Tax" + invoice_title = "INVOICE" + + # --- Optional payment / terms text ----------------------------- + if self._company_profile and self._company_profile.get("payment_details"): + raw_payment = self._company_profile["payment_details"] + else: + raw_payment = "Please pay by the due date. Thank you!" + + lines = [ln.strip() for ln in raw_payment.splitlines()] + payment_text = "\n".join(lines).strip() + + # --- Build final HTML ------------------------------------------- + html = f""" + + + + + + + + + + + + +
+ {logo_html} +
+ {company_html} +
+
+
{invoice_title}
+ + + + + + + + + + + + + + + + + +
Invoice no:{inv_no}
Invoice date:{issue}
Reference:{proj}
Due date:{due}
+
+ + + + + + + + + +
+
BILL TO
+
{client_block}
+
+ + + + + + + + + + + + + +
Subtotal{subtotal:,.2f} {currency}
{tax_line_label}{tax_total:,.2f} {currency}
TOTAL{total:,.2f} {currency}
+
{tax_summary_text}
+
+ + + + + + + + + + {item_rows_html} +
ITEMS AND DESCRIPTIONQTY/HRSPRICEAMOUNT ({currency})
+ + + + + + + +
+
PAYMENT DETAILS
+
+{payment_text} +
+
+ + + + + + +
AMOUNT DUE{total:,.2f} {currency}
+
+ + + + """ + + return html + + +class InvoicesDialog(QDialog): + """Manager for viewing and editing existing invoices.""" + + COL_NUMBER = 0 + COL_PROJECT = 1 + COL_ISSUE_DATE = 2 + COL_DUE_DATE = 3 + COL_CURRENCY = 4 + COL_TAX_LABEL = 5 + COL_TAX_RATE = 6 + COL_SUBTOTAL = 7 + COL_TAX = 8 + COL_TOTAL = 9 + COL_PAID_AT = 10 + COL_PAYMENT_NOTE = 11 + + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + parent: QWidget | None = None, + initial_project_id: int | None = None, + ) -> None: + super().__init__(parent) + self._db = db + self._reloading_invoices = False + self.cfg = load_db_config() + self.setWindowTitle(strings._("manage_invoices")) + self.resize(1100, 500) + + root = QVBoxLayout(self) + + # --- Project selector ------------------------------------------------- + form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + root.addLayout(form) + + proj_row = QHBoxLayout() + self.project_combo = QComboBox() + proj_row.addWidget(self.project_combo, 1) + form.addRow(strings._("project"), proj_row) + + self._reload_projects() + self._select_initial_project(initial_project_id) + + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + + # --- Table of invoices ----------------------------------------------- + self.table = QTableWidget() + self.table.setColumnCount(12) + self.table.setHorizontalHeaderLabels( + [ + strings._("invoice_number"), # COL_NUMBER + strings._("project"), # COL_PROJECT + strings._("invoice_issue_date"), # COL_ISSUE_DATE + strings._("invoice_due_date"), # COL_DUE_DATE + strings._("invoice_currency"), # COL_CURRENCY + strings._("invoice_tax_label"), # COL_TAX_LABEL + strings._("invoice_tax_rate"), # COL_TAX_RATE + strings._("invoice_subtotal"), # COL_SUBTOTAL + strings._("invoice_tax_total"), # COL_TAX + strings._("invoice_total"), # COL_TOTAL + strings._("invoice_paid_at"), # COL_PAID_AT + strings._("invoice_payment_note"), # COL_PAYMENT_NOTE + ] + ) + + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_NUMBER, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PROJECT, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_ISSUE_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_DUE_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_CURRENCY, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX_LABEL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX_RATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_SUBTOTAL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TOTAL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PAID_AT, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PAYMENT_NOTE, QHeaderView.Stretch) + + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.SelectedClicked + ) + + root.addWidget(self.table, 1) + + # Connect after constructing the table + self.table.itemChanged.connect(self._on_item_changed) + + # --- Buttons ---------------------------------------------------------- + btn_row = QHBoxLayout() + 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.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + + root.addLayout(btn_row) + + 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 + + def _reload_projects(self) -> None: + """Populate the project combo box.""" + self.project_combo.blockSignals(True) + try: + self.project_combo.clear() + for proj_id, name in self._db.list_projects(): + self.project_combo.addItem(name, proj_id) + finally: + self.project_combo.blockSignals(False) + + def _select_initial_project(self, project_id: int | None) -> None: + if project_id is None: + if self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + return + + idx = self.project_combo.findData(project_id) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + elif self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + + def _current_project(self) -> int | None: + idx = self.project_combo.currentIndex() + if idx < 0: + return None + data = self.project_combo.itemData(idx) + return int(data) if data is not None else None + + # ----------------------------------------------------------------- reloading + + def _on_project_changed(self, idx: int) -> None: + _ = idx + self._reload_invoices() + + def _reload_invoices(self) -> None: + """Load invoices for the current project into the table.""" + self._reloading_invoices = True + try: + self.table.setRowCount(0) + project_id = self._current_project() + rows = self._db.get_all_invoices(project_id) + + self.table.setRowCount(len(rows) or 0) + + for row_idx, r in enumerate(rows): + inv_id = int(r["id"]) + proj_name = r["project_name"] or "" + invoice_number = r["invoice_number"] or "" + issue_date = r["issue_date"] or "" + due_date = r["due_date"] or "" + currency = r["currency"] or "" + tax_label = r["tax_label"] or "" + tax_rate = ( + r["tax_rate_percent"] if r["tax_rate_percent"] is not None else None + ) + subtotal_cents = r["subtotal_cents"] or 0 + tax_cents = r["tax_cents"] or 0 + total_cents = r["total_cents"] or 0 + paid_at = r["paid_at"] or "" + payment_note = r["payment_note"] or "" + + # Column 0: invoice number (store invoice_id in UserRole) + num_item = QTableWidgetItem(invoice_number) + num_item.setData(Qt.ItemDataRole.UserRole, inv_id) + self.table.setItem(row_idx, self.COL_NUMBER, num_item) + + # Column 1: project name (read-only) + proj_item = QTableWidgetItem(proj_name) + proj_item.setFlags(proj_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row_idx, self.COL_PROJECT, proj_item) + + # Column 2: issue date + self.table.setItem( + row_idx, self.COL_ISSUE_DATE, QTableWidgetItem(issue_date) + ) + + # Column 3: due date + self.table.setItem( + row_idx, self.COL_DUE_DATE, QTableWidgetItem(due_date or "") + ) + + # Column 4: currency + self.table.setItem( + row_idx, self.COL_CURRENCY, QTableWidgetItem(currency) + ) + + # Column 5: tax label + self.table.setItem( + row_idx, self.COL_TAX_LABEL, QTableWidgetItem(tax_label or "") + ) + + # Column 6: tax rate + tax_rate_text = "" if tax_rate is None else f"{tax_rate:.2f}" + self.table.setItem( + row_idx, self.COL_TAX_RATE, QTableWidgetItem(tax_rate_text) + ) + + # Column 7-9: amounts (cents → dollars) + self.table.setItem( + row_idx, + self.COL_SUBTOTAL, + QTableWidgetItem(f"{subtotal_cents / 100.0:.2f}"), + ) + self.table.setItem( + row_idx, + self.COL_TAX, + QTableWidgetItem(f"{tax_cents / 100.0:.2f}"), + ) + self.table.setItem( + row_idx, + self.COL_TOTAL, + QTableWidgetItem(f"{total_cents / 100.0:.2f}"), + ) + + # Column 10: paid_at + self.table.setItem( + row_idx, self.COL_PAID_AT, QTableWidgetItem(paid_at or "") + ) + + # Column 11: payment note + self.table.setItem( + row_idx, + self.COL_PAYMENT_NOTE, + QTableWidgetItem(payment_note or ""), + ) + + finally: + self._reloading_invoices = False + + # ----------------------------------------------------------------- editing + + def _remove_invoice_due_reminder(self, row: int, inv_id: int) -> None: + """Delete any one-off reminder created for this invoice's due date. + + We look up reminders by the same text we used when creating them + to avoid needing extra schema just for this linkage. + """ + proj_item = self.table.item(row, self.COL_PROJECT) + num_item = self.table.item(row, self.COL_NUMBER) + if proj_item is None or num_item is None: + return + + project_name = proj_item.text().strip() + invoice_number = num_item.text().strip() + if not project_name or not invoice_number: + return + + target_text = _invoice_due_reminder_text(project_name, invoice_number) + + removed_any = False + + try: + reminders = self._db.get_all_reminders() + except Exception: + return + + for reminder in reminders: + if ( + reminder.id is not None + and reminder.reminder_type == ReminderType.ONCE + and reminder.text == target_text + ): + try: + self._db.delete_reminder(reminder.id) + removed_any = True + except Exception: + # Best effort; if deletion fails we silently continue. + pass + + if removed_any: + # Tell Reminders that reminders have changed + self.remindersChanged.emit() + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + """Handle inline edits and write them back to the database.""" + if self._reloading_invoices: + return + + row = item.row() + col = item.column() + + 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 + + text = item.text().strip() + + def _reset_from_db(field: str, formatter=lambda v: v) -> None: + """Reload a single field from DB and reset the cell.""" + self._reloading_invoices = True + try: + row_db = self._db.get_invoice_field_by_id(inv_id, field) + + if row_db is None: + return + value = row_db[field] + item.setText("" if value is None else formatter(value)) + finally: + self._reloading_invoices = False + + # ---- Invoice number (unique per project) ---------------------------- + if col == self.COL_NUMBER: + if not text: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_required"), + ) + _reset_from_db("invoice_number", lambda v: v or "") + return + try: + self._db.update_invoice_number(inv_id, text) + except sqlite3.IntegrityError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_unique"), + ) + _reset_from_db("invoice_number", lambda v: v or "") + return + + # ---- Dates: issue, due, paid_at (YYYY-MM-DD) ------------------------ + if col in (self.COL_ISSUE_DATE, self.COL_DUE_DATE, self.COL_PAID_AT): + new_date: QDate | None = None + if text: + new_date = QDate.fromString(text, "yyyy-MM-dd") + if not new_date.isValid(): + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_date_format"), + ) + field = { + self.COL_ISSUE_DATE: "issue_date", + self.COL_DUE_DATE: "due_date", + self.COL_PAID_AT: "paid_at", + }[col] + _reset_from_db(field, lambda v: v or "") + return + + # Cross-field validation: due/paid must not be before issue date + issue_item = self.table.item(row, self.COL_ISSUE_DATE) + issue_qd: QDate | None = None + if issue_item is not None: + issue_text = issue_item.text().strip() + if issue_text: + issue_qd = QDate.fromString(issue_text, "yyyy-MM-dd") + if not issue_qd.isValid(): + issue_qd = None + + if issue_qd is not None and new_date is not None: + if col == self.COL_DUE_DATE and new_date < issue_qd: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_due_before_issue"), + ) + _reset_from_db("due_date", lambda v: v or "") + return + if col == self.COL_PAID_AT and new_date < issue_qd: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_paid_before_issue"), + ) + _reset_from_db("paid_at", lambda v: v or "") + return + + field = { + self.COL_ISSUE_DATE: "issue_date", + self.COL_DUE_DATE: "due_date", + self.COL_PAID_AT: "paid_at", + }[col] + + self._db.set_invoice_field_by_id(inv_id, field, text or None) + + # If the invoice has just been marked as paid, remove any + # auto-created reminder for its due date. + if col == self.COL_PAID_AT and text and self.cfg.reminders: + self._remove_invoice_due_reminder(row, inv_id) + + return + + # ---- Simple text fields: currency, tax label, payment_note --- + if col in ( + self.COL_CURRENCY, + self.COL_TAX_LABEL, + self.COL_PAYMENT_NOTE, + ): + field = { + self.COL_CURRENCY: "currency", + self.COL_TAX_LABEL: "tax_label", + self.COL_PAYMENT_NOTE: "payment_note", + }[col] + + self._db.set_invoice_field_by_id(inv_id, field, text or None) + + if col == self.COL_CURRENCY and text: + # Normalize currency code display + self._reloading_invoices = True + try: + item.setText(text.upper()) + finally: + self._reloading_invoices = False + return + + # ---- Tax rate percent (float) --------------------------------------- + if col == self.COL_TAX_RATE: + if text: + try: + rate = float(text) + except ValueError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_tax_rate"), + ) + _reset_from_db( + "tax_rate_percent", + lambda v: "" if v is None else f"{v:.2f}", + ) + return + value = rate + else: + value = None + + self._db.set_invoice_field_by_id(inv_id, "tax_rate_percent", value) + return + + # ---- Monetary fields (subtotal, tax, total) in dollars -------------- + if col in (self.COL_SUBTOTAL, self.COL_TAX, self.COL_TOTAL): + field = { + self.COL_SUBTOTAL: "subtotal_cents", + self.COL_TAX: "tax_cents", + self.COL_TOTAL: "total_cents", + }[col] + if not text: + cents = 0 + else: + try: + value = float(text.replace(",", "")) + except ValueError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_amount"), + ) + _reset_from_db( + field, + lambda v: f"{(v or 0) / 100.0:.2f}", + ) + return + cents = int(round(value * 100)) + + self._db.set_invoice_field_by_id(inv_id, field, cents) + + # Normalise formatting in the table + self._reloading_invoices = True + try: + item.setText(f"{cents / 100.0:.2f}") + finally: + self._reloading_invoices = False + return diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index 195599f..866f682 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -4,13 +4,13 @@ from pathlib import Path from PySide6.QtWidgets import ( QDialog, - QVBoxLayout, + QDialogButtonBox, + QFileDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, - QDialogButtonBox, - QFileDialog, + QVBoxLayout, ) from . import strings diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index eb5dd83..f1f86dd 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -40,6 +40,8 @@ "next_day": "Next day", "today": "Today", "show": "Show", + "edit": "Edit", + "delete": "Delete", "history": "History", "export_accessible_flag": "&Export", "export_entries": "Export entries", @@ -101,6 +103,7 @@ "autosave": "autosave", "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday", + "move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday", "insert_images": "Insert images", "images": "Images", "reopen_failed": "Re-open failed", @@ -142,6 +145,7 @@ "tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.", "color_hex": "Colour", "date": "Date", + "page_or_document": "Page / Document", "add_a_tag": "Add a tag", "edit_tag_name": "Edit tag name", "new_tag_name": "New tag name:", @@ -151,6 +155,11 @@ "tag_already_exists_with_that_name": "A tag already exists with that name", "statistics": "Statistics", "main_window_statistics_accessible_flag": "Stat&istics", + "stats_group_pages": "Pages", + "stats_group_tags": "Tags", + "stats_group_documents": "Documents", + "stats_group_time_logging": "Time logging", + "stats_group_reminders": "Reminders", "stats_pages_with_content": "Pages with content (current version)", "stats_total_revisions": "Total revisions", "stats_page_most_revisions": "Page with most revisions", @@ -161,7 +170,18 @@ "stats_heatmap_metric": "Colour by", "stats_metric_words": "Words", "stats_metric_revisions": "Revisions", + "stats_metric_documents": "Documents", + "stats_total_documents": "Total documents", + "stats_date_most_documents": "Date with most documents", "stats_no_data": "No statistics available yet.", + "stats_time_total_hours": "Total hours logged", + "stats_time_day_most_hours": "Day with most hours logged", + "stats_time_project_most_hours": "Project with most hours logged", + "stats_time_activity_most_hours": "Activity with most hours logged", + "stats_total_reminders": "Total reminders", + "stats_date_most_reminders": "Day with most reminders", + "stats_metric_hours": "Hours", + "stats_metric_reminders": "Reminders", "select_notebook": "Select notebook", "bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.", "bug_report_placeholder": "Type your bug report here", @@ -189,10 +209,19 @@ "add_project": "Add project", "add_time_entry": "Add time entry", "time_period": "Time period", + "dont_group": "Don't group", + "by_activity": "by activity", "by_day": "by day", "by_month": "by month", "by_week": "by week", "date_range": "Date range", + "custom_range": "Custom", + "last_week": "Last week", + "last_month": "Last month", + "this_week": "This week", + "this_month": "This month", + "this_year": "This year", + "all_projects": "All projects", "delete_activity": "Delete activity", "delete_activity_confirm": "Are you sure you want to delete this activity?", "delete_activity_title": "Delete activity - are you sure?", @@ -202,6 +231,7 @@ "delete_time_entry": "Delete time entry", "group_by": "Group by", "hours": "Hours", + "created_at": "Created at", "invalid_activity_message": "The activity is invalid", "invalid_activity_title": "Invalid activity", "invalid_project_message": "The project is invalid", @@ -220,6 +250,8 @@ "projects": "Projects", "rename_activity": "Rename activity", "rename_project": "Rename project", + "reporting": "Reporting", + "reporting_and_invoicing": "Reporting and Invoicing", "run_report": "Run report", "add_activity_title": "Add activity", "add_activity_label": "Add an activity", @@ -235,8 +267,10 @@ "select_project_title": "Select project", "time_log": "Time log", "time_log_collapsed_hint": "Time log", - "time_log_date_label": "Time log date: {date}", - "time_log_for": "Time log for {date}", + "date_label": "Date: {date}", + "change_date": "Change date", + "select_date_title": "Select date", + "for": "For {date}", "time_log_no_date": "Time log", "time_log_no_entries": "No time entries yet", "time_log_report": "Time log report", @@ -259,28 +293,53 @@ "enable_tags_feature": "Enable Tags", "enable_time_log_feature": "Enable Time Logging", "enable_reminders_feature": "Enable Reminders", + "reminders_webhook_section_title": "Send Reminders to a webhook", + "reminders_webhook_url_label":"Webhook URL", + "reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)", + "enable_documents_feature": "Enable storing of documents", "pomodoro_time_log_default_text": "Focus session", "toolbar_pomodoro_timer": "Time-logging timer", "set_code_language": "Set code language", "cut": "Cut", "copy": "Copy", "paste": "Paste", + "collapse": "Collapse", + "expand": "Expand", + "remove_collapse": "Remove collapse", + "collapse_selection": "Collapse selection", "start": "Start", "pause": "Pause", "resume": "Resume", "stop_and_log": "Stop and log", + "manage_reminders": "Manage Reminders", + "upcoming_reminders": "Upcoming Reminders", + "no_upcoming_reminders": "No upcoming reminders", "once": "once", "daily": "daily", "weekdays": "weekdays", "weekly": "weekly", - "set_reminder": "Set reminder", - "edit_reminder": "Edit reminder", + "add_reminder": "Add Reminder", + "set_reminder": "Set Reminder", + "edit_reminder": "Edit Reminder", + "delete_reminder": "Delete Reminder", + "delete_reminders": "Delete Reminders", + "deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.", + "this_is_a_reminder_of_type": "Note: This is a reminder of type", + "this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.", "reminder": "Reminder", + "reminders": "Reminders", "time": "Time", - "once_today": "Once (today)", + "once": "Once", "every_day": "Every day", "every_weekday": "Every weekday (Mon-Fri)", "every_week": "Every week", + "every_fortnight": "Every 2 weeks", + "every_month": "Every month (same date)", + "every_month_nth_weekday": "Every month (e.g. 3rd Monday)", + "week_in_month": "Week in month", + "fortnightly": "Fortnightly", + "monthly_same_date": "Monthly (same date)", + "monthly_nth_weekday": "Monthly (nth weekday)", "repeat": "Repeat", "monday": "Monday", "tuesday": "Tuesday", @@ -289,5 +348,94 @@ "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday", - "day": "Day" + "monday_short": "Mon", + "tuesday_short": "Tue", + "wednesday_short": "Wed", + "thursday_short": "Thu", + "friday_short": "Fri", + "saturday_short": "Sat", + "sunday_short": "Sun", + "day": "Day", + "text": "Text", + "type": "Type", + "active": "Active", + "actions": "Actions", + "edit_code_block": "Edit code block", + "delete_code_block": "Delete code block", + "search_result_heading_document": "Document", + "toolbar_documents": "Documents Manager", + "project_documents_title": "Project documents", + "documents_col_file": "File", + "documents_col_description": "Description", + "documents_col_added": "Added", + "documents_col_tags": "Tags", + "documents_col_size": "Size", + "documents_add": "&Add", + "documents_open": "&Open", + "documents_delete": "&Delete", + "documents_no_project_selected": "Please choose a project first.", + "documents_file_filter_all": "All files (*)", + "documents_add_failed": "Could not add document: {error}", + "documents_open_failed": "Could not open document: {error}", + "documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)", + "documents_search_label": "Search", + "documents_search_placeholder": "Type to search documents (all projects)", + "documents_invalid_date_format": "Invalid date format", + "todays_documents": "Documents from this day", + "todays_documents_none": "No documents yet.", + "manage_invoices": "Manage Invoices", + "create_invoice": "Create Invoice", + "invoice_amount": "Amount", + "invoice_apply_tax": "Apply Tax", + "invoice_client_address": "Client Address", + "invoice_client_company": "Client Company", + "invoice_client_email": "Client E-mail", + "invoice_client_name": "Client Contact", + "invoice_currency": "Currency", + "invoice_dialog_title": "Create Invoice", + "invoice_due_date": "Due Date", + "invoice_hourly_rate": "Hourly Rate", + "invoice_hours": "Hours", + "invoice_issue_date": "Issue Date", + "invoice_mode_detailed": "Detailed mode", + "invoice_mode_summary": "Summary mode", + "invoice_number": "Invoice Number", + "invoice_save_and_export": "Save and export", + "invoice_save_pdf_title": "Save PDF", + "invoice_subtotal": "Subtotal", + "invoice_summary_default_desc": "Consultant services for the month of", + "invoice_summary_desc": "Summary description", + "invoice_summary_hours": "Summary hours", + "invoice_tax": "Tax details", + "invoice_tax_label": "Tax type", + "invoice_tax_rate": "Tax rate", + "invoice_tax_total": "Tax total", + "invoice_total": "Total", + "invoice_paid_at": "Paid on", + "invoice_payment_note": "Payment notes", + "invoice_project_required_title": "Project required", + "invoice_project_required_message": "Please select a specific project before trying to create an invoice.", + "invoice_need_report_title": "Report required", + "invoice_need_report_message": "Please run a time report before trying to create an invoice from it.", + "invoice_due_before_issue": "Due date cannot be earlier than the issue date.", + "invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.", + "enable_invoicing_feature": "Enable Invoicing (requires Time Logging)", + "invoice_company_profile": "Business Profile", + "invoice_company_name": "Business Name", + "invoice_company_address": "Address", + "invoice_company_phone": "Phone", + "invoice_company_email": "E-mail", + "invoice_company_tax_id": "Tax number", + "invoice_company_payment_details": "Payment details", + "invoice_company_logo": "Logo", + "invoice_company_logo_choose": "Choose logo", + "invoice_company_logo_set": "Logo has been set", + "invoice_company_logo_not_set": "Logo not set", + "invoice_number_unique": "Invoice number must be unique. This invoice number already exists.", + "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." } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 3ba5ba6..87a6a16 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -1,290 +1,437 @@ { - "db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher", - "db_issues_reported": "problème(s) signalé(s)", - "db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé", - "db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée", - "db_key_incorrect": "La clé est probablement incorrecte", - "db_database_error": "Erreur de base de données", - "database_maintenance": "Maintenance de la base de données", - "database_compact": "Compacter la base de données", - "database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.", - "database_compacted_successfully": "Base de données compactée avec succès !", - "encryption": "Chiffrement", - "remember_key": "Se souvenir de la clé", - "change_encryption_key": "Changer la clé de chiffrement", - "enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement", - "reenter_the_new_key": "Saisir de nouveau la nouvelle clé", - "key_mismatch": "Les clés ne correspondent pas", - "key_mismatch_explanation": "Les deux saisies ne correspondent pas.", - "empty_key": "La clé est vide", - "empty_key_explanation": "La clé ne peut pas être vide.", - "key_changed": "La clé a été modifiée", - "key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !", - "error": "Erreur", - "success": "Succès", - "close": "Fermer", - "find": "Rechercher", - "file": "Fichier", - "locale": "Langue", - "locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.", - "settings": "Paramètres", - "theme": "Thème", - "system": "Système", - "light": "Clair", - "dark": "Sombre", - "never": "Jamais", - "close_tab": "Fermer l'onglet", - "previous": "Précédent", - "previous_day": "Jour précédent", - "next": "Suivant", - "next_day": "Jour suivant", - "today": "Aujourd'hui", - "show": "Afficher", - "history": "Historique", - "export_accessible_flag": "E&xporter", - "export_entries": "Exporter les entrées", - "export_complete": "Exportation terminée", - "export_failed": "Échec de l'exportation", - "backup": "Sauvegarder", - "backup_complete": "Sauvegarde terminée", - "backup_failed": "Échec de la sauvegarde", - "quit": "Quitter", - "cancel": "Annuler", - "save": "Enregistrer", - "help": "Aide", - "saved": "Enregistré", - "saved_to": "Enregistré dans", - "documentation": "Documentation", - "couldnt_open": "Impossible d'ouvrir", - "report_a_bug": "Signaler un bug", - "version": "Version", - "update": "Mise à jour", - "check_for_updates": "Rechercher des mises à jour", - "could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n", - "update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide", - "you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n", - "there_is_a_new_version_available": "Une nouvelle version est disponible:\n", - "download_the_appimage": "Télécharger l'AppImage ?", - "downloading": "Téléchargement en cours", - "download_cancelled": "Téléchargement annulé", - "failed_to_download_update": "Échec du téléchargement de la mise à jour:\n", - "could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n", - "could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.", - "gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n", - "downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n", - "navigate": "Naviguer", - "current": "actuel", - "selected": "sélectionné", - "find_on_page": "Rechercher dans la page", - "find_next": "Rechercher le suivant", - "find_previous": "Rechercher le précédent", - "find_bar_type_to_search": "Tapez pour rechercher", - "find_bar_match_case": "Respecter la casse", - "history_dialog_preview": "Aperçu", - "history_dialog_diff": "Différences", - "history_dialog_revert_to_selected": "Revenir à la sélection", - "history_dialog_revert_failed": "Échec de la restauration", - "history_dialog_delete": "Supprimer la révision", - "history_dialog_delete_failed": "Impossible de supprimer la révision", - "key_prompt_enter_key": "Saisir la clé", - "lock_overlay_locked": "Verrouillé", - "lock_overlay_unlock": "Déverrouiller", - "main_window_lock_screen_accessibility": "&Verrouiller l'écran", - "main_window_ready": "Prêt", - "main_window_save_a_version": "Enregistrer une version", - "main_window_settings_accessible_flag": "&Paramètres", - "set_an_encryption_key": "Définir une clé de chiffrement", - "set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !", - "unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré", - "unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin", - "open_in_new_tab": "Ouvrir dans un nouvel onglet", - "autosave": "enregistrement automatique", - "unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant", - "move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable", - "insert_images": "Insérer des images", - "images": "Images", - "reopen_failed": "Échec de la réouverture", - "unlock_failed": "Échec du déverrouillage", - "could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.", - "unencrypted_export": "Export non chiffré", - "unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.", - "unrecognised_extension": "Extension non reconnue !", - "backup_encrypted_notebook": "Sauvegarder le bouquin chiffré", - "enter_a_name_for_this_version": "Saisir un nom pour cette version", - "new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à", - "appearance": "Apparence", - "security": "Sécurité", - "features": "Fonctionnalités", - "database": "Base de données", - "save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.", - "lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité", - "autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.", - "font_size": "Taille de police", - "font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.", - "search_for_notes_here": "Recherchez des notes ici", - "toolbar_format": "Format", - "toolbar_bold": "Gras", - "toolbar_italic": "Italique", - "toolbar_strikethrough": "Barré", - "toolbar_normal_paragraph_text": "Texte de paragraphe normal", - "toolbar_font_smaller": "Texte plus petit", - "toolbar_font_larger": "Texte plus grand", - "toolbar_bulleted_list": "Liste à puces", - "toolbar_numbered_list": "Liste numérotée", - "toolbar_code_block": "Bloc de code", - "toolbar_heading": "Titre", - "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", - "tags": "Étiquettes", - "tag": "Étiquette", - "manage_tags": "Gérer les étiquettes", - "add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée", - "tag_browser_title": "Navigateur d'étiquettes", - "tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.", - "color_hex": "Couleur", - "date": "Date", - "add_a_tag": "Ajouter une étiquette", - "edit_tag_name": "Modifier le nom de l'étiquette", - "new_tag_name": "Nouveau nom de l'étiquette :", - "change_color": "Changer la couleur", - "delete_tag": "Supprimer l'étiquette", - "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.", - "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà", - "statistics": "Statistiques", - "main_window_statistics_accessible_flag": "Stat&istiques", - "stats_pages_with_content": "Pages avec contenu (version actuelle)", - "stats_total_revisions": "Nombre total de révisions", - "stats_page_most_revisions": "Page avec le plus de révisions", - "stats_total_words": "Nombre total de mots (versions actuelles)", - "stats_unique_tags": "Étiquettes uniques", - "stats_page_most_tags": "Page avec le plus d'étiquettes", - "stats_activity_heatmap": "Carte de chaleur d'activité", - "stats_heatmap_metric": "Colorer selon", - "stats_metric_words": "Mots", - "stats_metric_revisions": "Révisions", - "stats_no_data": "Aucune statistique disponible pour le moment.", - "select_notebook": "Sélectionner un bouquin", - "bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.", - "bug_report_placeholder": "Saisissez votre rapport de bug ici", - "bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.", - "bug_report_send_failed": "Impossible d'envoyer le rapport de bug.", - "bug_report_sent_ok": "Rapport de bug envoyé. Merci !", - "send": "Envoyer", - "reminder": "Rappel", - "set_reminder": "Définir le rappel", - "reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !", - "invalid_time_title": "Heure invalide", - "invalid_time_message": "Veuillez saisir une heure au format HH:MM", - "dismiss": "Ignorer", - "toolbar_alarm": "Régler l'alarme de rappel", - "activities": "Activités", - "activity": "Activité", - "note": "Note", - "activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité", - "activity_delete_error_title": "Problème lors de la suppression de l'activité", - "activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité", - "activity_rename_error_title": "Problème lors du renommage de l'activité", - "activity_required_message": "Un nom d'activité est requis", - "activity_required_title": "Nom d'activité requis", - "add_activity": "Ajouter une activité", - "add_project": "Ajouter un projet", - "add_time_entry": "Ajouter une entrée de temps", - "time_period": "Période", - "by_day": "par jour", - "by_month": "par mois", - "by_week": "par semaine", - "date_range": "Plage de dates", - "delete_activity": "Supprimer l'activité", - "delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?", - "delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?", - "delete_project": "Supprimer le projet", - "delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?", - "delete_project_title": "Supprimer le projet - êtes-vous sûr ?", - "delete_time_entry": "Supprimer l'entrée de temps", - "group_by": "Grouper par", - "hours": "Heures", - "invalid_activity_message": "L'activité est invalide", - "invalid_activity_title": "Activité invalide", - "invalid_project_message": "Le projet est invalide", - "invalid_project_title": "Projet invalide", - "manage_activities": "Gérer les activités", - "manage_projects": "Gérer les projets", - "manage_projects_activities": "Gérer les activités du projet", - "open_time_log": "Ouvrir le journal de temps", - "project": "Projet", - "project_delete_error_message": "Un problème est survenu lors de la suppression du projet", - "project_delete_error_title": "Problème lors de la suppression du projet", - "project_rename_error_message": "Un problème est survenu lors du renommage du projet", - "project_rename_error_title": "Problème lors du renommage du projet", - "project_required_message": "Un projet est requis", - "project_required_title": "Projet requis", - "projects": "Projets", - "rename_activity": "Renommer l'activité", - "rename_project": "Renommer le projet", - "run_report": "Exécuter le rapport", - "add_activity_title": "Ajouter une activité", - "add_activity_label": "Ajouter une activité", - "rename_activity_label": "Renommer l'activité", - "add_project_title": "Ajouter un projet", - "add_project_label": "Ajouter un projet", - "rename_activity_title": "Renommer cette activité", - "rename_project_label": "Renommer le projet", - "rename_project_title": "Renommer ce projet", - "select_activity_message": "Sélectionner une activité", - "select_activity_title": "Sélectionner une activité", - "select_project_message": "Sélectionner un projet", - "select_project_title": "Sélectionner un projet", - "time_log": "Journal de temps", - "time_log_collapsed_hint": "Journal de temps", - "time_log_date_label": "Date du journal de temps : {date}", - "time_log_for": "Journal de temps pour {date}", - "time_log_no_date": "Journal de temps", - "time_log_no_entries": "Aucune entrée de temps pour l'instant", - "time_log_report": "Rapport de temps", - "time_log_report_title": "Journal de temps pour {project}", - "time_log_report_meta": "Du {start} au {end}, groupé par {granularity}", - "time_log_total_hours": "Total pour la journée : {hours:.2f}h", - "time_log_with_total": "Journal de temps ({hours:.2f}h)", - "update_time_entry": "Mettre à jour l'entrée de temps", - "time_report_total": "Total : {hours:.2f} heures", - "no_report_title": "Aucun rapport", - "no_report_message": "Veuillez exécuter un rapport avant d'exporter.", - "total": "Total", - "export_csv": "Exporter en CSV", - "export_csv_error_title": "Échec de l'exportation", - "export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}", - "export_pdf": "Exporter en PDF", - "export_pdf_error_title": "Échec de l'exportation PDF", - "export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}", - "enable_tags_feature": "Activer les étiquettes", - "enable_time_log_feature": "Activer le journal de temps", - "enable_reminders_feature": "Activer les rappels", - "pomodoro_time_log_default_text": "Session de concentration", - "toolbar_pomodoro_timer": "Minuteur de suivi du temps", - "set_code_language": "Définir le langage du code", - "cut": "Couper", - "copy": "Copier", - "paste": "Coller", - "start": "Démarrer", - "pause": "Pause", - "resume": "Reprendre", - "stop_and_log": "Arrêter et enregistrer", - "once": "une fois", - "daily": "quotidien", - "weekdays": "jours de semaine", - "weekly": "hebdomadaire", - "edit_reminder": "Modifier le rappel", - "time": "Heure", - "once_today": "Une fois (aujourd'hui)", - "every_day": "Tous les jours", - "every_weekday": "Tous les jours de semaine (lun-ven)", - "every_week": "Toutes les semaines", - "repeat": "Répéter", - "monday": "Lundi", - "tuesday": "Mardi", - "wednesday": "Mercredi", - "thursday": "Jeudi", - "friday": "Vendredi", - "saturday": "Samedi", - "sunday": "Dimanche", - "day": "Jour" + "db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher", + "db_issues_reported": "problème(s) signalé(s)", + "db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé", + "db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée", + "db_key_incorrect": "La clé est probablement incorrecte", + "db_database_error": "Erreur de base de données", + "database_maintenance": "Maintenance de la base de données", + "database_compact": "Compacter la base de données", + "database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.", + "database_compacted_successfully": "Base de données compactée avec succès !", + "encryption": "Chiffrement", + "remember_key": "Se souvenir de la clé", + "change_encryption_key": "Changer la clé de chiffrement", + "enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement", + "reenter_the_new_key": "Saisir de nouveau la nouvelle clé", + "key_mismatch": "Les clés ne correspondent pas", + "key_mismatch_explanation": "Les deux saisies ne correspondent pas.", + "empty_key": "La clé est vide", + "empty_key_explanation": "La clé ne peut pas être vide.", + "key_changed": "La clé a été modifiée", + "key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !", + "error": "Erreur", + "success": "Succès", + "close": "Fermer", + "find": "Rechercher", + "file": "Fichier", + "locale": "Langue", + "locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.", + "settings": "Paramètres", + "theme": "Thème", + "system": "Système", + "light": "Clair", + "dark": "Sombre", + "never": "Jamais", + "close_tab": "Fermer l'onglet", + "previous": "Précédent", + "previous_day": "Jour précédent", + "next": "Suivant", + "next_day": "Jour suivant", + "today": "Aujourd'hui", + "show": "Afficher", + "edit": "Modifier", + "delete": "Supprimer", + "history": "Historique", + "export_accessible_flag": "E&xporter", + "export_entries": "Exporter les entrées", + "export_complete": "Exportation terminée", + "export_failed": "Échec de l'exportation", + "backup": "Sauvegarder", + "backup_complete": "Sauvegarde terminée", + "backup_failed": "Échec de la sauvegarde", + "quit": "Quitter", + "cancel": "Annuler", + "save": "Enregistrer", + "help": "Aide", + "saved": "Enregistré", + "saved_to": "Enregistré dans", + "documentation": "Documentation", + "couldnt_open": "Impossible d'ouvrir", + "report_a_bug": "Signaler un bug", + "version": "Version", + "update": "Mise à jour", + "check_for_updates": "Rechercher des mises à jour", + "could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n", + "update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide", + "you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n", + "there_is_a_new_version_available": "Une nouvelle version est disponible:\n", + "download_the_appimage": "Télécharger l'AppImage ?", + "downloading": "Téléchargement en cours", + "download_cancelled": "Téléchargement annulé", + "failed_to_download_update": "Échec du téléchargement de la mise à jour:\n", + "could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n", + "could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.", + "gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n", + "downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n", + "navigate": "Naviguer", + "current": "actuel", + "selected": "sélectionné", + "find_on_page": "Rechercher dans la page", + "find_next": "Rechercher le suivant", + "find_previous": "Rechercher le précédent", + "find_bar_type_to_search": "Tapez pour rechercher", + "find_bar_match_case": "Respecter la casse", + "history_dialog_preview": "Aperçu", + "history_dialog_diff": "Différences", + "history_dialog_revert_to_selected": "Revenir à la sélection", + "history_dialog_revert_failed": "Échec de la restauration", + "history_dialog_delete": "Supprimer la révision", + "history_dialog_delete_failed": "Impossible de supprimer la révision", + "key_prompt_enter_key": "Saisir la clé", + "lock_overlay_locked": "Verrouillé", + "lock_overlay_unlock": "Déverrouiller", + "main_window_lock_screen_accessibility": "&Verrouiller l'écran", + "main_window_ready": "Prêt", + "main_window_save_a_version": "Enregistrer une version", + "main_window_settings_accessible_flag": "&Paramètres", + "set_an_encryption_key": "Définir une clé de chiffrement", + "set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !", + "unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré", + "unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin", + "open_in_new_tab": "Ouvrir dans un nouvel onglet", + "autosave": "enregistrement automatique", + "unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant", + "move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable", + "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", + "insert_images": "Insérer des images", + "images": "Images", + "reopen_failed": "Échec de la réouverture", + "unlock_failed": "Échec du déverrouillage", + "could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.", + "unencrypted_export": "Export non chiffré", + "unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.", + "unrecognised_extension": "Extension non reconnue !", + "backup_encrypted_notebook": "Sauvegarder le bouquin chiffré", + "enter_a_name_for_this_version": "Saisir un nom pour cette version", + "new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à", + "appearance": "Apparence", + "security": "Sécurité", + "features": "Fonctionnalités", + "database": "Base de données", + "save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.", + "lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité", + "autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.", + "font_size": "Taille de police", + "font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.", + "search_for_notes_here": "Recherchez des notes ici", + "toolbar_format": "Format", + "toolbar_bold": "Gras", + "toolbar_italic": "Italique", + "toolbar_strikethrough": "Barré", + "toolbar_normal_paragraph_text": "Texte de paragraphe normal", + "toolbar_font_smaller": "Texte plus petit", + "toolbar_font_larger": "Texte plus grand", + "toolbar_bulleted_list": "Liste à puces", + "toolbar_numbered_list": "Liste numérotée", + "toolbar_code_block": "Bloc de code", + "toolbar_heading": "Titre", + "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", + "tags": "Étiquettes", + "tag": "Étiquette", + "manage_tags": "Gérer les étiquettes", + "add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée", + "tag_browser_title": "Navigateur d'étiquettes", + "tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.", + "color_hex": "Couleur", + "date": "Date", + "page_or_document": "Page / Document", + "add_a_tag": "Ajouter une étiquette", + "edit_tag_name": "Modifier le nom de l'étiquette", + "new_tag_name": "Nouveau nom de l'étiquette :", + "change_color": "Changer la couleur", + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.", + "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà", + "statistics": "Statistiques", + "main_window_statistics_accessible_flag": "Stat&istiques", + "stats_group_pages": "Pages", + "stats_group_tags": "Étiquettes", + "stats_group_documents": "Documents", + "stats_group_time_logging": "Journal de temps", + "stats_group_reminders": "Rappels", + "stats_pages_with_content": "Pages avec contenu (version actuelle)", + "stats_total_revisions": "Nombre total de révisions", + "stats_page_most_revisions": "Page avec le plus de révisions", + "stats_total_words": "Nombre total de mots (versions actuelles)", + "stats_unique_tags": "Étiquettes uniques", + "stats_page_most_tags": "Page avec le plus d'étiquettes", + "stats_activity_heatmap": "Carte de chaleur d'activité", + "stats_heatmap_metric": "Colorer selon", + "stats_metric_words": "Mots", + "stats_metric_revisions": "Révisions", + "stats_metric_documents": "Documents", + "stats_total_documents": "Total des documents", + "stats_date_most_documents": "Date avec le plus de documents", + "stats_no_data": "Aucune statistique disponible pour le moment.", + "stats_time_total_hours": "Total des heures enregistrées", + "stats_time_day_most_hours": "Jour avec le plus d'heures enregistrées", + "stats_time_project_most_hours": "Projet avec le plus d'heures enregistrées", + "stats_time_activity_most_hours": "Activité avec le plus d'heures enregistrées", + "stats_total_reminders": "Total des rappels", + "stats_date_most_reminders": "Jour avec le plus de rappels", + "stats_metric_hours": "Heures", + "stats_metric_reminders": "Rappels", + "select_notebook": "Sélectionner un bouquin", + "bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.", + "bug_report_placeholder": "Saisissez votre rapport de bug ici", + "bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.", + "bug_report_send_failed": "Impossible d'envoyer le rapport de bug.", + "bug_report_sent_ok": "Rapport de bug envoyé. Merci !", + "send": "Envoyer", + "reminder": "Rappel", + "set_reminder": "Définir le rappel", + "reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !", + "invalid_time_title": "Heure invalide", + "invalid_time_message": "Veuillez saisir une heure au format HH:MM", + "dismiss": "Ignorer", + "toolbar_alarm": "Régler l'alarme de rappel", + "activities": "Activités", + "activity": "Activité", + "note": "Note", + "activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité", + "activity_delete_error_title": "Problème lors de la suppression de l'activité", + "activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité", + "activity_rename_error_title": "Problème lors du renommage de l'activité", + "activity_required_message": "Un nom d'activité est requis", + "activity_required_title": "Nom d'activité requis", + "add_activity": "Ajouter une activité", + "add_project": "Ajouter un projet", + "add_time_entry": "Ajouter une entrée de temps", + "time_period": "Période", + "dont_group": "Ne pas regrouper", + "by_activity": "par activité", + "by_day": "par jour", + "by_month": "par mois", + "by_week": "par semaine", + "date_range": "Plage de dates", + "custom_range": "Personnalisé", + "last_week": "La semaine dernière", + "last_month": "Le mois dernier", + "this_week": "Cette semaine", + "this_month": "Ce mois-ci", + "this_year": "Cette année", + "all_projects": "Tous les projets", + "delete_activity": "Supprimer l'activité", + "delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?", + "delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?", + "delete_project": "Supprimer le projet", + "delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?", + "delete_project_title": "Supprimer le projet - êtes-vous sûr ?", + "delete_time_entry": "Supprimer l'entrée de temps", + "group_by": "Grouper par", + "hours": "Heures", + "created_at": "Créé le", + "invalid_activity_message": "L'activité est invalide", + "invalid_activity_title": "Activité invalide", + "invalid_project_message": "Le projet est invalide", + "invalid_project_title": "Projet invalide", + "manage_activities": "Gérer les activités", + "manage_projects": "Gérer les projets", + "manage_projects_activities": "Gérer les activités du projet", + "open_time_log": "Ouvrir le journal de temps", + "project": "Projet", + "project_delete_error_message": "Un problème est survenu lors de la suppression du projet", + "project_delete_error_title": "Problème lors de la suppression du projet", + "project_rename_error_message": "Un problème est survenu lors du renommage du projet", + "project_rename_error_title": "Problème lors du renommage du projet", + "project_required_message": "Un projet est requis", + "project_required_title": "Projet requis", + "projects": "Projets", + "rename_activity": "Renommer l'activité", + "rename_project": "Renommer le projet", + "reporting": "Rapports", + "reporting_and_invoicing": "Rapports et facturation", + "run_report": "Exécuter le rapport", + "add_activity_title": "Ajouter une activité", + "add_activity_label": "Ajouter une activité", + "rename_activity_label": "Renommer l'activité", + "add_project_title": "Ajouter un projet", + "add_project_label": "Ajouter un projet", + "rename_activity_title": "Renommer cette activité", + "rename_project_label": "Renommer le projet", + "rename_project_title": "Renommer ce projet", + "select_activity_message": "Sélectionner une activité", + "select_activity_title": "Sélectionner une activité", + "select_project_message": "Sélectionner un projet", + "select_project_title": "Sélectionner un projet", + "time_log": "Journal de temps", + "time_log_collapsed_hint": "Journal de temps", + "date_label": "Date : {date}", + "change_date": "Modifier la date", + "select_date_title": "Sélectionner une date", + "for": "Pour {date}", + "time_log_no_date": "Journal de temps", + "time_log_no_entries": "Aucune entrée de temps pour l'instant", + "time_log_report": "Rapport de temps", + "time_log_report_title": "Journal de temps pour {project}", + "time_log_report_meta": "Du {start} au {end}, groupé par {granularity}", + "time_log_total_hours": "Total pour la journée : {hours:.2f}h", + "time_log_with_total": "Journal de temps ({hours:.2f}h)", + "update_time_entry": "Mettre à jour l'entrée de temps", + "time_report_total": "Total : {hours:.2f} heures", + "no_report_title": "Aucun rapport", + "no_report_message": "Veuillez exécuter un rapport avant d'exporter.", + "total": "Total", + "export_csv": "Exporter en CSV", + "export_csv_error_title": "Échec de l'exportation", + "export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}", + "export_pdf": "Exporter en PDF", + "export_pdf_error_title": "Échec de l'exportation PDF", + "export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}", + "enable_tags_feature": "Activer les étiquettes", + "enable_time_log_feature": "Activer le journal de temps", + "enable_reminders_feature": "Activer les rappels", + "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." } diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index 4a1a98e..90c12a8 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import Qt, QEvent -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +from PySide6.QtCore import QEvent, Qt +from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget from . import strings from .theme import ThemeManager diff --git a/bouquin/main.py b/bouquin/main.py index 958185d..6883755 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -2,13 +2,14 @@ from __future__ import annotations import sys from pathlib import Path -from PySide6.QtWidgets import QApplication -from PySide6.QtGui import QIcon -from .settings import APP_NAME, APP_ORG, get_settings -from .main_window import MainWindow -from .theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication + from . import strings +from .main_window import MainWindow +from .settings import APP_NAME, APP_ORG, get_settings +from .theme import Theme, ThemeConfig, ThemeManager def main(): diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 0e5e454..2759272 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -2,21 +2,21 @@ from __future__ import annotations import datetime import os -import sys import re - +import sys from pathlib import Path + from PySide6.QtCore import ( QDate, - QTimer, - Qt, - QSettings, - Slot, - QUrl, - QEvent, - QSignalBlocker, QDateTime, + QEvent, + QSettings, + QSignalBlocker, + Qt, QTime, + QTimer, + QUrl, + Slot, ) from PySide6.QtGui import ( QAction, @@ -27,43 +27,42 @@ from PySide6.QtGui import ( QFont, QGuiApplication, QKeySequence, - QTextCharFormat, QTextCursor, - QTextListFormat, ) from PySide6.QtWidgets import ( + QApplication, QCalendarWidget, QDialog, QFileDialog, + QLabel, QMainWindow, QMenu, QMessageBox, + QPushButton, QSizePolicy, QSplitter, QTableView, QTabWidget, QVBoxLayout, QWidget, - QLabel, - QPushButton, - QApplication, ) +from . import strings from .bug_report_dialog import BugReportDialog from .db import DBManager +from .documents import DocumentsDialog, TodaysDocumentsWidget from .find_bar import FindBar from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt from .lock_overlay import LockOverlay from .markdown_editor import MarkdownEditor from .pomodoro_timer import PomodoroManager -from .reminders import UpcomingRemindersWidget +from .reminders import UpcomingRemindersWidget, ReminderWebHook from .save_dialog import SaveDialog from .search import Search -from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config +from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .statistics_dialog import StatisticsDialog -from . import strings from .tags_widget import PageTagsWidget from .theme import ThemeManager from .time_log import TimeLogWidget @@ -108,15 +107,19 @@ class MainWindow(QMainWindow): self.search.resultDatesChanged.connect(self._on_search_dates_changed) # Features - self.time_log = TimeLogWidget(self.db) + self.time_log = TimeLogWidget(self.db, themes=self.themes) self.tags = PageTagsWidget(self.db) self.tags.tagActivated.connect(self._on_tag_activated) self.tags.tagAdded.connect(self._on_tag_added) self.upcoming_reminders = UpcomingRemindersWidget(self.db) + self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook) self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) + # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget + self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh) + self.pomodoro_manager = PomodoroManager(self.db, self) # Lock the calendar to the left panel at the top to stop it stretching @@ -127,6 +130,8 @@ class MainWindow(QMainWindow): left_layout.addWidget(self.calendar) left_layout.addWidget(self.search) left_layout.addWidget(self.upcoming_reminders) + self.todays_documents = TodaysDocumentsWidget(self.db, self._current_date_iso()) + left_layout.addWidget(self.todays_documents) left_layout.addWidget(self.time_log) left_layout.addWidget(self.tags) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) @@ -336,13 +341,15 @@ class MainWindow(QMainWindow): if not self.cfg.reminders: self.upcoming_reminders.hide() self.toolBar.actAlarm.setVisible(False) + if not self.cfg.documents: + self.todays_documents.hide() + self.toolBar.actDocuments.setVisible(False) # Restore window position from settings self._restore_window_position() # re-apply all runtime color tweaks when theme changes self.themes.themeChanged.connect(lambda _t: self._retheme_overrides()) - self._apply_calendar_text_colors() # apply once on startup so links / calendar colors are set immediately self._retheme_overrides() @@ -489,7 +496,7 @@ class MainWindow(QMainWindow): idx = self._tab_index_for_date(date) if idx != -1: self.tab_widget.setCurrentIndex(idx) - # keep calendar selection in sync (don’t trigger load) + # keep calendar selection in sync (don't trigger load) from PySide6.QtCore import QSignalBlocker with QSignalBlocker(self.calendar): @@ -512,7 +519,7 @@ class MainWindow(QMainWindow): editor = MarkdownEditor(self.themes) - # Apply user’s preferred font size + # Apply user's preferred font size self._apply_font_size(editor) # Set up the editor's event connections @@ -814,9 +821,13 @@ class MainWindow(QMainWindow): Given a 'new day' (system date), return the date we should move unfinished todos *to*. - If the new day is Saturday or Sunday, we skip ahead to the next Monday. - Otherwise we just return the same day. + By default, if the new day is Saturday or Sunday we skip ahead to the + next Monday (i.e., "next available weekday"). If the optional setting + `move_todos_include_weekends` is enabled, we move to the very next day + even if it's a weekend. """ + if getattr(self.cfg, "move_todos_include_weekends", False): + return day # Qt: Monday=1 ... Sunday=7 dow = day.dayOfWeek() if dow >= 6: # Saturday (6) or Sunday (7) @@ -859,6 +870,13 @@ class MainWindow(QMainWindow): into the rollover target date (today, or next Monday if today 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. """ if not getattr(self.cfg, "move_todos", False): @@ -871,7 +889,232 @@ class MainWindow(QMainWindow): target_date = self._rollover_target_date(today) target_iso = target_date.toString("yyyy-MM-dd") - all_unchecked: list[str] = [] + # Regexes for markdown headings and checkboxes + heading_re = re.compile(r"^\s{0,3}(#+)\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: + """ + Strip trailing closing hashes and whitespace, e.g. + "## Foo ###" -> "Foo" + """ + text = text.strip() + text = re.sub(r"\s+#+\s*$", "", text) + return text.strip() + + 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], + heading_level: int, + heading_text: str, + blocks: list[list[str]], + ) -> list[str]: + """Ensure a heading exists and append blocks to the end of its section.""" + normalized = _normalize_heading(heading_text) + + # 1) Find existing heading with same text (any level) + start_idx = None + effective_level = None + for idx, line in enumerate(target_lines): + m = heading_re.match(line) + if not m: + continue + level = len(m.group(1)) + text = _normalize_heading(m.group(2)) + if text == normalized: + start_idx = idx + effective_level = level + break + + # 2) If not found, create a new heading at the end + if start_idx is None: + if target_lines and target_lines[-1].strip(): + target_lines.append("") # blank line before new heading + target_lines.append(f"{'#' * heading_level} {heading_text}") + start_idx = len(target_lines) - 1 + effective_level = heading_level + + # 3) Find the end of this heading's section + end_idx = len(target_lines) + for i in range(start_idx + 1, len(target_lines)): + m = heading_re.match(target_lines[i]) + if m and len(m.group(1)) <= effective_level: + end_idx = i + break + + # 4) Insert before any trailing blank lines in the section + insert_at = end_idx + while ( + insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == "" + ): + insert_at -= 1 + + # Insert blocks (preserve internal blank lines) + for block in blocks: + 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 + + 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) + moved_blocks: list[tuple[tuple[int, str] | None, list[str]]] = [] any_moved = False # Look back N days (yesterday = 1, up to `days_back`) @@ -885,20 +1128,89 @@ class MainWindow(QMainWindow): lines = text.split("\n") remaining_lines: list[str] = [] moved_from_this_day = False + current_heading: tuple[int, str] | None = None - for line in lines: - # Unchecked markdown checkboxes: "- [ ] " or "- [☐] " - if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( - r"^\s*-\s*\[☐\]\s+", line - ): - item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) - all_unchecked.append(f"- [ ] {item_text}") - moved_from_this_day = True - any_moved = True - else: - remaining_lines.append(line) + in_fence = False + fence_marker: str | None = None + + i = 0 + while i < len(lines): + line = lines[i] + + # If we're not in a fenced code block, we can interpret headings/checkboxes + if not in_fence: + # Track the last seen heading (# / ## / ###) + 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: + remaining_lines = _prune_empty_headings(remaining_lines) modified_text = "\n".join(remaining_lines) # Save the cleaned-up source day self.db.save_new_version( @@ -910,9 +1222,64 @@ class MainWindow(QMainWindow): if not any_moved: return False - # Append everything we collected to the *target* date - unchecked_str = "\n".join(all_unchecked) + "\n" - self._load_selected_date(target_iso, unchecked_str) + # --- Merge all moved blocks into the *target* date --- + + target_text = self.db.get_entry(target_iso) or "" + # 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[list[str]]] = {} + plain_blocks: list[list[str]] = [] + + for heading_info, block in moved_blocks: + if heading_info is None: + plain_blocks.append(block) + else: + by_heading.setdefault(heading_info, []).append(block) + + # First insert all blocks that have headings + for (level, heading_text), blocks in by_heading.items(): + target_lines = _insert_blocks_under_heading( + target_lines, level, heading_text, blocks + ) + + # Then append all blocks without headings at the end, like before + if plain_blocks: + if target_lines and target_lines[-1].strip(): + target_lines.append("") # one blank line before the "unsectioned" todos + 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) + if not new_target_text.endswith("\n"): + new_target_text += "\n" + + # Save the updated target date and load it into the editor + self.db.save_new_version( + target_iso, + new_target_text, + strings._("unchecked_checkbox_items_moved_to_next_day"), + ) + self._load_selected_date(target_iso) return True def _on_date_changed(self): @@ -1020,20 +1387,10 @@ class MainWindow(QMainWindow): save_db_config(cfg) def _retheme_overrides(self): - self._apply_calendar_text_colors() self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) self.calendar.update() self.editor.viewport().update() - def _apply_calendar_text_colors(self): - pal = self.palette() - txt = pal.windowText().color() - fmt = QTextCharFormat() - fmt.setForeground(txt) - # Use normal text color for weekends - self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt) - self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt) - # --------------- Search sidebar/results helpers ---------------- # def _on_search_dates_changed(self, date_strs: list[str]): @@ -1103,6 +1460,7 @@ class MainWindow(QMainWindow): self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes") self._tb_alarm = self._on_alarm_requested self._tb_timer = self._on_timer_requested + self._tb_documents = self._on_documents_requested self._tb_font_larger = self._on_font_larger_requested self._tb_font_smaller = self._on_font_smaller_requested @@ -1116,6 +1474,7 @@ class MainWindow(QMainWindow): tb.checkboxesRequested.connect(self._tb_checkboxes) tb.alarmRequested.connect(self._tb_alarm) tb.timerRequested.connect(self._tb_timer) + tb.documentsRequested.connect(self._tb_documents) tb.insertImageRequested.connect(self._on_insert_image) tb.historyRequested.connect(self._open_history) tb.fontSizeLargerRequested.connect(self._tb_font_larger) @@ -1124,46 +1483,58 @@ class MainWindow(QMainWindow): self._toolbar_bound = True 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() + 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 QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actStrike) - self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) - self.toolBar.actItalic.setChecked(fmt.fontItalic()) - self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) + self.toolBar.actBold.setChecked(bold_on) + self.toolBar.actItalic.setChecked(italic_on) + self.toolBar.actStrike.setChecked(strike_on) - # Headings: decide which to check by current point size - def _approx(a, b, eps=0.5): # small float tolerance - return abs(float(a) - float(b)) <= eps - - cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() - - bH1 = _approx(cur_size, 24) - bH2 = _approx(cur_size, 18) - bH3 = _approx(cur_size, 14) + # Headings: infer from leading markdown markers + heading_level = 0 + m = re.match(r"^\s*(#{1,3})\s+", line) + if m: + heading_level = len(m.group(1)) QSignalBlocker(self.toolBar.actH1) QSignalBlocker(self.toolBar.actH2) QSignalBlocker(self.toolBar.actH3) QSignalBlocker(self.toolBar.actNormal) - self.toolBar.actH1.setChecked(bH1) - self.toolBar.actH2.setChecked(bH2) - self.toolBar.actH3.setChecked(bH3) - self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) + self.toolBar.actH1.setChecked(heading_level == 1) + self.toolBar.actH2.setChecked(heading_level == 2) + self.toolBar.actH3.setChecked(heading_level == 3) + 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.actNumbers) - self.toolBar.actBullets.setChecked(bool(bullets_on)) - self.toolBar.actNumbers.setChecked(bool(numbers_on)) + QSignalBlocker(self.toolBar.actCheckboxes) + + 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: """Change font size for all editor tabs and save the setting.""" @@ -1198,21 +1569,35 @@ class MainWindow(QMainWindow): self.upcoming_reminders._add_reminder() def _on_timer_requested(self): - """Start a Pomodoro timer for the current line.""" - editor = getattr(self, "editor", None) - if editor is None: - return + """Toggle the embedded Pomodoro timer for the current line.""" + action = self.toolBar.actTimer - # Get the current line text - line_text = editor.get_current_line_text().strip() - if not line_text: - line_text = strings._("pomodoro_time_log_default_text") + # Turned on -> start a new timer for the current line + if action.isChecked(): + editor = getattr(self, "editor", None) + if editor is None: + # No editor; immediately reset the toggle + action.setChecked(False) + return - # Get current date - date_iso = self.editor.current_date.toString("yyyy-MM-dd") + # Get the current line text + line_text = editor.get_current_line_task_text() + if not line_text: + line_text = strings._("pomodoro_time_log_default_text") - # Start the timer - self.pomodoro_manager.start_timer_for_line(line_text, date_iso) + # Get current date + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + + # Start the timer embedded in the sidebar + self.pomodoro_manager.start_timer_for_line(line_text, date_iso) + else: + # Turned off -> cancel any running timer and remove the widget + self.pomodoro_manager.cancel_timer() + + def _send_reminder_webhook(self, text: str): + if self.cfg.reminders and self.cfg.reminders_webhook_url: + reminder_webhook = ReminderWebHook(text) + reminder_webhook._send() def _show_flashing_reminder(self, text: str): """ @@ -1331,6 +1716,14 @@ class MainWindow(QMainWindow): timer.start(msecs) self._reminder_timers.append(timer) + # ----------- Documents handler ------------# + def _on_documents_requested(self): + documents_dlg = DocumentsDialog(self.db, self) + documents_dlg.exec() + # Refresh recent documents after any changes + if hasattr(self, "todays_documents"): + self.todays_documents.reload() + # ----------- History handler ------------# def _open_history(self): if hasattr(self.editor, "current_date"): @@ -1338,7 +1731,7 @@ class MainWindow(QMainWindow): else: date_iso = self._current_date_iso() - dlg = HistoryDialog(self.db, date_iso, self) + dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes) if dlg.exec() == QDialog.Accepted: # refresh editor + calendar (head pointer may have changed) self._load_selected_date(date_iso) @@ -1365,6 +1758,8 @@ class MainWindow(QMainWindow): self.tags.set_current_date(date_iso) if hasattr(self, "time_log"): self.time_log.set_current_date(date_iso) + if hasattr(self, "todays_documents"): + self.todays_documents.set_current_date(date_iso) def _on_tag_added(self): """Called when a tag is added - trigger autosave for current page""" @@ -1429,9 +1824,22 @@ class MainWindow(QMainWindow): self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes) self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme) self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos) + self.cfg.move_todos_include_weekends = getattr( + new_cfg, + "move_todos_include_weekends", + getattr(self.cfg, "move_todos_include_weekends", False), + ) self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags) self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) + self.cfg.reminders_webhook_url = getattr( + new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url + ) + self.cfg.reminders_webhook_secret = getattr( + new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret + ) + self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) + self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) @@ -1469,6 +1877,12 @@ class MainWindow(QMainWindow): else: self.upcoming_reminders.show() self.toolBar.actAlarm.setVisible(True) + if not self.cfg.documents: + self.todays_documents.hide() + self.toolBar.actDocuments.setVisible(False) + else: + self.todays_documents.show() + self.toolBar.actDocuments.setVisible(True) # ------------ Statistics handler --------------- # diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 9f48858..b1a6d66 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -3,26 +3,30 @@ from __future__ import annotations import base64 import re from pathlib import Path +from typing import Optional, Tuple +from PySide6.QtCore import QRect, Qt, QTimer, QUrl from PySide6.QtGui import ( + QDesktopServices, QFont, QFontDatabase, QFontMetrics, QImage, + QMouseEvent, + QTextBlock, + QTextBlockFormat, QTextCharFormat, QTextCursor, QTextDocument, QTextFormat, - QTextBlockFormat, QTextImageFormat, - QDesktopServices, ) -from PySide6.QtCore import Qt, QRect, QTimer, QUrl -from PySide6.QtWidgets import QTextEdit +from PySide6.QtWidgets import QDialog, QTextEdit -from .theme import ThemeManager -from .markdown_highlighter import MarkdownHighlighter from . import strings +from .code_block_editor_dialog import CodeBlockEditorDialog +from .markdown_highlighter import MarkdownHighlighter +from .theme import ThemeManager class MarkdownEditor(QTextEdit): @@ -30,6 +34,22 @@ class MarkdownEditor(QTextEdit): _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + # ===== Collapsible sections (editor-only folding) ===== + # We represent a collapsed region as: + # ▸ collapse + # ... hidden blocks ... + # + # + # The end-marker line is always hidden in the editor but preserved in markdown. + _COLLAPSE_ARROW_COLLAPSED = "▸" + _COLLAPSE_ARROW_EXPANDED = "▾" + _COLLAPSE_LABEL_COLLAPSE = "collapse" + _COLLAPSE_LABEL_EXPAND = "expand" + _COLLAPSE_END_MARKER = "" + # Accept either "collapse" or "expand" in the header text + _COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$") + _COLLAPSE_END_RE = re.compile(r"^([ \t]*)\s*$") + def __init__(self, theme_manager: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) @@ -46,18 +66,14 @@ class MarkdownEditor(QTextEdit): base_dir = Path(__file__).resolve().parent # Load regular text font (primary) - regular_font_path = base_dir / "fonts" / "NotoSans-Regular.ttf" + regular_font_path = base_dir / "fonts" / "DejaVuSans.ttf" regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path)) - if regular_font_id == -1: - print("Failed to load NotoSans-Regular.ttf") # Load Symbols font (fallback) symbols_font_path = base_dir / "fonts" / "NotoSansSymbols2-Regular.ttf" symbols_font_id = QFontDatabase.addApplicationFont(str(symbols_font_path)) symbols_families = QFontDatabase.applicationFontFamilies(symbols_font_id) self.symbols_font_family = symbols_families[0] - if symbols_font_id == -1: - print("Failed to load NotoSansSymbols2-Regular.ttf") # Use the regular Noto Sans family as the editor font regular_families = QFontDatabase.applicationFontFamilies(regular_font_id) @@ -89,9 +105,32 @@ class MarkdownEditor(QTextEdit): # Track current list type for smart enter handling self._last_enter_was_empty = False + # Track "double-enter" behavior for indentation retention. + # If we auto-insert indentation on a new line, the next Enter on that + # now-empty indented line should remove the indentation and return to + # column 0 (similar to how lists exit on a second Enter). + self._last_enter_was_empty_indent = False + # Track if we're currently updating text programmatically self._updating = False + # Track pending inline marker insertion (e.g. Italic with no selection) + self._pending_inline_marker: str | None = None + + # Help avoid double-click selecting of checkbox + self._suppress_next_checkbox_double_click = False + + # Guard to avoid recursive selection tweaks + self._adjusting_selection = False + + # Track when the current selection is being created via mouse drag, + # so we can treat it differently from triple-click / keyboard selections. + self._mouse_drag_selecting = False + + # After selections change, trim list prefixes from full-line selections + # (e.g. after triple-clicking a list item to select the line). + self.selectionChanged.connect(self._maybe_trim_list_prefix_from_line_selection) + # Connect to text changes for smart formatting self.textChanged.connect(self._on_text_changed) self.textChanged.connect(self._update_code_block_row_backgrounds) @@ -108,9 +147,12 @@ class MarkdownEditor(QTextEdit): ) def setDocument(self, doc): - super().setDocument(doc) # Recreate the highlighter for the new document # (the old one gets deleted with the old document) + if doc is None: + return + + super().setDocument(doc) if hasattr(self, "highlighter") and hasattr(self, "theme_manager"): self.highlighter = MarkdownHighlighter( self.document(), self.theme_manager, self @@ -197,47 +239,290 @@ class MarkdownEditor(QTextEdit): b = b.previous() return inside - def _update_code_block_row_backgrounds(self): - """Paint a full-width background for each line that is in a fenced code block.""" + def _update_code_block_row_backgrounds(self) -> None: + """Paint a full-width background behind each fenced ``` code block.""" + doc = self.document() if doc is None: return - sels = [] + if not hasattr(self, "highlighter") or self.highlighter is None: + return + bg_brush = self.highlighter.code_block_format.background() + selections: list[QTextEdit.ExtraSelection] = [] + inside = False block = doc.begin() + block_start_pos: int | None = None + while block.isValid(): text = block.text() stripped = text.strip() is_fence = stripped.startswith("```") - paint_this_line = is_fence or inside - if paint_this_line: - sel = QTextEdit.ExtraSelection() - fmt = QTextCharFormat() - fmt.setBackground(bg_brush) - fmt.setProperty(QTextFormat.FullWidthSelection, True) - fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") - sel.format = fmt - - cur = QTextCursor(doc) - cur.setPosition(block.position()) - sel.cursor = cur - sels.append(sel) - if is_fence: - inside = not inside + if not inside: + # Opening fence: remember where this block starts + inside = True + block_start_pos = block.position() + else: + # Closing fence: create ONE selection from opening fence + # to the end of this closing fence block. + inside = False + if block_start_pos is not None: + sel = QTextEdit.ExtraSelection() + fmt = QTextCharFormat() + fmt.setBackground(bg_brush) + fmt.setProperty(QTextFormat.FullWidthSelection, True) + fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") + sel.format = fmt + + cursor = QTextCursor(doc) + cursor.setPosition(block_start_pos) + # extend to the end of the closing fence block + cursor.setPosition( + block.position() + block.length() - 1, + QTextCursor.MoveMode.KeepAnchor, + ) + sel.cursor = cursor + + selections.append(sel) + block_start_pos = None block = block.next() + # If the document ends while we're still inside a code block, + # extend the selection to the end of the document. + if inside and block_start_pos is not None: + sel = QTextEdit.ExtraSelection() + fmt = QTextCharFormat() + fmt.setBackground(bg_brush) + fmt.setProperty(QTextFormat.FullWidthSelection, True) + fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") + sel.format = fmt + + cursor = QTextCursor(doc) + cursor.setPosition(block_start_pos) + cursor.movePosition(QTextCursor.End, QTextCursor.MoveMode.KeepAnchor) + sel.cursor = cursor + + selections.append(sel) + + # Keep any other extraSelections (current-line highlight etc.) others = [ s for s in self.extraSelections() if s.format.property(QTextFormat.UserProperty) != "codeblock_bg" ] - self.setExtraSelections(others + sels) + self.setExtraSelections(others + selections) + + def _find_code_block_bounds( + self, block: QTextBlock + ) -> Optional[Tuple[QTextBlock, QTextBlock]]: + """ + Given a block that is either inside a fenced code block or on a fence, + return (opening_fence_block, closing_fence_block). + Returns None if we can't find a proper pair. + """ + if not block.isValid(): + return None + + def is_fence(b: QTextBlock) -> bool: + return b.isValid() and b.text().strip().startswith("```") + + # If we're on a fence line, decide if it's opening or closing + if is_fence(block): + # If we're "inside" just before this fence, this one closes. + if self._is_inside_code_block(block.previous()): + close_block = block + open_block = block.previous() + while open_block.isValid() and not is_fence(open_block): + open_block = open_block.previous() + if not is_fence(open_block): + return None + return open_block, close_block + else: + # Treat as opening fence; search downward for the closing one. + open_block = block + close_block = open_block.next() + while close_block.isValid() and not is_fence(close_block): + close_block = close_block.next() + if not is_fence(close_block): + return None + return open_block, close_block + + # Normal interior line: search up for opening fence, down for closing. + open_block = block.previous() + while open_block.isValid() and not is_fence(open_block): + open_block = open_block.previous() + if not is_fence(open_block): + return None + + close_block = open_block.next() + while close_block.isValid() and not is_fence(close_block): + close_block = close_block.next() + if not is_fence(close_block): + return None + + return open_block, close_block + + def _get_code_block_text( + self, open_block: QTextBlock, close_block: QTextBlock + ) -> str: + """Return the inner text (between fences) as a normal '\\n'-joined string.""" + lines = [] + b = open_block.next() + while b.isValid() and b != close_block: + lines.append(b.text()) + b = b.next() + return "\n".join(lines) + + def _replace_code_block_text( + self, open_block: QTextBlock, close_block: QTextBlock, new_text: str + ) -> None: + """ + Replace everything between the two fences with `new_text`. + Fences themselves are left untouched. + """ + doc = self.document() + if doc is None: + return + + cursor = QTextCursor(doc) + + # Start just after the opening fence's newline + start_pos = open_block.position() + len(open_block.text()) + # End at the start of the closing fence + end_pos = close_block.position() + + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) + + cursor.beginEditBlock() + # Normalise trailing newline(s) + new_text = new_text.rstrip("\n") + if new_text: + cursor.removeSelectedText() + cursor.insertText("\n" + new_text + "\n") + else: + # Empty block - keep one blank line inside the fences + cursor.removeSelectedText() + cursor.insertText("\n\n") + cursor.endEditBlock() + + # Re-apply spacing and backgrounds + if hasattr(self, "_apply_code_block_spacing"): + self._apply_code_block_spacing() + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + + # Trigger rehighlight + if hasattr(self, "highlighter"): + self.highlighter.rehighlight() + + def _edit_code_block(self, block: QTextBlock) -> bool: + """Open a popup editor for the code block containing `block`. + + Returns True if a dialog was shown (regardless of OK/Cancel), + False if no well-formed fenced block was found. + """ + bounds = self._find_code_block_bounds(block) + if not bounds: + return False + + open_block, close_block = bounds + + # Current language from metadata (if any) + lang = None + if hasattr(self, "_code_metadata"): + lang = self._code_metadata.get_language(open_block.blockNumber()) + + code_text = self._get_code_block_text(open_block, close_block) + + dlg = CodeBlockEditorDialog(code_text, lang, parent=self, allow_delete=True) + result = dlg.exec() + if result != QDialog.DialogCode.Accepted: + # Dialog was shown but user cancelled; event is "handled". + return True + + # If the user requested deletion, remove the whole block + if hasattr(dlg, "was_deleted") and dlg.was_deleted(): + self._delete_code_block(open_block) + return True + + new_code = dlg.code() + new_lang = dlg.language() + + # Update document text but keep fences + self._replace_code_block_text(open_block, close_block, new_code) + + # Update metadata language if changed + if new_lang is not None: + if not hasattr(self, "_code_metadata"): + from .code_highlighter import CodeBlockMetadata + + self._code_metadata = CodeBlockMetadata() + self._code_metadata.set_language(open_block.blockNumber(), new_lang) + if hasattr(self, "highlighter"): + self.highlighter.rehighlight() + + return True + + def _delete_code_block(self, block: QTextBlock) -> bool: + """Delete the fenced code block containing `block`. + + Returns True if a block was deleted, False otherwise. + """ + bounds = self._find_code_block_bounds(block) + if not bounds: + return False + + open_block, close_block = bounds + fence_block_num = open_block.blockNumber() + + doc = self.document() + if doc is None: + return False + + # Remove from the opening fence down to just before the block after + # the closing fence (so we also remove the trailing blank line). + start_pos = open_block.position() + after_block = close_block.next() + if after_block.isValid(): + end_pos = after_block.position() + else: + end_pos = close_block.position() + len(close_block.text()) + + cursor = QTextCursor(doc) + cursor.beginEditBlock() + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + cursor.endEditBlock() + + # Clear language metadata for this block, if supported + if hasattr(self, "_code_metadata"): + clear = getattr(self._code_metadata, "clear_language", None) + if clear is not None and fence_block_num != -1: + clear(fence_block_num) + + # Refresh visuals (spacing + backgrounds + syntax) + if hasattr(self, "_apply_code_block_spacing"): + self._apply_code_block_spacing() + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + if hasattr(self, "highlighter"): + self.highlighter.rehighlight() + + # Move caret to where the block used to be + cursor = self.textCursor() + cursor.setPosition(start_pos) + self.setTextCursor(cursor) + self.setFocus() + + return True def _apply_line_spacing(self, height: float = 125.0): """Apply proportional line spacing to the whole document.""" @@ -259,8 +544,8 @@ class MarkdownEditor(QTextEdit): def _apply_code_block_spacing(self): """ - Make all fenced code-block lines (including ``` fences) single-spaced. - Call this AFTER _apply_line_spacing(). + Make all fenced code-block lines (including ``` fences) single-spaced + and give them a solid background. """ doc = self.document() if doc is None: @@ -269,6 +554,8 @@ class MarkdownEditor(QTextEdit): cursor = QTextCursor(doc) cursor.beginEditBlock() + bg_brush = self.highlighter.code_block_format.background() + inside = False block = doc.begin() while block.isValid(): @@ -277,14 +564,22 @@ class MarkdownEditor(QTextEdit): is_fence = stripped.startswith("```") is_code_line = is_fence or inside + fmt = block.blockFormat() + if is_code_line: - fmt = block.blockFormat() + # Single spacing for code lines fmt.setLineHeight( 0.0, QTextBlockFormat.LineHeightTypes.SingleHeight.value, ) - cursor.setPosition(block.position()) - cursor.setBlockFormat(fmt) + # Solid background for the whole line (no seams) + fmt.setBackground(bg_brush) + else: + # Not in a code block → clear any stale background + fmt.clearProperty(QTextFormat.BackgroundBrush) + + cursor.setPosition(block.position()) + cursor.setBlockFormat(fmt) if is_fence: inside = not inside @@ -293,6 +588,30 @@ class MarkdownEditor(QTextEdit): cursor.endEditBlock() + def _ensure_escape_line_after_closing_fence(self, fence_block: QTextBlock) -> None: + """ + Ensure there is at least one block *after* the given closing fence line. + + If the fence is the last block in the document, we append a newline, + so the caret can always move outside the code block. + """ + doc = self.document() + if doc is None or not fence_block.isValid(): + return + + after = fence_block.next() + if after.isValid(): + # There's already a block after the fence; nothing to do. + return + + # No block after fence → create a blank line + cursor = QTextCursor(doc) + cursor.beginEditBlock() + endpos = fence_block.position() + len(fence_block.text()) + cursor.setPosition(endpos) + cursor.insertText("\n") + cursor.endEditBlock() + def to_markdown(self) -> str: """Export current content as markdown.""" # First, extract any embedded images and convert to markdown @@ -403,6 +722,9 @@ class MarkdownEditor(QTextEdit): # Render any embedded images self._render_images() + # Apply folding for any collapse regions present in the markdown + self._refresh_collapse_folding() + self._update_code_block_row_backgrounds() QTimer.singleShot(0, self._update_code_block_row_backgrounds) @@ -486,6 +808,75 @@ class MarkdownEditor(QTextEdit): return 0 + def _maybe_trim_list_prefix_from_line_selection(self) -> None: + """ + If the current selection looks like a full-line selection on a list item + (for example, from a triple-click), trim the selection so that it starts + just *after* the visual list prefix (checkbox / bullet / number), and + ends at the end of the text on that line (not on the next line's newline). + """ + # When the user is actively dragging with the mouse, we *do* want the + # checkbox/bullet to be part of the selection (for deleting whole rows). + # So don't rewrite the selection in that case. + if getattr(self, "_mouse_drag_selecting", False): + return + + # Avoid re-entry when we move the cursor ourselves. + if getattr(self, "_adjusting_selection", False): + return + + cursor = self.textCursor() + if not cursor.hasSelection(): + return + + start = cursor.selectionStart() + end = cursor.selectionEnd() + if start == end: + return + + doc = self.document() + # 'end' is exclusive; use end - 1 so we land in the last selected block. + start_block = doc.findBlock(start) + end_block = doc.findBlock(end - 1) + if not start_block.isValid() or start_block != end_block: + # Only adjust single-line selections. + return + + # How much list prefix (indent + checkbox/bullet/number) this block has + prefix_len = self._list_prefix_length_for_block(start_block) + if prefix_len <= 0: + return + + block_start = start_block.position() + prefix_end = block_start + prefix_len + + # If the selection already starts after the prefix, nothing to do. + if start >= prefix_end: + return + + line_text = start_block.text() + line_end = block_start + len(line_text) # end of visible text on this line + + # Only treat it as a "full line" selection if it reaches the end of the + # visible text. Triple-click usually selects to at least here (often +1 for + # the newline). + if end < line_end: + return + + # Clamp the selection so that it ends at the end of this line's text, + # *not* at the newline / start of the next block. This keeps the caret + # blinking on the selected line instead of the next line. + visual_end = line_end + + self._adjusting_selection = True + try: + new_cursor = self.textCursor() + new_cursor.setPosition(prefix_end) + new_cursor.setPosition(visual_end, QTextCursor.KeepAnchor) + self.setTextCursor(new_cursor) + finally: + self._adjusting_selection = False + def _detect_list_type(self, line: str) -> tuple[str | None, str]: """ Detect if line is a list item. Returns (list_type, prefix). @@ -500,7 +891,7 @@ class MarkdownEditor(QTextEdit): ): return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ") - # Bullet list – Unicode bullet + # Bullet list - Unicode bullet if line.startswith(f"{self._BULLET_DISPLAY} "): return ("bullet", f"{self._BULLET_DISPLAY} ") @@ -540,40 +931,151 @@ class MarkdownEditor(QTextEdit): return None + def _maybe_skip_over_marker_run(self, key: Qt.Key) -> bool: + """Skip over common markdown marker runs when navigating with Left/Right. + + This prevents the caret from landing *inside* runs like '**', '***', '__', '___' or '~~', + which can cause temporary toolbar-state flicker and makes navigation feel like it takes + "two presses" to get past closing markers. + + Hold any modifier key (Shift/Ctrl/Alt/Meta) to disable this behavior. + """ + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + doc_max = self._doc_max_pos() + + # Right: run starts at the caret + if key == Qt.Key.Key_Right: + if p >= doc_max: + return False + ch = self._text_range(p, p + 1) + if ch not in ("*", "_", "~"): + return False + + run = 0 + while p + run < doc_max and self._text_range(p + run, p + run + 1) == ch: + run += 1 + + # Only skip multi-char runs (bold/strong/emphasis runs or strike) + if ch in ("*", "_") and run >= 2: + c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, run) + self.setTextCursor(c) + return True + if ch == "~" and run == 2: + c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2) + self.setTextCursor(c) + return True + + return False + + # Left: run ends at the caret + if key == Qt.Key.Key_Left: + if p <= 0: + return False + ch = self._text_range(p - 1, p) + if ch not in ("*", "_", "~"): + return False + + run = 0 + while p - 1 - run >= 0 and self._text_range(p - 1 - run, p - run) == ch: + run += 1 + + if ch in ("*", "_") and run >= 2: + c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, run) + self.setTextCursor(c) + return True + if ch == "~" and run == 2: + c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 2) + self.setTextCursor(c) + return True + + return False + def keyPressEvent(self, event): """Handle special key events for markdown editing.""" + c = self.textCursor() + block = c.block() - # --- Auto-close code fences when typing the 3rd backtick at line start --- - if event.text() == "`": - c = self.textCursor() - block = c.block() + in_code = self._is_inside_code_block(block) + is_fence_line = block.text().strip().startswith("```") + + # Only when we're *not* already in a code block or on a fence line. + if event.text() == "`" and not (in_code or is_fence_line): line = block.text() pos_in_block = c.position() - block.position() - - # text before caret on this line before = line[:pos_in_block] - # If we've typed exactly two backticks at line start (or after whitespace), - # treat this backtick as the "third" and expand to a full fenced block. - if before.endswith("``") and before.strip() == "``": - start = ( - block.position() + pos_in_block - 2 - ) # start of the two backticks + # "before" currently contains whatever's before the *third* backtick. + # Trigger when the user types a *third consecutive* backtick anywhere on the line. + # (We require the run immediately before the caret to be exactly two backticks, + # so we don't trigger on 4+ backticks.) + if before.endswith("``") and (len(before) < 3 or before[-3] != "`"): + doc = self.document() + if doc is not None: + # Remove the two backticks that were already typed + start = block.position() + pos_in_block - 2 + edit = QTextCursor(doc) + edit.beginEditBlock() + edit.setPosition(start) + edit.setPosition(start + 2, QTextCursor.KeepAnchor) + edit.removeSelectedText() + edit.endEditBlock() - edit = QTextCursor(self.document()) - edit.beginEditBlock() - edit.setPosition(start) - edit.setPosition(start + 2, QTextCursor.KeepAnchor) - edit.insertText("```\n\n```\n") - edit.endEditBlock() + # Move caret to where the code block should start + c.setPosition(start) + self.setTextCursor(c) - # place caret on the blank line between the fences - new_pos = start + 4 # after "```\n" - c.setPosition(new_pos) - self.setTextCursor(c) + # Now behave exactly like the toolbar button + self.apply_code() + return + # ------------------------------------------------------------ + + # If we're anywhere in a fenced code block (including the fences), + # treat the text as read-only and route edits through the dialog. + if in_code or is_fence_line: + key = event.key() + + # Navigation keys that are safe to pass through. + nav_keys_no_down = ( + Qt.Key.Key_Left, + Qt.Key.Key_Right, + Qt.Key.Key_Up, + Qt.Key.Key_Home, + Qt.Key.Key_End, + Qt.Key.Key_PageUp, + Qt.Key.Key_PageDown, + ) + + # Let these through: + # - pure navigation (except Down, which we handle specially later) + # - Enter/Return and Down, which are handled by dedicated logic below + if key in nav_keys_no_down: + super().keyPressEvent(event) return - # Step out of a code block with Down at EOF + if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Down): + # Let the existing Enter/Down code see these. + pass + else: + # Any other key (Backspace, Delete, characters, Tab, etc.) + # opens the code-block editor instead of editing inline. + if not self._edit_code_block(block): + # Fallback if bounds couldn't be found for some reason. + super().keyPressEvent(event) + return + + if ( + event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right) + and event.modifiers() == Qt.KeyboardModifier.NoModifier + and not self.textCursor().hasSelection() + ): + if self._maybe_skip_over_marker_run(event.key()): + return + + # --- Step out of a code block with Down at EOF --- if event.key() == Qt.Key.Key_Down: c = self.textCursor() b = c.block() @@ -584,7 +1086,8 @@ class MarkdownEditor(QTextEdit): nb = bb.next() return nb.isValid() and nb.text().strip().startswith("```") - # Case A: caret is on the line BEFORE the closing fence, at EOL → jump after the fence + # Case A: caret is on the line BEFORE the closing fence, at EOL + # → jump after the fence if ( self._is_inside_code_block(b) and pos_in_block >= len(line) @@ -605,7 +1108,8 @@ class MarkdownEditor(QTextEdit): self._update_code_block_row_backgrounds() return - # Case B: caret is ON the closing fence, and it's EOF → create a line and move to it + # Case B: caret is ON the closing fence, and it's EOF + # → create a line and move to it if ( b.text().strip().startswith("```") and self._is_inside_code_block(b) @@ -651,7 +1155,7 @@ class MarkdownEditor(QTextEdit): # of list prefixes (checkboxes / bullets / numbers). if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left): # Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of - # document / word-left) – we don't interfere with those. + # document / word-left) - we don't interfere with those. if event.modifiers() & Qt.ControlModifier: pass else: @@ -722,6 +1226,10 @@ class MarkdownEditor(QTextEdit): cursor = self.textCursor() current_line = self._get_current_line() + # Leading indentation (tabs/spaces) on the current line. + m_indent = re.match(r"^([ \t]*)", current_line) + line_indent = m_indent.group(1) if m_indent else "" + # Check if we're in a code block current_block = cursor.block() line_text = current_block.text() @@ -775,9 +1283,11 @@ class MarkdownEditor(QTextEdit): return - # Inside a code block (but not on a fence): newline stays code-style + # Inside a code block (but not on a fence): open the popup editor if block_state == 1: - super().keyPressEvent(event) + if not self._edit_code_block(current_block): + # Fallback if something is malformed + super().keyPressEvent(event) return # Check for list continuation @@ -804,23 +1314,60 @@ class MarkdownEditor(QTextEdit): return else: # Not empty - continue the list - self._last_enter_was_empty = False + self._last_enter_was_empty = True # Insert newline and continue the list super().keyPressEvent(event) cursor = self.textCursor() - cursor.insertText(prefix) + # Preserve any leading indentation so nested lists keep their level. + cursor.insertText(line_indent + prefix) + self._last_enter_was_empty_indent = False return else: + # Not a list: support indentation retention. If a line starts + # with indentation (tabs/spaces), carry that indentation to the + # next line. A *second* Enter on an empty indented line resets + # back to column 0. + if line_indent: + rest = current_line[len(line_indent) :] + indent_only = rest.strip() == "" + + if indent_only and self._last_enter_was_empty_indent: + # Second Enter on an empty indented line: remove the + # indentation-only line and start a fresh, unindented line. + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.insertText("\n") + self._last_enter_was_empty_indent = False + self._last_enter_was_empty = False + return + + # First Enter (or a non-empty indented line): keep the indent. + super().keyPressEvent(event) + cursor = self.textCursor() + cursor.insertText(line_indent) + self._last_enter_was_empty_indent = True + self._last_enter_was_empty = False + return + self._last_enter_was_empty = False + self._last_enter_was_empty_indent = False else: # Any other key resets the empty enter flag self._last_enter_was_empty = False + self._last_enter_was_empty_indent = False # Default handling super().keyPressEvent(event) def mouseMoveEvent(self, event): + # If the left button is down while the mouse moves, we consider this + # a drag selection (as opposed to a simple click). + if event.buttons() & Qt.LeftButton: + self._mouse_drag_selecting = True + else: + self._mouse_drag_selecting = False + # Change cursor when hovering a link url = self._url_at_pos(event.pos()) if url: @@ -834,6 +1381,12 @@ class MarkdownEditor(QTextEdit): # Let QTextEdit handle caret/selection first super().mouseReleaseEvent(event) + if event.button() == Qt.LeftButton: + # At this point the drag (if any) has finished and the final + # selection is already in place (and selectionChanged has fired). + # Clear the drag flag for future interactions. + self._mouse_drag_selecting = False + if event.button() != Qt.LeftButton: return @@ -853,7 +1406,13 @@ class MarkdownEditor(QTextEdit): def mousePressEvent(self, event): """Toggle a checkbox only when the click lands on its icon.""" + # default: don't suppress any upcoming double-click + self._suppress_next_checkbox_double_click = False + + # Fresh left-button press starts with "no drag" yet. if event.button() == Qt.LeftButton: + self._mouse_drag_selecting = False + pt = event.pos() # Cursor and block under the mouse @@ -861,6 +1420,45 @@ class MarkdownEditor(QTextEdit): block = cur.block() text = block.text() + # Click-to-toggle collapse regions: clicking the arrow on a + # "▸ collapse" / "▾ collapse" line expands/collapses the section. + parsed = self._parse_collapse_header(text) + if parsed: + indent, _is_collapsed = parsed + arrow_idx = len(indent) + if arrow_idx < len(text): + arrow = text[arrow_idx] + if arrow in ( + self._COLLAPSE_ARROW_COLLAPSED, + self._COLLAPSE_ARROW_EXPANDED, + ): + doc_pos = block.position() + arrow_idx + c_arrow = QTextCursor(self.document()) + c_arrow.setPosition( + max( + 0, + min( + doc_pos, + max(0, self.document().characterCount() - 1), + ), + ) + ) + r = self.cursorRect(c_arrow) + + fmt_font = ( + c_arrow.charFormat().font() + if c_arrow.charFormat().isValid() + else self.font() + ) + fm = QFontMetrics(fmt_font) + w = max(1, fm.horizontalAdvance(arrow)) + + # Make the hit area a bit generous. + hit_rect = QRect(r.x(), r.y(), w + (w // 2), r.height()) + if hit_rect.contains(pt): + self._toggle_collapse_at_block(block) + return + # The display tokens, e.g. "☐ " / "☑ " (icon + trailing space) unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} " checked = f"{self._CHECK_CHECKED_DISPLAY} " @@ -892,9 +1490,45 @@ class MarkdownEditor(QTextEdit): if icon: # absolute document position of the icon doc_pos = block.position() + i - r = char_rect_at(doc_pos, icon) + r_icon = char_rect_at(doc_pos, icon) - if r.contains(pt): + # --- Find where the first non-space "real text" starts --- + first_idx = i + len(icon) + 1 # skip icon + trailing space + while first_idx < len(text) and text[first_idx].isspace(): + first_idx += 1 + + # Start with some padding around the icon itself + left_pad = r_icon.width() // 2 + right_pad = r_icon.width() // 2 + + hit_left = r_icon.left() - left_pad + + # If there's actual text after the checkbox, clamp the + # clickable area so it stops *before* the first letter. + if first_idx < len(text): + first_doc_pos = block.position() + first_idx + c_first = QTextCursor(self.document()) + c_first.setPosition(first_doc_pos) + first_x = self.cursorRect(c_first).x() + + expanded_right = r_icon.right() + right_pad + hit_right = min(expanded_right, first_x) + else: + # No text after the checkbox on this line + hit_right = r_icon.right() + right_pad + + # Make sure the rect is at least 1px wide + if hit_right <= hit_left: + hit_right = r_icon.right() + + hit_rect = QRect( + hit_left, + r_icon.top(), + max(1, hit_right - hit_left), + r_icon.height(), + ) + + if hit_rect.contains(pt): # Build the replacement: swap ☐ <-> ☑ (keep trailing space) new_icon = ( self._CHECK_CHECKED_DISPLAY @@ -906,10 +1540,15 @@ class MarkdownEditor(QTextEdit): edit.setPosition(doc_pos) # icon + space edit.movePosition( - QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1 + QTextCursor.Right, + QTextCursor.KeepAnchor, + len(icon) + 1, ) edit.insertText(f"{new_icon} ") edit.endEditBlock() + + # if a double-click comes next, ignore it + self._suppress_next_checkbox_double_click = True return # handled # advance past this token @@ -920,21 +1559,412 @@ class MarkdownEditor(QTextEdit): # Default handling for anything else super().mousePressEvent(event) + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + # If the previous press toggled a checkbox, swallow this double-click + # so the base class does NOT turn it into a selection. + if getattr(self, "_suppress_next_checkbox_double_click", False): + self._suppress_next_checkbox_double_click = False + event.accept() + return + + cursor = self.cursorForPosition(event.pos()) + block = cursor.block() + + # If we're on or inside a code block, open the editor instead + if self._is_inside_code_block(block) or block.text().strip().startswith("```"): + # Only swallow the double-click if we actually opened a dialog. + if not self._edit_code_block(block): + super().mouseDoubleClickEvent(event) + return + + # Otherwise, let normal double-click behaviour happen + super().mouseDoubleClickEvent(event) + + # ------------------------ Toolbar action handlers ------------------------ + + # ------------------------ Inline markdown helpers ------------------------ + + def _doc_max_pos(self) -> int: + # QTextDocument includes a trailing null character; cursor positions stop before it. + doc = self.document() + return max(0, doc.characterCount() - 1) + + def _text_range(self, start: int, end: int) -> str: + """Return document text between [start, end) using QTextCursor indexing.""" + doc_max = self._doc_max_pos() + start = max(0, min(start, doc_max)) + end = max(0, min(end, doc_max)) + if end < start: + start, end = end, start + tc = QTextCursor(self.document()) + tc.setPosition(start) + tc.setPosition(end, QTextCursor.KeepAnchor) + return tc.selectedText() + + def _selection_wrapped_by( + self, + markers: tuple[str, ...], + *, + require_singletons: bool = False, + ) -> str | None: + """ + If the current selection is wrapped by any marker in `markers`, return the marker. + + Supports both cases: + 1) the selection itself includes the markers, e.g. "**bold**" + 2) the selection is the inner text, with markers immediately adjacent in the doc. + """ + c = self.textCursor() + if not c.hasSelection(): + return None + + sel = c.selectedText() + start = c.selectionStart() + end = c.selectionEnd() + doc_max = self._doc_max_pos() + + # Case 1: selection includes markers + for m in markers: + lm = len(m) + if len(sel) >= 2 * lm and sel.startswith(m) and sel.endswith(m): + return m + + # Case 2: markers adjacent to selection + for m in markers: + lm = len(m) + if start < lm or end + lm > doc_max: + continue + before = self._text_range(start - lm, start) + after = self._text_range(end, end + lm) + if before != m or after != m: + continue + + if require_singletons and lm == 1: + # Ensure the single marker isn't part of a double/triple (e.g. "**" or "__") + ch = m + left_marker_pos = start - 1 + right_marker_pos = end + + if ( + left_marker_pos - 1 >= 0 + and self._text_range(left_marker_pos - 1, left_marker_pos) == ch + ): + continue + if ( + right_marker_pos + 1 <= doc_max + and self._text_range(right_marker_pos + 1, right_marker_pos + 2) + == ch + ): + continue + + return m + + return None + + def _caret_between_markers( + self, marker: str, *, require_singletons: bool = False + ) -> bool: + """True if the caret is exactly between an opening and closing marker (e.g. **|**).""" + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + lm = len(marker) + doc_max = self._doc_max_pos() + if p < lm or p + lm > doc_max: + return False + + before = self._text_range(p - lm, p) + after = self._text_range(p, p + lm) + if before != marker or after != marker: + return False + + if require_singletons and lm == 1: + # Disallow if either side is adjacent to the same char (part of "**", "__", "***", etc.) + ch = marker + if p - 2 >= 0 and self._text_range(p - 2, p - 1) == ch: + return False + if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch: + return False + + return True + + def _caret_before_marker( + self, marker: str, *, require_singletons: bool = False + ) -> bool: + """True if the caret is immediately before `marker` (e.g. |**).""" + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + lm = len(marker) + doc_max = self._doc_max_pos() + if p + lm > doc_max: + return False + + after = self._text_range(p, p + lm) + if after != marker: + return False + + if require_singletons and lm == 1: + # Disallow if it's part of a run like "**" or "___". + ch = marker + if p - 1 >= 0 and self._text_range(p - 1, p) == ch: + return False + if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch: + return False + + return True + + def _unwrap_selection( + self, marker: str, *, replacement_marker: str | None = None + ) -> bool: + """ + Remove `marker` wrapping from the selection. + If replacement_marker is provided, replace marker with that (e.g. ***text*** -> *text*). + """ + c = self.textCursor() + if not c.hasSelection(): + return False + + sel = c.selectedText() + start = c.selectionStart() + end = c.selectionEnd() + lm = len(marker) + doc_max = self._doc_max_pos() + + def _select_inner( + edit_cursor: QTextCursor, inner_start: int, inner_len: int + ) -> None: + edit_cursor.setPosition(inner_start) + edit_cursor.setPosition(inner_start + inner_len, QTextCursor.KeepAnchor) + self.setTextCursor(edit_cursor) + + # Case 1: selection includes markers + if len(sel) >= 2 * lm and sel.startswith(marker) and sel.endswith(marker): + inner = sel[lm:-lm] + new_text = ( + f"{replacement_marker}{inner}{replacement_marker}" + if replacement_marker is not None + else inner + ) + c.beginEditBlock() + c.insertText(new_text) + c.endEditBlock() + + # Re-select the inner content (not the markers) + inner_start = c.position() - len(new_text) + if replacement_marker is not None: + inner_start += len(replacement_marker) + _select_inner(c, inner_start, len(inner)) + return True + + # Case 2: marker is adjacent to selection + if start >= lm and end + lm <= doc_max: + before = self._text_range(start - lm, start) + after = self._text_range(end, end + lm) + if before == marker and after == marker: + new_text = ( + f"{replacement_marker}{sel}{replacement_marker}" + if replacement_marker is not None + else sel + ) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(start - lm) + edit.setPosition(end + lm, QTextCursor.KeepAnchor) + edit.insertText(new_text) + edit.endEditBlock() + + inner_start = (start - lm) + ( + len(replacement_marker) if replacement_marker else 0 + ) + _select_inner(edit, inner_start, len(sel)) + return True + + return False + + def _wrap_selection(self, marker: str) -> None: + """Wrap the current selection with `marker` and keep the content selected.""" + c = self.textCursor() + if not c.hasSelection(): + return + sel = c.selectedText() + start = c.selectionStart() + lm = len(marker) + + c.beginEditBlock() + c.insertText(f"{marker}{sel}{marker}") + c.endEditBlock() + + # Re-select the original content + edit = QTextCursor(self.document()) + edit.setPosition(start + lm) + edit.setPosition(start + lm + len(sel), QTextCursor.KeepAnchor) + self.setTextCursor(edit) + + def _pos_inside_inline_span( + self, + patterns: list[tuple[re.Pattern, int]], + start_in_block: int, + end_in_block: int, + ) -> bool: + """True if [start_in_block, end_in_block] lies within the content region of any pattern match.""" + block_text = self.textCursor().block().text() + for pat, mlen in patterns: + for m in pat.finditer(block_text): + s, e = m.span() + cs, ce = s + mlen, e - mlen + if cs <= start_in_block and end_in_block <= ce: + return True + return False + + def is_markdown_bold_active(self) -> bool: + c = self.textCursor() + bold_markers = ("***", "___", "**", "__") + + if c.hasSelection(): + if self._selection_wrapped_by(bold_markers) is not None: + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [ + (re.compile(r"(? bool: + c = self.textCursor() + italic_markers = ("*", "_", "***", "___") + + if c.hasSelection(): + if ( + self._selection_wrapped_by(italic_markers, require_singletons=True) + is not None + ): + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [ + (re.compile(r"(? bool: + c = self.textCursor() + if c.hasSelection(): + if self._selection_wrapped_by(("~~",)) is not None: + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [(re.compile(r"~~(.+?)~~"), 2)] + return self._pos_inside_inline_span(patterns, start_in_block, end_in_block) + + if self._caret_between_markers("~~"): + return True + block = c.block() + pos_in_block = c.position() - block.position() + patterns = [(re.compile(r"~~(.+?)~~"), 2)] + return self._pos_inside_inline_span(patterns, pos_in_block, pos_in_block) + # ------------------------ Toolbar action handlers ------------------------ def apply_weight(self): - """Toggle bold formatting.""" + """Toggle bold formatting (markdown ** / __, and *** / ___).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - # Check if already bold - if selected.startswith("**") and selected.endswith("**"): - # Remove bold - new_text = selected[2:-2] - else: - # Add bold - new_text = f"**{selected}**" - cursor.insertText(new_text) + # If bold+italic, toggling bold should leave italic: ***text*** -> *text* + m = self._selection_wrapped_by(("***", "___")) + if m is not None: + repl = "*" if m == "***" else "_" + if self._unwrap_selection(m, replacement_marker=repl): + self.setFocus() + return + + # Normal bold: **text** / __text__ + m = self._selection_wrapped_by(("**", "__")) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + + # Not bold: wrap selection with ** + self._wrap_selection("**") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (**|**), remove them. + # - If we're inside bold and sitting right before the closing marker (**text|**), + # jump the caret *past* the marker (end-bold) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers("**") or self._caret_between_markers("__"): + marker = "**" if self._caret_between_markers("**") else "__" + p = cursor.position() + lm = len(marker) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - lm) + edit.setPosition(p + lm, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - lm) + self.setTextCursor(edit) + elif self.is_markdown_bold_active() and ( + self._caret_before_marker("**") or self._caret_before_marker("__") + ): + marker = "**" if self._caret_before_marker("**") else "__" + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + len(marker), + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: # No selection - just insert markers cursor.insertText("****") @@ -942,44 +1972,120 @@ class MarkdownEditor(QTextEdit): QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2 ) self.setTextCursor(cursor) + self._pending_inline_marker = "*" # Return focus to editor self.setFocus() def apply_italic(self): - """Toggle italic formatting.""" + """Toggle italic formatting (markdown * / _, and *** / ___).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - if ( - selected.startswith("*") - and selected.endswith("*") - and not selected.startswith("**") - ): - new_text = selected[1:-1] - else: - new_text = f"*{selected}*" - cursor.insertText(new_text) + # If bold+italic, toggling italic should leave bold: ***text*** -> **text** + m = self._selection_wrapped_by(("***", "___")) + if m is not None: + repl = "**" if m == "***" else "__" + if self._unwrap_selection(m, replacement_marker=repl): + self.setFocus() + return + + m = self._selection_wrapped_by(("*", "_"), require_singletons=True) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + + self._wrap_selection("*") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (*|*), remove them. + # - If we're inside italic and sitting right before the closing marker (*text|*), + # jump the caret past the marker (end-italic) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers( + "*", require_singletons=True + ) or self._caret_between_markers("_", require_singletons=True): + marker = ( + "*" + if self._caret_between_markers("*", require_singletons=True) + else "_" + ) + p = cursor.position() + lm = len(marker) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - lm) + edit.setPosition(p + lm, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - lm) + self.setTextCursor(edit) + self._pending_inline_marker = None + elif self.is_markdown_italic_active() and ( + self._caret_before_marker("*", require_singletons=True) + or self._caret_before_marker("_", require_singletons=True) + ): + marker = ( + "*" if self._caret_before_marker("*", require_singletons=True) else "_" + ) + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + len(marker), + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: cursor.insertText("**") cursor.movePosition( QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1 ) self.setTextCursor(cursor) + self._pending_inline_marker = "*" # Return focus to editor self.setFocus() def apply_strikethrough(self): - """Toggle strikethrough formatting.""" + """Toggle strikethrough formatting (markdown ~~).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - if selected.startswith("~~") and selected.endswith("~~"): - new_text = selected[2:-2] - else: - new_text = f"~~{selected}~~" - cursor.insertText(new_text) + m = self._selection_wrapped_by(("~~",)) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + self._wrap_selection("~~") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (~~|~~), remove them. + # - If we're inside strike and sitting right before the closing marker (~~text|~~), + # jump the caret past the marker (end-strike) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers("~~"): + p = cursor.position() + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - 2) + edit.setPosition(p + 2, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - 2) + self.setTextCursor(edit) + elif self.is_markdown_strike_active() and self._caret_before_marker("~~"): + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + 2, + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: cursor.insertText("~~~~") cursor.movePosition( @@ -991,86 +2097,106 @@ class MarkdownEditor(QTextEdit): self.setFocus() def apply_code(self): - """Insert a fenced code block, or navigate fences without creating inline backticks.""" - c = self.textCursor() + """ + Toolbar handler for the button. + + - If the caret is on / inside an existing fenced block, open the editor for it. + - Otherwise open the editor prefilled with any selected text, then insert a new + fenced block containing whatever the user typed. + """ + cursor = self.textCursor() doc = self.document() - - if c.hasSelection(): - # Wrap selection and ensure exactly one newline after the closing fence - selected = c.selectedText().replace("\u2029", "\n") - c.insertText(f"```\n{selected.rstrip()}\n```\n") - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - # tighten spacing for the new code block - self._apply_code_block_spacing() - - self.setFocus() + if doc is None: return - block = c.block() - line = block.text() - pos_in_block = c.position() - block.position() - stripped = line.strip() + block = cursor.block() - # If we're on a fence line, be helpful but never insert inline fences - if stripped.startswith("```"): - # Is this fence opening or closing? (look at blocks above) - inside_before = self._is_inside_code_block(block.previous()) - if inside_before: - # This fence closes the block → ensure a line after, then move there - endpos = block.position() + len(line) - edit = QTextCursor(doc) - edit.setPosition(endpos) - if not block.next().isValid(): - edit.insertText("\n") - c.setPosition(endpos + 1) - self.setTextCursor(c) - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - self.setFocus() - return - else: - # Opening fence → move caret to the next line (inside the block) - nb = block.next() - if not nb.isValid(): - e = QTextCursor(doc) - e.setPosition(block.position() + len(line)) - e.insertText("\n") - nb = block.next() - c.setPosition(nb.position()) - self.setTextCursor(c) - self.setFocus() - return - - # If we're inside a block (but not on a fence), don't mutate text - if self._is_inside_code_block(block): - self.setFocus() + # --- Case 1: already in a code block -> just edit that block --- + if self._is_inside_code_block(block) or block.text().strip().startswith("```"): + self._edit_code_block(block) return - # Outside any block → create a clean template on its own lines (never inline) - start_pos = c.position() - before = line[:pos_in_block] + # --- Case 2: creating a new block (optional selection) --- + if cursor.hasSelection(): + start_pos = cursor.selectionStart() + end_pos = cursor.selectionEnd() + # QTextEdit joins lines with U+2029 in selectedText() + initial_code = cursor.selectedText().replace("\u2029", "\n") + else: + start_pos = cursor.position() + end_pos = start_pos + initial_code = "" + + # Let the user type/edit the code in the popup first + dlg = CodeBlockEditorDialog(initial_code, language=None, parent=self) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + + code_text = dlg.code() + language = dlg.language() + + # Don't insert an entirely empty block + if not code_text.strip(): + return + + code_text = code_text.rstrip("\n") edit = QTextCursor(doc) edit.beginEditBlock() - # If there is text before the caret on the line, start the block on a new line - lead_break = "\n" if before else "" - # Insert the block; trailing newline guarantees you can Down-arrow out later - insert = f"{lead_break}```\n\n```\n" + # Remove selection (if any) so we can insert the new fenced block edit.setPosition(start_pos) - edit.insertText(insert) + edit.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) + edit.removeSelectedText() + + # Work out whether we're mid-line and need to break before the fence + block = doc.findBlock(start_pos) + line = block.text() + pos_in_block = start_pos - block.position() + before = line[:pos_in_block] + + # If there's text before the caret on this line, put the fence on a new line + lead_break = "\n" if before else "" + insert_str = f"{lead_break}```\n{code_text}\n```\n" + + edit.setPosition(start_pos) + edit.insertText(insert_str) edit.endEditBlock() - # Put caret on the blank line inside the block - c.setPosition(start_pos + len(lead_break) + 4) # after "```\n" - self.setTextCursor(c) + # Find the opening fence block we just inserted + open_block = doc.findBlock(start_pos + len(lead_break)) - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() + # Find the closing fence block + close_block = open_block.next() + while close_block.isValid() and not close_block.text().strip().startswith( + "```" + ): + close_block = close_block.next() - # tighten spacing for the new code block + if close_block.isValid(): + # Make sure there's always at least one line *after* the block + self._ensure_escape_line_after_closing_fence(close_block) + + # Store language metadata if the user chose one + if language is not None: + if not hasattr(self, "_code_metadata"): + from .code_highlighter import CodeBlockMetadata + + self._code_metadata = CodeBlockMetadata() + self._code_metadata.set_language(open_block.blockNumber(), language) + + # Refresh visuals self._apply_code_block_spacing() + self._update_code_block_row_backgrounds() + if hasattr(self, "highlighter"): + self.highlighter.rehighlight() + + # Put caret just after the code block so the user can keep writing normal text + after_block = close_block.next() if close_block.isValid() else None + if after_block and after_block.isValid(): + cursor = self.textCursor() + cursor.setPosition(after_block.position()) + self.setTextCursor(cursor) self.setFocus() @@ -1240,6 +2366,307 @@ class MarkdownEditor(QTextEdit): cursor.insertImage(img_format) cursor.insertText("\n") # Add newline after image + # ========== Collapse / Expand (folding) ========== + + def _parse_collapse_header(self, line: str) -> Optional[tuple[str, bool]]: + # If line is a collapse header, return (indent, is_collapsed) + m = self._COLLAPSE_HEADER_RE.match(line) + if not m: + return None + indent = m.group(1) + arrow = m.group(2) + return (indent, arrow == self._COLLAPSE_ARROW_COLLAPSED) + + def _is_collapse_end_marker(self, line: str) -> bool: + return bool(self._COLLAPSE_END_RE.match(line)) + + def _set_block_visible(self, block: QTextBlock, visible: bool) -> None: + """Hide/show a QTextBlock and nudge layout to update. + + When folding, we set lineCount=0 for hidden blocks (standard Qt recipe). + When showing again, we restore a sensible lineCount based on the block's + current layout so the document relayout doesn't glitch. + """ + if not block.isValid(): + return + if block.isVisible() == visible: + return + + block.setVisible(visible) + + try: + if not visible: + # Hidden blocks should contribute no height. + block.setLineCount(0) # type: ignore[attr-defined] + else: + # Restore an accurate lineCount if we can. + layout = block.layout() + lc = 1 + try: + lc = int(layout.lineCount()) if layout is not None else 1 + except Exception: + lc = 1 + block.setLineCount(max(1, lc)) # type: ignore[attr-defined] + except Exception: + pass + + doc = self.document() + if doc is not None: + doc.markContentsDirty(block.position(), block.length()) + + def _find_collapse_end_block( + self, header_block: QTextBlock + ) -> Optional[QTextBlock]: + # Find matching end marker for a header (supports nesting) + if not header_block.isValid(): + return None + + depth = 1 + b = header_block.next() + while b.isValid(): + line = b.text() + if self._COLLAPSE_HEADER_RE.match(line): + depth += 1 + elif self._is_collapse_end_marker(line): + depth -= 1 + if depth == 0: + return b + b = b.next() + return None + + def _set_collapse_header_state( + self, header_block: QTextBlock, collapsed: bool + ) -> None: + parsed = self._parse_collapse_header(header_block.text()) + if not parsed: + return + indent, _ = parsed + arrow = ( + self._COLLAPSE_ARROW_COLLAPSED + if collapsed + else self._COLLAPSE_ARROW_EXPANDED + ) + label = ( + self._COLLAPSE_LABEL_EXPAND if collapsed else self._COLLAPSE_LABEL_COLLAPSE + ) + new_line = f"{indent}{arrow} {label}" + + # Replace *only* the text inside this block (not the paragraph separator), + # to avoid any chance of the header visually "joining" adjacent lines. + doc = self.document() + if doc is None: + return + + cursor = QTextCursor(doc) + cursor.setPosition(header_block.position()) + cursor.beginEditBlock() + cursor.movePosition( + QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor + ) + cursor.insertText(new_line) + cursor.endEditBlock() + + def _toggle_collapse_at_block(self, header_block: QTextBlock) -> None: + parsed = self._parse_collapse_header(header_block.text()) + if not parsed: + return + + doc = self.document() + if doc is None: + return + + block_num = header_block.blockNumber() + _, is_collapsed = parsed + + end_block = self._find_collapse_end_block(header_block) + if end_block is None: + return + + # Flip header arrow + self._set_collapse_header_state(header_block, collapsed=not is_collapsed) + + # Refresh folding so nested regions keep their state + self._refresh_collapse_folding() + + # Re-resolve the header block after edits/layout changes + hb = doc.findBlockByNumber(block_num) + pos = hb.position() if hb.isValid() else header_block.position() + + # Keep caret on the header (start of line) + c = self.textCursor() + c.setPosition(max(0, min(pos, max(0, doc.characterCount() - 1)))) + self.setTextCursor(c) + self.setFocus() + + def _remove_collapse_at_block(self, header_block: QTextBlock) -> None: + # Remove a collapse wrapper (keep content, delete header + end marker) + end_block = self._find_collapse_end_block(header_block) + if end_block is None: + return + + doc = self.document() + if doc is None: + return + + # Ensure content visible + b = header_block.next() + while b.isValid() and b != end_block: + self._set_block_visible(b, True) + b = b.next() + + cur = QTextCursor(doc) + cur.beginEditBlock() + + # Delete header block + cur.setPosition(header_block.position()) + cur.select(QTextCursor.SelectionType.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() # paragraph separator + + # Find and delete the end marker block (scan forward) + probe = doc.findBlock(end_block.position()) + b2 = probe + for _ in range(0, 50): + if not b2.isValid(): + break + if self._is_collapse_end_marker(b2.text()): + cur.setPosition(b2.position()) + cur.select(QTextCursor.SelectionType.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() + break + b2 = b2.next() + + cur.endEditBlock() + + self._refresh_collapse_folding() + + def collapse_selection(self) -> None: + # Wrap the current selection in a collapsible region and collapse it + cursor = self.textCursor() + if not cursor.hasSelection(): + return + + doc = self.document() + if doc is None: + return + + sel_start = min(cursor.selectionStart(), cursor.selectionEnd()) + sel_end = max(cursor.selectionStart(), cursor.selectionEnd()) + + # Defensive clamp (prevents QTextCursor::setPosition out-of-range in edge cases) + doc_end = max(0, doc.characterCount() - 1) + sel_start = max(0, min(sel_start, doc_end)) + sel_end = max(0, min(sel_end, doc_end)) + + c1 = QTextCursor(doc) + c1.setPosition(sel_start) + start_block = c1.block() + + c2 = QTextCursor(doc) + c2.setPosition(sel_end) + end_block = c2.block() + + # If the selection ends exactly at the start of a block, treat the + # previous block as the "end" (Qt selections often report the start + # of the next block as selectionEnd()). + if ( + sel_end > sel_start + and end_block.isValid() + and sel_end == end_block.position() + and sel_end > 0 + ): + c2.setPosition(sel_end - 1) + end_block = c2.block() + + # Expand to whole blocks + start_pos = start_block.position() + end_pos_raw = end_block.position() + end_block.length() + end_pos = min(end_pos_raw, max(0, doc.characterCount() - 1)) + + # Inherit indentation from the first selected line (useful inside lists) + m = re.match(r"^[ \t]*", start_block.text()) + indent = m.group(0) if m else "" + + header_line = ( + f"{indent}{self._COLLAPSE_ARROW_COLLAPSED} {self._COLLAPSE_LABEL_EXPAND}" + ) + end_marker_line = f"{indent}{self._COLLAPSE_END_MARKER}" + + edit = QTextCursor(doc) + edit.beginEditBlock() + + # Insert end marker AFTER selection first (keeps start positions stable) + edit.setPosition(end_pos) + + # If the computed end position fell off the end of the document (common + # when the selection includes the last line without a trailing newline), + # ensure the end marker starts on its own line. + if end_pos_raw > end_pos and edit.position() > 0: + prev = doc.characterAt(edit.position() - 1) + if prev not in ("\n", "\u2029"): + edit.insertText("\n") + + # Also ensure we are not mid-line (marker should be its own block). + if edit.position() > 0: + prev = doc.characterAt(edit.position() - 1) + if prev not in ("\n", "\u2029"): + edit.insertText("\n") + + edit.insertText(end_marker_line + "\n") + + # Insert header BEFORE selection + edit.setPosition(start_pos) + edit.insertText(header_line + "\n") + edit.endEditBlock() + + self._refresh_collapse_folding() + + # Caret on header + header_block = doc.findBlock(start_pos) + c = self.textCursor() + c.setPosition(header_block.position()) + self.setTextCursor(c) + self.setFocus() + + def _refresh_collapse_folding(self) -> None: + # Apply folding to all collapse regions based on their arrow state + doc = self.document() + if doc is None: + return + + # Show everything except end markers (always hidden) + b = doc.begin() + while b.isValid(): + if self._is_collapse_end_marker(b.text()): + self._set_block_visible(b, False) + else: + self._set_block_visible(b, True) + b = b.next() + + # Hide content for any header that is currently collapsed + b = doc.begin() + while b.isValid(): + parsed = self._parse_collapse_header(b.text()) + if parsed and parsed[1] is True: + end_block = self._find_collapse_end_block(b) + if end_block is None: + b = b.next() + continue + + inner = b.next() + while inner.isValid() and inner != end_block: + self._set_block_visible(inner, False) + inner = inner.next() + + self._set_block_visible(end_block, False) + b = end_block + b = b.next() + + # Force a full relayout after visibility changes (prevents visual jitter) + doc.markContentsDirty(0, doc.characterCount()) + self.viewport().update() + # ========== Context Menu Support ========== def contextMenuEvent(self, event): @@ -1257,15 +2684,12 @@ class MarkdownEditor(QTextEdit): lang_menu = menu.addMenu(strings._("set_code_language")) languages = [ - "python", "bash", - "php", - "javascript", - "html", "css", - "sql", - "java", - "go", + "html", + "javascript", + "php", + "python", ] for lang in languages: action = QAction(lang.capitalize(), self) @@ -1276,6 +2700,46 @@ class MarkdownEditor(QTextEdit): menu.addSeparator() + edit_action = QAction(strings._("edit_code_block"), self) + edit_action.triggered.connect(lambda: self._edit_code_block(block)) + menu.addAction(edit_action) + + delete_action = QAction(strings._("delete_code_block"), self) + delete_action.triggered.connect(lambda: self._delete_code_block(block)) + menu.addAction(delete_action) + + menu.addSeparator() + + # Collapse / Expand actions + header_parsed = self._parse_collapse_header(block.text()) + if header_parsed: + _indent, is_collapsed = header_parsed + + menu.addSeparator() + + toggle_label = ( + strings._("expand") if is_collapsed else strings._("collapse") + ) + toggle_action = QAction(toggle_label, self) + toggle_action.triggered.connect( + lambda checked=False, b=block: self._toggle_collapse_at_block(b) + ) + menu.addAction(toggle_action) + + remove_action = QAction(strings._("remove_collapse"), self) + remove_action.triggered.connect( + lambda checked=False, b=block: self._remove_collapse_at_block(b) + ) + menu.addAction(remove_action) + + menu.addSeparator() + + if self.textCursor().hasSelection(): + collapse_sel_action = QAction(strings._("collapse_selection"), self) + collapse_sel_action.triggered.connect(self.collapse_selection) + menu.addAction(collapse_sel_action) + menu.addSeparator() + # Add standard context menu actions if self.textCursor().hasSelection(): menu.addAction(strings._("cut"), self.cut) @@ -1309,3 +2773,23 @@ class MarkdownEditor(QTextEdit): cursor = self.textCursor() block = cursor.block() return block.text() + + def get_current_line_task_text(self) -> str: + """ + Like get_current_line_text(), but with list / checkbox / number + prefixes stripped off for use in Pomodoro notes, etc. + """ + line = self.get_current_line_text() + + text = re.sub( + r"^\s*(?:" + r"-\s\[(?: |x|X)\]\s+" # markdown checkbox + r"|[☐☑]\s+" # Unicode checkbox + r"|•\s+" # Unicode bullet + r"|[-*+]\s+" # markdown bullets + r"|\d+\.\s+" # numbered 1. 2. etc + r")", + "", + line, + ) + return text.strip() diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index f9826ff..bb308d5 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -14,7 +14,7 @@ from PySide6.QtGui import ( QTextDocument, ) -from .theme import ThemeManager, Theme +from .theme import Theme, ThemeManager class MarkdownHighlighter(QSyntaxHighlighter): @@ -78,17 +78,18 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.theme_manager.current() == Theme.DARK or self.theme_manager._is_system_dark ): - # In dark mode, use a darker panel-like background - bg = pal.color(QPalette.AlternateBase) - fg = pal.color(QPalette.Text) + # In dark mode, use a darker panel-like background for codeblocks + code_bg = pal.color(QPalette.AlternateBase) + code_fg = pal.color(QPalette.Text) else: - # Light mode: keep the existing light gray - bg = QColor(245, 245, 245) - fg = QColor( # pragma: no cover + # Light mode: keep the existing light gray for code blocks + code_bg = QColor(245, 245, 245) + code_fg = QColor( # pragma: no cover 0, 0, 0 ) # avoiding using QPalette.Text as it can be white on macOS - self.code_block_format.setBackground(bg) - self.code_block_format.setForeground(fg) + + self.code_block_format.setBackground(code_bg) + self.code_block_format.setForeground(code_fg) # Headings self.h1_format = QTextCharFormat() @@ -110,6 +111,23 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.link_format.setFontUnderline(True) self.link_format.setAnchor(True) + # ---- Completed-task text (for checked checkboxes) ---- + # Use the app palette so this works in both light and dark themes. + text_fg = pal.color(QPalette.Text) + text_bg = pal.color(QPalette.Base) + + # Blend the text colour towards the background to "fade" it. + # t closer to 1.0 = closer to background / more faded. + t = 0.55 + faded = QColor( + int(text_fg.red() * (1.0 - t) + text_bg.red() * t), + int(text_fg.green() * (1.0 - t) + text_bg.green() * t), + int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t), + ) + + self.completed_task_format = QTextCharFormat() + self.completed_task_format.setForeground(faded) + # Checkboxes self.checkbox_format = QTextCharFormat() self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle) @@ -140,8 +158,7 @@ class MarkdownHighlighter(QSyntaxHighlighter): # Markdown syntax (the markers themselves) - make invisible self.syntax_format = QTextCharFormat() # Use the editor background color so they blend in - bg = pal.color(QPalette.Base) - hidden = QColor(bg) + hidden = QColor(text_bg) hidden.setAlpha(0) self.syntax_format.setForeground(hidden) # Make the markers invisible by setting font size to 0.1 points @@ -339,6 +356,15 @@ class MarkdownHighlighter(QSyntaxHighlighter): for m in re.finditer(r"[☐☑]", text): self._overlay_range(m.start(), 1, self.checkbox_format) - # (If you add Unicode bullets later…) for m in re.finditer(r"•", text): self._overlay_range(m.start(), 1, self.bullet_format) + + # Completed checkbox lines: fade the text after the checkbox. + m = re.match(r"^(\s*☑\s+)(.+)$", text) + if m and hasattr(self, "completed_task_format"): + prefix = m.group(1) + content = m.group(2) + start = len(prefix) + length = len(content) + if length > 0: + self._overlay_range(start, length, self.completed_task_format) diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py index fd29742..bde75fb 100644 --- a/bouquin/pomodoro_timer.py +++ b/bouquin/pomodoro_timer.py @@ -5,11 +5,11 @@ from typing import Optional from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, + QFrame, QHBoxLayout, QLabel, QPushButton, + QVBoxLayout, QWidget, ) @@ -18,16 +18,13 @@ from .db import DBManager from .time_log import TimeLogDialog -class PomodoroTimer(QDialog): - """A simple timer dialog for tracking work time on a specific task.""" +class PomodoroTimer(QFrame): + """A simple timer for tracking work time on a specific task.""" timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text) def __init__(self, task_text: str, parent: Optional[QWidget] = None): super().__init__(parent) - self.setWindowTitle(strings._("toolbar_pomodoro_timer")) - self.setModal(False) - self.setMinimumWidth(300) self._task_text = task_text self._elapsed_seconds = 0 @@ -43,7 +40,7 @@ class PomodoroTimer(QDialog): # Timer display self.time_label = QLabel("00:00:00") font = self.time_label.font() - font.setPointSize(24) + font.setPointSize(20) font.setBold(True) self.time_label.setFont(font) self.time_label.setAlignment(Qt.AlignCenter) @@ -103,7 +100,7 @@ class PomodoroTimer(QDialog): self._timer.stop() self.timerStopped.emit(self._elapsed_seconds, self._task_text) - self.accept() + self.close() class PomodoroManager: @@ -114,30 +111,99 @@ class PomodoroManager: self._parent = parent_window self._active_timer: Optional[PomodoroTimer] = None - def start_timer_for_line(self, line_text: str, date_iso: str): - """Start a new timer for the given line of text.""" - # Stop any existing timer - if self._active_timer and self._active_timer.isVisible(): - self._active_timer.close() + @staticmethod + def _seconds_to_logged_hours(elapsed_seconds: int) -> float: + """Convert elapsed seconds to decimal hours for logging. - # Create new timer - self._active_timer = PomodoroTimer(line_text, self._parent) + 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): + """ + Start a new timer for the given line of text and embed it into the + TimeLogWidget in the main window sidebar. + """ + # Cancel any existing timer first + self.cancel_timer() + + # The timer lives inside the TimeLogWidget in the sidebar + time_log_widget = getattr(self._parent, "time_log", None) + if time_log_widget is None: + return + + self._active_timer = PomodoroTimer(line_text, time_log_widget) self._active_timer.timerStopped.connect( lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso) ) - self._active_timer.show() + + # Ask the TimeLogWidget to own and display the widget + if hasattr(time_log_widget, "show_pomodoro_widget"): + time_log_widget.show_pomodoro_widget(self._active_timer) + else: + # Fallback - just attach it as a child widget + self._active_timer.setParent(time_log_widget) + self._active_timer.show() + + def cancel_timer(self): + """Cancel any running timer without logging and remove it from the sidebar.""" + if not self._active_timer: + return + + time_log_widget = getattr(self._parent, "time_log", None) + if time_log_widget is not None and hasattr( + time_log_widget, "clear_pomodoro_widget" + ): + time_log_widget.clear_pomodoro_widget() + else: + # Fallback if the widget API doesn't exist + self._active_timer.setParent(None) + + self._active_timer.deleteLater() + self._active_timer = None def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): """Handle timer stop - open time log dialog with pre-filled data.""" - # Convert seconds to decimal hours, rounded up - hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour + # Convert seconds to decimal hours, and handle rounding up or down + hours = self._seconds_to_logged_hours(elapsed_seconds) # Ensure minimum of 0.25 hours if hours < 0.25: hours = 0.25 + # Untoggle the toolbar button without retriggering the slot + tool_bar = getattr(self._parent, "toolBar", None) + if tool_bar is not None and hasattr(tool_bar, "actTimer"): + action = tool_bar.actTimer + was_blocked = action.blockSignals(True) + try: + action.setChecked(False) + finally: + action.blockSignals(was_blocked) + + # Remove the embedded widget + self.cancel_timer() + # Open time log dialog - dlg = TimeLogDialog(self._db, date_iso, self._parent) + dlg = TimeLogDialog( + self._db, + date_iso, + self._parent, + True, + themes=self._parent.themes, + close_after_add=True, + ) # Pre-fill the hours dlg.hours_spin.setValue(hours) @@ -147,3 +213,13 @@ class PomodoroManager: # Show the dialog dlg.exec() + + time_log_widget = getattr(self._parent, "time_log", None) + if time_log_widget is not None: + # Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only: + # reload the summary so the TimeLogWidget in sidebar updates its totals + time_log_widget._reload_summary() + if not time_log_widget.toggle_btn.isChecked(): + time_log_widget.summary_label.setText( + strings._("time_log_collapsed_hint") + ) diff --git a/bouquin/reminders.py b/bouquin/reminders.py index 5306206..6d8b0a1 100644 --- a/bouquin/reminders.py +++ b/bouquin/reminders.py @@ -4,32 +4,37 @@ from dataclasses import dataclass from enum import Enum from typing import Optional -from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal +from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QFormLayout, - QLineEdit, + QAbstractItemView, QComboBox, - QTimeEdit, - QPushButton, + QDateEdit, + QDialog, + QFormLayout, QFrame, - QWidget, - QToolButton, + QHBoxLayout, + QHeaderView, + QLineEdit, QListWidget, QListWidgetItem, - QStyle, - QSizePolicy, QMessageBox, + QPushButton, + QSizePolicy, + QSpinBox, + QStyle, QTableWidget, QTableWidgetItem, - QAbstractItemView, - QHeaderView, + QTimeEdit, + QToolButton, + QVBoxLayout, + QWidget, ) from . import strings from .db import DBManager +from .settings import load_db_config + +import requests class ReminderType(Enum): @@ -37,6 +42,9 @@ class ReminderType(Enum): DAILY = strings._("daily") WEEKDAYS = strings._("weekdays") # Mon-Fri WEEKLY = strings._("weekly") # specific day of week + FORTNIGHTLY = strings._("fortnightly") # every 2 weeks + MONTHLY_DATE = strings._("monthly_same_date") # same calendar date + MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday @dataclass @@ -64,13 +72,29 @@ class ReminderDialog(QDialog): self.setMinimumWidth(400) layout = QVBoxLayout(self) - form = QFormLayout() + self.form = QFormLayout() # Reminder text self.text_edit = QLineEdit() if reminder: self.text_edit.setText(reminder.text) - form.addRow("&" + strings._("reminder") + ":", self.text_edit) + self.form.addRow("&" + strings._("reminder") + ":", self.text_edit) + + # Date + self.date_edit = QDateEdit() + self.date_edit.setCalendarPopup(True) + self.date_edit.setDisplayFormat("yyyy-MM-dd") + + if reminder and reminder.date_iso: + d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if d.isValid(): + self.date_edit.setDate(d) + else: + self.date_edit.setDate(QDate.currentDate()) + else: + self.date_edit.setDate(QDate.currentDate()) + + self.form.addRow("&" + strings._("date") + ":", self.date_edit) # Time self.time_edit = QTimeEdit() @@ -79,15 +103,22 @@ class ReminderDialog(QDialog): parts = reminder.time_str.split(":") self.time_edit.setTime(QTime(int(parts[0]), int(parts[1]))) else: - self.time_edit.setTime(QTime.currentTime()) - form.addRow("&" + strings._("time") + ":", self.time_edit) + # Default to 5 minutes in the future + future = QTime.currentTime().addSecs(5 * 60) + self.time_edit.setTime(future) + self.form.addRow("&" + strings._("time") + ":", self.time_edit) # Recurrence type self.type_combo = QComboBox() - self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE) + self.type_combo.addItem(strings._("once"), ReminderType.ONCE) self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY) self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS) self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY) + self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY) + self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE) + self.type_combo.addItem( + strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY + ) if reminder: for i in range(self.type_combo.count()): @@ -96,7 +127,7 @@ class ReminderDialog(QDialog): break self.type_combo.currentIndexChanged.connect(self._on_type_changed) - form.addRow("&" + strings._("repeat") + ":", self.type_combo) + self.form.addRow("&" + strings._("repeat") + ":", self.type_combo) # Weekday selector (for weekly reminders) self.weekday_combo = QComboBox() @@ -115,11 +146,32 @@ class ReminderDialog(QDialog): if reminder and reminder.weekday is not None: self.weekday_combo.setCurrentIndex(reminder.weekday) else: - self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1) + self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1) - form.addRow("&" + strings._("day") + ":", self.weekday_combo) + self.form.addRow("&" + strings._("day") + ":", self.weekday_combo) + day_label = self.form.labelForField(self.weekday_combo) + day_label.setVisible(False) - layout.addLayout(form) + self.nth_spin = QSpinBox() + self.nth_spin.setRange(1, 5) # up to 5th Monday, etc. + self.nth_spin.setValue(1) + # If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso + if ( + reminder + and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY + and reminder.date_iso + ): + anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if anchor.isValid(): + nth_index = (anchor.day() - 1) // 7 # 0-based + self.nth_spin.setValue(nth_index + 1) + + self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin) + nth_label = self.form.labelForField(self.nth_spin) + nth_label.setVisible(False) + self.nth_spin.setVisible(False) + + layout.addLayout(self.form) # Buttons btn_layout = QHBoxLayout() @@ -139,9 +191,31 @@ class ReminderDialog(QDialog): self._on_type_changed() def _on_type_changed(self): - """Show/hide weekday selector based on reminder type.""" + """Show/hide weekday / nth selectors based on reminder type.""" reminder_type = self.type_combo.currentData() - self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY) + + show_weekday = reminder_type in ( + ReminderType.WEEKLY, + ReminderType.MONTHLY_NTH_WEEKDAY, + ) + self.weekday_combo.setVisible(show_weekday) + day_label = self.form.labelForField(self.weekday_combo) + day_label.setVisible(show_weekday) + + show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY + nth_label = self.form.labelForField(self.nth_spin) + self.nth_spin.setVisible(show_nth) + nth_label.setVisible(show_nth) + + # For new reminders, when switching to a type that uses a weekday, + # snap the weekday to match the currently selected date. + if reminder_type in ( + ReminderType.WEEKLY, + ReminderType.MONTHLY_NTH_WEEKDAY, + ) and (self._reminder is None or self._reminder.reminder_type != reminder_type): + dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun) + if 0 <= dow < self.weekday_combo.count(): + self.weekday_combo.setCurrentIndex(dow) def get_reminder(self) -> Reminder: """Get the configured reminder.""" @@ -150,13 +224,39 @@ class ReminderDialog(QDialog): time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}" weekday = None - if reminder_type == ReminderType.WEEKLY: + if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY): weekday = self.weekday_combo.currentData() date_iso = None + anchor_date = self.date_edit.date() + if reminder_type == ReminderType.ONCE: - # Right now this just means "today at the chosen time". - date_iso = QDate.currentDate().toString("yyyy-MM-dd") + # Fire once, on the chosen calendar date at the chosen time + date_iso = anchor_date.toString("yyyy-MM-dd") + + elif reminder_type == ReminderType.FORTNIGHTLY: + # Anchor: the chosen calendar date. Every 14 days from this date. + date_iso = anchor_date.toString("yyyy-MM-dd") + + elif reminder_type == ReminderType.MONTHLY_DATE: + # Anchor: the chosen calendar date. "Same date each month" + date_iso = anchor_date.toString("yyyy-MM-dd") + + elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY: + # Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.) + weekday = self.weekday_combo.currentData() + nth_index = self.nth_spin.value() - 1 # 0-based + + first = QDate(anchor_date.year(), anchor_date.month(), 1) + target_dow = weekday + 1 # Qt: Monday=1 + offset = (target_dow - first.dayOfWeek() + 7) % 7 + anchor = first.addDays(offset + nth_index * 7) + + # If nth weekday doesn't exist in this month, fall back to the last such weekday + if anchor.month() != anchor_date.month(): + anchor = anchor.addDays(-7) + + date_iso = anchor.toString("yyyy-MM-dd") return Reminder( id=self._reminder.id if self._reminder else None, @@ -183,7 +283,7 @@ class UpcomingRemindersWidget(QFrame): # Header with toggle button self.toggle_btn = QToolButton() - self.toggle_btn.setText("Upcoming Reminders") + self.toggle_btn.setText(strings._("upcoming_reminders")) self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.toggle_btn.setCheckable(True) self.toggle_btn.setChecked(False) @@ -192,7 +292,7 @@ class UpcomingRemindersWidget(QFrame): self.add_btn = QToolButton() self.add_btn.setText("⏰") - self.add_btn.setToolTip("Add Reminder") + self.add_btn.setToolTip(strings._("add_reminder")) self.add_btn.setAutoRaise(True) self.add_btn.clicked.connect(self._add_reminder) @@ -200,7 +300,7 @@ class UpcomingRemindersWidget(QFrame): self.manage_btn.setIcon( self.style().standardIcon(QStyle.SP_FileDialogDetailedView) ) - self.manage_btn.setToolTip("Manage All Reminders") + self.manage_btn.setToolTip(strings._("manage_reminders")) self.manage_btn.setAutoRaise(True) self.manage_btn.clicked.connect(self._manage_reminders) @@ -235,43 +335,36 @@ class UpcomingRemindersWidget(QFrame): main.addWidget(self.body) # Timer to check and fire reminders - # Start by syncing to the next minute boundary - self._check_timer = QTimer(self) - self._check_timer.timeout.connect(self._check_reminders) + # + # We tick once per second, but only hit the DB when the clock is + # exactly on a :00 second. That way a reminder for HH:MM fires at + # HH:MM:00, independent of when it was created. + self._tick_timer = QTimer(self) + self._tick_timer.setInterval(1000) # 1 second + self._tick_timer.timeout.connect(self._on_tick) + self._tick_timer.start() - # Calculate milliseconds until next minute (HH:MM:00) + # Also check once on startup so we don't miss reminders that + # should have fired a moment ago when the app wasn't running. + QTimer.singleShot(0, self._check_reminders) + + def _on_tick(self) -> None: + """Called every second; run reminder check only on exact minute boundaries.""" now = QDateTime.currentDateTime() - current_second = now.time().second() - current_msec = now.time().msec() - - # Milliseconds until next minute - ms_until_next_minute = (60 - current_second) * 1000 - current_msec - - # Start with a single-shot to sync to the minute - self._sync_timer = QTimer(self) - self._sync_timer.setSingleShot(True) - self._sync_timer.timeout.connect(self._start_regular_timer) - self._sync_timer.start(ms_until_next_minute) - - # Also check immediately in case there are pending reminders - QTimer.singleShot(1000, self._check_reminders) + if now.time().second() == 0: + # Only do the heavier DB work once per minute, at HH:MM:00, + # so reminders are aligned to the clock and not to when they + # were created. + self._check_reminders(now) def __del__(self): """Cleanup timers when widget is destroyed.""" try: - if hasattr(self, "_check_timer") and self._check_timer: - self._check_timer.stop() - if hasattr(self, "_sync_timer") and self._sync_timer: - self._sync_timer.stop() - except: + if hasattr(self, "_tick_timer") and self._tick_timer: + self._tick_timer.stop() + except Exception: pass # Ignore any cleanup errors - def _start_regular_timer(self): - """Start the regular check timer after initial sync.""" - # Now we're at a minute boundary, check and start regular timer - self._check_reminders() - self._check_timer.start(60000) # Check every minute - def _on_toggle(self, checked: bool): """Toggle visibility of reminder list.""" self.body.setVisible(checked) @@ -324,41 +417,99 @@ class UpcomingRemindersWidget(QFrame): self.reminder_list.addItem(item) if not upcoming: - item = QListWidgetItem("No upcoming reminders") + item = QListWidgetItem(strings._("no_upcoming_reminders")) item.setFlags(Qt.NoItemFlags) self.reminder_list.addItem(item) def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool: """Check if a reminder should fire on a given date.""" - if reminder.reminder_type == ReminderType.ONCE: + rtype = reminder.reminder_type + + if rtype == ReminderType.ONCE: if reminder.date_iso: return date.toString("yyyy-MM-dd") == reminder.date_iso return False - elif reminder.reminder_type == ReminderType.DAILY: + + if rtype == ReminderType.DAILY: return True - elif reminder.reminder_type == ReminderType.WEEKDAYS: + + if rtype == ReminderType.WEEKDAYS: # Monday=1, Sunday=7 return 1 <= date.dayOfWeek() <= 5 - elif reminder.reminder_type == ReminderType.WEEKLY: + + if rtype == ReminderType.WEEKLY: # Qt: Monday=1, reminder: Monday=0 return date.dayOfWeek() - 1 == reminder.weekday + + if rtype == ReminderType.FORTNIGHTLY: + if not reminder.date_iso: + return False + anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if not anchor.isValid() or date < anchor: + return False + days = anchor.daysTo(date) + return days % 14 == 0 + + if rtype == ReminderType.MONTHLY_DATE: + if not reminder.date_iso: + return False + anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if not anchor.isValid(): + return False + anchor_day = anchor.day() + # Clamp to the last day of this month (for 29/30/31) + first_of_month = QDate(date.year(), date.month(), 1) + last_of_month = first_of_month.addMonths(1).addDays(-1) + target_day = min(anchor_day, last_of_month.day()) + return date.day() == target_day + + if rtype == ReminderType.MONTHLY_NTH_WEEKDAY: + if not reminder.date_iso or reminder.weekday is None: + return False + + anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if not anchor.isValid(): + return False + + # Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.) + anchor_n = (anchor.day() - 1) // 7 + target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7) + + # Compute the anchor_n-th target weekday in this month + first = QDate(date.year(), date.month(), 1) + offset = (target_dow - first.dayOfWeek() + 7) % 7 + candidate = first.addDays(offset + anchor_n * 7) + + # If that nth weekday doesn't exist this month (e.g. 5th Monday), skip + if candidate.month() != date.month(): + return False + + return date == candidate + return False - def _check_reminders(self): - """Check if any reminders should fire now.""" + def _check_reminders(self, now: QDateTime | None = None): + """ + Check and trigger due reminders. + + This uses absolute clock time, so a reminder for HH:MM will fire + when the system clock reaches HH:MM:00, independent of when the + reminder was created. + """ # Guard: Check if database connection is valid if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: return - now = QDateTime.currentDateTime() - today = QDate.currentDate() - - # Round current time to the minute (set seconds to 0) - current_minute = QDateTime( - today, QTime(now.time().hour(), now.time().minute(), 0) - ) + if now is None: + now = QDateTime.currentDateTime() + today = now.date() reminders = self._db.get_all_reminders() + + # Small grace window (in seconds) so we still fire reminders if + # the app was just opened or the event loop was briefly busy. + GRACE_WINDOW_SECS = 120 # 2 minutes + for reminder in reminders: if not reminder.active: continue @@ -366,28 +517,35 @@ class UpcomingRemindersWidget(QFrame): if not self._should_fire_on_date(reminder, today): continue - # Parse time + # Parse time: stored as "HH:MM", we treat that as HH:MM:00 hour, minute = map(int, reminder.time_str.split(":")) target = QDateTime(today, QTime(hour, minute, 0)) - # Fire if we've passed the target minute (within last 2 minutes to catch missed ones) - seconds_diff = current_minute.secsTo(target) - if -120 <= seconds_diff <= 0: - # Check if we haven't already fired this one + # Skip if this reminder is still in the future + if now < target: + continue + + # How long ago should this reminder have fired? + seconds_late = target.secsTo(now) # target -> now + + if 0 <= seconds_late <= GRACE_WINDOW_SECS: + # Check if we haven't already fired this occurrence if not hasattr(self, "_fired_reminders"): self._fired_reminders = {} reminder_key = (reminder.id, target.toString()) - # Only fire once per reminder per target time - if reminder_key not in self._fired_reminders: - self._fired_reminders[reminder_key] = current_minute - self.reminderTriggered.emit(reminder.text) + if reminder_key in self._fired_reminders: + continue - # For ONCE reminders, deactivate after firing - if reminder.reminder_type == ReminderType.ONCE: - self._db.update_reminder_active(reminder.id, False) - self.refresh() # Refresh the list to show deactivated reminder + # Mark as fired and emit + self._fired_reminders[reminder_key] = now + self.reminderTriggered.emit(reminder.text) + + # For ONCE reminders, deactivate after firing + if reminder.reminder_type == ReminderType.ONCE: + self._db.update_reminder_active(reminder.id, False) + self.refresh() # Refresh the list to show deactivated reminder @Slot() def _add_reminder(self): @@ -418,8 +576,8 @@ class UpcomingRemindersWidget(QFrame): if not selected_items: return - from PySide6.QtWidgets import QMenu from PySide6.QtGui import QAction + from PySide6.QtWidgets import QMenu menu = QMenu(self) @@ -427,7 +585,7 @@ class UpcomingRemindersWidget(QFrame): if len(selected_items) == 1: reminder = selected_items[0].data(Qt.UserRole) if reminder: - edit_action = QAction("Edit", self) + edit_action = QAction(strings._("edit"), self) edit_action.triggered.connect( lambda: self._edit_reminder(selected_items[0]) ) @@ -435,9 +593,13 @@ class UpcomingRemindersWidget(QFrame): # Delete option for any selection if len(selected_items) == 1: - delete_text = "Delete" + delete_text = strings._("delete") else: - delete_text = f"Delete {len(selected_items)} Reminders" + delete_text = ( + strings._("delete") + + f" {len(selected_items)} " + + strings._("reminders") + ) delete_action = QAction(delete_text, self) delete_action.triggered.connect(lambda: self._delete_selected_reminders()) @@ -464,15 +626,31 @@ class UpcomingRemindersWidget(QFrame): # Confirmation message if len(unique_reminders) == 1: reminder = list(unique_reminders.values())[0] - msg = f"Delete reminder '{reminder.text}'?" + msg = ( + strings._("delete") + + " " + + strings._("reminder") + + f" '{reminder.text}'?" + ) if reminder.reminder_type != ReminderType.ONCE: - msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences." + msg += ( + "\n\n" + + strings._("this_is_a_reminder_of_type") + + f" '{reminder.reminder_type.value}'. " + + strings._("deleting_it_will_remove_all_future_occurrences") + ) else: - msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences." + msg = ( + strings._("delete") + + f"{len(unique_reminders)} " + + strings._("reminders") + + " ?\n\n" + + strings._("this_will_delete_the_actual_reminders") + ) reply = QMessageBox.question( self, - "Delete Reminders", + strings._("delete_reminders"), msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.No, @@ -485,13 +663,18 @@ class UpcomingRemindersWidget(QFrame): def _delete_reminder(self, reminder): """Delete a single reminder after confirmation.""" - msg = f"Delete reminder '{reminder.text}'?" + msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?" if reminder.reminder_type != ReminderType.ONCE: - msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences." + msg += ( + "\n\n" + + strings._("this_is_a_reminder_of_type") + + f" '{reminder.reminder_type.value}'. " + + strings._("deleting_it_will_remove_all_future_occurrences") + ) reply = QMessageBox.question( self, - "Delete Reminder", + strings._("delete_reminder"), msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.No, @@ -516,32 +699,40 @@ class ManageRemindersDialog(QDialog): super().__init__(parent) self._db = db - self.setWindowTitle("Manage Reminders") + self.setWindowTitle(strings._("manage_reminders")) self.setMinimumSize(700, 500) layout = QVBoxLayout(self) # Reminder list table self.table = QTableWidget() - self.table.setColumnCount(5) + self.table.setColumnCount(6) self.table.setHorizontalHeaderLabels( - ["Text", "Time", "Type", "Active", "Actions"] + [ + strings._("text"), + strings._("date"), + strings._("time"), + strings._("type"), + strings._("active"), + strings._("actions"), + ] ) self.table.horizontalHeader().setStretchLastSection(False) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) layout.addWidget(self.table) # Buttons btn_layout = QHBoxLayout() - add_btn = QPushButton("Add Reminder") + add_btn = QPushButton(strings._("add_reminder")) add_btn.clicked.connect(self._add_reminder) btn_layout.addWidget(add_btn) btn_layout.addStretch() - close_btn = QPushButton("Close") + close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) btn_layout.addWidget(close_btn) @@ -565,48 +756,105 @@ class ManageRemindersDialog(QDialog): text_item.setData(Qt.UserRole, reminder) self.table.setItem(row, 0, text_item) + # Date + date_display = "" + if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso: + d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if d.isValid(): + date_display = d.toString("yyyy-MM-dd") + else: + date_display = reminder.date_iso + + date_item = QTableWidgetItem(date_display) + self.table.setItem(row, 1, date_item) + # Time time_item = QTableWidgetItem(reminder.time_str) - self.table.setItem(row, 1, time_item) + self.table.setItem(row, 2, time_item) # Type - type_str = { + base_type_strs = { ReminderType.ONCE: "Once", ReminderType.DAILY: "Daily", ReminderType.WEEKDAYS: "Weekdays", ReminderType.WEEKLY: "Weekly", - }.get(reminder.reminder_type, "Unknown") + ReminderType.FORTNIGHTLY: "Fortnightly", + ReminderType.MONTHLY_DATE: "Monthly (date)", + ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)", + } + type_str = base_type_strs.get(reminder.reminder_type, "Unknown") - if ( - reminder.reminder_type == ReminderType.WEEKLY - and reminder.weekday is not None - ): - days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - type_str += f" ({days[reminder.weekday]})" + # Short day names we can reuse + days_short = [ + strings._("monday_short"), + strings._("tuesday_short"), + strings._("wednesday_short"), + strings._("thursday_short"), + strings._("friday_short"), + strings._("saturday_short"), + strings._("sunday_short"), + ] + + if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY: + # Show something like: Monthly (3rd Mon) + day_name = "" + if reminder.weekday is not None and 0 <= reminder.weekday < len( + days_short + ): + day_name = days_short[reminder.weekday] + + nth_label = "" + if reminder.date_iso: + anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") + if anchor.isValid(): + nth_index = (anchor.day() - 1) // 7 # 0-based (0..4) + ordinals = ["1st", "2nd", "3rd", "4th", "5th"] + if 0 <= nth_index < len(ordinals): + nth_label = ordinals[nth_index] + + parts = [] + if nth_label: + parts.append(nth_label) + if day_name: + parts.append(day_name) + + if parts: + type_str = f"Monthly ({' '.join(parts)})" + # else: fall back to the generic "Monthly (nth weekday)" + + else: + # For weekly / fortnightly types, still append the day name + if ( + reminder.reminder_type + in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY) + and reminder.weekday is not None + and 0 <= reminder.weekday < len(days_short) + ): + type_str += f" ({days_short[reminder.weekday]})" type_item = QTableWidgetItem(type_str) - self.table.setItem(row, 2, type_item) + self.table.setItem(row, 3, type_item) # Active active_item = QTableWidgetItem("✓" if reminder.active else "✗") - self.table.setItem(row, 3, active_item) + self.table.setItem(row, 4, active_item) # Actions actions_widget = QWidget() actions_layout = QHBoxLayout(actions_widget) actions_layout.setContentsMargins(2, 2, 2, 2) - edit_btn = QPushButton("Edit") + edit_btn = QPushButton(strings._("edit")) edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r)) actions_layout.addWidget(edit_btn) - delete_btn = QPushButton("Delete") + delete_btn = QPushButton(strings._("delete")) delete_btn.clicked.connect( lambda checked, r=reminder: self._delete_reminder(r) ) actions_layout.addWidget(delete_btn) - self.table.setCellWidget(row, 4, actions_widget) + self.table.setCellWidget(row, 5, actions_widget) def _add_reminder(self): """Add a new reminder.""" @@ -628,8 +876,8 @@ class ManageRemindersDialog(QDialog): """Delete a reminder.""" reply = QMessageBox.question( self, - "Delete Reminder", - f"Delete reminder '{reminder.text}'?", + strings._("delete_reminder"), + strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) @@ -637,3 +885,33 @@ class ManageRemindersDialog(QDialog): if reply == QMessageBox.Yes: self._db.delete_reminder(reminder.id) self._load_reminders() + + +class ReminderWebHook: + def __init__(self, text): + self.text = text + self.cfg = load_db_config() + + def _send(self): + payload: dict[str, str] = { + "reminder": self.text, + } + + url = self.cfg.reminders_webhook_url + secret = self.cfg.reminders_webhook_secret + + _headers = {} + if secret: + _headers["X-Bouquin-Secret"] = secret + + if url: + try: + requests.post( + url, + json=payload, + timeout=10, + headers=_headers, + ) + except Exception: + # We did our best + pass diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py index 6b4e05d..528896b 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -3,13 +3,7 @@ from __future__ import annotations import datetime from PySide6.QtGui import QFontMetrics -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QLabel, - QLineEdit, - QDialogButtonBox, -) +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout from . import strings diff --git a/bouquin/search.py b/bouquin/search.py index 95a94de..7dd7f7f 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -6,19 +6,19 @@ from typing import Iterable, Tuple from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, + QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QSizePolicy, - QHBoxLayout, QVBoxLayout, QWidget, ) from . import strings -Row = Tuple[str, str] +Row = Tuple[str, str, str, str, str | None] class Search(QWidget): @@ -52,9 +52,27 @@ class Search(QWidget): lay.addWidget(self.results) def _open_selected(self, item: QListWidgetItem): - date_str = item.data(Qt.ItemDataRole.UserRole) - if date_str: - self.openDateRequested.emit(date_str) + data = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(data, dict): + return + + kind = data.get("kind") + if kind == "page": + date_iso = data.get("date") + if date_iso: + self.openDateRequested.emit(date_iso) + elif kind == "document": + doc_id = data.get("doc_id") + file_name = data.get("file_name") or "document" + if doc_id is None: + return + self._open_document(int(doc_id), file_name) + + def _open_document(self, doc_id: int, file_name: str) -> None: + """Open the selected document in the user's default app.""" + from bouquin.document_utils import open_document_from_db + + open_document_from_db(self._db, doc_id, file_name, parent_widget=self) def _search(self, text: str): """ @@ -80,28 +98,28 @@ class Search(QWidget): self.resultDatesChanged.emit([]) # clear highlights return - self.resultDatesChanged.emit(sorted({d for d, _ in rows})) + # Only highlight calendar dates for page results + page_dates = sorted( + {key for (kind, key, _title, _text, _aux) in rows if kind == "page"} + ) + self.resultDatesChanged.emit(page_dates) self.results.show() - for date_str, content in rows: - # Build an HTML fragment around the match and whether to show ellipses - frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90) - # ---- Per-item widget: date on top, preview row below (with ellipses) ---- + for kind, key, title, text, aux in rows: + # Build an HTML fragment around the match + frag_html = self._make_html_snippet(text, query, radius=30, maxlen=90) + container = QWidget() outer = QVBoxLayout(container) - outer.setContentsMargins(8, 6, 8, 6) + outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(2) - # Date label (plain text) - date_lbl = QLabel() - date_lbl.setTextFormat(Qt.TextFormat.RichText) - date_lbl.setText(f"

{date_str}

") - date_f = date_lbl.font() - date_f.setPointSizeF(date_f.pointSizeF() + 1) - date_lbl.setFont(date_f) - outer.addWidget(date_lbl) + # ---- Heading (date for pages, "Document" for docs) ---- + heading = QLabel(title) + heading.setStyleSheet("font-weight:bold;") + outer.addWidget(heading) - # Preview row with optional ellipses + # ---- Preview row ---- row = QWidget() h = QHBoxLayout(row) h.setContentsMargins(0, 0, 0, 0) @@ -117,9 +135,9 @@ class Search(QWidget): else "(no preview)" ) h.addWidget(preview, 1) - outer.addWidget(row) + # Separator line line = QFrame() line.setFrameShape(QFrame.HLine) line.setFrameShadow(QFrame.Sunken) @@ -127,9 +145,22 @@ class Search(QWidget): # ---- Add to list ---- item = QListWidgetItem() - item.setData(Qt.ItemDataRole.UserRole, date_str) - item.setSizeHint(container.sizeHint()) + if kind == "page": + item.setData( + Qt.ItemDataRole.UserRole, + {"kind": "page", "date": key}, + ) + else: # document + item.setData( + Qt.ItemDataRole.UserRole, + { + "kind": "document", + "doc_id": int(key), + "file_name": aux or "", + }, + ) + item.setSizeHint(container.sizeHint()) self.results.addItem(item) self.results.setItemWidget(item, container) diff --git a/bouquin/settings.py b/bouquin/settings.py index 011d39a..fde863d 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path + from PySide6.QtCore import QSettings, QStandardPaths from .db import DBConfig @@ -41,9 +42,16 @@ def load_db_config() -> DBConfig: idle = s.value("ui/idle_minutes", 15, type=int) theme = s.value("ui/theme", "system", type=str) move_todos = s.value("ui/move_todos", False, type=bool) + move_todos_include_weekends = s.value( + "ui/move_todos_include_weekends", False, type=bool + ) tags = s.value("ui/tags", True, type=bool) time_log = s.value("ui/time_log", True, type=bool) reminders = s.value("ui/reminders", True, type=bool) + reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str) + reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str) + documents = s.value("ui/documents", True, type=bool) + invoicing = s.value("ui/invoicing", False, type=bool) locale = s.value("ui/locale", "en", type=str) font_size = s.value("ui/font_size", 11, type=int) return DBConfig( @@ -52,9 +60,14 @@ def load_db_config() -> DBConfig: idle_minutes=idle, theme=theme, move_todos=move_todos, + move_todos_include_weekends=move_todos_include_weekends, tags=tags, time_log=time_log, reminders=reminders, + reminders_webhook_url=reminders_webhook_url, + reminders_webhook_secret=reminders_webhook_secret, + documents=documents, + invoicing=invoicing, locale=locale, font_size=font_size, ) @@ -67,8 +80,13 @@ def save_db_config(cfg: DBConfig) -> None: s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/theme", str(cfg.theme)) s.setValue("ui/move_todos", str(cfg.move_todos)) + s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends)) s.setValue("ui/tags", str(cfg.tags)) s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/reminders", str(cfg.reminders)) + s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url)) + s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret)) + s.setValue("ui/documents", str(cfg.documents)) + s.setValue("ui/invoicing", str(cfg.invoicing)) s.setValue("ui/locale", str(cfg.locale)) s.setValue("ui/font_size", str(cfg.font_size)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 90f301d..3e1213c 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -2,34 +2,37 @@ from __future__ import annotations from pathlib import Path +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QPalette from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, QFrame, QGroupBox, - QLabel, QHBoxLayout, - QVBoxLayout, + QLabel, + QLineEdit, + QMessageBox, QPushButton, - QDialogButtonBox, QRadioButton, QSizePolicy, QSpinBox, - QMessageBox, - QWidget, QTabWidget, + QTextEdit, + QToolButton, + QVBoxLayout, + QWidget, ) -from PySide6.QtCore import Qt, Slot -from PySide6.QtGui import QPalette - - -from .db import DBConfig, DBManager -from .settings import load_db_config, save_db_config -from .theme import Theme -from .key_prompt import KeyPrompt from . import strings +from .db import DBConfig, DBManager +from .key_prompt import KeyPrompt +from .settings import load_db_config, save_db_config +from .theme import Theme class SettingsDialog(QDialog): @@ -42,7 +45,7 @@ class SettingsDialog(QDialog): self.current_settings = load_db_config() - self.setMinimumWidth(480) + self.setMinimumWidth(600) self.setSizeGripEnabled(True) # --- Tabs ---------------------------------------------------------- @@ -166,6 +169,25 @@ class SettingsDialog(QDialog): self.move_todos.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.move_todos) + # Optional: allow moving to the very next day even if it is a weekend. + self.move_todos_include_weekends = QCheckBox( + strings._("move_todos_include_weekends") + ) + self.move_todos_include_weekends.setChecked( + getattr(self.current_settings, "move_todos_include_weekends", False) + ) + self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor) + self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked()) + + move_todos_opts = QWidget() + move_todos_opts_layout = QVBoxLayout(move_todos_opts) + move_todos_opts_layout.setContentsMargins(24, 0, 0, 0) + move_todos_opts_layout.setSpacing(4) + move_todos_opts_layout.addWidget(self.move_todos_include_weekends) + features_layout.addWidget(move_todos_opts) + + self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled) + self.tags = QCheckBox(strings._("enable_tags_feature")) self.tags.setChecked(self.current_settings.tags) self.tags.setCursor(Qt.PointingHandCursor) @@ -176,12 +198,145 @@ class SettingsDialog(QDialog): self.time_log.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.time_log) + self.invoicing = QCheckBox(strings._("enable_invoicing_feature")) + invoicing_enabled = getattr(self.current_settings, "invoicing", False) + self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log) + self.invoicing.setCursor(Qt.PointingHandCursor) + features_layout.addWidget(self.invoicing) + # Invoicing only if time_log is enabled + if not self.current_settings.time_log: + self.invoicing.setChecked(False) + self.invoicing.setEnabled(False) + self.time_log.toggled.connect(self._on_time_log_toggled) + + # --- Reminders feature + webhook options ------------------------- self.reminders = QCheckBox(strings._("enable_reminders_feature")) self.reminders.setChecked(self.current_settings.reminders) + self.reminders.toggled.connect(self._on_reminders_toggled) self.reminders.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.reminders) + # Container for reminder-specific options, indented under the checkbox + self.reminders_options_container = QWidget() + reminders_options_layout = QVBoxLayout(self.reminders_options_container) + reminders_options_layout.setContentsMargins(24, 0, 0, 0) + reminders_options_layout.setSpacing(4) + + self.reminders_options_toggle = QToolButton() + self.reminders_options_toggle.setText( + strings._("reminders_webhook_section_title") + ) + self.reminders_options_toggle.setCheckable(True) + self.reminders_options_toggle.setChecked(False) + self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.reminders_options_toggle.setArrowType(Qt.RightArrow) + self.reminders_options_toggle.clicked.connect( + self._on_reminders_options_toggled + ) + + toggle_row = QHBoxLayout() + toggle_row.addWidget(self.reminders_options_toggle) + toggle_row.addStretch() + reminders_options_layout.addLayout(toggle_row) + + # Actual options (labels + QLineEdits) + self.reminders_options_widget = QWidget() + options_form = QFormLayout(self.reminders_options_widget) + options_form.setContentsMargins(0, 0, 0, 0) + options_form.setSpacing(4) + + self.reminders_webhook_url = QLineEdit( + self.current_settings.reminders_webhook_url or "" + ) + self.reminders_webhook_secret = QLineEdit( + self.current_settings.reminders_webhook_secret or "" + ) + self.reminders_webhook_secret.setEchoMode(QLineEdit.Password) + + options_form.addRow( + strings._("reminders_webhook_url_label") + ":", + self.reminders_webhook_url, + ) + options_form.addRow( + strings._("reminders_webhook_secret_label") + ":", + self.reminders_webhook_secret, + ) + + reminders_options_layout.addWidget(self.reminders_options_widget) + + features_layout.addWidget(self.reminders_options_container) + + self.reminders_options_container.setVisible(self.reminders.isChecked()) + self.reminders_options_widget.setVisible(False) + + self.documents = QCheckBox(strings._("enable_documents_feature")) + self.documents.setChecked(self.current_settings.documents) + self.documents.setCursor(Qt.PointingHandCursor) + features_layout.addWidget(self.documents) + layout.addWidget(features_group) + + # --- Invoicing / company profile section ------------------------- + self.invoicing_group = QGroupBox(strings._("invoice_company_profile")) + invoicing_layout = QFormLayout(self.invoicing_group) + + profile = self._db.get_company_profile() or ( + None, + None, + None, + None, + None, + None, + None, + ) + name, address, phone, email, tax_id, payment_details, logo_bytes = profile + + self.company_name_edit = QLineEdit(name or "") + self.company_address_edit = QTextEdit(address or "") + self.company_phone_edit = QLineEdit(phone or "") + self.company_email_edit = QLineEdit(email or "") + self.company_tax_id_edit = QLineEdit(tax_id or "") + self.company_payment_details_edit = QTextEdit() + self.company_payment_details_edit.setPlainText(payment_details or "") + + invoicing_layout.addRow( + strings._("invoice_company_name") + ":", self.company_name_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_address") + ":", self.company_address_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_phone") + ":", self.company_phone_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_email") + ":", self.company_email_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_payment_details") + ":", + self.company_payment_details_edit, + ) + + # Logo picker - store bytes on self._logo_bytes + self._logo_bytes = logo_bytes + logo_row = QHBoxLayout() + self.logo_label = QLabel(strings._("invoice_company_logo_not_set")) + if logo_bytes: + self.logo_label.setText(strings._("invoice_company_logo_set")) + logo_btn = QPushButton(strings._("invoice_company_logo_choose")) + logo_btn.clicked.connect(self._on_choose_logo) + logo_row.addWidget(self.logo_label) + logo_row.addWidget(logo_btn) + invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row) + + # Show/hide this whole block based on invoicing checkbox + self.invoicing_group.setVisible(self.invoicing.isChecked()) + self.invoicing.toggled.connect(self.invoicing_group.setVisible) + + layout.addWidget(self.invoicing_group) + layout.addStretch() return page @@ -305,17 +460,92 @@ class SettingsDialog(QDialog): idle_minutes=self.idle_spin.value(), theme=selected_theme.value, move_todos=self.move_todos.isChecked(), + move_todos_include_weekends=self.move_todos_include_weekends.isChecked(), tags=self.tags.isChecked(), time_log=self.time_log.isChecked(), reminders=self.reminders.isChecked(), + reminders_webhook_url=self.reminders_webhook_url.text().strip() or None, + reminders_webhook_secret=self.reminders_webhook_secret.text().strip() + or None, + documents=self.documents.isChecked(), + invoicing=( + self.invoicing.isChecked() if self.time_log.isChecked() else False + ), locale=self.locale_combobox.currentText(), font_size=self.font_size.value(), ) save_db_config(self._cfg) + + # Save company profile only if invoicing is enabled + if self.invoicing.isChecked() and self.time_log.isChecked(): + self._db.save_company_profile( + name=self.company_name_edit.text().strip() or None, + address=self.company_address_edit.toPlainText().strip() or None, + phone=self.company_phone_edit.text().strip() or None, + email=self.company_email_edit.text().strip() or None, + tax_id=self.company_tax_id_edit.text().strip() or None, + payment_details=self.company_payment_details_edit.toPlainText().strip() + or None, + logo=getattr(self, "_logo_bytes", None), + ) + self.parent().themes.set(selected_theme) self.accept() + def _on_reminders_options_toggled(self, checked: bool) -> None: + """ + Expand/collapse the advanced reminders options (webhook URL/secret). + """ + if checked: + self.reminders_options_toggle.setArrowType(Qt.DownArrow) + self.reminders_options_widget.show() + else: + self.reminders_options_toggle.setArrowType(Qt.RightArrow) + self.reminders_options_widget.hide() + + def _on_reminders_toggled(self, checked: bool) -> None: + """ + Conditionally show reminder webhook options depending + on if the reminders feature is toggled on or off. + """ + if hasattr(self, "reminders_options_container"): + self.reminders_options_container.setVisible(checked) + + # When turning reminders off, also collapse the section + if not checked and hasattr(self, "reminders_options_toggle"): + self.reminders_options_toggle.setChecked(False) + self._on_reminders_options_toggled(False) + + def _on_time_log_toggled(self, checked: bool) -> None: + """ + Enforce 'invoicing depends on time logging'. + """ + if not checked: + # Turn off + disable invoicing if time logging is disabled + self.invoicing.setChecked(False) + self.invoicing.setEnabled(False) + else: + # Let the user enable invoicing when time logging is enabled + self.invoicing.setEnabled(True) + + def _on_choose_logo(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + strings._("invoice_company_logo_choose"), + "", + "Images (*.png *.jpg *.jpeg *.bmp)", + ) + if not path: + return + + try: + with open(path, "rb") as f: + self._logo_bytes = f.read() + self.logo_label.setText(Path(path).name) + except OSError as exc: + QMessageBox.warning(self, strings._("error"), str(exc)) + def _change_key(self): p1 = KeyPrompt( self, diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py index 37b5394..5f58767 100644 --- a/bouquin/statistics_dialog.py +++ b/bouquin/statistics_dialog.py @@ -3,24 +3,24 @@ from __future__ import annotations import datetime as _dt from typing import Dict -from PySide6.QtCore import Qt, QSize, Signal -from PySide6.QtGui import QColor, QPainter, QPen, QBrush +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen from PySide6.QtWidgets import ( + QComboBox, QDialog, - QVBoxLayout, QFormLayout, - QLabel, QGroupBox, QHBoxLayout, - QComboBox, + QLabel, QScrollArea, - QWidget, QSizePolicy, + QVBoxLayout, + QWidget, ) from . import strings from .db import DBManager - +from .settings import load_db_config # ---------- Activity heatmap ---------- @@ -150,7 +150,7 @@ class DateHeatmap(QWidget): fm = painter.fontMetrics() # --- weekday labels on left ------------------------------------- - # Python's weekday(): Monday=0 ... Sunday=6, same as your rows. + # Python's weekday(): Monday=0 ... Sunday=6 weekday_labels = ["M", "T", "W", "T", "F", "S", "S"] for dow in range(7): @@ -215,7 +215,7 @@ class DateHeatmap(QWidget): col = int((x - self._margin_left) // cell_span) # week index row = int((y - self._margin_top) // cell_span) # dow (0..6) - # Only 7 rows (Mon–Sun) + # Only 7 rows (Mon-Sun) if not (0 <= row < 7): return @@ -248,8 +248,9 @@ class StatisticsDialog(QDialog): self._db = db self.setWindowTitle(strings._("statistics")) - self.setMinimumWidth(600) - self.setMinimumHeight(400) + self.setMinimumWidth(650) + self.setMinimumHeight(650) + root = QVBoxLayout(self) ( @@ -263,50 +264,212 @@ class StatisticsDialog(QDialog): page_most_tags, page_most_tags_count, revisions_by_date, + time_minutes_by_date, + total_time_minutes, + day_most_time, + day_most_time_minutes, + project_most_minutes_name, + project_most_minutes, + activity_most_minutes_name, + activity_most_minutes, + reminders_by_date, + total_reminders, + day_most_reminders, + day_most_reminders_count, ) = self._gather_stats() - # --- Numeric summary at the top ---------------------------------- - form = QFormLayout() - root.addLayout(form) + self.cfg = load_db_config() - form.addRow( + # Optional: per-date document counts for the heatmap. + documents_by_date: Dict[_dt.date, int] = {} + total_documents = 0 + date_most_documents: _dt.date | None = None + date_most_documents_count = 0 + + if self.cfg.documents: + try: + documents_by_date = self._db.documents_by_date() or {} + except Exception: + documents_by_date = {} + + if documents_by_date: + total_documents = sum(documents_by_date.values()) + # Choose the date with the highest count, tie-breaking by earliest date. + date_most_documents, date_most_documents_count = sorted( + documents_by_date.items(), + key=lambda item: (-item[1], item[0]), + )[0] + + # For the heatmap + self._documents_by_date = documents_by_date + self._time_by_date = time_minutes_by_date + self._reminders_by_date = reminders_by_date + self._words_by_date = words_by_date + self._revisions_by_date = revisions_by_date + + # ------------------------------------------------------------------ + # Feature groups + # ------------------------------------------------------------------ + + # --- Pages / words / revisions ----------------------------------- + pages_group = QGroupBox(strings._("stats_group_pages")) + pages_form = QFormLayout(pages_group) + + pages_form.addRow( strings._("stats_pages_with_content"), QLabel(str(pages_with_content)), ) - form.addRow( + pages_form.addRow( strings._("stats_total_revisions"), QLabel(str(total_revisions)), ) if page_most_revisions: - form.addRow( + pages_form.addRow( strings._("stats_page_most_revisions"), QLabel(f"{page_most_revisions} ({page_most_revisions_count})"), ) else: - form.addRow(strings._("stats_page_most_revisions"), QLabel("—")) + pages_form.addRow( + strings._("stats_page_most_revisions"), + QLabel("—"), + ) - form.addRow( + pages_form.addRow( strings._("stats_total_words"), QLabel(str(total_words)), ) - # Unique tag names - form.addRow( - strings._("stats_unique_tags"), - QLabel(str(unique_tags)), - ) + root.addWidget(pages_group) - if page_most_tags: - form.addRow( - strings._("stats_page_most_tags"), - QLabel(f"{page_most_tags} ({page_most_tags_count})"), + # --- Tags --------------------------------------------------------- + if self.cfg.tags: + tags_group = QGroupBox(strings._("stats_group_tags")) + tags_form = QFormLayout(tags_group) + + tags_form.addRow( + strings._("stats_unique_tags"), + QLabel(str(unique_tags)), ) - else: - form.addRow(strings._("stats_page_most_tags"), QLabel("—")) - # --- Heatmap with switcher --------------------------------------- - if words_by_date or revisions_by_date: + if page_most_tags: + tags_form.addRow( + strings._("stats_page_most_tags"), + QLabel(f"{page_most_tags} ({page_most_tags_count})"), + ) + else: + tags_form.addRow( + strings._("stats_page_most_tags"), + QLabel("—"), + ) + + root.addWidget(tags_group) + + # --- Documents ---------------------------------------------------- + if self.cfg.documents: + docs_group = QGroupBox(strings._("stats_group_documents")) + docs_form = QFormLayout(docs_group) + + docs_form.addRow( + strings._("stats_total_documents"), + QLabel(str(total_documents)), + ) + + if date_most_documents: + doc_most_label = ( + f"{date_most_documents.isoformat()} ({date_most_documents_count})" + ) + else: + doc_most_label = "—" + + docs_form.addRow( + strings._("stats_date_most_documents"), + QLabel(doc_most_label), + ) + + root.addWidget(docs_group) + + # --- Time logging ------------------------------------------------- + if self.cfg.time_log: + time_group = QGroupBox(strings._("stats_group_time_logging")) + time_form = QFormLayout(time_group) + + total_hours = total_time_minutes / 60.0 if total_time_minutes else 0.0 + time_form.addRow( + strings._("stats_time_total_hours"), + QLabel(f"{total_hours:.2f}h"), + ) + + if day_most_time: + day_hours = ( + day_most_time_minutes / 60.0 if day_most_time_minutes else 0.0 + ) + day_label = f"{day_most_time} ({day_hours:.2f}h)" + else: + day_label = "—" + time_form.addRow( + strings._("stats_time_day_most_hours"), + QLabel(day_label), + ) + + if project_most_minutes_name: + proj_hours = ( + project_most_minutes / 60.0 if project_most_minutes else 0.0 + ) + proj_label = f"{project_most_minutes_name} ({proj_hours:.2f}h)" + else: + proj_label = "—" + time_form.addRow( + strings._("stats_time_project_most_hours"), + QLabel(proj_label), + ) + + if activity_most_minutes_name: + act_hours = ( + activity_most_minutes / 60.0 if activity_most_minutes else 0.0 + ) + act_label = f"{activity_most_minutes_name} ({act_hours:.2f}h)" + else: + act_label = "—" + time_form.addRow( + strings._("stats_time_activity_most_hours"), + QLabel(act_label), + ) + + root.addWidget(time_group) + + # --- Reminders ---------------------------------------------------- + if self.cfg.reminders: + rem_group = QGroupBox(strings._("stats_group_reminders")) + rem_form = QFormLayout(rem_group) + + rem_form.addRow( + strings._("stats_total_reminders"), + QLabel(str(total_reminders)), + ) + + if day_most_reminders: + rem_label = f"{day_most_reminders} ({day_most_reminders_count})" + else: + rem_label = "—" + + rem_form.addRow( + strings._("stats_date_most_reminders"), + QLabel(rem_label), + ) + + root.addWidget(rem_group) + + # ------------------------------------------------------------------ + # Heatmap with metric switcher + # ------------------------------------------------------------------ + if ( + words_by_date + or revisions_by_date + or documents_by_date + or time_minutes_by_date + or reminders_by_date + ): group = QGroupBox(strings._("stats_activity_heatmap")) group_layout = QVBoxLayout(group) @@ -315,14 +478,30 @@ class StatisticsDialog(QDialog): combo_row.addWidget(QLabel(strings._("stats_heatmap_metric"))) self.metric_combo = QComboBox() self.metric_combo.addItem(strings._("stats_metric_words"), "words") - self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions") + self.metric_combo.addItem( + strings._("stats_metric_revisions"), + "revisions", + ) + if documents_by_date: + self.metric_combo.addItem( + strings._("stats_metric_documents"), + "documents", + ) + if self.cfg.time_log and time_minutes_by_date: + self.metric_combo.addItem( + strings._("stats_metric_hours"), + "hours", + ) + if self.cfg.reminders and reminders_by_date: + self.metric_combo.addItem( + strings._("stats_metric_reminders"), + "reminders", + ) combo_row.addWidget(self.metric_combo) combo_row.addStretch(1) group_layout.addLayout(combo_row) self._heatmap = DateHeatmap() - self._words_by_date = words_by_date - self._revisions_by_date = revisions_by_date scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -339,11 +518,19 @@ class StatisticsDialog(QDialog): else: root.addWidget(QLabel(strings._("stats_no_data"))) + self.resize(self.sizeHint().width(), self.sizeHint().height()) + # ---------- internal helpers ---------- def _apply_metric(self, metric: str) -> None: if metric == "revisions": self._heatmap.set_data(self._revisions_by_date) + elif metric == "documents": + self._heatmap.set_data(self._documents_by_date) + elif metric == "hours": + self._heatmap.set_data(self._time_by_date) + elif metric == "reminders": + self._heatmap.set_data(self._reminders_by_date) else: self._heatmap.set_data(self._words_by_date) diff --git a/bouquin/strings.py b/bouquin/strings.py index eff0e18..71e838b 100644 --- a/bouquin/strings.py +++ b/bouquin/strings.py @@ -1,5 +1,5 @@ -from importlib.resources import files import json +from importlib.resources import files # Get list of locales root = files("bouquin") / "locales" diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index a5d12d0..3d81c3a 100644 --- a/bouquin/tag_browser.py +++ b/bouquin/tag_browser.py @@ -1,21 +1,22 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( + QColorDialog, QDialog, - QVBoxLayout, QHBoxLayout, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, QTreeWidget, QTreeWidgetItem, - QPushButton, - QLabel, - QColorDialog, - QMessageBox, - QInputDialog, + QVBoxLayout, ) +from sqlcipher4.dbapi2 import IntegrityError -from .db import DBManager from . import strings -from sqlcipher3.dbapi2 import IntegrityError +from .db import DBManager +from .settings import load_db_config class TagBrowserDialog(QDialog): @@ -25,6 +26,7 @@ class TagBrowserDialog(QDialog): def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None): super().__init__(parent) self._db = db + self.cfg = load_db_config() self.setWindowTitle( strings._("tag_browser_title") + " / " + strings._("manage_tags") ) @@ -38,9 +40,18 @@ class TagBrowserDialog(QDialog): layout.addWidget(instructions) self.tree = QTreeWidget() - self.tree.setHeaderLabels( - [strings._("tag"), strings._("color_hex"), strings._("date")] - ) + if not self.cfg.documents: + self.tree.setHeaderLabels( + [strings._("tag"), strings._("color_hex"), strings._("date")] + ) + else: + self.tree.setHeaderLabels( + [ + strings._("tag"), + strings._("color_hex"), + strings._("page_or_document"), + ] + ) self.tree.setColumnWidth(0, 200) self.tree.setColumnWidth(1, 100) self.tree.itemActivated.connect(self._on_item_activated) @@ -119,6 +130,7 @@ class TagBrowserDialog(QDialog): self.tree.addTopLevelItem(root) + # Pages with this tag pages = self._db.get_pages_for_tag(name) for date_iso, _content in pages: child = QTreeWidgetItem(["", "", date_iso]) @@ -127,6 +139,21 @@ class TagBrowserDialog(QDialog): ) root.addChild(child) + # Documents with this tag + if self.cfg.documents: + docs = self._db.get_documents_for_tag(name) + for doc_id, project_name, file_name in docs: + label = file_name + if project_name: + label = f"{file_name} ({project_name})" + child = QTreeWidgetItem(["", "", label]) + child.setData( + 0, + Qt.ItemDataRole.UserRole, + {"type": "document", "id": doc_id}, + ) + root.addChild(child) + if focus_tag and name.lower() == focus_tag.lower(): focus_item = root @@ -153,12 +180,25 @@ class TagBrowserDialog(QDialog): def _on_item_activated(self, item: QTreeWidgetItem, column: int): data = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(data, dict): - if data.get("type") == "page": + item_type = data.get("type") + + if item_type == "page": date_iso = data.get("date") if date_iso: self.openDateRequested.emit(date_iso) self.accept() + elif item_type == "document": + doc_id = data.get("id") + if doc_id is not None: + self._open_document(int(doc_id), str(data.get("file_name"))) + + def _open_document(self, doc_id: int, file_name: str) -> None: + """Open a tagged document from the list.""" + from bouquin.document_utils import open_document_from_db + + open_document_from_db(self._db, doc_id, file_name, parent_widget=self) + def _add_a_tag(self): """Add a new tag""" diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py index 423bd06..7ac4ad4 100644 --- a/bouquin/tags_widget.py +++ b/bouquin/tags_widget.py @@ -4,16 +4,16 @@ from typing import Optional from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( + QCompleter, QFrame, QHBoxLayout, - QVBoxLayout, - QWidget, - QToolButton, QLabel, QLineEdit, QSizePolicy, QStyle, - QCompleter, + QToolButton, + QVBoxLayout, + QWidget, ) from . import strings diff --git a/bouquin/theme.py b/bouquin/theme.py index 305f249..87b77f9 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -1,11 +1,13 @@ from __future__ import annotations + from dataclasses import dataclass from enum import Enum -from PySide6.QtGui import QPalette, QColor, QGuiApplication -from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget -from PySide6.QtCore import QObject, Signal from weakref import WeakSet +from PySide6.QtCore import QObject, Qt, Signal +from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat +from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget + class Theme(Enum): SYSTEM = "system" @@ -174,6 +176,14 @@ class ThemeManager(QObject): cal.setPalette(app_pal) cal.setStyleSheet("") + # --- Normalise weekend colours on *all* themed calendars ------------- + # Qt's default is red for weekends; we want them to match normal text. + weekday_color = app_pal.windowText().color() + weekend_fmt = QTextCharFormat() + weekend_fmt.setForeground(weekday_color) + cal.setWeekdayTextFormat(Qt.Saturday, weekend_fmt) + cal.setWeekdayTextFormat(Qt.Sunday, weekend_fmt) + cal.update() def _calendar_qss(self, highlight_css: str) -> str: diff --git a/bouquin/time_log.py b/bouquin/time_log.py index a76ccf6..1e4b303 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -2,45 +2,49 @@ from __future__ import annotations import csv import html - from collections import defaultdict +from datetime import datetime from typing import Optional -from sqlcipher3.dbapi2 import IntegrityError -from PySide6.QtCore import Qt, QDate, QUrl -from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout +from PySide6.QtCore import QDate, Qt, QUrl, Signal +from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument from PySide6.QtPrintSupport import QPrinter from PySide6.QtWidgets import ( + QAbstractItemView, + QCalendarWidget, + QComboBox, + QCompleter, + QDateEdit, QDialog, - QFrame, - QVBoxLayout, - QHBoxLayout, - QWidget, + QDialogButtonBox, + QDoubleSpinBox, QFileDialog, QFormLayout, - QLabel, - QComboBox, - QLineEdit, - QDoubleSpinBox, - QPushButton, - QTableWidget, - QTableWidgetItem, - QAbstractItemView, + QFrame, + QHBoxLayout, QHeaderView, - QTabWidget, + QInputDialog, + QLabel, + QLineEdit, QListWidget, QListWidgetItem, - QDateEdit, QMessageBox, - QCompleter, - QToolButton, + QPushButton, QSizePolicy, QStyle, - QInputDialog, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QToolButton, + QVBoxLayout, + QWidget, ) +from sqlcipher4.dbapi2 import IntegrityError -from .db import DBManager from . import strings +from .db import DBManager +from .settings import load_db_config +from .theme import ThemeManager class TimeLogWidget(QFrame): @@ -49,9 +53,18 @@ class TimeLogWidget(QFrame): Shown in the left sidebar above the Tags widget. """ - def __init__(self, db: DBManager, parent: QWidget | None = None): + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + themes: ThemeManager | None = None, + parent: QWidget | None = None, + ): super().__init__(parent) self._db = db + self.cfg = load_db_config() + self._themes = themes self._current_date: Optional[str] = None self.setFrameShape(QFrame.StyledPanel) @@ -66,6 +79,21 @@ class TimeLogWidget(QFrame): self.toggle_btn.setArrowType(Qt.RightArrow) self.toggle_btn.clicked.connect(self._on_toggle) + self.log_btn = QToolButton() + self.log_btn.setText("➕") + self.log_btn.setToolTip(strings._("add_time_entry")) + self.log_btn.setAutoRaise(True) + self.log_btn.clicked.connect(self._open_dialog_log_only) + + self.report_btn = QToolButton() + self.report_btn.setText("📈") + self.report_btn.setAutoRaise(True) + self.report_btn.clicked.connect(self._on_run_report) + if self.cfg.invoicing: + self.report_btn.setToolTip(strings._("reporting_and_invoicing")) + else: + self.report_btn.setToolTip(strings._("reporting")) + self.open_btn = QToolButton() self.open_btn.setIcon( self.style().standardIcon(QStyle.SP_FileDialogDetailedView) @@ -78,6 +106,8 @@ class TimeLogWidget(QFrame): header.setContentsMargins(0, 0, 0, 0) header.addWidget(self.toggle_btn) header.addStretch(1) + header.addWidget(self.log_btn) + header.addWidget(self.report_btn) header.addWidget(self.open_btn) # Body: simple summary label for the day @@ -89,6 +119,8 @@ class TimeLogWidget(QFrame): self.summary_label = QLabel(strings._("time_log_no_entries")) self.summary_label.setWordWrap(True) self.body_layout.addWidget(self.summary_label) + # Optional embedded Pomodoro timer widget lives underneath the summary. + self._pomodoro_widget: Optional[QWidget] = None self.body.setVisible(False) main = QVBoxLayout(self) @@ -104,8 +136,40 @@ class TimeLogWidget(QFrame): if not self.toggle_btn.isChecked(): self.summary_label.setText(strings._("time_log_collapsed_hint")) + def show_pomodoro_widget(self, widget: QWidget) -> None: + """Embed Pomodoro timer widget in the body area.""" + if self._pomodoro_widget is not None: + self.body_layout.removeWidget(self._pomodoro_widget) + self._pomodoro_widget.deleteLater() + + self._pomodoro_widget = widget + self.body_layout.addWidget(widget) + widget.show() + + # Ensure the body is visible so the timer is obvious + self.body.setVisible(True) + self.toggle_btn.setChecked(True) + self.toggle_btn.setArrowType(Qt.DownArrow) + + def clear_pomodoro_widget(self) -> None: + """Remove any embedded Pomodoro timer widget.""" + if self._pomodoro_widget is None: + return + + self.body_layout.removeWidget(self._pomodoro_widget) + self._pomodoro_widget.deleteLater() + self._pomodoro_widget = None + # ----- internals --------------------------------------------------- + def _on_run_report(self) -> None: + dlg = TimeReportDialog(self._db, self) + + # Bubble the remindersChanged signal further up + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + def _on_toggle(self, checked: bool) -> None: self.body.setVisible(checked) self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) @@ -155,7 +219,27 @@ class TimeLogWidget(QFrame): if not self._current_date: return - dlg = TimeLogDialog(self._db, self._current_date, self) + dlg = TimeLogDialog(self._db, self._current_date, self, themes=self._themes) + dlg.exec() + + # Always refresh summary + header totals + self._reload_summary() + + if not self.toggle_btn.isChecked(): + self.summary_label.setText(strings._("time_log_collapsed_hint")) + + def _open_dialog_log_only(self) -> None: + if not self._current_date: + return + + dlg = TimeLogDialog( + self._db, + self._current_date, + self, + True, + themes=self._themes, + close_after_add=True, + ) dlg.exec() # Always refresh summary + header totals @@ -168,30 +252,49 @@ class TimeLogWidget(QFrame): class TimeLogDialog(QDialog): """ Per-day time log dialog. - - Lets you: - 1) choose a project - 2) enter an activity (free-text with autocomplete) - 3) enter time in decimal hours (0.25 = 15 min, 0.17 ≈ 10 min) - 4) manage entries for this date """ - def __init__(self, db: DBManager, date_iso: str, parent=None): + def __init__( + self, + db: DBManager, + date_iso: str, + parent=None, + log_entry_only: bool | None = False, + themes: ThemeManager | None = None, + close_after_add: bool | None = False, + ): super().__init__(parent) self._db = db + self._themes = themes self._date_iso = date_iso self._current_entry_id: Optional[int] = None - # Guard flag used when repopulating the table so we don’t treat + self.cfg = load_db_config() + # Guard flag used when repopulating the table so we don't treat # programmatic item changes as user edits. self._reloading_entries: bool = False - self.setWindowTitle(strings._("time_log_for").format(date=date_iso)) + self.total_hours = 0 + + self.close_after_add = close_after_add + + self.setWindowTitle(strings._("for").format(date=date_iso)) self.resize(900, 600) root = QVBoxLayout(self) - # --- Top: date label - root.addWidget(QLabel(strings._("time_log_date_label").format(date=date_iso))) + # --- Top: date label + change-date button + date_row = QHBoxLayout() + + self.date_label = QLabel(strings._("date_label").format(date=date_iso)) + date_row.addWidget(self.date_label) + + date_row.addStretch(1) + + self.change_date_btn = QPushButton(strings._("change_date")) + self.change_date_btn.clicked.connect(self._on_change_date_clicked) + date_row.addWidget(self.change_date_btn) + + root.addLayout(date_row) # --- Project / activity / hours row form = QFormLayout() @@ -225,6 +328,7 @@ class TimeLogDialog(QDialog): self.hours_spin.setRange(0.0, 24.0) self.hours_spin.setDecimals(2) self.hours_spin.setSingleStep(0.25) + self.hours_spin.setValue(0.25) form.addRow(strings._("hours"), self.hours_spin) root.addLayout(form) @@ -238,24 +342,21 @@ class TimeLogDialog(QDialog): self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.setEnabled(False) - self.report_btn = QPushButton("&" + strings._("run_report")) - self.report_btn.clicked.connect(self._on_run_report) - btn_row.addStretch(1) btn_row.addWidget(self.add_update_btn) btn_row.addWidget(self.delete_btn) - btn_row.addWidget(self.report_btn) root.addLayout(btn_row) # --- Table of entries for this date self.table = QTableWidget() - self.table.setColumnCount(4) + self.table.setColumnCount(5) self.table.setHorizontalHeaderLabels( [ strings._("project"), strings._("activity"), strings._("note"), strings._("hours"), + strings._("created_at"), ] ) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) @@ -264,6 +365,7 @@ class TimeLogDialog(QDialog): self.table.horizontalHeader().setSectionResizeMode( 3, QHeaderView.ResizeToContents ) + self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.itemSelectionChanged.connect(self._on_row_selected) @@ -271,8 +373,19 @@ class TimeLogDialog(QDialog): self.table.itemChanged.connect(self._on_table_item_changed) root.addWidget(self.table, 1) - # --- Close button + # --- Total time, Reporting and Close button close_row = QHBoxLayout() + self.total_label = QLabel( + strings._("time_log_total_hours").format(hours=self.total_hours) + ) + if self.cfg.invoicing: + self.report_btn = QPushButton("&" + strings._("reporting_and_invoicing")) + else: + self.report_btn = QPushButton("&" + strings._("reporting")) + self.report_btn.clicked.connect(self._on_run_report) + + close_row.addWidget(self.total_label) + close_row.addWidget(self.report_btn) close_row.addStretch(1) close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) @@ -284,6 +397,12 @@ class TimeLogDialog(QDialog): self._reload_activities() self._reload_entries() + if log_entry_only: + self.delete_btn.hide() + self.report_btn.hide() + self.table.hide() + self.resize(self.sizeHint().width(), self.sizeHint().height()) + # ----- Data loading ------------------------------------------------ def _reload_projects(self) -> None: @@ -315,11 +434,16 @@ class TimeLogDialog(QDialog): note = r[7] or "" minutes = r[6] hours = minutes / 60.0 + created_at = r[8] + ca_utc = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + ca_local = ca_utc.astimezone() + created = f"{ca_local.day} {ca_local.strftime('%b %Y, %H:%M%p')}" item_proj = QTableWidgetItem(project_name) item_act = QTableWidgetItem(activity_name) item_note = QTableWidgetItem(note) item_hours = QTableWidgetItem(f"{hours:.2f}") + item_created_at = QTableWidgetItem(created) # store the entry id on the first column item_proj.setData(Qt.ItemDataRole.UserRole, entry_id) @@ -328,15 +452,68 @@ class TimeLogDialog(QDialog): self.table.setItem(row_idx, 1, item_act) self.table.setItem(row_idx, 2, item_note) self.table.setItem(row_idx, 3, item_hours) + self.table.setItem(row_idx, 4, item_created_at) finally: self._reloading_entries = False + total_minutes = sum(r[6] for r in rows) + self.total_hours = total_minutes / 60.0 + self.total_label.setText( + strings._("time_log_total_hours").format(hours=self.total_hours) + ) + self._current_entry_id = None self.delete_btn.setEnabled(False) self.add_update_btn.setText("&" + strings._("add_time_entry")) # ----- Actions ----------------------------------------------------- + def _on_change_date_clicked(self) -> None: + """Let the user choose a different date and reload entries.""" + + # Start from current dialog date; fall back to today if invalid + current_qdate = QDate.fromString(self._date_iso, Qt.ISODate) + if not current_qdate.isValid(): + current_qdate = QDate.currentDate() + + dlg = QDialog(self) + dlg.setWindowTitle(strings._("select_date_title")) + + layout = QVBoxLayout(dlg) + + calendar = QCalendarWidget(dlg) + calendar.setSelectedDate(current_qdate) + layout.addWidget(calendar) + # Apply the same theming as the main sidebar calendar + if self._themes is not None: + self._themes.register_calendar(calendar) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg + ) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + layout.addWidget(buttons) + + if dlg.exec() != QDialog.Accepted: + return + + new_qdate = calendar.selectedDate() + new_iso = new_qdate.toString(Qt.ISODate) + if new_iso == self._date_iso: + # No change + return + + # Update state + self._date_iso = new_iso + + # Update window title and header label + self.setWindowTitle(strings._("for").format(date=new_iso)) + self.date_label.setText(strings._("date_label").format(date=new_iso)) + + # Reload entries for the newly selected date + self._reload_entries() + def _ensure_project_id(self) -> Optional[int]: """Get selected project_id from combo.""" idx = self.project_combo.currentIndex() @@ -384,6 +561,8 @@ class TimeLogDialog(QDialog): ) self._reload_entries() + if self.close_after_add: + self.close() def _on_row_selected(self) -> None: items = self.table.selectedItems() @@ -440,7 +619,7 @@ class TimeLogDialog(QDialog): hours_item = self.table.item(row, 3) if proj_item is None or act_item is None or hours_item is None: - # Incomplete row – nothing to do. + # Incomplete row - nothing to do. return # Recover the entry id from the hidden UserRole on the project cell @@ -649,7 +828,7 @@ class TimeCodeManagerDialog(QDialog): try: self._db.add_project(name) except ValueError: - # Empty / invalid name – nothing to do, but be defensive + # Empty / invalid name - nothing to do, but be defensive QMessageBox.warning( self, strings._("invalid_project_title"), @@ -827,17 +1006,21 @@ class TimeReportDialog(QDialog): Shows decimal hours per time period. """ + remindersChanged = Signal() + def __init__(self, db: DBManager, parent=None): super().__init__(parent) self._db = db + self.cfg = load_db_config() # state for last run - self._last_rows: list[tuple[str, str, int]] = [] + self._last_rows: list[tuple[str, str, str, str, int]] = [] self._last_total_minutes: int = 0 self._last_project_name: str = "" self._last_start: str = "" self._last_end: str = "" self._last_gran_label: str = "" + self._last_time_logs: list = [] self.setWindowTitle(strings._("time_log_report")) self.resize(600, 400) @@ -845,30 +1028,63 @@ class TimeReportDialog(QDialog): root = QVBoxLayout(self) form = QFormLayout() + + self.invoice_btn = QPushButton(strings._("create_invoice")) + self.invoice_btn.clicked.connect(self._on_create_invoice) + + self.manage_invoices_btn = QPushButton(strings._("manage_invoices")) + self.manage_invoices_btn.clicked.connect(self._on_manage_invoices) + # Project self.project_combo = QComboBox() + self.project_combo.addItem(strings._("all_projects"), None) + self.project_combo.currentIndexChanged.connect( + self._update_invoice_button_state + ) + self._update_invoice_button_state() for proj_id, name in self._db.list_projects(): self.project_combo.addItem(name, proj_id) form.addRow(strings._("project"), self.project_combo) # Date range today = QDate.currentDate() - self.from_date = QDateEdit(today.addDays(-7)) + start_of_month = QDate(today.year(), today.month(), 1) + + self.range_preset = QComboBox() + self.range_preset.addItem(strings._("custom_range"), "custom") + self.range_preset.addItem(strings._("today"), "today") + self.range_preset.addItem(strings._("last_week"), "last_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_year"), "this_year") + self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed) + + self.from_date = QDateEdit(start_of_month) self.from_date.setCalendarPopup(True) self.to_date = QDateEdit(today) self.to_date.setCalendarPopup(True) range_row = QHBoxLayout() + range_row.addWidget(self.range_preset) range_row.addWidget(self.from_date) range_row.addWidget(QLabel("—")) range_row.addWidget(self.to_date) + form.addRow(strings._("date_range"), range_row) + # After widgets are created, choose default preset + idx = self.range_preset.findData("this_month") + if idx != -1: + self.range_preset.setCurrentIndex(idx) + # Granularity self.granularity = QComboBox() + self.granularity.addItem(strings._("dont_group"), "none") self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_week"), "week") self.granularity.addItem(strings._("by_month"), "month") + self.granularity.addItem(strings._("by_activity"), "activity") form.addRow(strings._("group_by"), self.granularity) root.addLayout(form) @@ -888,13 +1104,18 @@ class TimeReportDialog(QDialog): run_row.addWidget(run_btn) run_row.addWidget(export_btn) run_row.addWidget(pdf_btn) + # Only show invoicing if the feature is enabled + if getattr(self._db.cfg, "invoicing", False): + run_row.addWidget(self.invoice_btn) + run_row.addWidget(self.manage_invoices_btn) root.addLayout(run_row) # Table self.table = QTableWidget() - self.table.setColumnCount(4) + self.table.setColumnCount(5) self.table.setHorizontalHeaderLabels( [ + strings._("project"), strings._("time_period"), strings._("activity"), strings._("note"), @@ -904,8 +1125,9 @@ class TimeReportDialog(QDialog): self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode( - 3, QHeaderView.ResizeToContents + 4, QHeaderView.ResizeToContents ) root.addWidget(self.table, 1) @@ -921,39 +1143,190 @@ class TimeReportDialog(QDialog): close_row.addWidget(close_btn) root.addLayout(close_row) + def _configure_table_columns(self, granularity: str) -> None: + if granularity == "none": + # Show notes + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("time_period"), + strings._("activity"), + strings._("note"), + strings._("hours"), + ] + ) + # project, period, activity, note stretch; hours shrink + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + elif granularity == "activity": + # Grouped by activity only: no time period, no note column + self.table.setColumnCount(3) + self.table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("activity"), + strings._("hours"), + ] + ) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + else: + # Grouped: no note column + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("time_period"), + strings._("activity"), + strings._("hours"), + ] + ) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + + def _on_range_preset_changed(self, index: int) -> None: + preset = self.range_preset.currentData() + today = QDate.currentDate() + + if preset == "today": + start = end = today + + elif preset == "this_week": + # Monday-based week, clamp end to today + # dayOfWeek(): Monday=1, Sunday=7 + start = today.addDays(1 - today.dayOfWeek()) + end = today + + elif preset == "last_week": + # Compute Monday-Sunday of the previous week (Monday-based weeks) + # 1. Monday of this week: + start_of_this_week = today.addDays(1 - today.dayOfWeek()) + # 2. Last week is 7 days before that: + start = start_of_this_week.addDays(-7) # last week's Monday + 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": + start = QDate(today.year(), today.month(), 1) + end = today + + elif preset == "this_year": + start = QDate(today.year(), 1, 1) + end = today + + else: # "custom" - leave fields as user-set + return + + # Update date edits without triggering anything else + self.from_date.blockSignals(True) + self.to_date.blockSignals(True) + self.from_date.setDate(start) + self.to_date.setDate(end) + self.from_date.blockSignals(False) + self.to_date.blockSignals(False) + def _run_report(self): idx = self.project_combo.currentIndex() if idx < 0: return - proj_id = int(self.project_combo.itemData(idx)) + proj_data = self.project_combo.itemData(idx) start = self.from_date.date().toString("yyyy-MM-dd") end = self.to_date.date().toString("yyyy-MM-dd") gran = self.granularity.currentData() - # Keep human-friendly copies for PDF header - self._last_project_name = self.project_combo.currentText() self._last_start = start self._last_end = end self._last_gran_label = self.granularity.currentText() + self._last_gran = gran # remember which grouping was used - rows = self._db.time_report(proj_id, start, end, gran) + self._configure_table_columns(gran) - self._last_rows = rows - self._last_total_minutes = sum(r[3] for r in rows) + rows_for_table: list[tuple[str, str, str, str, int]] = [] - self.table.setRowCount(len(rows)) - for i, (time_period, activity_name, note, minutes) in enumerate(rows): + if proj_data is None: + # All projects + self._last_all_projects = True + self._last_time_logs = [] + self._last_project_name = strings._("all_projects") + rows_for_table = self._db.time_report_all(start, end, gran) + else: + self._last_all_projects = False + proj_id = int(proj_data) + self._last_time_logs = self._db.time_logs_for_range(proj_id, start, end) + project_name = self.project_combo.currentText() + self._last_project_name = project_name + + per_project_rows = self._db.time_report(proj_id, start, end, gran) + # Adapt DB rows (period, activity, note, minutes) → include project + rows_for_table = [ + (project_name, period, activity, note, minutes) + for (period, activity, note, minutes) in per_project_rows + ] + + # Store for export + self._last_rows = rows_for_table + self._last_total_minutes = sum(r[4] for r in rows_for_table) + + # Per-project totals + self._last_project_totals = defaultdict(int) + for project, _period, _activity, _note, minutes in rows_for_table: + self._last_project_totals[project] += minutes + + # Populate table + self.table.setRowCount(len(rows_for_table)) + for i, (project, time_period, activity_name, note, minutes) in enumerate( + rows_for_table + ): hrs = minutes / 60.0 - self.table.setItem(i, 0, QTableWidgetItem(time_period)) - self.table.setItem(i, 1, QTableWidgetItem(activity_name)) - self.table.setItem(i, 2, QTableWidgetItem(note)) - self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) + if self._last_gran == "activity": + self.table.setItem(i, 0, QTableWidgetItem(project)) + self.table.setItem(i, 1, QTableWidgetItem(activity_name)) + self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}")) + else: + self.table.setItem(i, 0, QTableWidgetItem(project)) + self.table.setItem(i, 1, QTableWidgetItem(time_period)) + self.table.setItem(i, 2, QTableWidgetItem(activity_name)) + if self._last_gran == "none": + self.table.setItem(i, 3, QTableWidgetItem(note or "")) + self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}")) + else: + # no note column + self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) + + # Summary label - include per-project totals when in "all projects" mode total_hours = self._last_total_minutes / 60.0 - self.total_label.setText( - strings._("time_report_total").format(hours=total_hours) - ) + if self._last_all_projects: + per_project_bits = [ + f"{proj}: {mins/60.0:.2f}h" + for proj, mins in sorted(self._last_project_totals.items()) + ] + self.total_label.setText( + strings._("time_report_total").format(hours=total_hours) + + " (" + + ", ".join(per_project_bits) + + ")" + ) + else: + self.total_label.setText( + strings._("time_report_total").format(hours=total_hours) + ) def _export_csv(self): if not self._last_rows: @@ -972,30 +1345,52 @@ class TimeReportDialog(QDialog): ) if not filename: return + if not filename.endswith(".csv"): + filename = f"{filename}.csv" try: with open(filename, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) + gran = getattr(self, "_last_gran", "day") + show_note = gran == "none" + show_period = gran != "activity" + # Header - writer.writerow( - [ - strings._("time_period"), - strings._("activity"), - strings._("note"), - strings._("hours"), - ] - ) + header: list[str] = [strings._("project")] + if show_period: + header.append(strings._("time_period")) + header.append(strings._("activity")) + if show_note: + header.append(strings._("note")) + header.append(strings._("hours")) + writer.writerow(header) # Data rows - for time_period, activity_name, note, minutes in self._last_rows: + for ( + project, + time_period, + activity_name, + note, + minutes, + ) in self._last_rows: hours = minutes / 60.0 - writer.writerow([time_period, activity_name, note, f"{hours:.2f}"]) + row: list[str] = [project] + if show_period: + row.append(time_period) + row.append(activity_name) + if show_note: + row.append(note or "") + row.append(f"{hours:.2f}") + writer.writerow(row) # Blank line + total total_hours = self._last_total_minutes / 60.0 writer.writerow([]) - writer.writerow([strings._("total"), "", f"{total_hours:.2f}"]) + total_row = [""] * len(header) + total_row[0] = strings._("total") + total_row[-1] = f"{total_hours:.2f}" + writer.writerow(total_row) except OSError as exc: QMessageBox.warning( self, @@ -1020,18 +1415,23 @@ class TimeReportDialog(QDialog): ) if not filename: return + if not filename.endswith(".pdf"): + filename = f"{filename}.pdf" - # ---------- Build chart image (hours per period) ---------- - per_period_minutes: dict[str, int] = defaultdict(int) - for period, _activity, note, minutes in self._last_rows: - per_period_minutes[period] += minutes + # ---------- Build chart image ---------- + # Default: hours per time period. If grouped by activity: hours per activity. + gran = getattr(self, "_last_gran", "day") + per_bucket_minutes: dict[str, int] = defaultdict(int) + for _project, period, activity, _note, minutes in self._last_rows: + bucket = activity if gran == "activity" else period + per_bucket_minutes[bucket] += minutes - periods = sorted(per_period_minutes.keys()) + buckets = sorted(per_bucket_minutes.keys()) chart_w, chart_h = 800, 220 chart = QImage(chart_w, chart_h, QImage.Format_ARGB32) chart.fill(Qt.white) - if periods: + if buckets: painter = QPainter(chart) try: painter.setRenderHint(QPainter.Antialiasing, True) @@ -1059,9 +1459,9 @@ class TimeReportDialog(QDialog): # Border painter.drawRect(left, top, width, height) - max_hours = max(per_period_minutes[p] for p in periods) / 60.0 + max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0 if max_hours > 0: - n = len(periods) + n = len(buckets) bar_spacing = width / max(1, n) bar_width = bar_spacing * 0.6 @@ -1086,8 +1486,8 @@ class TimeReportDialog(QDialog): painter.setBrush(QColor(80, 140, 200)) painter.setPen(Qt.NoPen) - for i, period in enumerate(periods): - hours = per_period_minutes[period] / 60.0 + for i, label in enumerate(buckets): + hours = per_bucket_minutes[label] / 60.0 bar_h = int((hours / max_hours) * (height - 10)) if bar_h <= 0: continue # pragma: no cover @@ -1100,7 +1500,7 @@ class TimeReportDialog(QDialog): # X labels after bars, in black painter.setPen(Qt.black) - for i, period in enumerate(periods): + for i, label in enumerate(buckets): x_center = left + bar_spacing * (i + 0.5) x = int(x_center - bar_width / 2) painter.drawText( @@ -1109,7 +1509,7 @@ class TimeReportDialog(QDialog): int(bar_width), 20, Qt.AlignHCenter | Qt.AlignTop, - period, + label, ) finally: painter.end() @@ -1118,22 +1518,53 @@ class TimeReportDialog(QDialog): project = html.escape(self._last_project_name or "") start = html.escape(self._last_start or "") end = html.escape(self._last_end or "") - gran = html.escape(self._last_gran_label or "") + gran_key = getattr(self, "_last_gran", "day") + gran_label = html.escape(self._last_gran_label or "") total_hours = self._last_total_minutes / 60.0 - # Table rows (period, activity, hours) + # Table rows row_html_parts: list[str] = [] - for period, activity, note, minutes in self._last_rows: - hours = minutes / 60.0 - row_html_parts.append( + if gran_key == "activity": + for project, _period, activity, _note, minutes in self._last_rows: + hours = minutes / 60.0 + row_html_parts.append( + "" + f"{html.escape(project)}" + f"{html.escape(activity)}" + f"{hours:.2f}" + "" + ) + else: + for project, period, activity, _note, minutes in self._last_rows: + hours = minutes / 60.0 + row_html_parts.append( + "" + f"{html.escape(project)}" + f"{html.escape(period)}" + f"{html.escape(activity)}" + f"{hours:.2f}" + "" + ) + rows_html = "\n".join(row_html_parts) + + if gran_key == "activity": + table_header_html = ( "" - f"{html.escape(period)}" - f"{html.escape(activity)}" - f"{hours:.2f}" + f"{html.escape(strings._('project'))}" + f"{html.escape(strings._('activity'))}" + f"{html.escape(strings._('hours'))}" + "" + ) + else: + table_header_html = ( + "" + f"{html.escape(strings._('project'))}" + f"{html.escape(strings._('time_period'))}" + f"{html.escape(strings._('activity'))}" + f"{html.escape(strings._('hours'))}" "" ) - rows_html = "\n".join(row_html_parts) html_doc = f""" @@ -1180,15 +1611,11 @@ class TimeReportDialog(QDialog):

{html.escape(strings._("time_log_report_title").format(project=project))}

{html.escape(strings._("time_log_report_meta").format( - start=start, end=end, granularity=gran))} + start=start, end=end, granularity=gran_label))}

- - - - - + {table_header_html} {rows_html}
{html.escape(strings._("time_period"))}{html.escape(strings._("activity"))}{html.escape(strings._("hours"))}

{html.escape(strings._("time_report_total").format(hours=total_hours))}

@@ -1215,3 +1642,55 @@ class TimeReportDialog(QDialog): strings._("export_pdf_error_title"), strings._("export_pdf_error_message").format(error=str(exc)), ) + + def _update_invoice_button_state(self) -> None: + data = self.project_combo.currentData() + if data is not None: + self.invoice_btn.show() + else: + self.invoice_btn.hide() + + def _on_manage_invoices(self) -> None: + from .invoices import InvoicesDialog + + dlg = InvoicesDialog(self._db, parent=self) + + # When the dialog says "reminders changed", forward that outward + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + + def _on_create_invoice(self) -> None: + idx = self.project_combo.currentIndex() + if idx < 0: + return + + project_id_data = self.project_combo.itemData(idx) + if project_id_data is None: + # Currently invoices are per-project, not cross-project + QMessageBox.information( + self, + strings._("invoice_project_required_title"), + strings._("invoice_project_required_message"), + ) + return + + proj_id = int(project_id_data) + + # Ensure we have a recent run to base this on + if not self._last_time_logs: + QMessageBox.information( + self, + strings._("invoice_need_report_title"), + strings._("invoice_need_report_message"), + ) + return + + start = self.from_date.date().toString("yyyy-MM-dd") + end = self.to_date.date().toString("yyyy-MM-dd") + + from .invoices import InvoiceDialog + + dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self) + dlg.remindersChanged.connect(self.remindersChanged.emit) + dlg.exec() diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 8873ffd..8e8c4bf 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence from PySide6.QtWidgets import QToolBar from . import strings @@ -20,6 +20,7 @@ class ToolBar(QToolBar): insertImageRequested = Signal() alarmRequested = Signal() timerRequested = Signal() + documentsRequested = Signal() fontSizeLargerRequested = Signal() fontSizeSmallerRequested = Signal() @@ -95,8 +96,9 @@ class ToolBar(QToolBar): self.actNumbers.setToolTip(strings._("toolbar_numbered_list")) self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) - self.actCheckboxes = QAction("☐", self) + self.actCheckboxes = QAction("☑", self) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) + self.actCheckboxes.setCheckable(True) self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images @@ -106,7 +108,7 @@ class ToolBar(QToolBar): self.actInsertImg.triggered.connect(self.insertImageRequested) # History button - self.actHistory = QAction("🔁", self) + self.actHistory = QAction("↺", self) self.actHistory.setToolTip(strings._("history")) self.actHistory.triggered.connect(self.historyRequested) @@ -118,23 +120,21 @@ class ToolBar(QToolBar): # Focus timer self.actTimer = QAction("⌛", self) self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) + self.actTimer.setCheckable(True) self.actTimer.triggered.connect(self.timerRequested) - # Set exclusive buttons in QActionGroups + # Documents + self.actDocuments = QAction("📁", self) + self.actDocuments.setToolTip(strings._("toolbar_documents")) + self.actDocuments.triggered.connect(self.documentsRequested) + # Headings are mutually exclusive (like radio buttons) self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) - for a in ( - self.actBold, - self.actItalic, - self.actStrike, - self.actH1, - self.actH2, - self.actH3, - self.actNormal, - ): + for a in (self.actH1, self.actH2, self.actH3, self.actNormal): a.setCheckable(True) a.setActionGroup(self.grpHeadings) + # List types are mutually exclusive self.grpLists = QActionGroup(self) self.grpLists.setExclusive(True) for a in (self.actBullets, self.actNumbers, self.actCheckboxes): @@ -159,6 +159,7 @@ class ToolBar(QToolBar): self.actInsertImg, self.actAlarm, self.actTimer, + self.actDocuments, self.actHistory, ] ) @@ -182,12 +183,13 @@ class ToolBar(QToolBar): # Lists self._style_letter_button(self.actBullets, "•") self._style_letter_button(self.actNumbers, "1.") - self._style_letter_button(self.actCheckboxes, "☐") + self._style_letter_button(self.actCheckboxes, "☑") self._style_letter_button(self.actAlarm, "⏰") self._style_letter_button(self.actTimer, "⌛") + self._style_letter_button(self.actDocuments, "📁") # History - self._style_letter_button(self.actHistory, "🔁") + self._style_letter_button(self.actHistory, "↺") def _style_letter_button( self, diff --git a/bouquin/version_check.py b/bouquin/version_check.py index b2010d5..be89695 100644 --- a/bouquin/version_check.py +++ b/bouquin/version_check.py @@ -5,23 +5,17 @@ import os import re import subprocess # nosec import tempfile +from importlib.resources import files from pathlib import Path import requests -from importlib.resources import files from PySide6.QtCore import QStandardPaths, Qt -from PySide6.QtWidgets import ( - QApplication, - QMessageBox, - QWidget, - QProgressDialog, -) -from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication +from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtSvg import QSvgRenderer +from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget -from .settings import APP_NAME from . import strings - +from .settings import APP_NAME # Where to fetch the latest version string from VERSION_URL = "https://mig5.net/bouquin/version.txt" @@ -101,6 +95,9 @@ class VersionChecker: """ 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 ---------- # def show_version_dialog(self) -> None: @@ -120,8 +117,8 @@ class VersionChecker: check_button = box.addButton( strings._("check_for_updates"), QMessageBox.ActionRole ) - box.addButton(QMessageBox.Close) + box.addButton(QMessageBox.Close) box.exec() if box.clickedButton() is check_button: @@ -165,21 +162,32 @@ class VersionChecker: return # Newer version is available - reply = QMessageBox.question( - self._parent, - strings._("update"), - ( - strings._("there_is_a_new_version_available") - + available_raw - + "\n\n" - + strings._("download_the_appimage") - ), - QMessageBox.Yes | QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return - self._download_and_verify_appimage(available_raw) + 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 ---------- # def _download_file( diff --git a/debian/bouquin.desktop b/debian/bouquin.desktop new file mode 100644 index 0000000..ba622e5 --- /dev/null +++ b/debian/bouquin.desktop @@ -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 diff --git a/debian/bouquin.install b/debian/bouquin.install new file mode 100644 index 0000000..33cffb7 --- /dev/null +++ b/debian/bouquin.install @@ -0,0 +1,2 @@ +debian/bouquin.desktop usr/share/applications/ +bouquin/icons/bouquin.svg usr/share/icons/hicolor/scalable/apps/ diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..c49574f --- /dev/null +++ b/debian/changelog @@ -0,0 +1,45 @@ +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 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 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 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 Tue, 23 Dec 2025 17:30:00 +1100 + +bouquin (0.7.5) unstable; urgency=medium + + * Add libxcb-cursor0 dependency + + -- Miguel Jacq Sun, 21 Dec 2025 15:30:00 +1100 + +bouquin (0.7.4) unstable; urgency=medium + + * Initial build for Debian + + -- Miguel Jacq Sun, 21 Dec 2025 13:00:00 +1100 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..e787e41 --- /dev/null +++ b/debian/control @@ -0,0 +1,39 @@ +Source: bouquin +Section: office +Priority: optional +Maintainer: Miguel Jacq +Rules-Requires-Root: no +Build-Depends: + debhelper-compat (= 13), + dh-python, + pybuild-plugin-pyproject, + python3-poetry-core, + python3-all, + 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 +Standards-Version: 4.6.2 +Homepage: https://git.mig5.net/mig5/bouquin + +Package: bouquin +Architecture: amd64 +Depends: ${misc:Depends}, ${python3:Depends}, + python3-pyside6.qtwidgets, + python3-pyside6.qtcore, + python3-pyside6.qtgui, + python3-pyside6.qtsvg, + python3-pyside6.qtprintsupport, + python3-sqlcipher4, + python3-requests, + python3-markdown, + libxcb-cursor0, + fonts-noto-core +Description: A simple, opinionated notebook application written in Python, PyQt and SQLCipher. + Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher. diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..c5ff83b --- /dev/null +++ b/debian/rules @@ -0,0 +1,6 @@ +#!/usr/bin/make -f +export PYBUILD_NAME=bouquin +export PYBUILD_SYSTEM=pyproject + +%: + dh $@ --with python3 --buildsystem=pybuild diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/source/options b/debian/source/options new file mode 100644 index 0000000..c32a8c1 --- /dev/null +++ b/debian/source/options @@ -0,0 +1,6 @@ +tar-ignore = ".git" +tar-ignore = ".venv" +tar-ignore = "__pycache__" +tar-ignore = ".pytest_cache" +tar-ignore = "dist" +tar-ignore = "build" diff --git a/poetry.lock b/poetry.lock index b968699..0cf8448 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,26 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "bouquin-sqlcipher4" +version = "4.12.0" +description = "DB-API 2.0 interface for SQLCipher 4.x, for use with Bouquin" +optional = false +python-versions = "<4.0,>=3.10" +files = [ + {file = "bouquin_sqlcipher4-4.12.0-cp313-cp313-manylinux_2_41_x86_64.whl", hash = "sha256:75e8bcee58301f0d5d81d293e7cf930f1cab172984c0edb82328d271f03ed172"}, + {file = "bouquin_sqlcipher4-4.12.0-py3-none-any.whl", hash = "sha256:140c009acfcbbeb315d6eb3bd4aaedffd37176ce1cec04341c7cc3c5330744ec"}, + {file = "bouquin_sqlcipher4-4.12.0.tar.gz", hash = "sha256:2e7bc0a5fe84b26474fde99e9ad0d96e91eb984313c6695ae1d48daba3c96f07"}, +] + [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] @@ -146,103 +158,103 @@ files = [ [[package]] name = "coverage" -version = "7.12.0" +version = "7.13.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, - {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, - {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, - {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, - {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, - {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, - {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, - {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, - {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, - {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, - {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, - {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, - {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, - {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, - {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, - {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, - {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, - {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, - {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, - {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, - {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, - {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, - {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, - {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, + {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"}, + {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"}, + {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.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"}, + {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.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"}, + {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"}, + {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"}, + {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"}, + {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"}, + {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.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"}, + {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.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"}, + {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"}, + {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"}, + {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"}, + {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"}, + {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"}, + {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.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"}, + {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.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"}, + {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"}, + {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"}, + {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"}, + {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"}, + {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"}, + {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.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"}, + {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.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"}, + {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"}, + {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"}, + {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"}, + {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"}, + {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"}, + {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.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"}, + {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.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"}, + {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"}, + {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"}, + {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"}, + {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"}, + {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"}, + {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.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"}, + {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.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"}, + {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"}, + {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"}, + {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"}, + {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"}, + {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"}, + {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.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"}, + {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.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"}, + {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"}, + {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"}, + {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"}, + {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"}, + {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"}, ] [package.dependencies] @@ -267,13 +279,13 @@ xdg-desktop-portal = ["jeepney"] [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] @@ -309,28 +321,28 @@ files = [ [[package]] name = "markdown" -version = "3.10" +version = "3.10.1" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.10" files = [ - {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, - {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, + {file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"}, + {file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"}, ] [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"] [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] @@ -380,57 +392,57 @@ tomli = {version = "*", markers = "python_version < \"3.11\""} [[package]] name = "pyside6" -version = "6.10.0" +version = "6.10.1" description = "Python bindings for the Qt cross-platform application and UI framework" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "pyside6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"}, - {file = "pyside6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01"}, - {file = "pyside6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820"}, - {file = "pyside6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f"}, - {file = "pyside6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339"}, + {file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"}, + {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"}, + {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"}, + {file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"}, + {file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"}, ] [package.dependencies] -PySide6_Addons = "6.10.0" -PySide6_Essentials = "6.10.0" -shiboken6 = "6.10.0" +PySide6_Addons = "6.10.1" +PySide6_Essentials = "6.10.1" +shiboken6 = "6.10.1" [[package]] name = "pyside6-addons" -version = "6.10.0" +version = "6.10.1" description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "pyside6_addons-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"}, ] [package.dependencies] -PySide6_Essentials = "6.10.0" -shiboken6 = "6.10.0" +PySide6_Essentials = "6.10.1" +shiboken6 = "6.10.1" [[package]] name = "pyside6-essentials" -version = "6.10.0" +version = "6.10.1" description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"}, ] [package.dependencies] -shiboken6 = "6.10.0" +shiboken6 = "6.10.1" [[package]] name = "pytest" @@ -534,198 +546,72 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "shiboken6" -version = "6.10.0" +version = "6.10.1" description = "Python/C++ bindings helper module" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543"}, - {file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"}, - {file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148"}, - {file = "shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717"}, - {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, -] - -[[package]] -name = "sqlcipher3-wheels" -version = "0.5.5.post0" -description = "DB-API 2.0 interface for SQLCipher 3.x" -optional = false -python-versions = "*" -files = [ - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:836cff85673ab9bdfe0f3e2bc38aefddb5f3a4c0de397b92f83546bb94ea38aa"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3fde9076a8810d19044f65fdfeeee5a9d044176ce91adc2258c8b18cb945474"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ad3ccb27f3fc9260b1bcebfd33fc5af1c2a1bf6a50e8e1bf7991d492458b438"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94bb8ab8cf7ae3dc0d51dcb75bf242ae4bd2f18549bfc975fd696c181e9ea8ca"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0bf0a169b480615ea2021e7266e1154990762216d1fd8105b93d1fee336f49"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79cc1af145345e9bd625c961e4efc8fc6c6eefcaec90fbcf1c6b981492c08031"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d8b9f1c6d283acc5a0da16574c0f7690ba5b14cb5935f3078ccf8404a530075"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:952a23069a149a192a5eb8a9e552772b38c012825238175bc810f445a3aa8000"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24f1a57a4aa18d9ecd38cfce69dd06e58cfb521151a8316e18183e603e7108f4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6568c64adc55f9882ba36c11a446810bd5d4c03796aab8ecb9024f3bca9eb2cd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26c2b58d2f2a9dd23ad4c310fb6c0f0c82ca4f36a0d4177a70f0efeb332798ee"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46827ffc7e705c5ecdf23ec69f56dd55b20857dc3c3c4893e360de8a38b4e708"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4033bbe2f0342936736ce7b8b2626f532509315576d5376764b410deae181cad"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win32.whl", hash = "sha256:bfb26dbba945860427bd3f82c132e6d2ef409baa062d315b952dd5a930b25870"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_amd64.whl", hash = "sha256:168270b8fb295314aa4ee9f74435ceee42207bd16fe908f646a829b3a9daedad"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_arm64.whl", hash = "sha256:1f1bb2c4c6defa812eb0238055a283cf3c2f400e956a57c25cf65cbdbac6783f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:55d557376a90f14baf0f35e917f8644c3a8cf48897947fcd7ecf51d490dd689f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1739264a971901088fe1670befb8a8a707543186c8eecc58158ca287e309b2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:920e19345b6c5335b61e9fbed2625f96dbc3b0269ab5120baeae2c9288f0be01"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462734f6d0703f863f5968419d229de75bbf2a829f762bfb257b6df2355f977"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c397305b65612da76f254c692ff866571aa98fd3817ed0e40fce6d568d704966"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf3467fe834075b58215c50f9db7355ef86a73d256ac8ba5fffb8c946741a5dc"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a76d783c095a3c95185757c418e3bad3eab69cbf986970d422cce5431e84d7f5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf8d78895ee0f04dc525942a1f40796fa7c3d7d7fb36c987f55c243ce34192d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d583a10dbe9a1752968788c2d6438461ec7068608ceaa72e6468d80727c3152e"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8c3156b39bb8f24dfbe17a49126d8fa404b00c01d7aa84e64a2293db1dae1a38"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:15c3cf77b31973aa008174518fa439d8620a093964c2d9edcb8d23d543345839"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5743db0f3492359c2ab3a56b6bed00ecba193f2c75c74e8e3d78a45f1eb7c95"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc40a213a3633f19c96432304a16f0cff7c4aeca1a3d2042d4be36e576e64a70"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win32.whl", hash = "sha256:433456ce962ae50887d6428d55bad46e5748a2cdd3d036180eb0bcdbe8bae9f9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_amd64.whl", hash = "sha256:ca4332b1890cc4f80587be8bd529e20475bd3291e07f11115b1fc773947b264a"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_arm64.whl", hash = "sha256:a4634300cb2440baf17a78d6481d10902de4a2a6518f83a5ab2fe081e6b20b42"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8df43c11d767c6aac5cc300c1957356a9fd1b25f1946891003cf20a0146241"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:797653f08ecffcef2948dfd907fb20dab402d9efde6217c96befafc236e96c5b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dca428fde0d1a522473f766465324d6539d324f219f4f7c159a3eb0d4f9983c5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e97922240a04b44637eabf39f86d243fe61fe7db1bd2ad219eb4053158f263"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8d3a366e52a6732b1ccff14f9ca77ecbee53abfce87c417bf05d4301484584f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dce28a2431260251d7acf253ea1950983e48dfec64245126b39a770d5a88f507"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea64cce27152cae453c353038336bda0dc1f885e5e8e30b5cd28b8c9b498bbeb"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02d9e6120a496f083c525efc34408d4f2ca282da05bebcc967a0aa1e12a0d6ca"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:875cfc61bbf694b8327c2485e5ed40573e8b715f4e583502f12c51c8d5a92dd5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0e9ed3ff9c00ba3888f8dbc0c7c84377ef66f21c5f4ac373fc690dcf5e9bd594"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08ad6d767502429e497b6d03b5ae11e43e896d36f05ac8e60c12d8f124378bc1"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecdefdcf7ab8cb14b3147a59af83e8e3e5e3bed46fc43ab86a657f5c306a83d2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75ffe5677407bf20a32486eb6facfbb07a353ce7c9aecc9fefd4e9d3275605d7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win32.whl", hash = "sha256:97b6c6556b430b5c0dff53e8f709f90ba53294c2a3958a8c38f573c6dbf467d9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_amd64.whl", hash = "sha256:248cae211991f1ffb3a885a1223e62abee70c6c208fc2224a8dbf73d4e825baa"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_arm64.whl", hash = "sha256:5a49fc3a859a53fd025dc2fa08410292d267018897fc63198de6c92860fa8be7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f798e1591fa5ba14d9da08a54f18e7000fd74973cde12eb862a3928a69b7996"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:369011b8dc68741313a8b77bb68a70b76052390eaf819e4cd6e13d0acbea602d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5658990462a728c1f4b472d23c1f7f577eb2bced5bbbf7c2b45158b8340484bd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907a166e4e563da67fe22c480244459512e32d3e00853b3f1e6fdb9da6aa2da6"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ba972405e9f16042e37cbcb4fef47248339c8410847390d41268bd45dc3f6ca"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5f1b380fe3b869f701f9d2a8c09e9edfeec261573c8bb009a3336717260d65"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2978c9d964ad643b0bc61e19d8d608a515ff270e9a2f1f6c5aeb8ad56255def"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29457feb1516a2542aa7676e6d03bf913191690bf1ed6c82353782a380388508"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:021af568414741a45bfca41d682da64916a7873498a31d896cc34ad540939c6b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7471d12eef489eea60cc3806bae0690f6d0733f7aea371a3ad5c5642f3bc04a9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f5faf683db9ade192a870e28b1eeeec2eb0aeca18e88fa52195a4639974c7cb"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:64fe6bac67d2b807b91102eef41d7f389e008ded80575ba597b454e05f9522e5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63ef1e23eb9729e79783e2ab4b19f64276c155ba0a85ba1eeb21e248c6ce0956"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win32.whl", hash = "sha256:4eafde00138dd3753085b4f5eab0811247207b699de862589f886a94ad3628a4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_amd64.whl", hash = "sha256:909864f275460646d0bf5475dc42e9c2cadd79cd40805ea32fe9a69300595301"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_arm64.whl", hash = "sha256:a831846cc6b01d7f99576efbf797b61a269dffa6885f530b6957573ce1a24f10"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:aaad03f3eb099401306fead806908c85b923064a9da7a99c33a72c3b7c9143bf"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d4b4cde184d9e354152fd1867bcbaee468529865703ad863840a0ce4eb60cd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac3ad3cf9e1d0f08e8d22a65115368f2b22b9e96403fa644e146e1221c65c454"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c4d3dca4acdbc5543bb00aee1e0715db797aa2819db5b7ca3feed3ab3366ff"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf7026851ea60d63c1a88f62439da78b68bfbfec192c781255e3cfb34b6efc12"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ae4a83678c41c2cdbf3c2b18fc46be32225260c7b4807087bdb43793ee90fa"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:678c67d60b35eced29777fe9398b6e6a6638156f143c80662a0c7c99ce115be7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:9830af5aef2c17686d6e7c78c20b92c7b57c9d7921a03e4c549b48fe0e98c5c0"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:08651e17c868a6a22124b6ab71e939a5bb4737e0535f381ce35077dc8116c4b3"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:b58b4c944d7ce20cd7b426ae8834433b5b1152391960960b778b37803f0ffc1c"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2b38818468ddb0c8fc4b172031d65ced3be22ba82360c45909a0546b2857d3e4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win32.whl", hash = "sha256:91d1f2284d13b68f213d05b51cd6242f4cfa065d291af6f353f9cbedd28d8c0d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:a5c724f95366ba7a2895147b0690609b6774384fa8621aa46a66cf332e4b612f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e37b33263fad4accdba45c8566566d45fc01f47fd4afa3e265df9e0e3581d4f4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f1e29495bc968e37352c315d23a38462d7e77fcfa1597d005d17ed93f9f3103"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad8a774f545eb5471587e0389fca4f855f36d58901c65547796d59fc13aee458"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a5a0251e4ceca127b26d18f0965b7f3c820a2dd2c51c25015c819300fd5859"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e40535f0f57e8b605e1cbce1399c96bcd5ab99e60992d2c7669c689d0cbe5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e04e1dd62d019cde936d18fcd21361f6c4695e0e73fd6dc509c4ccd9446d26d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2df377e3d04f5c427c9f79ef95bdf0b982bde76c1dbd4441f83268f3f1993a53"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:5f3cb8db19e7462ccb2e34b56feaccb2aac675ad8f77e28f8222b3e7c47d1f92"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:b40860b3e6d6108473836a29d3600e1b335192637e16e9421b43b58147ced3c1"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:ca4fd9e8b669e81bb74479bde61ee475d7a6832d667e2ce80e6136ddd7a0fedd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:771e74a22f48c40b6402d0ca1d569ced5a796e118d4472da388744b5aa0ebd3f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win32.whl", hash = "sha256:4589bfca18ecf787598262327f7329fe1f4fc2655c04899d84451562e2099a57"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:f646ab958a601bad8925a876f5aa68bdf0ec3584630143ed1ad8e9df4e447044"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dfb8106a05af1cb1eadeea996171b52c80f18851972e49ffe91539e4fc064b0f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b8b9b77a898b721fc634858fc43552119d3d303485adc6f28f3e76f028d5ea04"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c65efab1a0ab14f75314039694ac35d3181a5c8cf43584bd537b36caf2a6ccf9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b450eee7e201c48aae58e2d45ef5d309a19cd49952cfb58d546fefbeef0a100"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8f0997202d7628c4312f0398122bdc5ada7fa79939d248652af40d9da689ef8"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae69bef7628236d426e408fb14a40f0027bac1658a06efd29549b26ba369372"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef48e874bcc3ebf623672ec99f9aaa7b8a4f62fb270e33dad6db3739ea111086"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9006dc1a73e2b2a53421aa72decbcff08cb109f67a20f7d15a64ab140e0a1d2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:96c07a345740fa71c0d8fc5fa7ea182ee24f62ebbf33d4d10c8c72d866dc332d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:85a78af6f6e782e0df36f93c9e7c2dd568204f60a2ea55025c21d1837dea95ec"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:19eadc55bf69f9e9799a808cdcfc6657cf30511cb32849235d555adfa048a99f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:377e8ad3bb3c17c43f860b570fd15e048246ade92babc9b310f2c417500aca57"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f8e07aec529d6fa31516201c524b0cfac108a9a6044a148f236291aae7991195"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win32.whl", hash = "sha256:703ab55b77b1c1ebb80eb0b27574a8eadf109739d252de7f646dc41cb82f1a65"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win_amd64.whl", hash = "sha256:b4f4b2e019c6d1ad33d5fc3163d31d0f731a073a5a52cdfae7b85408548ce342"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a84e3b098a29b8c298b01291cf8bc850a507ca45507d43674a84a8d33b7595b2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9905b580cfdbd6945e44d81332483deace167d33e956ffae5c4b27eddeb676e7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c403a7418631dc7185ef8053acc765101f4f64cc0bf50d1bc44ae7d40fc28e"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d63f89bf28de4ce82a7c324275ce733bf31eb29ec1121e48261af89b5b7f30b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc6dc782f5be4883279079c79fa88578258a0fd24651f6d69b0f4be2716f7d7e"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743c177822c60e66c5c9579b4f384bd98e60fd4a2abe0eacdec0af4747d925bc"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5eeb87220e4d2abf6faad1ecb3b3ee88c4d9caad6cf2ce4c0a73a91c4c7ad9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9318b814363b4bc062e54852ea62f58b69e7da9e51211afd6c55e9170e1ae9a0"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7a1f58a2614f2ad9fcb4822f6da56313cbb88309880512bf5d01bd3d9142b87"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ae9427ddde670f605c84f704c12d840628467cc0f0a8e9ce6489577eef6a0479"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:be75ae311433254af3c6fe6eb68bf80ac6ef547ec0cf4594f5b301a044682186"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:73e1438437bbe67d453e2908b90b17b357a992a9ac0011ad20af1ea7c2b4cd58"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:393c0a9af080c1c1a0801cca9448eff3633dafc1a7934fdce58a8d1c15d8bd2b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win32.whl", hash = "sha256:13a79fc8e9b69bf6d70e7fa5a53bd42fab83dc3e6e93da7fa82871ec40874e43"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_amd64.whl", hash = "sha256:b1648708e5bf599b4455bf330faa4777c3963669acb2f3fa25240123a01f8b2b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_arm64.whl", hash = "sha256:4c3dd2f54bdd518b90e28b07c31cdfe34c8bd182c5107a30a9c2ef9569cd6cf9"}, - {file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"}, + {file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"}, + {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"}, + {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"}, + {file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"}, + {file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"}, ] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {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.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {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.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {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.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {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.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {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.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {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.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {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.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {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.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {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.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {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.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {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]] @@ -741,22 +627,22 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.14" -content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df" +content-hash = "0ce61476b8c35f864d395ab3a4ee4645a1ad2e16aa800cba5e77e5bfee23d6a6" diff --git a/pyproject.toml b/pyproject.toml index ce8e44a..0eef382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.5.2" +version = "0.8.3" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" @@ -12,9 +12,9 @@ include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/Not [tool.poetry.dependencies] python = ">=3.10,<3.14" pyside6 = ">=6.8.1,<7.0.0" -sqlcipher3-wheels = "^0.5.5.post0" -requests = "^2.32.5" -markdown = "^3.10" +bouquin-sqlcipher4 = "^4.12.0" +requests = "^2.32.3" +markdown = "^3.7" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" diff --git a/release.sh b/release.sh index a7e9c28..0d3945e 100755 --- a/release.sh +++ b/release.sh @@ -2,18 +2,117 @@ set -eo pipefail -rm -rf dist +# Parse the args +while getopts "v:" OPTION +do + case $OPTION in + v) + VERSION=$OPTARG + ;; + ?) + usage + exit + ;; + esac +done + +if [[ -z "${VERSION}" ]]; then + echo "You forgot to pass -v [version]!" + exit 1 +fi + +set +e +sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml + +git add pyproject.toml +git commit -m "Bump to ${VERSION}" +git push origin main + +set -e + +# Clean caches etc +filedust -y . # Publish to Pypi poetry build poetry publish # Make AppImage -sudo apt-get install libfuse-dev +sudo apt-get -y install libfuse-dev poetry run pyproject-appimage mv Bouquin.AppImage dist/ # Sign packages for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done -echo "Don't forget to update version string on remote server." +# Deb stuff +DISTS=( + debian:trixie +) + +for dist in ${DISTS[@]}; do + release=$(echo ${dist} | cut -d: -f2) + mkdir -p dist/${release} + + docker build -f Dockerfile.debbuild -t bouquin-deb:${release} \ + --no-cache \ + --progress=plain \ + --build-arg BASE_IMAGE=${dist} . + + docker run --rm \ + -e SUITE="${release}" \ + -v "$PWD":/src \ + -v "$PWD/dist/${release}":/out \ + bouquin-deb:${release} + + debfile=$(ls -1 dist/${release}/*.deb) + reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}" +done + + +# 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" diff --git a/rpm/bouquin.spec b/rpm/bouquin.spec new file mode 100644 index 0000000..d50d461 --- /dev/null +++ b/rpm/bouquin.spec @@ -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 - %{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 - %{version}-%{release} +- Add ability to delete an invoice via 'Manage Invoices' dialog +* Fri Dec 26 2025 Miguel Jacq - %{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 - %{version}-%{release} +- Initial RPM packaging for Fedora 42 diff --git a/screenshots/screenshot.png b/screenshots/screenshot.png index a375c45..a5bc5e9 100644 Binary files a/screenshots/screenshot.png and b/screenshots/screenshot.png differ diff --git a/tests/conftest.py b/tests/conftest.py index 658b7e6..4058d77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,3 +58,65 @@ def fresh_db(tmp_db_cfg): assert ok, "DB connect() should succeed" yield db db.close() + + +@pytest.fixture(autouse=True) +def _stub_code_block_editor_dialog(monkeypatch): + """ + In tests, replace the interactive CodeBlockEditorDialog with a tiny stub + that never shows a real QDialog and never blocks on exec(). + """ + import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog + + class _TestCodeBlockEditorDialog: + def __init__( + self, code: str, language: str | None, parent=None, allow_delete=False + ): + # Simulate what the real dialog would “start with” + self._code = code + self._language = language + + def exec(self) -> int: + # Pretend the user clicked OK immediately. + # (If you prefer “Cancel by default”, return Rejected instead.) + return QDialog.DialogCode.Accepted + + def code(self) -> str: + # In tests we just return the initial code unchanged. + return self._code + + def language(self) -> str | None: + # Ditto for language. + return self._language + + # MarkdownEditor imported CodeBlockEditorDialog into its own module, + # so patch that name – everything in MarkdownEditor will use this stub. + monkeypatch.setattr( + markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog + ) + + +# --- Freeze Qt time helper (for alarm parsing tests) --- +@pytest.fixture +def freeze_qt_time(monkeypatch): + """Freeze QDateTime.currentDateTime/QTime.currentTime to midday today. + + This avoids flakiness when tests run close to midnight, so that + QTime.currentTime().addSecs(3600) is still the same calendar day. + """ + import bouquin.main_window as _mwmod + from PySide6.QtCore import QDate, QDateTime, QTime + + today = QDate.currentDate() + fixed_time = QTime(12, 0) + fixed_dt = QDateTime(today, fixed_time) + + # Patch the *imported* Qt symbols that main_window uses + monkeypatch.setattr( + _mwmod.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt)) + ) + monkeypatch.setattr( + _mwmod.QTime, "currentTime", staticmethod(lambda: QTime(fixed_time)) + ) + yield diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py index 8d773e9..df839fd 100644 --- a/tests/test_bug_report_dialog.py +++ b/tests/test_bug_report_dialog.py @@ -1,8 +1,8 @@ import bouquin.bug_report_dialog as bugmod -from bouquin.bug_report_dialog import BugReportDialog from bouquin import strings -from PySide6.QtWidgets import QMessageBox +from bouquin.bug_report_dialog import BugReportDialog from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QMessageBox def test_bug_report_truncates_text_to_max_chars(qtbot): diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py new file mode 100644 index 0000000..1ced14c --- /dev/null +++ b/tests/test_code_block_editor_dialog.py @@ -0,0 +1,364 @@ +from bouquin import strings +from bouquin.code_block_editor_dialog import ( + CodeBlockEditorDialog, + CodeEditorWithLineNumbers, +) +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QFont, QPaintEvent, QTextCursor +from PySide6.QtWidgets import QPushButton + + +def _find_button_by_text(widget, text): + for btn in widget.findChildren(QPushButton): + if text.lower() in btn.text().lower(): + return btn + return None + + +def test_code_block_dialog_delete_flow(qtbot): + dlg = CodeBlockEditorDialog("print(1)", "python", allow_delete=True) + qtbot.addWidget(dlg) + delete_txt = strings._("delete_code_block") + delete_btn = _find_button_by_text(dlg, delete_txt) + assert delete_btn is not None + assert not dlg.was_deleted() + with qtbot.waitSignal(dlg.finished, timeout=2000): + delete_btn.click() + assert dlg.was_deleted() + + +def test_code_block_dialog_language_and_code(qtbot): + dlg = CodeBlockEditorDialog("x = 1", "not-a-lang", allow_delete=False) + qtbot.addWidget(dlg) + delete_txt = strings._("delete_code_block") + assert _find_button_by_text(dlg, delete_txt) is None + assert dlg.code() == "x = 1" + assert dlg.language() is None + + +def test_line_number_area_size_hint(qtbot, app): + """Test _LineNumberArea.sizeHint() method.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + line_area = editor._line_number_area + size_hint = line_area.sizeHint() + + # Should return a QSize with width from editor + assert isinstance(size_hint, QSize) + assert size_hint.width() > 0 + assert size_hint.height() == 0 + + +def test_line_number_area_paint_event(qtbot, app): + """Test _LineNumberArea.paintEvent() method.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("Line 1\nLine 2\nLine 3") + editor.show() + + # Trigger a paint event on the line number area + line_area = editor._line_number_area + paint_event = QPaintEvent(QRect(0, 0, line_area.width(), line_area.height())) + line_area.paintEvent(paint_event) + + # Should not crash + + +def test_line_number_font_pixel_size_fallback(qtbot, app): + """Test _line_number_font() with pixel-sized font.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + # Set a pixel-sized font (pointSize will be -1) + font = QFont() + font.setPixelSize(14) + editor.setFont(font) + + # Get line number font - should use the fallback + line_font = editor._line_number_font() + + # Should have calculated a size + assert line_font.pointSizeF() > 0 or line_font.pixelSize() > 0 + + +def test_code_editor_resize_event(qtbot, app): + """Test CodeEditorWithLineNumbers.resizeEvent() method.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.show() + + # Resize the editor + editor.resize(400, 300) + + # Line number area should be repositioned + line_area = editor._line_number_area + assert line_area.geometry().width() > 0 + assert line_area.geometry().height() == editor.contentsRect().height() + + +def test_code_editor_update_with_scroll(qtbot, app): + """Test _update_line_number_area with dy (scroll) parameter.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + # Add enough text to enable scrolling + text = "\n".join([f"Line {i}" for i in range(100)]) + editor.setPlainText(text) + editor.show() + + # Trigger update with scroll offset + rect = QRect(0, 0, 100, 100) + editor._update_line_number_area(rect, dy=10) + + # Should not crash + + +def test_code_editor_update_without_scroll(qtbot, app): + """Test _update_line_number_area without scroll (dy=0).""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("Line 1\nLine 2") + editor.show() + + # Trigger update without scroll + rect = QRect(0, 0, 100, 100) + editor._update_line_number_area(rect, dy=0) + + # Should not crash + + +def test_code_editor_update_contains_viewport(qtbot, app): + """Test _update_line_number_area when rect contains viewport.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("Test") + editor.show() + + # Trigger update with rect that contains viewport + viewport_rect = editor.viewport().rect() + editor._update_line_number_area(viewport_rect, dy=0) + + # Should trigger width update (covers line 82) + + +def test_line_number_area_paint_with_multiple_blocks(qtbot, app): + """Test line_number_area_paint_event with multiple text blocks.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + # Add multiple lines + text = "\n".join([f"Line {i}" for i in range(20)]) + editor.setPlainText(text) + editor.show() + + # Force a paint event + line_area = editor._line_number_area + rect = QRect(0, 0, line_area.width(), line_area.height()) + paint_event = QPaintEvent(rect) + + # This should exercise the painting loop + editor.line_number_area_paint_event(paint_event) + + # Should not crash + + +def test_line_number_area_paint_with_long_file(qtbot, app): + """Test line_number_area_paint_event with many lines.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + # Add 1000+ lines to test digit calculation and painting + text = "\n".join([f"Line {i}" for i in range(1000)]) + editor.setPlainText(text) + editor.show() + + # Trigger paint event + line_area = editor._line_number_area + paint_event = QPaintEvent(line_area.rect()) + editor.line_number_area_paint_event(paint_event) + + # Line number width should accommodate 4 digits + width = editor.line_number_area_width() + assert width > 30 # Should be wider for 4-digit numbers + + +def test_code_block_editor_dialog_with_delete(qtbot, app): + """Test CodeBlockEditorDialog with allow_delete=True.""" + dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=True) + qtbot.addWidget(dialog) + + # Should have delete button functionality + assert hasattr(dialog, "_delete_requested") + assert dialog._delete_requested is False + + # Simulate delete click + dialog._on_delete_clicked() + + assert dialog._delete_requested is True + assert dialog.was_deleted() is True + + +def test_code_block_editor_dialog_without_delete(qtbot, app): + """Test CodeBlockEditorDialog with allow_delete=False.""" + dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=False) + qtbot.addWidget(dialog) + + # Should not have been deleted + assert dialog.was_deleted() is False + + +def test_code_block_editor_dialog_language_selection(qtbot, app): + """Test language selection in dialog.""" + dialog = CodeBlockEditorDialog("test", "javascript") + qtbot.addWidget(dialog) + + # Should have selected javascript + assert dialog.language() == "javascript" + + # Change language + dialog._lang_combo.setCurrentText("python") + assert dialog.language() == "python" + + # Empty language + dialog._lang_combo.setCurrentText("") + assert dialog.language() is None + + +def test_code_block_editor_dialog_code_retrieval(qtbot, app): + """Test getting code from dialog.""" + original_code = "def foo():\n pass" + dialog = CodeBlockEditorDialog(original_code, None) + qtbot.addWidget(dialog) + + # Should return the code + assert dialog.code() == original_code + + # Modify code + new_code = "def bar():\n return 42" + dialog._code_edit.setPlainText(new_code) + assert dialog.code() == new_code + + +def test_code_editor_with_empty_text(qtbot, app): + """Test editor with no text.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.show() + + # Should still paint line numbers + line_area = editor._line_number_area + paint_event = QPaintEvent(line_area.rect()) + editor.line_number_area_paint_event(paint_event) + + # Should not crash + + +def test_code_editor_block_count_changed(qtbot, app): + """Test that block count changes trigger width updates.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + initial_width = editor.line_number_area_width() + + # Add lots of lines (should require more digits) + text = "\n".join([f"Line {i}" for i in range(1000)]) + editor.setPlainText(text) + + new_width = editor.line_number_area_width() + + # Width should increase for more digits + assert new_width > initial_width + + +def test_code_editor_cursor_position_changed(qtbot, app): + """Test that cursor position changes update line number area.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("Line 1\nLine 2\nLine 3") + editor.show() + + # Move cursor + cursor = editor.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + editor.setTextCursor(cursor) + + # Should trigger line number area update (via signal connection) + # Just verify it doesn't crash + + +def test_line_number_area_width_calculation(qtbot, app): + """Test line number area width calculation with various block counts.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + + # Test with 1 line (should use minimum 2 digits) + editor.setPlainText("One line") + width_1 = editor.line_number_area_width() + assert width_1 > 0 + + # Test with 10 lines (2 digits) + editor.setPlainText("\n".join(["Line"] * 10)) + width_10 = editor.line_number_area_width() + assert width_10 >= width_1 + + # Test with 100 lines (3 digits) + editor.setPlainText("\n".join(["Line"] * 100)) + width_100 = editor.line_number_area_width() + assert width_100 > width_10 + + +def test_code_editor_viewport_margins(qtbot, app): + """Test that viewport margins are set correctly.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("Test") + editor.show() + + # Left margin should equal line number area width + margins = editor.viewportMargins() + line_width = editor.line_number_area_width() + + assert margins.left() == line_width + assert margins.top() == 0 + assert margins.right() == 0 + assert margins.bottom() == 0 + + +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() == "" diff --git a/tests/test_code_highlighter.py b/tests/test_code_highlighter.py index 145e156..57ab8e7 100644 --- a/tests/test_code_highlighter.py +++ b/tests/test_code_highlighter.py @@ -1,5 +1,5 @@ -from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata -from PySide6.QtGui import QTextCharFormat, QFont +from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter +from PySide6.QtGui import QFont, QTextCharFormat def test_get_language_patterns_python(app): diff --git a/tests/test_db.py b/tests/test_db.py index 7896c98..48eff24 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,10 +1,12 @@ -import pytest -import json, csv +import csv import datetime as dt -from sqlcipher3 import dbapi2 as sqlite -from bouquin.db import DBManager +import json from datetime import date, timedelta +import pytest +from bouquin.db import DBManager +from sqlcipher4 import dbapi2 as sqlite + def _today(): return dt.date.today().isoformat() @@ -61,8 +63,10 @@ def test_dates_with_content_and_search(fresh_db): assert _today() in dates and _yesterday() in dates and _tomorrow() in dates hits = list(fresh_db.search_entries("alpha")) - assert any(d == _today() for d, _ in hits) - assert any(d == _tomorrow() for d, _ in hits) + # search_entries now returns (kind, key, title, text, aux) + page_dates = [key for (kind, key, _title, _text, _aux) in hits if kind == "page"] + assert _today() in page_dates + assert _tomorrow() in page_dates def test_get_all_entries_and_export(fresh_db, tmp_path): @@ -369,7 +373,7 @@ def test_db_gather_stats_empty_database(fresh_db): """Test gather_stats on empty database.""" stats = fresh_db.gather_stats() - assert len(stats) == 10 + assert len(stats) == 22 ( pages_with_content, total_revisions, @@ -381,6 +385,18 @@ def test_db_gather_stats_empty_database(fresh_db): page_most_tags, page_most_tags_count, revisions_by_date, + time_minutes_by_date, + total_time_minutes, + day_most_time, + day_most_time_minutes, + project_most_minutes_name, + project_most_minutes, + activity_most_minutes_name, + activity_most_minutes, + reminders_by_date, + total_reminders, + day_most_reminders, + day_most_reminders_count, ) = stats assert pages_with_content == 0 @@ -417,6 +433,7 @@ def test_db_gather_stats_with_content(fresh_db): page_most_tags, page_most_tags_count, revisions_by_date, + *_rest, ) = stats assert pages_with_content == 2 @@ -433,7 +450,7 @@ def test_db_gather_stats_word_counting(fresh_db): fresh_db.save_new_version("2024-01-01", "one two three four five", "test") stats = fresh_db.gather_stats() - _, _, _, _, words_by_date, total_words, _, _, _, _ = stats + _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats assert total_words == 5 @@ -459,7 +476,7 @@ def test_db_gather_stats_with_tags(fresh_db): fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag stats = fresh_db.gather_stats() - _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats + _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats assert unique_tags == 3 assert page_most_tags == "2024-01-01" @@ -475,7 +492,7 @@ def test_db_gather_stats_revisions_by_date(fresh_db): fresh_db.save_new_version("2024-01-02", "Fourth", "v1") stats = fresh_db.gather_stats() - _, _, _, _, _, _, _, _, _, revisions_by_date = stats + _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats assert date(2024, 1, 1) in revisions_by_date assert revisions_by_date[date(2024, 1, 1)] == 3 @@ -490,7 +507,7 @@ def test_db_gather_stats_handles_malformed_dates(fresh_db): fresh_db.save_new_version("2024-01-15", "Test", "v1") stats = fresh_db.gather_stats() - _, _, _, _, _, _, _, _, _, revisions_by_date = stats + _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats # Should have parsed the date correctly assert date(2024, 1, 15) in revisions_by_date @@ -503,7 +520,7 @@ def test_db_gather_stats_current_version_only(fresh_db): fresh_db.save_new_version("2024-01-01", "one two three four five", "v2") stats = fresh_db.gather_stats() - _, _, _, _, words_by_date, total_words, _, _, _, _ = stats + _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats # Should count words from current version (5 words), not old version assert total_words == 5 @@ -515,7 +532,7 @@ def test_db_gather_stats_no_tags(fresh_db): fresh_db.save_new_version("2024-01-01", "No tags here", "test") stats = fresh_db.gather_stats() - _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats + _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats assert unique_tags == 0 assert page_most_tags is None diff --git a/tests/test_document_utils.py b/tests/test_document_utils.py new file mode 100644 index 0000000..e1301df --- /dev/null +++ b/tests/test_document_utils.py @@ -0,0 +1,289 @@ +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QMessageBox, QWidget + + +def test_open_document_from_db_success(qtbot, app, fresh_db): + """Test successfully opening a document.""" + # Import here to avoid circular import issues + from bouquin.document_utils import open_document_from_db + + # Add a project and document + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content for document") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + # Mock QDesktopServices.openUrl + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + # Call the function + success = open_document_from_db( + fresh_db, doc_id, doc_path.name, parent_widget=None + ) + + # Verify success + assert success is True + + # Verify openUrl was called with a QUrl + assert mock_open.called + args = mock_open.call_args[0] + assert isinstance(args[0], QUrl) + + # Verify the URL points to a local file + url_string = args[0].toString() + assert url_string.startswith("file://") + assert "bouquin_doc_" in url_string + assert doc_path.suffix in url_string + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db): + """Test opening a document with a parent widget provided.""" + from bouquin.document_utils import open_document_from_db + + # Create a parent widget + parent = QWidget() + qtbot.addWidget(parent) + + # Add a project and document + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".pdf")) + doc_path.write_text("PDF content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + success = open_document_from_db( + fresh_db, doc_id, doc_path.name, parent_widget=parent + ) + + assert success is True + assert mock_open.called + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db): + """Test opening a non-existent document returns False.""" + from bouquin.document_utils import open_document_from_db + + # Try to open a document that doesn't exist + success = open_document_from_db( + fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None + ) + + # Should return False + assert success is False + + +def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db): + """Test that error dialog is shown when parent widget is provided.""" + from bouquin.document_utils import open_document_from_db + + parent = QWidget() + qtbot.addWidget(parent) + + # Mock QMessageBox.warning + with patch.object(QMessageBox, "warning") as mock_warning: + success = open_document_from_db( + fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent + ) + + # Should return False and show warning + assert success is False + assert mock_warning.called + + # Verify warning was shown with correct parent + call_args = mock_warning.call_args[0] + assert call_args[0] is parent + + +def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db): + """Test that no error dialog is shown when parent widget is None.""" + from bouquin.document_utils import open_document_from_db + + with patch.object(QMessageBox, "warning") as mock_warning: + success = open_document_from_db( + fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None + ) + + # Should return False but NOT show warning + assert success is False + assert not mock_warning.called + + +def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db): + """Test that the temporary file has the correct extension.""" + from bouquin.document_utils import open_document_from_db + + # Test various file extensions + extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"] + + for ext in extensions: + proj_id = fresh_db.add_project(f"Project for {ext}") + doc_path = Path(tempfile.mktemp(suffix=ext)) + doc_path.write_text(f"content for {ext}") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object( + QDesktopServices, "openUrl", return_value=True + ) as mock_open: + open_document_from_db(fresh_db, doc_id, doc_path.name) + + # Get the URL that was opened + url = mock_open.call_args[0][0] + url_string = url.toString() + + # Verify the extension is preserved + assert ext in url_string, f"Extension {ext} not found in {url_string}" + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db): + """Test opening a document without a file extension.""" + from bouquin.document_utils import open_document_from_db + + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp()) # No suffix + doc_path.write_text("content without extension") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + success = open_document_from_db(fresh_db, doc_id, doc_path.name) + + # Should still succeed + assert success is True + assert mock_open.called + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db): + """Test handling when QDesktopServices.openUrl returns False.""" + from bouquin.document_utils import open_document_from_db + + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + # Mock openUrl to return False (failure) + with patch.object(QDesktopServices, "openUrl", return_value=False): + success = open_document_from_db(fresh_db, doc_id, doc_path.name) + + # Should return False + assert success is False + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_binary_content(qtbot, app, fresh_db): + """Test opening a document with binary content.""" + from bouquin.document_utils import open_document_from_db + + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".bin")) + + # Write some binary data + binary_data = bytes([0, 1, 2, 3, 255, 254, 253]) + doc_path.write_bytes(binary_data) + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + success = open_document_from_db(fresh_db, doc_id, doc_path.name) + + assert success is True + assert mock_open.called + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_large_file(qtbot, app, fresh_db): + """Test opening a large document.""" + from bouquin.document_utils import open_document_from_db + + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".bin")) + + # Create a 1MB file + large_data = b"x" * (1024 * 1024) + doc_path.write_bytes(large_data) + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + success = open_document_from_db(fresh_db, doc_id, doc_path.name) + + assert success is True + assert mock_open.called + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db): + """Test that temporary files have the correct prefix.""" + from bouquin.document_utils import open_document_from_db + + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + open_document_from_db(fresh_db, doc_id, doc_path.name) + + url = mock_open.call_args[0][0] + url_path = url.toLocalFile() + + # Verify the temp file has the bouquin_doc_ prefix + assert "bouquin_doc_" in url_path + finally: + doc_path.unlink(missing_ok=True) + + +def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db): + """Test opening the same document multiple times.""" + from bouquin.document_utils import open_document_from_db + + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open: + # Open the same document 3 times + for _ in range(3): + success = open_document_from_db(fresh_db, doc_id, doc_path.name) + assert success is True + + # Should have been called 3 times + assert mock_open.call_count == 3 + + # Each call should create a different temp file + call_urls = [call[0][0].toString() for call in mock_open.call_args_list] + # All URLs should be different (different temp files) + assert len(set(call_urls)) == 3 + finally: + doc_path.unlink(missing_ok=True) diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..0740b40 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,1060 @@ +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bouquin.db import DBConfig +from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox + +# ============================================================================= +# TodaysDocumentsWidget Tests +# ============================================================================= + + +def test_todays_documents_widget_init(qtbot, app, fresh_db): + """Test TodaysDocumentsWidget initialization.""" + date_iso = "2024-01-15" + widget = TodaysDocumentsWidget(fresh_db, date_iso) + qtbot.addWidget(widget) + + assert widget._db is fresh_db + assert widget._current_date == date_iso + assert widget.toggle_btn is not None + assert widget.open_btn is not None + assert widget.list is not None + assert not widget.body.isVisible() + + +def test_todays_documents_widget_reload_no_documents(qtbot, app, fresh_db): + """Test reload when there are no documents for today.""" + date_iso = "2024-01-15" + widget = TodaysDocumentsWidget(fresh_db, date_iso) + qtbot.addWidget(widget) + + # Should have one disabled item saying "no documents" + assert widget.list.count() == 1 + item = widget.list.item(0) + assert not (item.flags() & Qt.ItemIsEnabled) + + +def test_todays_documents_widget_reload_with_documents(qtbot, app, fresh_db): + """Test reload when there are documents for today.""" + # Add a project + proj_id = fresh_db.add_project("Test Project") + + # Add a document to the project + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + # Mark document as accessed today + date_iso = "2024-01-15" + # The todays_documents method checks updated_at, so we need to ensure + # the document shows up in today's query + + widget = TodaysDocumentsWidget(fresh_db, date_iso) + qtbot.addWidget(widget) + + # At minimum, widget should be created without error + assert widget.list is not None + finally: + doc_path.unlink(missing_ok=True) + + +def test_todays_documents_widget_set_current_date(qtbot, app, fresh_db): + """Test changing the current date.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + # Change date + widget.set_current_date("2024-01-16") + assert widget._current_date == "2024-01-16" + + +def test_todays_documents_widget_open_document(qtbot, app, fresh_db): + """Test opening a document.""" + # Add a project and document + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + # Mock QDesktopServices.openUrl + with patch.object( + QDesktopServices, "openUrl", return_value=True + ) as mock_open_url: + widget._open_document(doc_id, doc_path.name) + + # Verify openUrl was called + assert mock_open_url.called + args = mock_open_url.call_args[0] + assert isinstance(args[0], QUrl) + finally: + doc_path.unlink(missing_ok=True) + + +def test_todays_documents_widget_open_document_error(qtbot, app, fresh_db): + """Test opening a non-existent document shows error.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + # Try to open non-existent document + with patch.object(QMessageBox, "warning") as mock_warning: + widget._open_document(99999, "nonexistent.txt") + assert mock_warning.called + + +def test_todays_documents_widget_open_documents_dialog(qtbot, app, fresh_db): + """Test opening the full documents dialog.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + # Mock DocumentsDialog + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + + with patch("bouquin.documents.DocumentsDialog", return_value=mock_dialog): + widget._open_documents_dialog() + assert mock_dialog.exec.called + + +# ============================================================================= +# DocumentsDialog Tests +# ============================================================================= + + +def test_documents_dialog_init(qtbot, app, fresh_db): + """Test DocumentsDialog initialization.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog._db is fresh_db + assert dialog.project_combo is not None + assert dialog.search_edit is not None + assert dialog.table is not None + assert dialog.table.columnCount() == 5 + + +def test_documents_dialog_init_with_initial_project(qtbot, app, fresh_db): + """Test DocumentsDialog with initial project ID.""" + proj_id = fresh_db.add_project("Test Project") + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Should select the specified project + # Verify project combo is populated + assert dialog.project_combo.count() > 0 + + +def test_documents_dialog_reload_projects(qtbot, app, fresh_db): + """Test reloading projects list.""" + # Add some projects + fresh_db.add_project("Project 1") + fresh_db.add_project("Project 2") + + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Check projects are loaded (including "All projects" option) + assert dialog.project_combo.count() >= 2 + + +def test_documents_dialog_reload_documents_no_project(qtbot, app, fresh_db): + """Test reloading documents when no project is selected.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog._reload_documents() + # Should not crash + + +def test_documents_dialog_reload_documents_with_project(qtbot, app, fresh_db): + """Test reloading documents for a specific project.""" + # Add project and document + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Table should have at least one row + assert ( + dialog.table.rowCount() >= 0 + ) # Might be 0 or 1 depending on how DB is set up + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_add_document(qtbot, app, fresh_db): + """Test adding a document.""" + proj_id = fresh_db.add_project("Test Project") + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Create a temporary file to add + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + # Mock file dialog to return our test file + with patch.object( + QFileDialog, "getOpenFileNames", return_value=([str(doc_path)], "") + ): + dialog._on_add_clicked() + + # Verify document was added (table should reload) + # The count might not change if the view isn't refreshed properly in test + # but the DB should have the document + docs = fresh_db.documents_for_project(proj_id) + assert len(docs) > 0 + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_add_document_no_project(qtbot, app, fresh_db): + """Test adding a document with no project selected shows warning.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Set to "All projects" (None) + dialog.project_combo.setCurrentIndex(0) + + with patch.object(QMessageBox, "warning") as mock_warning: + dialog._on_add_clicked() + assert mock_warning.called + + +def test_documents_dialog_add_document_file_error(qtbot, app, fresh_db): + """Test adding a document that doesn't exist shows warning.""" + proj_id = fresh_db.add_project("Test Project") + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Mock file dialog to return a non-existent file + with patch.object( + QFileDialog, "getOpenFileNames", return_value=(["/nonexistent/file.txt"], "") + ): + with patch.object(QMessageBox, "warning"): + dialog._on_add_clicked() + # Should show warning for file not found + # (this depends on add_document_from_path implementation) + + +def test_documents_dialog_open_document(qtbot, app, fresh_db): + """Test opening a document from the dialog.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + with patch.object( + QDesktopServices, "openUrl", return_value=True + ) as mock_open_url: + dialog._open_document(doc_id, doc_path.name) + assert mock_open_url.called + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_delete_document(qtbot, app, fresh_db): + """Test deleting a document.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Select the document in the table + if dialog.table.rowCount() > 0: + dialog.table.setCurrentCell(0, 0) + + # Mock confirmation dialog + with patch.object( + QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes + ): + dialog._on_delete_clicked() + + # Document should be deleted + fresh_db.documents_for_project(proj_id) + # Depending on implementation, might be 0 or filtered + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_delete_document_no_selection(qtbot, app, fresh_db): + """Test deleting with no selection does nothing.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Don't select anything + dialog.table.setCurrentCell(-1, -1) + + # Should not crash + dialog._on_delete_clicked() + + +def test_documents_dialog_delete_document_cancelled(qtbot, app, fresh_db): + """Test cancelling document deletion.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + dialog.table.setCurrentCell(0, 0) + + # Mock confirmation dialog to return No + with patch.object( + QMessageBox, "question", return_value=QMessageBox.StandardButton.No + ): + dialog._on_delete_clicked() + + # Document should still exist + docs = fresh_db.documents_for_project(proj_id) + assert len(docs) > 0 + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_edit_description(qtbot, app, fresh_db): + """Test editing a document's description inline.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + # Get the description cell + desc_item = dialog.table.item(0, dialog.DESC_COL) + if desc_item: + # Simulate editing + desc_item.setText("New description") + dialog._on_item_changed(desc_item) + + # Verify description was updated in DB + docs = fresh_db.documents_for_project(proj_id) + if len(docs) > 0: + _, _, _, _, description, _, _ = docs[0] + assert description == "New description" + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_edit_tags(qtbot, app, fresh_db): + """Test editing a document's tags inline.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + # Get the tags cell + tags_item = dialog.table.item(0, dialog.TAGS_COL) + if tags_item: + # Simulate editing tags + tags_item.setText("tag1, tag2, tag3") + dialog._on_item_changed(tags_item) + + # Verify tags were updated in DB + tags = fresh_db.get_tags_for_document(doc_id) + tag_names = [name for (_, name, _) in tags] + assert "tag1" in tag_names + assert "tag2" in tag_names + assert "tag3" in tag_names + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_tags_color_application(qtbot, app, fresh_db): + """Test that tag colors are applied to the tags cell.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + # Add a tag with a color + fresh_db.add_tag("colored_tag", "#FF0000") + fresh_db.set_tags_for_document(doc_id, ["colored_tag"]) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + tags_item = dialog.table.item(0, dialog.TAGS_COL) + if tags_item: + # Check that background color was applied + bg_color = tags_item.background().color() + assert bg_color.isValid() + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_search_functionality(qtbot, app, fresh_db): + """Test search functionality across all projects.""" + # Add multiple projects with documents + proj1 = fresh_db.add_project("Project 1") + proj2 = fresh_db.add_project("Project 2") + + doc1_path = Path(tempfile.mktemp(suffix=".txt")) + doc1_path.write_text("apple content") + doc2_path = Path(tempfile.mktemp(suffix=".txt")) + doc2_path.write_text("banana content") + + try: + fresh_db.add_document_from_path(proj1, str(doc1_path)) + fresh_db.add_document_from_path(proj2, str(doc2_path)) + + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Perform search + dialog.search_edit.setText("apple") + dialog._on_search_text_changed("apple") + + # Should show search results + # Implementation depends on search_documents query + finally: + doc1_path.unlink(missing_ok=True) + doc2_path.unlink(missing_ok=True) + + +def test_documents_dialog_manage_projects_button(qtbot, app, fresh_db): + """Test clicking manage projects button.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Mock TimeCodeManagerDialog + mock_mgr_dialog = MagicMock() + mock_mgr_dialog.exec.return_value = QDialog.Accepted + + with patch("bouquin.documents.TimeCodeManagerDialog", return_value=mock_mgr_dialog): + dialog._manage_projects() + assert mock_mgr_dialog.exec.called + + +def test_documents_dialog_format_size(qtbot, app, fresh_db): + """Test file size formatting.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Test various sizes + assert "B" in dialog._format_size(500) + assert "KB" in dialog._format_size(2048) + assert "MB" in dialog._format_size(2 * 1024 * 1024) + assert "GB" in dialog._format_size(2 * 1024 * 1024 * 1024) + + +def test_documents_dialog_current_project_all(qtbot, app, fresh_db): + """Test _current_project returns None for 'All Projects'.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Set to first item (All Projects) + dialog.project_combo.setCurrentIndex(0) + + proj_id = dialog._current_project() + assert proj_id is None + + +def test_documents_dialog_current_project_specific(qtbot, app, fresh_db): + """Test _current_project returns correct project ID.""" + proj_id = fresh_db.add_project("Test Project") + + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Find and select the test project + for i in range(dialog.project_combo.count()): + if dialog.project_combo.itemData(i) == proj_id: + dialog.project_combo.setCurrentIndex(i) + break + + current_proj = dialog._current_project() + if current_proj is not None: + assert current_proj == proj_id + + +def test_documents_dialog_table_double_click_opens_document(qtbot, app, fresh_db): + """Test double-clicking a document opens it.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + with patch.object(QDesktopServices, "openUrl", return_value=True): + # Simulate double-click + dialog._on_open_clicked() + + # Should attempt to open if a row is selected + # (behavior depends on whether table selection is set up properly) + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_selected_doc_meta_no_selection(qtbot, app, fresh_db): + """Test _selected_doc_meta with no selection.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + doc_id, file_name = dialog._selected_doc_meta() + assert doc_id is None + assert file_name is None + + +def test_documents_dialog_selected_doc_meta_with_selection(qtbot, app, fresh_db): + """Test _selected_doc_meta with a valid selection.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + dialog.table.setCurrentCell(0, 0) + + sel_doc_id, sel_file_name = dialog._selected_doc_meta() + # May or may not be None depending on how table is populated + # At minimum, should not crash + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_item_changed_ignores_during_reload(qtbot, app, fresh_db): + """Test _on_item_changed is ignored during reload.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Set reloading flag + dialog._reloading_docs = True + + # Create a mock item + from PySide6.QtWidgets import QTableWidgetItem + + item = QTableWidgetItem("test") + + # Should not crash or do anything + dialog._on_item_changed(item) + + dialog._reloading_docs = False + + +def test_documents_dialog_search_clears_properly(qtbot, app, fresh_db): + """Test clearing search box resets to project view.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Enter search text + dialog.search_edit.setText("test") + dialog._on_search_text_changed("test") + + # Clear search + dialog.search_edit.clear() + dialog._on_search_text_changed("") + + # Should reset to normal project view + assert dialog._search_text == "" + + +def test_todays_documents_widget_reload_with_project_names(qtbot, app, fresh_db): + """Test reload when documents have project names.""" + # Add a project and document + proj_id = fresh_db.add_project("My Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + # Mock todays_documents to return a document with project name + with patch.object(fresh_db, "todays_documents") as mock_today: + mock_today.return_value = [(doc_id, doc_path.name, "My Project")] + + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + widget.reload() + + # Should have one item with project name in label + assert widget.list.count() == 1 + item = widget.list.item(0) + assert "My Project" in item.text() + assert doc_path.name in item.text() + + # Check data was stored + data = item.data(Qt.ItemDataRole.UserRole) + assert isinstance(data, dict) + assert data["doc_id"] == doc_id + assert data["file_name"] == doc_path.name + finally: + doc_path.unlink(missing_ok=True) + + +def test_todays_documents_widget_on_toggle_expand(qtbot, app, fresh_db): + """Test toggle behavior when expanding.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + widget.show() + qtbot.waitExposed(widget) + + # Initially collapsed + assert not widget.body.isVisible() + + # Call _on_toggle directly + widget._on_toggle(True) + + # Should be expanded + assert widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.DownArrow + + +def test_todays_documents_widget_on_toggle_collapse(qtbot, app, fresh_db): + """Test toggle behavior when collapsing.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + widget.show() + qtbot.waitExposed(widget) + + # Expand first + widget._on_toggle(True) + assert widget.body.isVisible() + + # Now collapse + widget._on_toggle(False) + + # Should be collapsed + assert not widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.RightArrow + + +def test_todays_documents_widget_set_current_date_triggers_reload(qtbot, app, fresh_db): + """Test that set_current_date triggers a reload.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + # Mock reload to verify it's called + with patch.object(widget, "reload") as mock_reload: + widget.set_current_date("2024-01-16") + + assert widget._current_date == "2024-01-16" + assert mock_reload.called + + +def test_todays_documents_widget_double_click_with_invalid_data(qtbot, app, fresh_db): + """Test double-clicking item with invalid data.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + # Add item with invalid data + from PySide6.QtWidgets import QListWidgetItem + + item = QListWidgetItem("Test") + item.setData(Qt.ItemDataRole.UserRole, "not a dict") + widget.list.addItem(item) + + # Double-click should not crash + widget._open_selected_document(item) + + +def test_todays_documents_widget_double_click_with_missing_doc_id(qtbot, app, fresh_db): + """Test double-clicking item with missing doc_id.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + from PySide6.QtWidgets import QListWidgetItem + + item = QListWidgetItem("Test") + item.setData(Qt.ItemDataRole.UserRole, {"file_name": "test.txt"}) + widget.list.addItem(item) + + # Should return early without crashing + widget._open_selected_document(item) + + +def test_todays_documents_widget_double_click_with_missing_filename( + qtbot, app, fresh_db +): + """Test double-clicking item with missing file_name.""" + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + + from PySide6.QtWidgets import QListWidgetItem + + item = QListWidgetItem("Test") + item.setData(Qt.ItemDataRole.UserRole, {"doc_id": 1}) + widget.list.addItem(item) + + # Should return early without crashing + widget._open_selected_document(item) + + +def test_documents_dialog_reload_calls_on_init(qtbot, app, fresh_db): + """Test that _reload_documents is called on initialization.""" + # Add a project so the combo will have items + fresh_db.add_project("Test Project") + + # This covers line 300 + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Should have projects loaded (covers _reload_projects line 300-301) + assert dialog.project_combo.count() > 0 + + +def test_documents_dialog_tags_column_hidden_when_disabled(qtbot, app, tmp_path): + """Test that tags column is hidden when tags are disabled in config.""" + # Create a config with tags disabled + db_path = tmp_path / "test.db" + cfg = DBConfig( + path=db_path, + key="test-key", + idle_minutes=0, + theme="light", + move_todos=True, + tags=False, # Tags disabled + time_log=True, + reminders=True, + locale="en", + font_size=11, + ) + + from bouquin.db import DBManager + + db = DBManager(cfg) + db.connect() + + try: + # Add project and document + proj_id = db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test") + + try: + db.add_document_from_path(proj_id, str(doc_path)) + + # Patch load_db_config to return our custom config + with patch("bouquin.documents.load_db_config", return_value=cfg): + dialog = DocumentsDialog(db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Tags column should be hidden (covers lines 400-401) + # The column is hidden inside _reload_documents when there are rows + assert dialog.table.isColumnHidden(dialog.TAGS_COL) + finally: + doc_path.unlink(missing_ok=True) + finally: + db.close() + + +def test_documents_dialog_project_changed_triggers_reload(qtbot, app, fresh_db): + """Test that changing project triggers document reload.""" + fresh_db.add_project("Project 1") + fresh_db.add_project("Project 2") + + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Mock _reload_documents + with patch.object(dialog, "_reload_documents") as mock_reload: + # Change project + dialog._on_project_changed(1) + + # Should have triggered reload (covers line 421-424) + assert mock_reload.called + + +def test_documents_dialog_add_with_cancelled_dialog(qtbot, app, fresh_db): + """Test adding document when file dialog is cancelled.""" + proj_id = fresh_db.add_project("Test Project") + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Mock file dialog to return empty (cancelled) + with patch.object(QFileDialog, "getOpenFileNames", return_value=([], "")): + initial_count = dialog.table.rowCount() + dialog._on_add_clicked() + + # No documents should be added (covers line 442) + assert dialog.table.rowCount() == initial_count + + +def test_documents_dialog_delete_with_cancelled_confirmation(qtbot, app, fresh_db): + """Test deleting document when user cancels confirmation.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + dialog.table.setCurrentCell(0, 0) + + # Mock to return No + with patch.object( + QMessageBox, "question", return_value=QMessageBox.StandardButton.No + ): + dialog._on_delete_clicked() + + # Document should still exist (covers line 486) + docs = fresh_db.documents_for_project(proj_id) + assert len(docs) > 0 + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_edit_tags_with_empty_result(qtbot, app, fresh_db): + """Test editing tags when result is empty after setting.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + if dialog.table.rowCount() > 0: + tags_item = dialog.table.item(0, dialog.TAGS_COL) + if tags_item: + # Set empty tags + tags_item.setText("") + dialog._on_item_changed(tags_item) + + # Background should be cleared (covers lines 523-524) + # Just verify no crash + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_on_open_with_no_selection(qtbot, app, fresh_db): + """Test _on_open_clicked with no selection.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Don't select anything + dialog.table.setCurrentCell(-1, -1) + + # Should not crash (early return) + dialog._on_open_clicked() + + +def test_documents_dialog_search_with_results(qtbot, app, fresh_db): + """Test search functionality with actual results.""" + proj_id = fresh_db.add_project("Test Project") + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("searchable content") + + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + + # Update document to have searchable description + fresh_db.update_document_description(doc_id, "searchable description") + + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Mock search_documents to return results + with patch.object(fresh_db, "search_documents") as mock_search: + mock_search.return_value = [ + ( + doc_id, + proj_id, + "Test Project", + doc_path.name, + "searchable description", + 100, + "2024-01-15", + ) + ] + + # Perform search + dialog.search_edit.setText("searchable") + dialog._on_search_text_changed("searchable") + + # Should show results + assert dialog.table.rowCount() > 0 + finally: + doc_path.unlink(missing_ok=True) + + +def test_documents_dialog_on_item_changed_invalid_item(qtbot, app, fresh_db): + """Test _on_item_changed with None item.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Call with None + dialog._on_item_changed(None) + + # Should not crash + + +def test_documents_dialog_on_item_changed_no_file_item(qtbot, app, fresh_db): + """Test _on_item_changed when file item is None.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Manually add a row without proper file item + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + desc_item = QTableWidgetItem("Test") + dialog.table.setItem(0, dialog.DESC_COL, desc_item) + + # Call on_item_changed + dialog._on_item_changed(desc_item) + + # Should return early without crashing + + +def test_documents_dialog_format_size_edge_cases(qtbot, app, fresh_db): + """Test _format_size with edge cases.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Test 0 bytes + assert dialog._format_size(0) == "0 B" + + # Test exact KB boundary + assert "1.0 KB" in dialog._format_size(1024) + + # Test exact MB boundary + assert "1.0 MB" in dialog._format_size(1024 * 1024) + + # Test exact GB boundary + assert "1.0 GB" in dialog._format_size(1024 * 1024 * 1024) + + +def test_documents_dialog_selected_doc_meta_no_file_item(qtbot, app, fresh_db): + """Test _selected_doc_meta when file item is None.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Add a row without file item + dialog.table.setRowCount(1) + dialog.table.setCurrentCell(0, 0) + + doc_id, file_name = dialog._selected_doc_meta() + + # Should return None, None + assert doc_id is None + assert file_name is None + + +def test_documents_dialog_initial_project_selection(qtbot, app, fresh_db): + """Test dialog with initial_project_id selects correct project.""" + proj_id = fresh_db.add_project("Selected Project") + + # Add a document to ensure something shows + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text("test") + + try: + fresh_db.add_document_from_path(proj_id, str(doc_path)) + + dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id) + qtbot.addWidget(dialog) + + # Should have selected the project + current_proj = dialog._current_project() + assert current_proj == proj_id + finally: + doc_path.unlink(missing_ok=True) + + +def test_todays_documents_widget_reload_multiple_documents(qtbot, app, fresh_db): + """Test reload with multiple documents.""" + proj_id = fresh_db.add_project("Project") + + # Add multiple documents + doc_ids = [] + for i in range(3): + doc_path = Path(tempfile.mktemp(suffix=".txt")) + doc_path.write_text(f"content {i}") + try: + doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path)) + doc_ids.append((doc_id, doc_path.name)) + finally: + doc_path.unlink(missing_ok=True) + + # Mock todays_documents + with patch.object(fresh_db, "todays_documents") as mock_today: + mock_today.return_value = [ + (doc_id, name, "Project") for doc_id, name in doc_ids + ] + + widget = TodaysDocumentsWidget(fresh_db, "2024-01-15") + qtbot.addWidget(widget) + widget.reload() + + # Should have 3 items + assert widget.list.count() == 3 + + +def test_documents_dialog_manage_projects_button_clicked(qtbot, app, fresh_db): + """Test clicking manage projects button.""" + dialog = DocumentsDialog(fresh_db) + qtbot.addWidget(dialog) + + # Mock TimeCodeManagerDialog + mock_mgr_dialog = MagicMock() + mock_mgr_dialog.exec.return_value = QDialog.Accepted + + with patch("bouquin.documents.TimeCodeManagerDialog", return_value=mock_mgr_dialog): + dialog._manage_projects() + + # Should have opened the manager dialog + assert mock_mgr_dialog.exec.called diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py index c0ab938..de67c7e 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -1,10 +1,9 @@ import pytest - +from bouquin.find_bar import FindBar +from bouquin.markdown_editor import MarkdownEditor +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import QTextEdit, QWidget -from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.find_bar import FindBar @pytest.fixture diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py index da97a5a..98ab9c8 100644 --- a/tests/test_history_dialog.py +++ b/tests/test_history_dialog.py @@ -1,7 +1,6 @@ -from PySide6.QtWidgets import QWidget, QMessageBox, QApplication -from PySide6.QtCore import Qt, QTimer - from bouquin.history_dialog import HistoryDialog +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget def test_history_dialog_lists_and_revert(qtbot, fresh_db): diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..89ef202 --- /dev/null +++ b/tests/test_invoices.py @@ -0,0 +1,1346 @@ +from datetime import date, timedelta + +import pytest +from bouquin.invoices import ( + _INVOICE_REMINDER_TIME, + InvoiceDetailMode, + InvoiceDialog, + InvoiceLineItem, + InvoicesDialog, + _invoice_due_reminder_text, +) +from bouquin.reminders import Reminder, ReminderType +from PySide6.QtCore import QDate, Qt +from PySide6.QtWidgets import QMessageBox + +# ============================================================================ +# Tests for InvoiceDetailMode enum +# ============================================================================ + + +def test_invoice_detail_mode_enum_values(app): + """Test InvoiceDetailMode enum has expected values.""" + assert InvoiceDetailMode.DETAILED == "detailed" + assert InvoiceDetailMode.SUMMARY == "summary" + + +def test_invoice_detail_mode_is_string(app): + """Test InvoiceDetailMode enum inherits from str.""" + assert isinstance(InvoiceDetailMode.DETAILED, str) + assert isinstance(InvoiceDetailMode.SUMMARY, str) + + +# ============================================================================ +# Tests for InvoiceLineItem dataclass +# ============================================================================ + + +def test_invoice_line_item_creation(app): + """Test creating an InvoiceLineItem instance.""" + item = InvoiceLineItem( + description="Development work", + hours=5.5, + rate_cents=10000, + amount_cents=55000, + ) + + assert item.description == "Development work" + assert item.hours == 5.5 + assert item.rate_cents == 10000 + assert item.amount_cents == 55000 + + +def test_invoice_line_item_with_zero_values(app): + """Test InvoiceLineItem with zero values.""" + item = InvoiceLineItem( + description="", + hours=0.0, + rate_cents=0, + amount_cents=0, + ) + + assert item.description == "" + assert item.hours == 0.0 + assert item.rate_cents == 0 + assert item.amount_cents == 0 + + +# ============================================================================ +# Tests for _invoice_due_reminder_text helper function +# ============================================================================ + + +def test_invoice_due_reminder_text_normal(app): + """Test reminder text generation with normal inputs.""" + result = _invoice_due_reminder_text("Project Alpha", "INV-001") + assert result == "Invoice INV-001 for Project Alpha is due" + + +def test_invoice_due_reminder_text_with_whitespace(app): + """Test reminder text strips whitespace from inputs.""" + result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ") + assert result == "Invoice INV-002 for Project Beta is due" + + +def test_invoice_due_reminder_text_empty_project(app): + """Test reminder text with empty project name.""" + result = _invoice_due_reminder_text("", "INV-003") + assert result == "Invoice INV-003 for (no project) is due" + + +def test_invoice_due_reminder_text_empty_invoice_number(app): + """Test reminder text with empty invoice number.""" + result = _invoice_due_reminder_text("Project Gamma", "") + assert result == "Invoice ? for Project Gamma is due" + + +def test_invoice_due_reminder_text_both_empty(app): + """Test reminder text with both inputs empty.""" + result = _invoice_due_reminder_text("", "") + assert result == "Invoice ? for (no project) is due" + + +# ============================================================================ +# Tests for InvoiceDialog +# ============================================================================ + + +@pytest.fixture +def invoice_dialog_setup(qtbot, fresh_db): + """Set up a project with time logs for InvoiceDialog testing.""" + # Create a project + proj_id = fresh_db.add_project("Test Project") + + # Create an activity + act_id = fresh_db.add_activity("Development") + + # Set billing info + fresh_db.upsert_project_billing( + proj_id, + hourly_rate_cents=15000, # $150/hr + currency="USD", + tax_label="VAT", + tax_rate_percent=20.0, + client_name="John Doe", + client_company="Acme Corp", + client_address="123 Main St", + client_email="john@acme.com", + ) + + # Create some time logs + today = date.today() + start_date = (today - timedelta(days=7)).isoformat() + end_date = today.isoformat() + + # Add time logs for testing (2.5 hours = 150 minutes) + for i in range(3): + log_date = (today - timedelta(days=i)).isoformat() + fresh_db.add_time_log( + log_date, + proj_id, + act_id, + 150, # 2.5 hours in minutes + f"Note {i}", + ) + + time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date) + + return { + "db": fresh_db, + "proj_id": proj_id, + "act_id": act_id, + "start_date": start_date, + "end_date": end_date, + "time_rows": time_rows, + } + + +def test_invoice_dialog_init(qtbot, invoice_dialog_setup): + """Test InvoiceDialog initialization.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + assert dialog._db is setup["db"] + assert dialog._project_id == setup["proj_id"] + assert dialog._start == setup["start_date"] + assert dialog._end == setup["end_date"] + assert len(dialog._time_rows) == 3 + + +def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup): + """Test InvoiceDialog initialization without explicit time_rows.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + ) + qtbot.addWidget(dialog) + + # Should fetch time rows from DB + assert len(dialog._time_rows) == 3 + + +def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup): + """Test that InvoiceDialog loads billing defaults from project.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + assert dialog.currency_edit.text() == "USD" + assert dialog.rate_spin.value() == 150.0 + assert dialog.client_name_edit.text() == "John Doe" + assert dialog.client_company_combo.currentText() == "Acme Corp" + + +def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db): + """Test InvoiceDialog with project that has no billing info.""" + proj_id = fresh_db.add_project("Test Project No Billing") + today = date.today() + start = (today - timedelta(days=1)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + + # Should use defaults + assert dialog.currency_edit.text() == "AUD" + assert dialog.rate_spin.value() == 0.0 + assert dialog.client_name_edit.text() == "" + + +def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup): + """Test _project_name method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + project_name = dialog._project_name() + assert project_name == "Test Project" + + +def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup): + """Test _suggest_invoice_number method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + invoice_number = dialog._suggest_invoice_number() + # Should be in format YYYY-001 for first invoice (3 digits) + current_year = date.today().year + assert invoice_number.startswith(str(current_year)) + assert invoice_number.endswith("-001") + + +def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup): + """Test that invoice number suggestions increment.""" + setup = invoice_dialog_setup + + # Create an invoice first + dialog1 = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog1) + + # Save an invoice to increment the counter + invoice_number_1 = dialog1._suggest_invoice_number() + setup["db"].create_invoice( + project_id=setup["proj_id"], + invoice_number=invoice_number_1, + issue_date=date.today().isoformat(), + due_date=(date.today() + timedelta(days=14)).isoformat(), + currency="USD", + tax_label=None, + tax_rate_percent=None, + detail_mode=InvoiceDetailMode.DETAILED, + line_items=[], + time_log_ids=[], + ) + + # Create another dialog and check the number increments + dialog2 = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog2) + + invoice_number_2 = dialog2._suggest_invoice_number() + current_year = date.today().year + assert invoice_number_2 == f"{current_year}-002" + + +def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup): + """Test _populate_detailed_rows method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) # $150/hr in cents + + # Check that table has rows + assert dialog.table.rowCount() == 3 + + # Check that hours are displayed (COL_HOURS uses cellWidget, not item) + for row in range(3): + hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS) + assert hours_widget is not None + assert hours_widget.value() == 2.5 + + +def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup): + """Test _total_hours_from_table method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) + + total_hours = dialog._total_hours_from_table() + # 3 rows * 2.5 hours = 7.5 hours + assert total_hours == 7.5 + + +def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup): + """Test _detail_line_items method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(150.0) + dialog._populate_detailed_rows(15000) + + line_items = dialog._detail_line_items() + assert len(line_items) == 3 + + for item in line_items: + assert isinstance(item, InvoiceLineItem) + assert item.hours == 2.5 + assert item.rate_cents == 15000 + assert item.amount_cents == 37500 # 2.5 * 15000 + + +def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup): + """Test _summary_line_items method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(150.0) + dialog._populate_detailed_rows(15000) + + line_items = dialog._summary_line_items() + assert len(line_items) == 1 # Summary should have one line + + item = line_items[0] + assert isinstance(item, InvoiceLineItem) + # The description comes from summary_desc_edit which has a localized default + # Just check it's not empty + assert len(item.description) > 0 + assert item.hours == 7.5 # Total of 3 * 2.5 + assert item.rate_cents == 15000 + assert item.amount_cents == 112500 # 7.5 * 15000 + + +def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup): + """Test _recalc_amounts method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) + dialog.rate_spin.setValue(200.0) # Change rate to $200/hr + + dialog._recalc_amounts() + + # Check that amounts were recalculated + for row in range(3): + amount_item = dialog.table.item(row, dialog.COL_AMOUNT) + assert amount_item is not None + # 2.5 hours * $200 = $500 + assert amount_item.text() == "500.00" + + +def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup): + """Test _recalc_totals method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + + # Enable tax + dialog.tax_checkbox.setChecked(True) + dialog.tax_rate_spin.setValue(10.0) + + dialog._recalc_totals() + + # 7.5 hours * $100 = $750 + # Tax: $750 * 10% = $75 + # Total: $750 + $75 = $825 + assert "750.00" in dialog.subtotal_label.text() + assert "75.00" in dialog.tax_label_total.text() + assert "825.00" in dialog.total_label.text() + + +def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup): + """Test _on_tax_toggled method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + dialog.show() + + # Initially unchecked (from fixture setup with tax) + dialog.tax_checkbox.setChecked(False) + dialog._on_tax_toggled(False) + + # Tax fields should be hidden + assert not dialog.tax_label.isVisible() + assert not dialog.tax_label_edit.isVisible() + assert not dialog.tax_rate_label.isVisible() + assert not dialog.tax_rate_spin.isVisible() + + # Check the box + dialog.tax_checkbox.setChecked(True) + dialog._on_tax_toggled(True) + + # Tax fields should be visible + assert dialog.tax_label.isVisible() + assert dialog.tax_label_edit.isVisible() + assert dialog.tax_rate_label.isVisible() + assert dialog.tax_rate_spin.isVisible() + + +def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup): + """Test _on_client_company_changed method for autofill.""" + setup = invoice_dialog_setup + + # Create another project with different client + proj_id_2 = setup["db"].add_project("Project 2") + setup["db"].upsert_project_billing( + proj_id_2, + hourly_rate_cents=20000, + currency="EUR", + tax_label="GST", + tax_rate_percent=15.0, + client_name="Jane Smith", + client_company="Tech Industries", + client_address="456 Oak Ave", + client_email="jane@tech.com", + ) + + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Initially should have first project's client + assert dialog.client_name_edit.text() == "John Doe" + + # Change to second company + dialog.client_company_combo.setCurrentText("Tech Industries") + dialog._on_client_company_changed("Tech Industries") + + # Should autofill with second client's info + assert dialog.client_name_edit.text() == "Jane Smith" + assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave" + assert dialog.client_email_edit.text() == "jane@tech.com" + + +def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup): + """Test _create_due_date_reminder method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + due_date = (date.today() + timedelta(days=14)).isoformat() + invoice_number = "INV-TEST-001" + invoice_id = 999 # Fake invoice ID for testing + + dialog._create_due_date_reminder(invoice_id, invoice_number, due_date) + + # Check that reminder was created + reminders = setup["db"].get_all_reminders() + assert len(reminders) > 0 + + # Find our reminder + expected_text = _invoice_due_reminder_text("Test Project", invoice_number) + matching_reminders = [r for r in reminders if r.text == expected_text] + assert len(matching_reminders) == 1 + + reminder = matching_reminders[0] + assert reminder.reminder_type == ReminderType.ONCE + assert reminder.date_iso == due_date + assert reminder.time_str == _INVOICE_REMINDER_TIME + + +# ============================================================================ +# Tests for InvoicesDialog +# ============================================================================ + + +@pytest.fixture +def invoices_dialog_setup(qtbot, fresh_db): + """Set up projects with invoices for InvoicesDialog testing.""" + # Create projects + proj_id_1 = fresh_db.add_project("Project Alpha") + proj_id_2 = fresh_db.add_project("Project Beta") + + # Create invoices for project 1 + today = date.today() + for i in range(3): + issue_date = (today - timedelta(days=i * 7)).isoformat() + due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat() + paid_at = today.isoformat() if i == 0 else None # First one is paid + + fresh_db.create_invoice( + project_id=proj_id_1, + invoice_number=f"ALPHA-{i+1}", + issue_date=issue_date, + due_date=due_date, + currency="USD", + tax_label="VAT", + tax_rate_percent=20.0, + detail_mode=InvoiceDetailMode.DETAILED, + line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr + time_log_ids=[], + ) + + # Update paid_at separately if needed + if paid_at: + invoice_rows = fresh_db.get_all_invoices(proj_id_1) + if invoice_rows: + inv_id = invoice_rows[0]["id"] + fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at) + + # Create invoices for project 2 + for i in range(2): + issue_date = (today - timedelta(days=i * 10)).isoformat() + due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat() + + fresh_db.create_invoice( + project_id=proj_id_2, + invoice_number=f"BETA-{i+1}", + issue_date=issue_date, + due_date=due_date, + currency="EUR", + tax_label=None, + tax_rate_percent=None, + detail_mode=InvoiceDetailMode.SUMMARY, + line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr + time_log_ids=[], + ) + + return { + "db": fresh_db, + "proj_id_1": proj_id_1, + "proj_id_2": proj_id_2, + } + + +def test_invoices_dialog_init(qtbot, invoices_dialog_setup): + """Test InvoicesDialog initialization.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + assert dialog._db is setup["db"] + assert dialog.project_combo.count() >= 2 # 2 projects + + +def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup): + """Test InvoicesDialog initialization with specific project.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Should select the specified project + current_proj = dialog._current_project() + assert current_proj == setup["proj_id_1"] + + +def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup): + """Test _reload_projects method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + initial_count = dialog.project_combo.count() + assert initial_count >= 2 # Should have 2 projects from setup + + # Create a new project + setup["db"].add_project("Project Gamma") + + # Reload projects + dialog._reload_projects() + + # Should have one more project + assert dialog.project_combo.count() == initial_count + 1 + + +def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup): + """Test _current_project method when specific project is selected.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + current_proj = dialog._current_project() + assert current_proj == setup["proj_id_1"] + + +def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup): + """Test _reload_invoices with first project selected by default.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + # First project should be selected by default (Project Alpha with 3 invoices) + # The exact project depends on creation order, so just check we have some invoices + assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2) + + +def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup): + """Test _reload_invoices with single project selected.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + dialog._reload_invoices() + + # Should show only 3 invoices from proj1 + assert dialog.table.rowCount() == 3 + + +def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup): + """Test _on_project_changed method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"]) + qtbot.addWidget(dialog) + + # Start with project 2 (2 invoices) + assert dialog.table.rowCount() == 2 + + # Find the index of project 1 + for i in range(dialog.project_combo.count()): + if dialog.project_combo.itemData(i) == setup["proj_id_1"]: + dialog.project_combo.setCurrentIndex(i) + break + + dialog._on_project_changed(dialog.project_combo.currentIndex()) + + # Should now show 3 invoices from proj1 + assert dialog.table.rowCount() == 3 + + +def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup): + """Test _remove_invoice_due_reminder method.""" + setup = invoices_dialog_setup + + # Create a reminder for an invoice + due_date = (date.today() + timedelta(days=7)).isoformat() + invoice_number = "TEST-REMINDER-001" + project_name = "Project Alpha" + + reminder_text = _invoice_due_reminder_text(project_name, invoice_number) + reminder = Reminder( + id=None, + text=reminder_text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + date_iso=due_date, + active=True, + ) + reminder.id = setup["db"].save_reminder(reminder) + + # Verify reminder exists + reminders = setup["db"].get_all_reminders() + assert len(reminders) == 1 + + # Create dialog and populate with invoices + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Manually add a row to test the removal (simulating the invoice row) + row = dialog.table.rowCount() + dialog.table.insertRow(row) + + # Set the project and invoice number items + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem(project_name) + num_item = QTableWidgetItem(invoice_number) + dialog.table.setItem(row, dialog.COL_PROJECT, proj_item) + dialog.table.setItem(row, dialog.COL_NUMBER, num_item) + + # Mock invoice_id + num_item.setData(Qt.ItemDataRole.UserRole, 999) + + # Call the removal method + dialog._remove_invoice_due_reminder(row, 999) + + # Reminder should be deleted + reminders_after = setup["db"].get_all_reminders() + assert len(reminders_after) == 0 + + +def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup): + """Test _on_item_changed for invoice number editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row's invoice ID + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + # Change the invoice number + num_item.setText("ALPHA-MODIFIED") + + # Trigger the change handler + dialog._on_item_changed(num_item) + + # Verify the change was saved to DB + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number") + assert invoice_data["invoice_number"] == "ALPHA-MODIFIED" + + +def test_invoices_dialog_on_item_changed_empty_invoice_number( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects empty invoice number.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox to auto-close + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Get the first row's invoice number item + num_item = dialog.table.item(0, dialog.COL_NUMBER) + original_number = num_item.text() + + # Try to set empty invoice number + num_item.setText("") + dialog._on_item_changed(num_item) + + # Should be reset to original + assert num_item.text() == original_number + + +def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup): + """Test _on_item_changed for issue date editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + new_date = "2024-01-15" + issue_item.setText(new_date) + + dialog._on_item_changed(issue_item) + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date") + assert invoice_data["issue_date"] == new_date + + +def test_invoices_dialog_on_item_changed_invalid_date( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid date format.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + original_date = issue_item.text() + + # Try to set invalid date + issue_item.setText("not-a-date") + dialog._on_item_changed(issue_item) + + # Should be reset to original + assert issue_item.text() == original_date + + +def test_invoices_dialog_on_item_changed_due_before_issue( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects due date before issue date.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Set issue date + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + issue_item.setText("2024-02-01") + dialog._on_item_changed(issue_item) + + # Try to set due date before issue date + due_item = dialog.table.item(0, dialog.COL_DUE_DATE) + original_due = due_item.text() + due_item.setText("2024-01-01") + dialog._on_item_changed(due_item) + + # Should be reset + assert due_item.text() == original_due + + +def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup): + """Test _on_item_changed for currency editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + currency_item = dialog.table.item(0, dialog.COL_CURRENCY) + currency_item.setText("gbp") # lowercase + + dialog._on_item_changed(currency_item) + + # Should be normalized to uppercase + assert currency_item.text() == "GBP" + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency") + assert invoice_data["currency"] == "GBP" + + +def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup): + """Test _on_item_changed for tax rate editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE) + tax_rate_item.setText("15.5") + + dialog._on_item_changed(tax_rate_item) + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent") + assert invoice_data["tax_rate_percent"] == 15.5 + + +def test_invoices_dialog_on_item_changed_invalid_tax_rate( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid tax rate.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE) + original_rate = tax_rate_item.text() + + # Try to set invalid tax rate + tax_rate_item.setText("not-a-number") + dialog._on_item_changed(tax_rate_item) + + # Should be reset to original + assert tax_rate_item.text() == original_rate + + +def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup): + """Test _on_item_changed for subtotal editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL) + subtotal_item.setText("1234.56") + + dialog._on_item_changed(subtotal_item) + + # Verify change was saved (in cents) + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents") + assert invoice_data["subtotal_cents"] == 123456 + + # Should be normalized to 2 decimals + assert subtotal_item.text() == "1234.56" + + +def test_invoices_dialog_on_item_changed_invalid_amount( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid amount.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL) + original_subtotal = subtotal_item.text() + + # Try to set invalid amount + subtotal_item.setText("not-a-number") + dialog._on_item_changed(subtotal_item) + + # Should be reset to original + assert subtotal_item.text() == original_subtotal + + +def test_invoices_dialog_on_item_changed_paid_at_removes_reminder( + qtbot, invoices_dialog_setup +): + """Test that marking invoice as paid removes due date reminder.""" + setup = invoices_dialog_setup + + # Create a reminder for an invoice + due_date = (date.today() + timedelta(days=7)).isoformat() + invoice_number = "ALPHA-1" + project_name = "Project Alpha" + + reminder_text = _invoice_due_reminder_text(project_name, invoice_number) + reminder = Reminder( + id=None, + text=reminder_text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + date_iso=due_date, + active=True, + ) + reminder.id = setup["db"].save_reminder(reminder) + + # Verify reminder exists + reminders = setup["db"].get_all_reminders() + assert any(r.text == reminder_text for r in reminders) + + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Find the ALPHA-1 invoice row + for row in range(dialog.table.rowCount()): + num_item = dialog.table.item(row, dialog.COL_NUMBER) + if num_item and num_item.text() == "ALPHA-1": + # Mark as paid + paid_item = dialog.table.item(row, dialog.COL_PAID_AT) + paid_item.setText(date.today().isoformat()) + dialog._on_item_changed(paid_item) + break + + # Reminder should be removed + reminders_after = setup["db"].get_all_reminders() + assert not any(r.text == reminder_text for r in reminders_after) + + +def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup): + """Test that _on_item_changed is ignored during reload.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Set reloading flag + dialog._reloading_invoices = True + + # Try to change an item + num_item = dialog.table.item(0, dialog.COL_NUMBER) + original_number = num_item.text() + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + num_item.setText("SHOULD-BE-IGNORED") + dialog._on_item_changed(num_item) + + # Change should not be saved to DB + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number") + assert invoice_data["invoice_number"] == original_number + + +def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup): + """Test _update_mode_enabled method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + dialog.show() + + # Initially detailed mode should be selected + assert dialog.rb_detailed.isChecked() + + # Table should be enabled in detailed mode + assert dialog.table.isEnabled() + + # Switch to summary mode + dialog.rb_summary.setChecked(True) + dialog._update_mode_enabled() + + # Table should be disabled in summary mode + assert not dialog.table.isEnabled() + + +def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db): + """Test InvoiceDialog with project that has no time logs.""" + proj_id = fresh_db.add_project("Empty Project") + today = date.today() + start = (today - timedelta(days=7)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + + # Should handle empty time logs gracefully + assert len(dialog._time_rows) == 0 + assert dialog.table.rowCount() == 0 + + +def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup): + """Test that InvoiceDialog loads existing client companies.""" + setup = invoice_dialog_setup + + # Create another project with a different client company + proj_id_2 = setup["db"].add_project("Project 2") + setup["db"].upsert_project_billing( + proj_id_2, + hourly_rate_cents=10000, + currency="EUR", + tax_label="VAT", + tax_rate_percent=19.0, + client_name="Jane Doe", + client_company="Beta Corp", + client_address="456 Main St", + client_email="jane@beta.com", + ) + + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Should have both companies in the combo + companies = [ + dialog.client_company_combo.itemText(i) + for i in range(dialog.client_company_combo.count()) + ] + assert "Acme Corp" in companies + assert "Beta Corp" in companies + + +def test_invoice_line_item_equality(app): + """Test InvoiceLineItem equality.""" + item1 = InvoiceLineItem("Work", 5.0, 10000, 50000) + item2 = InvoiceLineItem("Work", 5.0, 10000, 50000) + item3 = InvoiceLineItem("Other", 5.0, 10000, 50000) + + assert item1 == item2 + assert item1 != item3 + + +def test_invoices_dialog_empty_database(qtbot, fresh_db): + """Test InvoicesDialog with no projects or invoices.""" + dialog = InvoicesDialog(fresh_db) + qtbot.addWidget(dialog) + + # Should have no projects in combo + assert dialog.project_combo.count() == 0 + assert dialog.table.rowCount() == 0 + + +def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db): + """Test that tax fields are hidden when tax_rate_percent is None.""" + proj_id = fresh_db.add_project("No Tax Project") + fresh_db.upsert_project_billing( + proj_id, + hourly_rate_cents=10000, + currency="USD", + tax_label="Tax", + tax_rate_percent=None, # No tax + client_name="Client", + client_company="Company", + client_address="Address", + client_email="email@test.com", + ) + + today = date.today() + start = (today - timedelta(days=1)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + dialog.show() + + # Tax checkbox should be unchecked + assert not dialog.tax_checkbox.isChecked() + + # Tax fields should be hidden + assert not dialog.tax_label.isVisible() + assert not dialog.tax_label_edit.isVisible() + assert not dialog.tax_rate_label.isVisible() + assert not dialog.tax_rate_spin.isVisible() + + +def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup): + """Test that issue and due dates have correct default values.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Issue date should be today + assert dialog.issue_date_edit.date() == QDate.currentDate() + + # Due date should be 14 days from today + QDate.currentDate().addDays(14) + assert dialog.issue_date_edit.date() == QDate.currentDate() + + +def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup): + """Test that unchecking a line item updates the total cost.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(False) + + # Initial total: 3 rows * 2.5 hours * $100 = $750 + dialog._recalc_totals() + assert "750.00" in dialog.subtotal_label.text() + assert "750.00" in dialog.total_label.text() + + # Uncheck the first row + include_item = dialog.table.item(0, dialog.COL_INCLUDE) + include_item.setCheckState(Qt.Unchecked) + + # Wait for signal processing + qtbot.wait(10) + + # New total: 2 rows * 2.5 hours * $100 = $500 + assert "500.00" in dialog.subtotal_label.text() + assert "500.00" in dialog.total_label.text() + + +def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup): + """Test that checkbox toggling works correctly with tax enabled.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(True) + dialog.tax_rate_spin.setValue(10.0) + + # Initial: 3 rows * 2.5 hours * $100 = $750 + # Tax: $750 * 10% = $75 + # Total: $825 + dialog._recalc_totals() + assert "750.00" in dialog.subtotal_label.text() + assert "75.00" in dialog.tax_label_total.text() + assert "825.00" in dialog.total_label.text() + + # Uncheck two rows + dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + + # Wait for signal processing + qtbot.wait(10) + + # New total: 1 row * 2.5 hours * $100 = $250 + # Tax: $250 * 10% = $25 + # Total: $275 + assert "250.00" in dialog.subtotal_label.text() + assert "25.00" in dialog.tax_label_total.text() + assert "275.00" in dialog.total_label.text() + + +def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup): + """Test that rechecking a previously unchecked item updates totals.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(False) + + # Uncheck all items + for row in range(dialog.table.rowCount()): + dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + + qtbot.wait(10) + + # Total should be 0 + assert "0.00" in dialog.total_label.text() + + # Re-check first item + dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked) + qtbot.wait(10) + + # Total should be 1 row * 2.5 hours * $100 = $250 + assert "250.00" in dialog.total_label.text() + + +def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup): + """Test _select_initial_project method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + # Initially should have first project selected (either proj1 or proj2) + initial_proj = dialog._current_project() + assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]] + + # Select specific project + dialog._select_initial_project(setup["proj_id_2"]) + + # Should now have proj_id_2 selected + assert dialog._current_project() == setup["proj_id_2"] diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py index f044fac..9aedffb 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -1,5 +1,4 @@ from bouquin.key_prompt import KeyPrompt - from PySide6.QtCore import QTimer from PySide6.QtWidgets import QFileDialog, QLineEdit @@ -97,7 +96,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path): def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app): - """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57""" + """Test KeyPrompt with show_db_change but no initial_db_path""" prompt = KeyPrompt(show_db_change=True, initial_db_path=None) qtbot.addWidget(prompt) @@ -168,7 +167,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path): def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): - """Test browsing when initial_db_path is set - covers line 57 with non-None path""" + """Test browsing when initial_db_path is set""" initial_db = tmp_path / "initial.db" initial_db.touch() @@ -180,7 +179,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): # Mock the file dialog to return a different file def mock_get_open_filename(*args, **kwargs): - # Verify that start_dir was passed correctly (line 57) + # Verify that start_dir was passed correctly return str(new_db), "SQLCipher DB (*.db)" monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py index 05de5f9..46b3cfd 100644 --- a/tests/test_lock_overlay.py +++ b/tests/test_lock_overlay.py @@ -1,7 +1,7 @@ +from bouquin.lock_overlay import LockOverlay +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtCore import QEvent from PySide6.QtWidgets import QWidget -from bouquin.lock_overlay import LockOverlay -from bouquin.theme import ThemeManager, ThemeConfig, Theme def test_lock_overlay_reacts_to_theme(app, qtbot): diff --git a/tests/test_main.py b/tests/test_main.py index 2a357fb..5bfb774 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import importlib import runpy + import pytest diff --git a/tests/test_main_window.py b/tests/test_main_window.py index bfe0972..6c09e71 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,22 +1,19 @@ -import pytest import importlib.metadata - from datetime import date, timedelta from pathlib import Path - -import bouquin.main_window as mwmod -from bouquin.main_window import MainWindow -from bouquin.theme import Theme, ThemeConfig, ThemeManager -from bouquin.settings import get_settings -from bouquin.key_prompt import KeyPrompt -from bouquin.db import DBConfig, DBManager -from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect -from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog -from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent - from unittest.mock import Mock, patch +import bouquin.main_window as mwmod import bouquin.version_check as version_check +import pytest +from bouquin.db import DBConfig, DBManager +from bouquin.key_prompt import KeyPrompt +from bouquin.main_window import MainWindow +from bouquin.settings import get_settings +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer +from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor +from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): @@ -1861,44 +1858,76 @@ def test_main_window_update_tag_views_no_tags_widget( assert True -def test_main_window_with_tags_disabled(qtbot, app, tmp_path): - """Test MainWindow with tags disabled in config - covers line 319""" - db_path = tmp_path / "notebook.db" +def test_main_window_without_tags(qtbot, app, tmp_db_cfg): + """Test main window when tags feature is disabled.""" s = get_settings() - s.setValue("db/default_db", str(db_path)) - s.setValue("db/key", "test-key") + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") - s.setValue("ui/tags", False) # Disable tags + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", False) # Disabled s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() - # Tags widget should be hidden - assert w.tags.isHidden() + # Verify tags widget is hidden + assert window.tags.isHidden() -def test_main_window_with_time_log_disabled(qtbot, app, tmp_path): - """Test MainWindow with time_log disabled in config - covers line 321""" - db_path = tmp_path / "notebook.db" +def test_main_window_without_time_log(qtbot, app, tmp_db_cfg): + """Test main window when time_log feature is disabled.""" s = get_settings() - s.setValue("db/default_db", str(db_path)) - s.setValue("db/key", "test-key") + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) s.setValue("ui/tags", True) - s.setValue("ui/time_log", False) # Disable time log + s.setValue("ui/time_log", False) # Disabled + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - w = MainWindow(themes=themes) - qtbot.addWidget(w) - w.show() + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() - # Time log widget should be hidden - assert w.time_log.isHidden() + # Verify time_log widget is hidden + assert window.time_log.isHidden() + assert not window.toolBar.actTimer.isVisible() + + +def test_main_window_without_documents(qtbot, app, tmp_db_cfg): + """Test main window when documents feature is disabled.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/documents", False) # Disabled + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + # Verify documents widget is hidden + assert window.todays_documents.isHidden() + assert not window.toolBar.actDocuments.isVisible() def test_export_csv_format(qtbot, app, tmp_path, monkeypatch): @@ -2161,53 +2190,6 @@ def test_main_window_without_reminders(qtbot, app, tmp_db_cfg): assert not window.toolBar.actAlarm.isVisible() -def test_main_window_without_time_log(qtbot, app, tmp_db_cfg): - """Test main window when time_log feature is disabled.""" - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - s.setValue("ui/idle_minutes", 0) - s.setValue("ui/theme", "light") - s.setValue("ui/move_todos", True) - s.setValue("ui/tags", True) - s.setValue("ui/time_log", False) # Disabled - s.setValue("ui/reminders", True) - s.setValue("ui/locale", "en") - s.setValue("ui/font_size", 11) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - window = MainWindow(themes=themes) - qtbot.addWidget(window) - window.show() - - # Verify time_log widget is hidden - assert window.time_log.isHidden() - assert not window.toolBar.actTimer.isVisible() - - -def test_main_window_without_tags(qtbot, app, tmp_db_cfg): - """Test main window when tags feature is disabled.""" - s = get_settings() - s.setValue("db/default_db", str(tmp_db_cfg.path)) - s.setValue("db/key", tmp_db_cfg.key) - s.setValue("ui/idle_minutes", 0) - s.setValue("ui/theme", "light") - s.setValue("ui/move_todos", True) - s.setValue("ui/tags", False) # Disabled - s.setValue("ui/time_log", True) - s.setValue("ui/reminders", True) - s.setValue("ui/locale", "en") - s.setValue("ui/font_size", 11) - - themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - window = MainWindow(themes=themes) - qtbot.addWidget(window) - window.show() - - # Verify tags widget is hidden - assert window.tags.isHidden() - - def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db): """Test closing the current tab via _close_current_tab.""" s = get_settings() @@ -2244,7 +2226,7 @@ def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db): assert window.tab_widget.count() == initial_count - 1 -def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db): +def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time): """Test parsing inline alarms from markdown (⏰ HH:MM format).""" from PySide6.QtCore import QTime @@ -2270,7 +2252,7 @@ def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db): window._open_date_in_tab(today_qdate) # Set content with a future alarm - future_time = QTime.currentTime().addSecs(3600) # 1 hour from now + future_time = QTime.currentTime().addSecs(3600) alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}" # Set the editor's current_date attribute @@ -2326,7 +2308,7 @@ def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db): assert len(window._reminder_timers) == 0 -def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db): +def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time): """Test that past alarms are skipped.""" from PySide6.QtCore import QTime @@ -2368,7 +2350,7 @@ def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db): assert len(window._reminder_timers) == 0 -def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db): +def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time): """Test alarm with no text before emoji uses fallback.""" from PySide6.QtCore import QTime diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index cc02ad8..9dac5d6 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1,21 +1,21 @@ import base64 + import pytest - -from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl -from PySide6.QtGui import ( - QImage, - QColor, - QKeyEvent, - QTextCursor, - QTextDocument, - QFont, - QTextCharFormat, -) -from PySide6.QtWidgets import QApplication, QTextEdit - +import re from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_highlighter import MarkdownHighlighter -from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl +from PySide6.QtGui import ( + QColor, + QFont, + QImage, + QKeyEvent, + QTextCharFormat, + QTextCursor, + QTextDocument, +) +from PySide6.QtWidgets import QApplication, QTextEdit def _today(): @@ -73,8 +73,13 @@ def test_apply_styles_and_headings(editor, qtbot): editor.apply_italic() editor.apply_strikethrough() editor.apply_heading(24) - md = editor.to_markdown() - assert "**" in md and "*~~~~*" in md + md = editor.to_markdown().strip() + + 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): @@ -151,6 +156,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor): 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): qtbot.addWidget(editor) editor.show() @@ -164,81 +216,132 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor): assert editor.toPlainText().startswith("\u2022 \n") -def test_triple_backtick_autoexpands(editor, qtbot): +def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot): + # Start empty editor.from_markdown("") press_backtick(qtbot, editor, 2) - press_backtick(qtbot, editor, 1) # triggers expansion + press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut qtbot.wait(0) t = text(editor) - assert t.count("```") == 2 - assert t.startswith("```\n\n```") - assert t.endswith("\n") - # caret is on the blank line inside the block - assert editor.textCursor().blockNumber() == 1 - assert lines_keep(editor)[1] == "" + + # The two typed backticks should have been removed + assert "`" not in t + + # With the new dialog-based implementation, and our test stub that accepts + # the dialog with empty code, no fenced code block is inserted. + assert "```" not in t + assert t == "" -def test_toolbar_inserts_block_on_own_lines(editor, qtbot): - editor.from_markdown("hello") - editor.moveCursor(QTextCursor.End) - editor.apply_code() # action inserts fenced code block +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) - t = text(editor) - assert "hello```" not in t # never inline - assert t.startswith("hello\n```") - assert t.endswith("```\n") - # caret inside block (blank line) - assert editor.textCursor().blockNumber() == 2 - assert lines_keep(editor)[2] == "" + # 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_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot): - editor.from_markdown("") - editor.apply_code() # create a block (caret now on blank line inside) +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) - pos_before = editor.textCursor().position() - t_before = text(editor) + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None - editor.apply_code() # pressing inside should be a no-op + # Toggle to expand + editor._toggle_collapse_at_block(header) qtbot.wait(0) - assert text(editor) == t_before - assert editor.textCursor().position() == pos_before + 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_toolbar_on_opening_fence_jumps_inside(editor, qtbot): - editor.from_markdown("") - editor.apply_code() +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) - # Go to opening fence (line 0) - editor.moveCursor(QTextCursor.Start) - editor.apply_code() # should jump inside the block - qtbot.wait(0) + end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text()) + assert end_marker is not None - assert editor.textCursor().blockNumber() == 1 - assert lines_keep(editor)[1] == "" + # End marker is its own block, and remains hidden + assert end_marker.text().strip() == "" + assert end_marker.isVisible() is False - -def test_toolbar_on_closing_fence_jumps_out(editor, qtbot): - editor.from_markdown("") - editor.apply_code() - qtbot.wait(0) - - # Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after) - editor.moveCursor(QTextCursor.End) # blank-after - editor.moveCursor(QTextCursor.Up) # closing fence - editor.moveCursor(QTextCursor.StartOfLine) - - editor.apply_code() # jump to the line after the fence - qtbot.wait(0) - - # Now on the blank line after the block - assert editor.textCursor().block().text() == "" - assert editor.textCursor().block().previous().text().strip() == "```" + # 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): @@ -522,25 +625,6 @@ def test_apply_italic_and_strike(editor): assert editor.textCursor().position() == len(editor.toPlainText()) - 2 -def test_apply_code_inline_block_navigation(editor): - # Selection case -> fenced block around selection - editor.setPlainText("code") - c = editor.textCursor() - c.select(QTextCursor.SelectionType.Document) - editor.setTextCursor(c) - editor.apply_code() - assert "```\ncode\n```\n" in editor.toPlainText() - - # No selection, at EOF with no following block -> creates block and extra newline path - editor.setPlainText("before") - editor.moveCursor(QTextCursor.MoveOperation.End) - editor.apply_code() - t = editor.toPlainText() - assert t.endswith("before\n```\n\n```\n") - # Caret should be inside the code block blank line - assert editor.textCursor().position() == len("before\n") + 4 - - def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path): # Non-existent path should just return (early exit) bad = tmp_path / "missing.png" @@ -1653,7 +1737,7 @@ def test_markdown_highlighter_special_characters(qtbot, app): highlighter = MarkdownHighlighter(doc, theme_manager) text = """ -Special chars: < > & " ' +Special chars: < > & " ' Escaped: \\* \\_ \\` Unicode: 你好 café résumé """ @@ -2006,7 +2090,7 @@ def test_editor_delete_operations(qtbot, app): def test_markdown_highlighter_dark_theme(qtbot, app): - """Test markdown highlighter with dark theme - covers lines 74-75""" + """Test markdown highlighter with dark theme""" # Create theme manager with dark theme themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) @@ -2371,7 +2455,7 @@ def test_highlighter_code_block_with_language(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # Verify syntax highlighting was applied (lines 186-193) + # Verify syntax highlighting was applied # We can't easily verify the exact formatting, but we ensure no crash @@ -2383,13 +2467,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # The overlap detection (lines 252, 264) should prevent issues - def test_highlighter_italic_edge_cases(editor, qtbot): """Test italic formatting edge cases.""" # Test edge case: avoiding stealing markers that are part of double - # This tests lines 267-270 editor.setPlainText("**not italic* text**") # Force rehighlight diff --git a/tests/test_markdown_editor_additional.py b/tests/test_markdown_editor_additional.py new file mode 100644 index 0000000..4037ed1 --- /dev/null +++ b/tests/test_markdown_editor_additional.py @@ -0,0 +1,932 @@ +""" +Additional tests for markdown_editor.py to improve test coverage. +These tests should be added to test_markdown_editor.py. +""" + +import pytest +from bouquin.markdown_editor import MarkdownEditor +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtCore import QPoint, Qt +from PySide6.QtGui import ( + QColor, + QImage, + QKeyEvent, + QMouseEvent, + QTextCursor, + QTextDocument, +) + + +def text(editor) -> str: + return editor.toPlainText() + + +def lines_keep(editor): + """Split preserving a trailing empty line if the text ends with '\\n'.""" + return text(editor).split("\n") + + +def press_backtick(qtbot, widget, n=1): + """Send physical backtick key events (avoid IME/dead-key issues).""" + for _ in range(n): + qtbot.keyClick(widget, Qt.Key_QuoteLeft) + + +@pytest.fixture +def editor(app, qtbot): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + ed = MarkdownEditor(themes) + qtbot.addWidget(ed) + ed.show() + qtbot.waitExposed(ed) + ed.setFocus() + return ed + + +def test_update_code_block_backgrounds_with_no_document(app, qtbot): + """Test _update_code_block_row_backgrounds when document is None.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + qtbot.addWidget(editor) + + # Create a new empty document to replace the current one + new_doc = QTextDocument() + editor.setDocument(new_doc) + editor.setDocument(None) + + # Should not crash even with no document + editor._update_code_block_row_backgrounds() + + +def test_find_code_block_bounds_invalid_block(editor): + """Test _find_code_block_bounds with invalid block.""" + editor.setPlainText("some text") + + # Create an invalid block + doc = editor.document() + invalid_block = doc.findBlockByNumber(999) # doesn't exist + + result = editor._find_code_block_bounds(invalid_block) + assert result is None + + +def test_find_code_block_bounds_on_closing_fence(editor): + """Test _find_code_block_bounds when on a closing fence.""" + editor.setPlainText("```\ncode\n```") + + doc = editor.document() + closing_fence = doc.findBlockByNumber(2) # the closing ``` + + result = editor._find_code_block_bounds(closing_fence) + assert result is not None + open_block, close_block = result + assert open_block.blockNumber() == 0 + assert close_block.blockNumber() == 2 + + +def test_find_code_block_bounds_on_opening_fence(editor): + """Test _find_code_block_bounds when on an opening fence.""" + editor.setPlainText("```\ncode\n```") + + doc = editor.document() + opening_fence = doc.findBlockByNumber(0) + + result = editor._find_code_block_bounds(opening_fence) + assert result is not None + open_block, close_block = result + assert open_block.blockNumber() == 0 + assert close_block.blockNumber() == 2 + + +def test_find_code_block_bounds_no_closing_fence(editor): + """Test _find_code_block_bounds when closing fence is missing.""" + editor.setPlainText("```\ncode without closing") + + doc = editor.document() + opening_fence = doc.findBlockByNumber(0) + + result = editor._find_code_block_bounds(opening_fence) + assert result is None + + +def test_find_code_block_bounds_no_opening_fence(editor): + """Test _find_code_block_bounds from inside code block with no opening.""" + # Simulate a malformed block (shouldn't happen in practice) + editor.setPlainText("code\n```") + + doc = editor.document() + code_line = doc.findBlockByNumber(0) + + result = editor._find_code_block_bounds(code_line) + assert result is None + + +def test_edit_code_block_checks_document(app, qtbot): + """Test _edit_code_block when editor has no document.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + qtbot.addWidget(editor) + + # Set up editor with code block + editor.setPlainText("```\ncode\n```") + doc = editor.document() + block = doc.findBlockByNumber(1) + + # Now remove the document + editor.setDocument(None) + + # The method will try to work but should handle gracefully + # It actually returns True because it processes the block from the old doc + # This tests that it doesn't crash + editor._edit_code_block(block) + # Just verify it doesn't crash - return value is implementation dependent + + +def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch): + """Test _edit_code_block when dialog is cancelled.""" + import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog + + class CancelledDialog: + def __init__(self, code, language, parent=None, allow_delete=False): + self._code = code + self._language = language + + def exec(self): + return QDialog.DialogCode.Rejected + + def code(self): + return self._code + + def language(self): + return self._language + + monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", CancelledDialog) + + editor.setPlainText("```python\ncode\n```") + doc = editor.document() + block = doc.findBlockByNumber(1) + + result = editor._edit_code_block(block) + # Should return True (event handled) even though cancelled + assert result is True + + +def test_edit_code_block_with_delete(editor, qtbot, monkeypatch): + """Test _edit_code_block when user deletes the block.""" + import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog + + class DeleteDialog: + def __init__(self, code, language, parent=None, allow_delete=False): + self._code = code + self._language = language + self._deleted = True + + def exec(self): + return QDialog.DialogCode.Accepted + + def code(self): + return self._code + + def language(self): + return self._language + + def was_deleted(self): + return self._deleted + + monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", DeleteDialog) + + editor.setPlainText("```python\noriginal code\n```\nafter") + editor.toPlainText() + + doc = editor.document() + block = doc.findBlockByNumber(1) + + result = editor._edit_code_block(block) + assert result is True + + # Code block should be deleted + new_text = editor.toPlainText() + assert "original code" not in new_text + + +def test_edit_code_block_language_change(editor, qtbot, monkeypatch): + """Test _edit_code_block with language change.""" + import bouquin.markdown_editor as markdown_editor + from PySide6.QtWidgets import QDialog + + class LanguageChangeDialog: + def __init__(self, code, language, parent=None, allow_delete=False): + self._code = code + self._language = "javascript" # Change from python + + def exec(self): + return QDialog.DialogCode.Accepted + + def code(self): + return self._code + + def language(self): + return self._language + + monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", LanguageChangeDialog) + + editor.setPlainText("```python\ncode\n```") + doc = editor.document() + block = doc.findBlockByNumber(1) + + result = editor._edit_code_block(block) + assert result is True + + # Verify metadata was updated + assert hasattr(editor, "_code_metadata") + lang = editor._code_metadata.get_language(0) + assert lang == "javascript" + + +def test_delete_code_block_no_bounds(editor): + """Test _delete_code_block when bounds can't be found.""" + editor.setPlainText("not a code block") + doc = editor.document() + block = doc.findBlockByNumber(0) + + result = editor._delete_code_block(block) + assert result is False + + +def test_delete_code_block_checks_document(app, qtbot): + """Test _delete_code_block when editor has no document.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + qtbot.addWidget(editor) + + # Set up with code block + editor.setPlainText("```\ncode\n```") + doc = editor.document() + block = doc.findBlockByNumber(1) + + # Remove the document + editor.setDocument(None) + + # The method will attempt to work but should handle gracefully + # Just verify it doesn't crash + editor._delete_code_block(block) + + +def test_delete_code_block_at_end_of_document(editor): + """Test _delete_code_block when code block is at end of document.""" + editor.setPlainText("```\ncode\n```") # No trailing newline + doc = editor.document() + block = doc.findBlockByNumber(1) + + result = editor._delete_code_block(block) + assert result is True + + # Should be empty or minimal + assert "code" not in editor.toPlainText() + + +def test_delete_code_block_with_text_after(editor): + """Test _delete_code_block when there's text after the block.""" + editor.setPlainText("```\ncode\n```\ntext after") + doc = editor.document() + block = doc.findBlockByNumber(1) + + result = editor._delete_code_block(block) + assert result is True + + # Code should be gone, text after should remain + new_text = editor.toPlainText() + assert "code" not in new_text + assert "text after" in new_text + + +def test_apply_line_spacing_no_document(app): + """Test _apply_line_spacing when document is None.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + editor.setDocument(None) + + # Should not crash + editor._apply_line_spacing(125.0) + + +def test_apply_code_block_spacing(editor): + """Test _apply_code_block_spacing applies correct spacing.""" + editor.setPlainText("```\nline1\nline2\n```") + + # Apply spacing + editor._apply_code_block_spacing() + + # Verify blocks have spacing applied + doc = editor.document() + for i in range(doc.blockCount()): + block = doc.findBlockByNumber(i) + assert block.isValid() + + +def test_to_markdown_with_code_metadata(editor): + """Test to_markdown includes code block metadata.""" + editor.setPlainText("```python\ncode\n```") + + # Set some metadata + editor._code_metadata.set_language(0, "python") + + md = editor.to_markdown() + + # Should include metadata comment + assert "code-langs" in md or "code" in md + + +def test_from_markdown_creates_code_metadata(app): + """Test from_markdown creates _code_metadata if missing.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Remove the attribute + if hasattr(editor, "_code_metadata"): + delattr(editor, "_code_metadata") + + # Should recreate it + editor.from_markdown("# test") + + assert hasattr(editor, "_code_metadata") + + +def test_embed_images_preserves_original_size(editor, tmp_path): + """Test that embedded images preserve their original dimensions.""" + # Create a test image + img = tmp_path / "test.png" + qimg = QImage(100, 50, QImage.Format_RGBA8888) + qimg.fill(QColor(255, 0, 0)) + qimg.save(str(img)) + + # Create markdown with image + import base64 + + with open(img, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + + md = f"![test](data:image/png;base64,{b64})" + editor.from_markdown(md) + + # Image should be embedded with original size + doc = editor.document() + assert doc is not None + + +def test_trim_list_prefix_no_selection(editor): + """Test _maybe_trim_list_prefix_from_line_selection with no selection.""" + editor.setPlainText("- item") + cursor = editor.textCursor() + cursor.clearSelection() + editor.setTextCursor(cursor) + + # Should not crash + editor._maybe_trim_list_prefix_from_line_selection() + + +def test_trim_list_prefix_multiline_selection(editor): + """Test _maybe_trim_list_prefix_from_line_selection across multiple lines.""" + editor.setPlainText("- item1\n- item2") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + editor.setTextCursor(cursor) + + # Should not trim multi-line selections + editor._maybe_trim_list_prefix_from_line_selection() + + +def test_trim_list_prefix_not_full_line(editor): + """Test _maybe_trim_list_prefix_from_line_selection with partial selection.""" + editor.setPlainText("- item text here") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 5) + editor.setTextCursor(cursor) + + # Partial line selection should not be trimmed + editor._maybe_trim_list_prefix_from_line_selection() + + +def test_trim_list_prefix_already_after_prefix(editor): + """Test _maybe_trim_list_prefix when selection already after prefix.""" + editor.setPlainText("- item text") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3) # After "- " + cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor) + editor.setTextCursor(cursor) + + # Should not need adjustment + editor._maybe_trim_list_prefix_from_line_selection() + + +def test_trim_list_prefix_during_adjustment(editor): + """Test _maybe_trim_list_prefix re-entry guard.""" + editor.setPlainText("- item") + editor._adjusting_selection = True + + # Should return early due to guard + editor._maybe_trim_list_prefix_from_line_selection() + + editor._adjusting_selection = False + + +def test_detect_list_type_checkbox_checked(editor): + """Test _detect_list_type with checked checkbox.""" + list_type, prefix = editor._detect_list_type( + f"{editor._CHECK_CHECKED_DISPLAY} done" + ) + assert list_type == "checkbox" + assert editor._CHECK_UNCHECKED_DISPLAY in prefix + + +def test_detect_list_type_numbered(editor): + """Test _detect_list_type with numbered list.""" + list_type, prefix = editor._detect_list_type("1. item") + assert list_type == "number" + # The prefix will be "2. " because it increments for the next item + assert "." in prefix + + +def test_detect_list_type_markdown_bullet(editor): + """Test _detect_list_type with markdown bullet.""" + list_type, prefix = editor._detect_list_type("- item") + assert list_type == "bullet" + + +def test_detect_list_type_not_a_list(editor): + """Test _detect_list_type with regular text.""" + list_type, prefix = editor._detect_list_type("regular text") + assert list_type is None + assert prefix == "" + + +def test_list_prefix_length_numbered(editor): + """Test _list_prefix_length_for_block with numbered list.""" + editor.setPlainText("123. item") + doc = editor.document() + block = doc.findBlockByNumber(0) + + length = editor._list_prefix_length_for_block(block) + assert length > 0 + + +def test_key_press_ctrl_home(editor, qtbot): + """Test Ctrl+Home key combination.""" + editor.setPlainText("line1\nline2\nline3") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.ControlModifier, "") + editor.keyPressEvent(event) + + # Should move to start of document + assert editor.textCursor().position() == 0 + + +def test_key_press_ctrl_left(editor, qtbot): + """Test Ctrl+Left key combination.""" + editor.setPlainText("word1 word2 word3") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.ControlModifier, "") + editor.keyPressEvent(event) + + # Should move left by word + + +def test_key_press_home_in_list(editor, qtbot): + """Test Home key in list item.""" + editor.setPlainText("- item text") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.NoModifier, "") + editor.keyPressEvent(event) + + # Should jump to after "- " + pos = editor.textCursor().position() + assert pos > 0 + + +def test_key_press_left_in_list_prefix(editor, qtbot): + """Test Left key when in list prefix region.""" + editor.setPlainText("- item") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Right) # Inside "- " + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.NoModifier, "") + editor.keyPressEvent(event) + + # Should snap to after prefix + + +def test_key_press_up_in_code_block(editor, qtbot): + """Test Up key inside code block.""" + editor.setPlainText("```\ncode line 1\ncode line 2\n```") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Down) + cursor.movePosition(QTextCursor.Down) # On "code line 2" + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Up, Qt.NoModifier, "") + editor.keyPressEvent(event) + + # Should move up normally in code block + + +def test_key_press_down_in_list_item(editor, qtbot): + """Test Down key in list item.""" + editor.setPlainText("- item1\n- item2") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Right) # In prefix of first item + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Down, Qt.NoModifier, "") + editor.keyPressEvent(event) + + # Should snap to after prefix on next line + + +def test_key_press_enter_after_markers(editor, qtbot): + """Test Enter key after style markers.""" + editor.setPlainText("text **") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") + editor.keyPressEvent(event) + + # Should handle markers + + +def test_key_press_enter_on_closing_fence(editor, qtbot): + """Test Enter key on closing fence line.""" + editor.setPlainText("```\ncode\n```") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.movePosition(QTextCursor.StartOfLine) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") + editor.keyPressEvent(event) + + # Should create new line after fence + + +def test_key_press_backspace_empty_checkbox(editor, qtbot): + """Test Backspace in empty checkbox item.""" + editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "") + editor.keyPressEvent(event) + + # Should remove checkbox + + +def test_key_press_backspace_numbered_list(editor, qtbot): + """Test Backspace at start of numbered list item.""" + editor.setPlainText("1. ") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "") + editor.keyPressEvent(event) + + +def test_key_press_tab_in_bullet_list(editor, qtbot): + """Test Tab key in bullet list.""" + editor.setPlainText("- item") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t") + editor.keyPressEvent(event) + + # Should indent + + +def test_key_press_shift_tab_in_bullet_list(editor, qtbot): + """Test Shift+Tab in indented bullet list.""" + editor.setPlainText(" - item") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.ShiftModifier, "") + editor.keyPressEvent(event) + + # Should unindent + + +def test_key_press_tab_in_checkbox(editor, qtbot): + """Test Tab in checkbox item.""" + editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t") + editor.keyPressEvent(event) + + +def test_apply_weight_to_selection(editor, qtbot): + """Test apply_weight makes text bold.""" + editor.setPlainText("text to bold") + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + editor.apply_weight() + + md = editor.to_markdown() + assert "**" in md + + +def test_apply_italic_to_selection(editor, qtbot): + """Test apply_italic makes text italic.""" + editor.setPlainText("text to italicize") + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + editor.apply_italic() + + md = editor.to_markdown() + assert "*" in md or "_" in md + + +def test_apply_strikethrough_to_selection(editor, qtbot): + """Test apply_strikethrough.""" + editor.setPlainText("text to strike") + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + editor.apply_strikethrough() + + md = editor.to_markdown() + assert "~~" in md + + +def test_apply_code_on_selection(editor, qtbot): + """Test apply_code with selected text.""" + editor.setPlainText("some code") + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # apply_code opens dialog - with test stub it accepts + editor.apply_code() + + # The stub dialog will create a code block + editor.toPlainText() + # May contain code block elements depending on dialog behavior + + +def test_toggle_numbers_on_plain_text(editor, qtbot): + """Test toggle_numbers converts text to numbered list.""" + editor.setPlainText("item 1") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + editor.toggle_numbers() + + text = editor.toPlainText() + assert "1." in text + + +def test_toggle_bullets_on_plain_text(editor, qtbot): + """Test toggle_bullets converts text to bullet list.""" + editor.setPlainText("item 1") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + editor.toggle_bullets() + + text = editor.toPlainText() + # Will have unicode bullet + assert editor._BULLET_DISPLAY in text + + +def test_toggle_bullets_removes_bullets(editor, qtbot): + """Test toggle_bullets removes existing bullets.""" + editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + editor.toggle_bullets() + + text = editor.toPlainText() + # Should have removed bullet + assert text.strip() == "item 1" + + +def test_toggle_checkboxes_on_bullets(editor, qtbot): + """Test toggle_checkboxes converts bullets to checkboxes.""" + editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + editor.toggle_checkboxes() + + text = editor.toPlainText() + # Should have checkbox characters + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_apply_heading_various_levels(editor, qtbot): + """Test apply_heading with different levels.""" + test_cases = [ + (24, "#"), # H1 + (18, "##"), # H2 + (14, "###"), # H3 + (12, ""), # Normal (no heading) + ] + + for size, expected_marker in test_cases: + editor.setPlainText("heading text") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + editor.apply_heading(size) + + text = editor.toPlainText() + if expected_marker: + assert text.startswith(expected_marker) + + +def test_insert_image_from_path_invalid_extension(editor, tmp_path): + """Test insert_image_from_path with invalid extension.""" + invalid_file = tmp_path / "file.txt" + invalid_file.write_text("not an image") + + # Should not crash + editor.insert_image_from_path(invalid_file) + + +def test_insert_image_from_path_nonexistent(editor, tmp_path): + """Test insert_image_from_path with nonexistent file.""" + nonexistent = tmp_path / "doesnt_exist.png" + + # Should not crash + editor.insert_image_from_path(nonexistent) + + +def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot): + """Test clicking checkbox toggles it from unchecked to checked.""" + editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") + + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + rect = editor.cursorRect() + pos = QPoint(rect.left() + 2, rect.center().y()) + + event = QMouseEvent( + QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier + ) + + editor.mousePressEvent(event) + + text = editor.toPlainText() + # Should toggle to checked + assert editor._CHECK_CHECKED_DISPLAY in text + + +def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot): + """Test clicking checked checkbox toggles it to unchecked.""" + editor.setPlainText(f"{editor._CHECK_CHECKED_DISPLAY} completed task") + + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + rect = editor.cursorRect() + pos = QPoint(rect.left() + 2, rect.center().y()) + + event = QMouseEvent( + QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier + ) + + editor.mousePressEvent(event) + + text = editor.toPlainText() + # Should toggle to unchecked + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_mouse_double_click_suppression(editor, qtbot): + """Test double-click suppression for checkboxes.""" + editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") + + # Simulate the suppression flag being set + editor._suppress_next_checkbox_double_click = True + + pos = QPoint(10, 10) + event = QMouseEvent( + QMouseEvent.MouseButtonDblClick, + pos, + Qt.LeftButton, + Qt.LeftButton, + Qt.NoModifier, + ) + + editor.mouseDoubleClickEvent(event) + + # Flag should be cleared + assert not editor._suppress_next_checkbox_double_click + + +def test_context_menu_in_code_block(editor, qtbot): + """Test context menu when in code block.""" + editor.setPlainText("```python\ncode\n```") + + from PySide6.QtGui import QContextMenuEvent + + # Position in the code block + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Down) + editor.setTextCursor(cursor) + + rect = editor.cursorRect() + QContextMenuEvent(QContextMenuEvent.Mouse, rect.center()) + + # Should not crash + # Note: actual menu exec is blocked in tests, but we verify it doesn't crash + + +def test_set_code_block_language(editor, qtbot): + """Test _set_code_block_language sets metadata.""" + editor.setPlainText("```\ncode\n```") + doc = editor.document() + block = doc.findBlockByNumber(1) + + editor._set_code_block_language(block, "python") + + # Metadata should be set + lang = editor._code_metadata.get_language(0) + assert lang == "python" + + +def test_get_current_line_task_text_strips_prefixes(editor, qtbot): + """Test get_current_line_task_text removes list/checkbox prefixes.""" + test_cases = [ + (f"{editor._CHECK_UNCHECKED_DISPLAY} task text", "task text"), + (f"{editor._BULLET_DISPLAY} bullet text", "bullet text"), + ("- markdown bullet", "markdown bullet"), + ("1. numbered item", "numbered item"), + ] + + for input_text, expected in test_cases: + editor.setPlainText(input_text) + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + editor.setTextCursor(cursor) + + result = editor.get_current_line_task_text() + assert result == expected + + +# Test for selection changed event +def test_selection_changed_in_list(editor, qtbot): + """Test selectionChanged event in list items.""" + editor.setPlainText("- item one\n- item two") + + # Select text in first item + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3) + cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor) + editor.setTextCursor(cursor) + + # Trigger selection changed + editor.selectionChanged.emit() + + # Should handle gracefully diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 9d34a4f..1dd4d95 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -1,5 +1,55 @@ from unittest.mock import Mock, patch -from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager + +from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget + + +class DummyTimeLogWidget(QWidget): + """Minimal stand-in for the real TimeLogWidget used by PomodoroManager.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.layout = QVBoxLayout(self) + self.summary_label = QLabel(self) + # toggle_btn and _reload_summary are used by PomodoroManager._on_timer_stopped + self.toggle_btn = Mock() + self.toggle_btn.isChecked.return_value = True + + def show_pomodoro_widget(self, widget): + # Manager calls this when embedding the timer + if widget is not None: + self.layout.addWidget(widget) + + def clear_pomodoro_widget(self): + # Manager calls this when removing the embedded timer + while self.layout.count(): + item = self.layout.takeAt(0) + w = item.widget() + if w is not None: + w.setParent(None) + + def _reload_summary(self): + # Called after TimeLogDialog closes; no-op is fine for tests + pass + + +class DummyMainWindow(QWidget): + """Minimal stand-in for MainWindow that PomodoroManager expects.""" + + def __init__(self, app, parent=None): + super().__init__(parent) + # Sidebar time log widget + self.time_log = DummyTimeLogWidget(self) + + # Toolbar with an actTimer QAction so QSignalBlocker works + self.toolBar = QToolBar(self) + self.toolBar.actTimer = QAction(self) + self.toolBar.addAction(self.toolBar.actTimer) + + # Themes attribute used when constructing TimeLogDialog + self.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) def test_pomodoro_timer_init(qtbot, app, fresh_db): @@ -147,15 +197,6 @@ def test_pomodoro_timer_modal_state(qtbot, app): assert timer.isModal() is False -def test_pomodoro_timer_window_title(qtbot, app): - """Test timer window title.""" - timer = PomodoroTimer("Test task") - qtbot.addWidget(timer) - - # Window title should contain some reference to timer/pomodoro - assert len(timer.windowTitle()) > 0 - - def test_pomodoro_manager_init(app, fresh_db): """Test PomodoroManager initialization.""" parent = Mock() @@ -168,10 +209,10 @@ def test_pomodoro_manager_init(app, fresh_db): def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): """Test starting a timer through the manager.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() + parent = DummyMainWindow(app) qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) line_text = "Important task" @@ -181,15 +222,16 @@ def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): assert manager._active_timer is not None assert manager._active_timer._task_text == line_text - qtbot.addWidget(manager._active_timer) + # Timer should be embedded in the sidebar time log widget + assert manager._active_timer.parent() is parent.time_log def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): - """Test that starting a new timer closes the previous one.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() + """Test that starting a new timer closes/replaces the previous one.""" + parent = DummyMainWindow(app) qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) # Start first timer @@ -205,16 +247,20 @@ def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): assert first_timer is not second_timer assert second_timer._task_text == "Task 2" + assert second_timer.parent() is parent.time_log def test_pomodoro_manager_on_timer_stopped_minimum_hours( qtbot, app, fresh_db, monkeypatch ): - """Test that timer stopped with very short time logs minimum hours.""" - parent = Mock() + """Timer stopped with very short time logs should enforce minimum hours.""" + parent = DummyMainWindow(app) + qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) - # Mock TimeLogDialog to avoid actually showing it + # Mock TimeLogDialog to avoid showing it mock_dialog = Mock() mock_dialog.hours_spin = Mock() mock_dialog.note = Mock() @@ -230,8 +276,11 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours( def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): - """Test that elapsed time is properly rounded to decimal hours.""" - parent = Mock() + """Elapsed time should be rounded to a 0.25-hour increment.""" + parent = DummyMainWindow(app) + qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) mock_dialog = Mock() @@ -240,21 +289,50 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey mock_dialog.exec = Mock() with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): - # Test with 1800 seconds (30 minutes) + # 1800 seconds (30 min) should round up to 0.5 manager._on_timer_stopped(1800, "Task", "2024-01-15") mock_dialog.hours_spin.setValue.assert_called_once() hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] - # Should round up and be a multiple of 0.25 + assert hours_set > 0 - assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25 + # Should be a multiple of 0.25 + 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( qtbot, app, fresh_db, monkeypatch ): - """Test that timer stopped pre-fills the note in time log dialog.""" - parent = Mock() + """Timer stopped should pre-fill the note in the time log dialog.""" + parent = DummyMainWindow(app) + qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) mock_dialog = Mock() @@ -273,11 +351,11 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note( def test_pomodoro_manager_timer_stopped_signal_connection( qtbot, app, fresh_db, monkeypatch ): - """Test that timer stopped signal is properly connected.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() + """Timer's stop button should result in TimeLogDialog being executed.""" + parent = DummyMainWindow(app) qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) # Mock TimeLogDialog @@ -291,11 +369,12 @@ def test_pomodoro_manager_timer_stopped_signal_connection( timer = manager._active_timer qtbot.addWidget(timer) - # Simulate timer stopped + # Simulate timer having run for a bit timer._elapsed_seconds = 1000 + + # Clicking "Stop and log" should emit timerStopped and open the dialog timer._stop_and_log() - # TimeLogDialog should have been created assert mock_dialog.exec.called diff --git a/tests/test_reminders.py b/tests/test_reminders.py index c003d86..a52c559 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -1,15 +1,42 @@ -from unittest.mock import patch +from datetime import date, timedelta +from unittest.mock import MagicMock, patch + +import pytest from bouquin.reminders import ( - Reminder, - ReminderType, - ReminderDialog, - UpcomingRemindersWidget, ManageRemindersDialog, + Reminder, + ReminderDialog, + ReminderType, + UpcomingRemindersWidget, ) -from PySide6.QtCore import QDate, QTime +from PySide6.QtCore import QDate, QDateTime, QTime from PySide6.QtWidgets import QDialog, QMessageBox, QWidget -from datetime import date, timedelta + +@pytest.fixture +def freeze_reminders_time(monkeypatch): + # Freeze 'now' used inside bouquin.reminders to 12:00 today + import bouquin.reminders as rem + + today = QDate.currentDate() + fixed_time = QTime(12, 0) + fixed_dt = QDateTime(today, fixed_time) + monkeypatch.setattr( + rem.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt)) + ) + yield + + +def _add_daily_reminder(db, text="Standup", time_str="23:59"): + r = Reminder( + id=None, + text=text, + time_str=time_str, + reminder_type=ReminderType.DAILY, + active=True, + ) + r.id = db.save_reminder(r) + return r def test_reminder_type_enum(app): @@ -274,20 +301,24 @@ def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db): widget = UpcomingRemindersWidget(fresh_db) qtbot.addWidget(widget) - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - mock_get.return_value = Reminder( - id=None, - text="New reminder", - time_str="10:00", - reminder_type=ReminderType.DAILY, - ) + new_reminder = Reminder( + id=None, + text="New reminder", + time_str="10:00", + reminder_type=ReminderType.DAILY, + ) - widget._add_reminder() + # Mock the entire ReminderDialog class to avoid Qt parent issues + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + mock_dialog.get_reminder.return_value = new_reminder - # Reminder should be saved - reminders = fresh_db.get_all_reminders() - assert len(reminders) > 0 + with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog): + widget._add_reminder() + + # Reminder should be saved + reminders = fresh_db.get_all_reminders() + assert len(reminders) > 0 def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db): @@ -310,17 +341,20 @@ def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db): if widget.reminder_list.count() > 0: item = widget.reminder_list.item(0) - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - updated = Reminder( - id=1, - text="Updated", - time_str="11:00", - reminder_type=ReminderType.DAILY, - ) - mock_get.return_value = updated + updated = Reminder( + id=1, + text="Updated", + time_str="11:00", + reminder_type=ReminderType.DAILY, + ) - widget._edit_reminder(item) + # Mock the entire ReminderDialog class to avoid Qt parent issues + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + mock_dialog.get_reminder.return_value = updated + + with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog): + widget._edit_reminder(item) def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db): @@ -380,17 +414,6 @@ def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app): widget._check_reminders() -def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db): - """Test starting the regular check timer.""" - widget = UpcomingRemindersWidget(fresh_db) - qtbot.addWidget(widget) - - widget._start_regular_timer() - - # Timer should be running - assert widget._check_timer.isActive() - - def test_manage_reminders_dialog_init(qtbot, app, fresh_db): """Test ManageRemindersDialog initialization.""" dialog = ManageRemindersDialog(fresh_db) @@ -435,19 +458,23 @@ def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db): initial_count = dialog.table.rowCount() - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - mock_get.return_value = Reminder( - id=None, - text="New", - time_str="10:00", - reminder_type=ReminderType.DAILY, - ) + new_reminder = Reminder( + id=None, + text="New", + time_str="10:00", + reminder_type=ReminderType.DAILY, + ) - dialog._add_reminder() + # Mock the entire ReminderDialog class to avoid Qt parent issues + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + mock_dialog.get_reminder.return_value = new_reminder - # Table should have one more row - assert dialog.table.rowCount() == initial_count + 1 + with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog): + dialog._add_reminder() + + # Table should have one more row + assert dialog.table.rowCount() == initial_count + 1 def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db): @@ -464,16 +491,20 @@ def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db): dialog = ManageRemindersDialog(fresh_db) qtbot.addWidget(dialog) - with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted): - with patch.object(ReminderDialog, "get_reminder") as mock_get: - mock_get.return_value = Reminder( - id=1, - text="Updated", - time_str="11:00", - reminder_type=ReminderType.DAILY, - ) + updated = Reminder( + id=1, + text="Updated", + time_str="11:00", + reminder_type=ReminderType.DAILY, + ) - dialog._edit_reminder(reminder) + # Mock the entire ReminderDialog class to avoid Qt parent issues + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + mock_dialog.get_reminder.return_value = updated + + with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog): + dialog._edit_reminder(reminder) def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db): @@ -544,7 +575,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db): qtbot.addWidget(dialog) # Check that the type column shows the day - type_item = dialog.table.item(0, 2) + type_item = dialog.table.item(0, 3) assert "Wed" in type_item.text() @@ -599,7 +630,11 @@ def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db): widget = UpcomingRemindersWidget(fresh_db) qtbot.addWidget(widget) - with patch.object(ManageRemindersDialog, "exec"): + # Mock the entire ManageRemindersDialog class to avoid Qt parent issues + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + + with patch("bouquin.reminders.ManageRemindersDialog", return_value=mock_dialog): widget._manage_reminders() @@ -799,3 +834,104 @@ def test_edit_reminder_dialog(qtbot, fresh_db): # Verify fields are populated assert dlg.text_edit.text() == "Original text" assert dlg.time_edit.time().toString("HH:mm") == "14:30" + + +def test_upcoming_reminders_context_menu_shows( + qtbot, app, fresh_db, freeze_reminders_time, monkeypatch +): + from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget + from PySide6 import QtGui, QtWidgets + from PySide6.QtCore import QPoint + + # Add a future reminder for today + r = Reminder( + id=None, + text="Ping", + time_str="23:59", + reminder_type=ReminderType.DAILY, + active=True, + ) + r.id = fresh_db.save_reminder(r) + + w = UpcomingRemindersWidget(fresh_db) + qtbot.addWidget(w) + w.refresh() + + # Select first upcoming item so context menu code path runs + assert w.reminder_list.count() > 0 + w.reminder_list.setCurrentItem(w.reminder_list.item(0)) + + called = {"exec": False, "actions": []} + + class DummyAction: + def __init__(self, text, parent=None): + self._text = text + + class _Sig: + def connect(self, fn): + pass + + self.triggered = _Sig() + + class DummyMenu: + def __init__(self, parent=None): + pass + + def addAction(self, action): + called["actions"].append(getattr(action, "_text", str(action))) + + def exec(self, *_, **__): + called["exec"] = True + + # Patch the modules that the inline imports will read from + monkeypatch.setattr(QtWidgets, "QMenu", DummyMenu, raising=True) + monkeypatch.setattr(QtGui, "QAction", DummyAction, raising=True) + + # Invoke directly (normally via right-click) + w._show_reminder_context_menu(QPoint(5, 5)) + + assert called["exec"] is True + assert len(called["actions"]) >= 2 # at least Edit/Deactivate/Delete + + +def test_upcoming_reminders_delete_selected_dedupes( + qtbot, app, fresh_db, freeze_reminders_time, monkeypatch +): + from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget + from PySide6.QtCore import QItemSelectionModel + from PySide6.QtWidgets import QMessageBox + + r = Reminder( + id=None, + text="Duplicate target", + time_str="23:59", + reminder_type=ReminderType.DAILY, + active=True, + ) + r.id = fresh_db.save_reminder(r) + + w = UpcomingRemindersWidget(fresh_db) + qtbot.addWidget(w) + w.refresh() + + assert w.reminder_list.count() >= 2 # daily -> multiple upcoming occurrences + + # First selects & clears; second adds to selection + w.reminder_list.setCurrentRow(0, QItemSelectionModel.SelectionFlag.ClearAndSelect) + w.reminder_list.setCurrentRow(1, QItemSelectionModel.SelectionFlag.Select) + + deleted_ids = [] + + def fake_delete(rem_id): + deleted_ids.append(rem_id) + + # Auto-confirm deletion + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.Yes) + ) + monkeypatch.setattr(fresh_db, "delete_reminder", fake_delete) + + w._delete_selected_reminders() + + # Should de-duplicate to a single DB delete call + assert deleted_ids == [r.id] diff --git a/tests/test_search.py b/tests/test_search.py index 6f3ab23..a333907 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -33,7 +33,10 @@ def test_open_selected_with_data(qtbot, fresh_db): it = QListWidgetItem("dummy") from PySide6.QtCore import Qt - it.setData(Qt.ItemDataRole.UserRole, "1999-12-31") + it.setData( + Qt.ItemDataRole.UserRole, + {"kind": "page", "date": "1999-12-31"}, + ) s.results.addItem(it) s._open_selected(it) assert seen == ["1999-12-31"] @@ -95,6 +98,6 @@ def test_populate_results_shows_both_ellipses(qtbot, fresh_db): qtbot.addWidget(s) s.show() long = "X" * 40 + "alpha" + "Y" * 40 - rows = [("2000-01-01", long)] + rows = [("page", "2000-01-01", "2000-01-01", long, None)] s._populate_results("alpha", rows) assert s.results.count() >= 1 diff --git a/tests/test_settings.py b/tests/test_settings.py index f272ab2..086d590 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,9 +1,5 @@ -from bouquin.settings import ( - get_settings, - load_db_config, - save_db_config, -) from bouquin.db import DBConfig +from bouquin.settings import get_settings, load_db_config, save_db_config def _clear_db_settings(): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index ad53951..0b1dafd 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,11 +1,11 @@ -from bouquin.db import DBManager, DBConfig -from bouquin.key_prompt import KeyPrompt import bouquin.settings_dialog as sd -from bouquin.settings_dialog import SettingsDialog -from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.db import DBConfig, DBManager +from bouquin.key_prompt import KeyPrompt from bouquin.settings import get_settings +from bouquin.settings_dialog import SettingsDialog +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtCore import QTimer -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog +from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py index 8ff73b1..e3d2b5f 100644 --- a/tests/test_statistics_dialog.py +++ b/tests/test_statistics_dialog.py @@ -1,13 +1,11 @@ import datetime as _dt -from datetime import datetime, timedelta, date +from datetime import date, datetime, timedelta from bouquin import strings - -from PySide6.QtCore import Qt, QPoint, QDate -from PySide6.QtWidgets import QLabel, QWidget -from PySide6.QtTest import QTest - from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog +from PySide6.QtCore import QDate, QPoint, Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QLabel, QWidget class FakeStatsDB: @@ -16,6 +14,7 @@ class FakeStatsDB: def __init__(self): d1 = _dt.date(2024, 1, 1) d2 = _dt.date(2024, 1, 2) + self.stats = ( 2, # pages_with_content 5, # total_revisions @@ -27,7 +26,20 @@ class FakeStatsDB: "2024-01-02", # page_most_tags 2, # page_most_tags_count {d1: 1, d2: 2}, # revisions_by_date + {d1: 60, d2: 120}, # time_minutes_by_date + 180, # total_time_minutes + "2024-01-02", # day_most_time + 120, # day_most_time_minutes + "Project A", # project_most_minutes_name + 120, # project_most_minutes + "Activity A", # activity_most_minutes_name + 120, # activity_most_minutes + {d1: 1, d2: 3}, # reminders_by_date + 4, # total_reminders + "2024-01-02", # day_most_reminders + 3, # day_most_reminders_count ) + self.called = False def gather_stats(self): @@ -59,7 +71,7 @@ def test_statistics_dialog_populates_fields_and_heatmap(qtbot): # Heatmap is created and uses "words" by default words_by_date = db.stats[4] - revisions_by_date = db.stats[-1] + revisions_by_date = db.stats[9] assert hasattr(dlg, "_heatmap") assert dlg._heatmap._data == words_by_date @@ -82,13 +94,25 @@ class EmptyStatsDB: 0, # pages_with_content 0, # total_revisions None, # page_most_revisions - 0, + 0, # page_most_revisions_count {}, # words_by_date 0, # total_words 0, # unique_tags None, # page_most_tags - 0, + 0, # page_most_tags_count {}, # revisions_by_date + {}, # time_minutes_by_date + 0, # total_time_minutes + None, # day_most_time + 0, # day_most_time_minutes + None, # project_most_minutes_name + 0, # project_most_minutes + None, # activity_most_minutes_name + 0, # activity_most_minutes + {}, # reminders_by_date + 0, # total_reminders + None, # day_most_reminders + 0, # day_most_reminders_count ) @@ -632,5 +656,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db): # Force a repaint to execute paintEvent heatmap.repaint() - # The month continuation logic (line 175) should prevent duplicate labels + # The month continuation logic should prevent duplicate labels # We can't easily test the visual output, but we ensure no crash diff --git a/tests/test_tabs.py b/tests/test_tabs.py index fe73828..b495356 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -1,12 +1,11 @@ import types -from PySide6.QtWidgets import QFileDialog -from PySide6.QtGui import QTextCursor - -from bouquin.theme import ThemeManager, ThemeConfig, Theme -from bouquin.settings import get_settings -from bouquin.main_window import MainWindow from bouquin.history_dialog import HistoryDialog +from bouquin.main_window import MainWindow +from bouquin.settings import get_settings +from bouquin.theme import Theme, ThemeConfig, ThemeManager +from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QFileDialog def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): diff --git a/tests/test_tags.py b/tests/test_tags.py index 8564c6b..cb1f1d1 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,23 +1,20 @@ +import bouquin.strings as strings import pytest - -from PySide6.QtCore import Qt, QPoint, QEvent, QDate -from PySide6.QtGui import QMouseEvent, QColor +from bouquin.db import DBManager +from bouquin.flow_layout import FlowLayout +from bouquin.strings import load_strings +from bouquin.tag_browser import TagBrowserDialog +from bouquin.tags_widget import PageTagsWidget, TagChip +from PySide6.QtCore import QDate, QEvent, QPoint, Qt +from PySide6.QtGui import QColor, QMouseEvent from PySide6.QtWidgets import ( QApplication, - QMessageBox, - QInputDialog, QColorDialog, QDialog, + QInputDialog, + QMessageBox, ) -from bouquin.db import DBManager -from bouquin.strings import load_strings -from bouquin.tags_widget import PageTagsWidget, TagChip -from bouquin.tag_browser import TagBrowserDialog -from bouquin.flow_layout import FlowLayout -from sqlcipher3.dbapi2 import IntegrityError - -import bouquin.strings as strings - +from sqlcipher4.dbapi2 import IntegrityError # ============================================================================ # DB Layer Tag Tests @@ -1649,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db): def test_flow_layout_take_at_invalid_index(app): """Test FlowLayout.takeAt with out-of-bounds index""" - from PySide6.QtWidgets import QWidget, QLabel + from PySide6.QtWidgets import QLabel, QWidget widget = QWidget() layout = FlowLayout(widget) @@ -1673,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app): def test_flow_layout_take_at_boundary(app): """Test FlowLayout.takeAt at exact boundary""" - from PySide6.QtWidgets import QWidget, QLabel + from PySide6.QtWidgets import QLabel, QWidget widget = QWidget() layout = FlowLayout(widget) diff --git a/tests/test_theme.py b/tests/test_theme.py index 6f19a62..a1dc283 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,8 +1,7 @@ +from bouquin.theme import Theme, ThemeConfig, ThemeManager from PySide6.QtGui import QPalette from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget -from bouquin.theme import Theme, ThemeConfig, ThemeManager - def test_theme_manager_apply_light_and_dark(app): cfg = ThemeConfig(theme=Theme.LIGHT) diff --git a/tests/test_time_log.py b/tests/test_time_log.py index 68dad54..b03029e 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1,21 +1,18 @@ -import pytest from datetime import date, timedelta -from PySide6.QtCore import Qt, QDate -from PySide6.QtWidgets import ( - QMessageBox, - QInputDialog, - QFileDialog, -) -from sqlcipher3.dbapi2 import IntegrityError +from unittest.mock import MagicMock, patch -from bouquin.theme import ThemeManager, ThemeConfig, Theme +import bouquin.strings as strings +import pytest +from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.time_log import ( - TimeLogWidget, - TimeLogDialog, TimeCodeManagerDialog, + TimeLogDialog, + TimeLogWidget, TimeReportDialog, ) -import bouquin.strings as strings +from PySide6.QtCore import QDate, Qt +from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox +from sqlcipher4.dbapi2 import IntegrityError @pytest.fixture @@ -1187,8 +1184,8 @@ def test_time_report_dialog_creation(qtbot, fresh_db): dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) - assert dialog.project_combo.count() == 0 - assert dialog.granularity.count() == 3 # day, week, month + assert dialog.project_combo.count() == 1 + assert dialog.granularity.count() == 5 def test_time_report_dialog_loads_projects(qtbot, fresh_db): @@ -1199,21 +1196,41 @@ def test_time_report_dialog_loads_projects(qtbot, fresh_db): dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) - assert dialog.project_combo.count() == 2 + assert dialog.project_combo.count() == 3 def test_time_report_dialog_default_date_range(qtbot, fresh_db): - """Dialog defaults to last 7 days.""" + """Dialog defaults to start of month.""" dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) today = QDate.currentDate() - week_ago = today.addDays(-7) + start_of_month = QDate(today.year(), today.month(), 1) - assert dialog.from_date.date() == week_ago + assert dialog.from_date.date() == start_of_month 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): """Run a time report.""" strings.load_strings("en") @@ -1227,12 +1244,14 @@ def test_time_report_dialog_run_report(qtbot, fresh_db): dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(0) # day + idx_day = dialog.granularity.findData("day") + assert idx_day != -1 + dialog.granularity.setCurrentIndex(idx_day) dialog._run_report() assert dialog.table.rowCount() == 1 - assert "Activity" in dialog.table.item(0, 1).text() + assert "Activity" in dialog.table.item(0, 2).text() assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text() @@ -1414,13 +1433,18 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db): dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(1) # week + + idx_week = dialog.granularity.findData("week") + assert idx_week != -1 + dialog.granularity.setCurrentIndex(idx_week) dialog._run_report() # Should aggregate to single week assert dialog.table.rowCount() == 1 + # In grouped modes the Note column is hidden → hours are in column 3 hours_text = dialog.table.item(0, 3).text() + assert "2.5" in hours_text or "2.50" in hours_text @@ -1442,13 +1466,17 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db): dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(2) # month + + idx_month = dialog.granularity.findData("month") + assert idx_month != -1 + dialog.granularity.setCurrentIndex(idx_month) dialog._run_report() # Should aggregate to single month assert dialog.table.rowCount() == 1 hours_text = dialog.table.item(0, 3).text() + assert "2.5" in hours_text or "2.50" in hours_text @@ -1497,40 +1525,6 @@ def test_time_log_widget_calculates_per_project_totals(qtbot, fresh_db): assert "1.50h" in summary -def test_time_report_dialog_csv_export_handles_os_error( - qtbot, fresh_db, tmp_path, monkeypatch -): - """CSV export handles OSError gracefully.""" - strings.load_strings("en") - proj_id = fresh_db.add_project("Project") - act_id = fresh_db.add_activity("Activity") - fresh_db.add_time_log(_today(), proj_id, act_id, 60) - - dialog = TimeReportDialog(fresh_db) - qtbot.addWidget(dialog) - - dialog.project_combo.setCurrentIndex(0) - dialog._run_report() - - # Use a path that will cause an error (e.g., directory instead of file) - bad_path = str(tmp_path) - - def mock_get_save_filename(*args, **kwargs): - return bad_path, "CSV Files (*.csv)" - - monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) - - warning_shown = {"shown": False} - - def mock_warning(*args): - warning_shown["shown"] = True - - monkeypatch.setattr(QMessageBox, "warning", mock_warning) - - dialog._export_csv() - assert warning_shown["shown"] - - # ============================================================================ # Additional TimeLogWidget Edge Cases # ============================================================================ @@ -1968,10 +1962,13 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db): dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) - dialog.project_combo.setCurrentIndex(0) + dialog.project_combo.setCurrentIndex(1) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(1) # week + + idx_week = dialog.granularity.findData("week") + assert idx_week != -1 + dialog.granularity.setCurrentIndex(idx_week) dialog._run_report() @@ -2007,7 +2004,10 @@ def test_time_report_dialog_pdf_export_with_multiple_periods( dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(0) # day + + idx_day = dialog.granularity.findData("day") + assert idx_day != -1 + dialog.granularity.setCurrentIndex(idx_day) dialog._run_report() @@ -2185,10 +2185,10 @@ def test_full_workflow_add_project_activity_log_report( # Verify report assert report_dialog.table.rowCount() == 1 - assert "Test Activity" in report_dialog.table.item(0, 1).text() + assert "Test Activity" in report_dialog.table.item(0, 2).text() assert ( - "2.5" in report_dialog.table.item(0, 3).text() - or "2.50" in report_dialog.table.item(0, 3).text() + "2.5" in report_dialog.table.item(0, 4).text() + or "2.50" in report_dialog.table.item(0, 4).text() ) # 5. Export CSV @@ -2596,3 +2596,437 @@ def test_time_log_with_entry(qtbot, fresh_db): # Widget should have been created successfully assert widget is not None + + +def test_time_log_widget_open_dialog_log_only_when_no_date(qtbot, app, fresh_db): + """Test _open_dialog_log_only when _current_date is None.""" + widget = TimeLogWidget(fresh_db, themes=None) + qtbot.addWidget(widget) + + # Set current date to None + widget._current_date = None + + # Click should return early without crashing + widget._open_dialog_log_only() + + # No dialog should be shown + + +def test_time_log_widget_open_dialog_log_only_opens_dialog(qtbot, app, fresh_db): + """Test _open_dialog_log_only opens TimeLogDialog.""" + widget = TimeLogWidget(fresh_db, themes=None) + qtbot.addWidget(widget) + + # Set a valid date + widget._current_date = "2024-01-15" + + # Mock TimeLogDialog + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + + with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog): + widget._open_dialog_log_only() + + # Dialog should have been created with correct parameters + assert mock_dialog.exec.called + + +def test_time_log_widget_open_dialog_log_only_refreshes_when_collapsed( + qtbot, app, fresh_db +): + """Test that opening dialog updates summary when widget is collapsed.""" + widget = TimeLogWidget(fresh_db, themes=None) + qtbot.addWidget(widget) + widget._current_date = "2024-01-15" + + # Collapse the widget + widget.toggle_btn.setChecked(False) + + # Mock TimeLogDialog + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + + with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog): + widget._open_dialog_log_only() + + # Should show collapsed hint + assert ( + "collapsed" in widget.summary_label.text().lower() + or widget.summary_label.text() != "" + ) + + +def test_time_log_dialog_log_entry_only_mode(qtbot, app, fresh_db): + """Test TimeLogDialog in log_entry_only mode.""" + dialog = TimeLogDialog( + fresh_db, "2024-01-15", log_entry_only=True, themes=None, close_after_add=True + ) + qtbot.addWidget(dialog) + + # In log_entry_only mode, these should be hidden + assert not dialog.delete_btn.isVisible() + assert not dialog.report_btn.isVisible() + assert not dialog.table.isVisible() + + +def test_time_log_dialog_log_entry_only_false(qtbot, app, fresh_db): + """Test TimeLogDialog in normal mode (log_entry_only=False).""" + dialog = TimeLogDialog( + fresh_db, "2024-01-15", log_entry_only=False, themes=None, close_after_add=False + ) + qtbot.addWidget(dialog) + dialog.show() + qtbot.waitExposed(dialog) + + # In normal mode, these should be visible + assert dialog.delete_btn.isVisible() + assert dialog.report_btn.isVisible() + assert dialog.table.isVisible() + + +def test_time_log_dialog_change_date_cancelled(qtbot, app, fresh_db): + """Test _on_change_date_clicked when user cancels.""" + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Mock exec to return rejected + with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected): + original_date = dialog._date_iso + dialog._on_change_date_clicked() + + # Date should not change when cancelled + assert dialog._date_iso == original_date + + +def test_time_log_dialog_change_date_accepted(qtbot, app, fresh_db): + """Test _on_change_date_clicked when user accepts (covers lines 410-450).""" + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Mock exec to return accepted - the dialog will use whatever date is in the calendar + with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted): + # Just verify it doesn't crash - actual date may or may not change + # depending on what the real QCalendarWidget selects + dialog._on_change_date_clicked() + + # Dialog should still be functional + assert dialog._date_iso is not None + + +def test_time_log_dialog_change_date_with_invalid_current_date(qtbot, app, fresh_db): + """Test _on_change_date_clicked when current date is invalid (covers lines 410-412).""" + dialog = TimeLogDialog(fresh_db, "invalid-date", themes=None) + qtbot.addWidget(dialog) + + # Should fall back to current date without crashing + with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected): + dialog._on_change_date_clicked() + + +def test_time_log_dialog_change_date_with_themes(qtbot, app, fresh_db): + """Test _on_change_date_clicked with theme manager (covers line 423-424).""" + themes_mock = MagicMock() + themes_mock.register_calendar = MagicMock() + + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=themes_mock) + qtbot.addWidget(dialog) + + # Mock exec to return rejected + with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected): + dialog._on_change_date_clicked() + + # Theme should have been applied to calendar + assert themes_mock.register_calendar.called + + +def test_time_log_dialog_table_item_changed_incomplete_row(qtbot, app, fresh_db): + """Test _on_table_item_changed with incomplete row.""" + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Block signals to prevent Qt cleanup + dialog.table.blockSignals(True) + + # Add incomplete row + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + # Only add project item, missing others + proj_item = QTableWidgetItem("Project") + dialog.table.setItem(0, 0, proj_item) + + # Call _on_table_item_changed + dialog._on_table_item_changed(proj_item) + + dialog.table.blockSignals(False) + + # Should return early without crashing (covers lines 556-558) + + +def test_time_log_dialog_table_item_changed_creates_new_project(qtbot, app, fresh_db): + """Test _on_table_item_changed creating a new project on the fly.""" + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Block signals to prevent Qt cleanup + dialog.table.blockSignals(True) + + # Add a complete row with new project name + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem("Brand New Project") + proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID + act_item = QTableWidgetItem("Activity") + note_item = QTableWidgetItem("Note") + hours_item = QTableWidgetItem("2.5") + + dialog.table.setItem(0, 0, proj_item) + dialog.table.setItem(0, 1, act_item) + dialog.table.setItem(0, 2, note_item) + dialog.table.setItem(0, 3, hours_item) + + # Call _on_table_item_changed + with patch.object(dialog, "_on_add_or_update"): + dialog._on_table_item_changed(proj_item) + + # Should have created project and called add/update + projects = fresh_db.list_projects() + project_names = [name for _, name in projects] + assert "Brand New Project" in project_names + + dialog.table.blockSignals(False) + + +def test_time_log_dialog_table_item_changed_without_note(qtbot, app, fresh_db): + """Test _on_table_item_changed when note_item is None.""" + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Block signals to prevent Qt cleanup + dialog.table.blockSignals(True) + + # Add row without note + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem("Project") + proj_item.setData(Qt.ItemDataRole.UserRole, None) + act_item = QTableWidgetItem("Activity") + hours_item = QTableWidgetItem("1.0") + + dialog.table.setItem(0, 0, proj_item) + dialog.table.setItem(0, 1, act_item) + # Note: Don't set note_item (leave as None) + dialog.table.setItem(0, 3, hours_item) + + # Call _on_table_item_changed + with patch.object(dialog, "_on_add_or_update"): + dialog._on_table_item_changed(proj_item) + + # Should handle None note gracefully (covers line 567) + assert dialog.note.text() == "" + + dialog.table.blockSignals(False) + + +def test_time_log_dialog_table_item_changed_sets_button_state_for_new_entry( + qtbot, app, fresh_db +): + """Test that _on_table_item_changed sets correct button state for new entry.""" + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Block signals to prevent Qt cleanup + dialog.table.blockSignals(True) + + # Add row without entry ID (new entry) + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem("Project") + proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID + act_item = QTableWidgetItem("Activity") + hours_item = QTableWidgetItem("1.0") + + dialog.table.setItem(0, 0, proj_item) + dialog.table.setItem(0, 1, act_item) + dialog.table.setItem(0, 3, hours_item) + + with patch.object(dialog, "_on_add_or_update"): + dialog._on_table_item_changed(proj_item) + + # Delete button should be disabled for new entry (covers lines 601-603) + assert not dialog.delete_btn.isEnabled() + + dialog.table.blockSignals(False) + + +def test_time_log_dialog_table_item_changed_sets_button_state_for_existing_entry( + qtbot, app, fresh_db +): + """Test that _on_table_item_changed sets correct button state for existing entry.""" + # Add a time log entry first + proj_id = fresh_db.add_project("Test Project") + act_id = fresh_db.add_activity("Activity") + entry_id = fresh_db.add_time_log( + "2024-01-15", proj_id, act_id, 120, "Note" + ) # 120 minutes = 2 hours + + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Block signals to prevent Qt cleanup + dialog.table.blockSignals(True) + + # Add row with entry ID + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem("Test Project") + proj_item.setData(Qt.ItemDataRole.UserRole, entry_id) + act_item = QTableWidgetItem("Activity") + hours_item = QTableWidgetItem("2.0") + + dialog.table.setItem(0, 0, proj_item) + dialog.table.setItem(0, 1, act_item) + dialog.table.setItem(0, 3, hours_item) + + with patch.object(dialog, "_on_add_or_update"): + dialog._on_table_item_changed(proj_item) + + # Delete button should be enabled for existing entry (covers lines 604-606) + assert dialog.delete_btn.isEnabled() + + dialog.table.blockSignals(False) + + +def test_time_log_dialog_table_item_changed_finds_existing_project_by_name( + qtbot, app, fresh_db +): + """Test _on_table_item_changed finding existing project by name.""" + proj_id = fresh_db.add_project("Existing Project") + + dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None) + qtbot.addWidget(dialog) + + # Block signals to prevent Qt cleanup + dialog.table.blockSignals(True) + + # Add row with existing project name + dialog.table.setRowCount(1) + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem("Existing Project") + proj_item.setData(Qt.ItemDataRole.UserRole, None) + act_item = QTableWidgetItem("Activity") + hours_item = QTableWidgetItem("1.0") + + dialog.table.setItem(0, 0, proj_item) + dialog.table.setItem(0, 1, act_item) + dialog.table.setItem(0, 3, hours_item) + + with patch.object(dialog, "_on_add_or_update"): + dialog._on_table_item_changed(proj_item) + + # Should find and select existing project (covers lines 571-580) + assert dialog.project_combo.currentData() == proj_id + + dialog.table.blockSignals(False) + + +def test_time_report_dialog_initialization(qtbot, app, fresh_db): + """Test TimeReportDialog initialization.""" + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + # Should initialize without crashing + assert dialog is not None + + +def test_time_code_manager_dialog_initialization(qtbot, app, fresh_db): + """Test TimeCodeManagerDialog initialization.""" + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + # Should initialize without crashing + assert dialog is not None + + +def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db): + """Test TimeCodeManagerDialog with initial tab focus.""" + # Test with projects tab + dialog = TimeCodeManagerDialog(fresh_db, focus_tab="projects") + qtbot.addWidget(dialog) + assert dialog.tabs.currentIndex() == 0 + + # Test with activities tab + dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities") + qtbot.addWidget(dialog2) + assert dialog2.tabs.currentIndex() == 1 + + +def test_time_report_no_grouping_returns_each_entry_and_note(fresh_db): + """Granularity 'none' returns one row per entry and includes notes.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + date = _today() + + fresh_db.add_time_log(date, proj_id, act_id, 60, note="First") + fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second") + + report = fresh_db.time_report(proj_id, date, date, "none") + + # Two separate rows, not aggregated. + assert len(report) == 2 + + # Each row is (period, activity_name, note, total_minutes) + periods = {r[0] for r in report} + activities = {r[1] for r in report} + notes = {r[2] for r in report} + minutes = sorted(r[3] for r in report) + + assert periods == {date} + assert activities == {"Activity"} + assert notes == {"First", "Second"} + assert minutes == [30, 60] + + +def test_time_report_dialog_granularity_none_shows_each_entry_and_notes( + qtbot, fresh_db +): + """'Don't group' granularity shows one row per log entry and includes notes.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + date = _today() + + fresh_db.add_time_log(date, proj_id, act_id, 60, note="First") + fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second") + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + # Select the concrete project (index 0 is "All projects") + dialog.project_combo.setCurrentIndex(1) + dialog.from_date.setDate(QDate.fromString(date, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date, "yyyy-MM-dd")) + + idx_none = dialog.granularity.findData("none") + assert idx_none != -1 + dialog.granularity.setCurrentIndex(idx_none) + + dialog._run_report() + + # Two rows, not aggregated + assert dialog.table.rowCount() == 2 + + # Notes in column 3 + notes = {dialog.table.item(row, 3).text() for row in range(dialog.table.rowCount())} + assert "First" in notes + assert "Second" in notes + + # Hours in last column (index 4) when not grouped + hours = [dialog.table.item(row, 4).text() for row in range(dialog.table.rowCount())] + assert any("1.00" in h or "1.0" in h for h in hours) + assert any("0.50" in h or "0.5" in h for h in hours) diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index 3794760..fdc8829 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -1,8 +1,8 @@ import pytest -from PySide6.QtWidgets import QWidget from bouquin.markdown_editor import MarkdownEditor -from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.toolbar import ToolBar +from PySide6.QtWidgets import QWidget @pytest.fixture diff --git a/tests/test_version_check.py b/tests/test_version_check.py index b5afe12..2573020 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,9 +1,10 @@ -import pytest -from unittest.mock import Mock, patch import subprocess +from unittest.mock import Mock, patch + +import pytest from bouquin.version_check import VersionChecker -from PySide6.QtWidgets import QMessageBox, QWidget from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QMessageBox, QWidget def test_version_checker_init(app): @@ -172,45 +173,6 @@ def test_check_for_updates_already_latest(qtbot, app): assert mock_info.called -def test_check_for_updates_new_version_available_declined(qtbot, app): - """Test check for updates when new version is available but user declines.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "2.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "question", return_value=QMessageBox.No): - # Should not proceed to download - checker.check_for_updates() - - -def test_check_for_updates_new_version_available_accepted(qtbot, app): - """Test check for updates when new version is available and user accepts.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "2.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes): - with patch.object( - checker, "_download_and_verify_appimage" - ) as mock_download: - checker.check_for_updates() - - # Should call download - mock_download.assert_called_once_with("2.0.0") - - def test_download_file_success(qtbot, app, tmp_path): """Test downloading a file successfully.""" checker = VersionChecker()