diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 8c2fb8d..87b67ff 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -35,16 +35,3 @@ 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 fbe5a7e..5bb3794 100644
--- a/.forgejo/workflows/lint.yml
+++ b/.forgejo/workflows/lint.yml
@@ -25,17 +25,3 @@ 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 fad2f6f..18ced32 100644
--- a/.forgejo/workflows/trivy.yml
+++ b/.forgejo/workflows/trivy.yml
@@ -24,17 +24,3 @@ jobs:
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
-
- # Notify if any previous step in this job failed
- - name: Notify on failure
- if: ${{ failure() }}
- env:
- WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
- REPOSITORY: ${{ forgejo.repository }}
- RUN_NUMBER: ${{ forgejo.run_number }}
- SERVER_URL: ${{ forgejo.server_url }}
- run: |
- curl -X POST \
- -H "Content-Type: application/json" \
- -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
- "$WEBHOOK_URL"
diff --git a/.gitignore b/.gitignore
index 07c956d..851b242 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,3 @@ __pycache__
dist
.coverage
*.db
-*.pdf
-*.csv
-*.html
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index 6281daa..0000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-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 45edf09..f9290ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,145 +1,3 @@
-# 0.7.3
-
- * Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
- * Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
-
-# 0.7.2
-
- * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
-
-# 0.7.1
-
- * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
- * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
- * Invoicing should not be enabled by default
- * Fix Reminders to fire right on the minute after adding them during runtime
- * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
- * Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
-
-# 0.7.0
-
- * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
- * Add 'Last week' to Time Report dialog range option
- * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
-
-# 0.6.4
-
- * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
- * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
- * Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
-
-# 0.6.3
-
- * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
- * Allow 'All Projects' for timesheet reports.
- * Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
- * Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
-
-# 0.6.2
-
- * Ensure that adding a document whilst on an older date page, uses that date as its upload date
- * Add 'Created at' to time log table.
- * Show total hours for the day in the time log table (not just in the widget in sidebar)
- * Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
- * Indent tabs by 4 spaces in code block editor dialog
-
-# 0.6.1
-
- * Consolidate some code related to opening documents using the Documents feature.
- * Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time.
- * More code coverage
-
-# 0.6.0
-
- * Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature.
- * Close time log dialog if opened via the + button from sidebar widget
- * Only show tags in Statistics widget if tags are enabled
- * Fix rounding up/down in Pomodoro timer to the closest 15 min interval
-
-# 0.5.5
-
- * Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option)
- * Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text.
- * Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager)
- * Ensure time log reports have an extension
-
-# 0.5.4
-
- * Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it)
-
-# 0.5.3
-
- * Prevent triple-click select from selecting the list item (e.g checkbox, bullet)
- * Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues.
- * Change History icon (again)
- * Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it)
- * Prevent double-click of checkbox leading to selecting/highlighting it
- * Slightly fade the text of a checkbox line if the checkbox is checked.
- * Fix weekend date colours being incorrect on theme change while app is running
- * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops
- * Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
-
-# 0.5.2
-
- * Update icon again to remove background
- * Adjust History icon and reorder toolbar items
- * Try to address checkbox/bullet size issues (again)
- * Fix HTML export of markdown (with newlines, tables and other styling preserved)
- * Remove table tool
-
-# 0.5.1
-
- * Try to address Noto Sans font issue that works for both numbers and checkbox/bullets.
- * Update icon
- * Update French translations
- * Improve size of flashing reminder dialog
-
-# 0.5
-
- * More Italian translations, thank you @mdaleo404
- * Set locked status on window title when locked
- * Don't exit on incorrect key, let it be tried again
- * Make reminders be its own dataset rather than tied to current string.
- * Add support for repeated reminders
- * Make reminders be a feature that can be turned on and off
- * Add syntax highlighting for code blocks (right-click to set it)
- * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
- * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
-
-# 0.4.5
-
- * Make it possible to delete revisions
- * Make it possible to force-lock the screen even if idle timer hasn't tripped
- * Add shortcuts for lock and unlock of screen
- * Other misc bug fixes
-
-# 0.4.4.1
-
- * Adjust some widget heights/settings text wrap
- * Adjust shortcuts
- * History unicode symbol
- * Icon in version dialog
-
-# 0.4.4
-
- * Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
- * Moving unchecked TODOs now skips placing them on weekends.
- * Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
- * Check for new version / download new AppImage via the Help -> Version screen.
- * Remove extra newline after headings
-
-# 0.4.3
-
- * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora
-
-# 0.4.2
-
- * Improve Statistics widget height
- * Improve SaveDialog widget width
- * Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
- * Make it possible to change regular text size
- * Refactored Settings dialog to use tabs to reduce its size
-
# 0.4.1
* Allow time log entries to be edited directly in their table cells
diff --git a/README.md b/README.md
index da87442..9e3e466 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,9 @@
# Bouquin
-
-
-
## Introduction
-Bouquin ("Book-ahn") is a notebook and planner application written in Python, Qt and SQLCipher.
+Bouquin ("Book-ahn") is a notebook and planner application written in Python, PyQt and SQLCipher.
It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
@@ -19,43 +16,22 @@ To increase security, the SQLCipher key is requested when the app is opened, and
to disk unless the user configures it to be in the settings.
There is deliberately no network connectivity or syncing intended, other than the option to send a bug
-report from within the app, or optionally to check for new versions to upgrade to.
+report from within the app.
## Screenshots
### General view
-
-
-
+
### History panes
-
-
-
-
-
-### Tags
-
-
-
-
-### Time Logging
-
-
-
-
-
-### Statistics
-
-
-
-
+
+
## Some of the 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
+ * All changes are version controlled, with ability to view/diff versions and revert
* Automatic rendering of basic Markdown syntax
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
* Images are supported
@@ -68,16 +44,15 @@ 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 yesterday to today, on startup
* English, French and Italian locales provided
- * Ability to set reminder alarms (which will be flashed as the reminder)
- * Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
- * Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
+ * Ability to set reminder alarms in the app against the current line of text on today's date
+ * Ability to log time per day and run timesheet reports
## How to install
-Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
+Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian 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).
diff --git a/bouquin.desktop b/bouquin.desktop
deleted file mode 100644
index aa519c3..0000000
--- a/bouquin.desktop
+++ /dev/null
@@ -1,6 +0,0 @@
-[Desktop Entry]
-Type=Application
-Name=Bouquin
-Exec=Bouquin.AppImage
-Icon=bouquin
-Categories=Office
diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py
index 0743985..fae2821 100644
--- a/bouquin/bug_report_dialog.py
+++ b/bouquin/bug_report_dialog.py
@@ -3,17 +3,19 @@ from __future__ import annotations
import importlib.metadata
import requests
+
from PySide6.QtWidgets import (
QDialog,
- QDialogButtonBox,
- QLabel,
- QMessageBox,
- QTextEdit,
QVBoxLayout,
+ QLabel,
+ QTextEdit,
+ QDialogButtonBox,
+ QMessageBox,
)
from . import strings
+
BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs"
@@ -68,6 +70,10 @@ class BugReportDialog(QDialog):
self.text_edit.setPlainText(text[: self.MAX_CHARS])
self.text_edit.blockSignals(False)
+ # Clamp cursor position to end of text
+ if pos > self.MAX_CHARS:
+ pos = self.MAX_CHARS
+
cursor.setPosition(pos)
self.text_edit.setTextCursor(cursor)
@@ -82,7 +88,10 @@ class BugReportDialog(QDialog):
return
# Get current app version
- version = importlib.metadata.version("bouquin")
+ try:
+ version = importlib.metadata.version("bouquin")
+ except importlib.metadata.PackageNotFoundError:
+ version = "unknown"
payload: dict[str, str] = {
"message": text,
diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py
deleted file mode 100644
index 8df348d..0000000
--- a/bouquin/code_block_editor_dialog.py
+++ /dev/null
@@ -1,208 +0,0 @@
-from __future__ import annotations
-
-from PySide6.QtCore import QRect, QSize, Qt
-from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
-from PySide6.QtWidgets import (
- QComboBox,
- QDialog,
- QDialogButtonBox,
- QLabel,
- QPlainTextEdit,
- QVBoxLayout,
- QWidget,
-)
-
-from . import strings
-
-
-class _LineNumberArea(QWidget):
- def __init__(self, editor: "CodeEditorWithLineNumbers"):
- super().__init__(editor)
- self._editor = editor
-
- def sizeHint(self) -> QSize: # type: ignore[override]
- return QSize(self._editor.line_number_area_width(), 0)
-
- def paintEvent(self, event): # type: ignore[override]
- self._editor.line_number_area_paint_event(event)
-
-
-class CodeEditorWithLineNumbers(QPlainTextEdit):
- """QPlainTextEdit with a non-selectable line-number gutter on the left."""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._line_number_area = _LineNumberArea(self)
-
- self.blockCountChanged.connect(self._update_line_number_area_width)
- self.updateRequest.connect(self._update_line_number_area)
- self.cursorPositionChanged.connect(self._line_number_area.update)
-
- self._update_line_number_area_width()
- self._update_tab_stop_width()
-
- # ---- layout / sizing -------------------------------------------------
-
- def setFont(self, font: QFont) -> None: # type: ignore[override]
- """Ensure tab width stays at 4 spaces when the font changes."""
- super().setFont(font)
- self._update_tab_stop_width()
-
- def _update_tab_stop_width(self) -> None:
- """Set tab width to 4 spaces."""
- metrics = QFontMetrics(self.font())
- # Tab width = width of 4 space characters
- self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
-
- def line_number_area_width(self) -> int:
- # Enough digits for large-ish code blocks.
- digits = max(2, len(str(max(1, self.blockCount()))))
- fm = QFontMetrics(self._line_number_font())
- return fm.horizontalAdvance("9" * digits) + 8
-
- def _line_number_font(self) -> QFont:
- """Font to use for line numbers (slightly smaller than main text)."""
- font = self.font()
- if font.pointSize() > 0:
- font.setPointSize(font.pointSize() - 1)
- else:
- # fallback for pixel-sized fonts
- font.setPointSizeF(font.pointSizeF() * 0.9)
- return font
-
- def _update_line_number_area_width(self) -> None:
- margin = self.line_number_area_width()
- self.setViewportMargins(margin, 0, 0, 0)
-
- def resizeEvent(self, event): # type: ignore[override]
- super().resizeEvent(event)
- cr = self.contentsRect()
- self._line_number_area.setGeometry(
- QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
- )
-
- def _update_line_number_area(self, rect, dy) -> None:
- if dy:
- self._line_number_area.scroll(0, dy)
- else:
- self._line_number_area.update(
- 0, rect.y(), self._line_number_area.width(), rect.height()
- )
-
- if rect.contains(self.viewport().rect()):
- self._update_line_number_area_width()
-
- # ---- painting --------------------------------------------------------
-
- def line_number_area_paint_event(self, event) -> None:
- painter = QPainter(self._line_number_area)
- painter.fillRect(event.rect(), self.palette().base())
-
- # Use a slightly smaller font for numbers
- painter.setFont(self._line_number_font())
-
- # Faded colour: same blend used for completed-task text in
- # MarkdownHighlighter (text colour towards background).
- pal = self.palette()
- text_fg = pal.color(QPalette.Text)
- text_bg = pal.color(QPalette.Base)
- t = 0.55 # same factor as completed_task_format
- faded = QColor(
- int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
- int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
- int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
- )
- painter.setPen(faded)
-
- block = self.firstVisibleBlock()
- block_number = block.blockNumber()
- top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
- bottom = top + self.blockBoundingRect(block).height()
- fm = self.fontMetrics()
- line_height = fm.height()
- right_margin = self._line_number_area.width() - 4
-
- while block.isValid() and top <= event.rect().bottom():
- if block.isVisible() and bottom >= event.rect().top():
- number = str(block_number + 1)
- painter.setPen(self.palette().text().color())
- painter.drawText(
- 0,
- int(top),
- right_margin,
- line_height,
- Qt.AlignRight | Qt.AlignVCenter,
- number,
- )
-
- block = block.next()
- top = bottom
- bottom = top + self.blockBoundingRect(block).height()
- block_number += 1
-
-
-class CodeBlockEditorDialog(QDialog):
- def __init__(
- self, code: str, language: str | None, parent=None, allow_delete: bool = False
- ):
- super().__init__(parent)
- self.setWindowTitle(strings._("edit_code_block"))
-
- self.setMinimumSize(650, 650)
- self._code_edit = CodeEditorWithLineNumbers(self)
- self._code_edit.setPlainText(code)
-
- # Track whether the user clicked "Delete"
- self._delete_requested = False
-
- # Language selector (optional)
- self._lang_combo = QComboBox(self)
- languages = [
- "",
- "bash",
- "css",
- "html",
- "javascript",
- "php",
- "python",
- ]
- self._lang_combo.addItems(languages)
- if language and language in languages:
- self._lang_combo.setCurrentText(language)
-
- # Buttons
- buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
- parent=self,
- )
- buttons.accepted.connect(self.accept)
- buttons.rejected.connect(self.reject)
-
- if allow_delete:
- delete_btn = buttons.addButton(
- strings._("delete_code_block"),
- QDialogButtonBox.ButtonRole.DestructiveRole,
- )
- delete_btn.clicked.connect(self._on_delete_clicked)
-
- layout = QVBoxLayout(self)
- layout.addWidget(QLabel(strings._("locale") + ":", self))
- layout.addWidget(self._lang_combo)
- layout.addWidget(self._code_edit)
- layout.addWidget(buttons)
-
- def _on_delete_clicked(self) -> None:
- """Mark this dialog as 'delete requested' and close as Accepted."""
- self._delete_requested = True
- self.accept()
-
- def was_deleted(self) -> bool:
- """Return True if the user chose to delete the code block."""
- return self._delete_requested
-
- def code(self) -> str:
- return self._code_edit.toPlainText()
-
- def language(self) -> str | None:
- text = self._lang_combo.currentText().strip()
- return text or None
diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py
deleted file mode 100644
index 74ef6d4..0000000
--- a/bouquin/code_highlighter.py
+++ /dev/null
@@ -1,373 +0,0 @@
-from __future__ import annotations
-
-import re
-from typing import Dict, Optional
-
-from PySide6.QtGui import QColor, QFont, QTextCharFormat
-
-
-class CodeHighlighter:
- """Syntax highlighter for different programming languages."""
-
- # Language keywords
- KEYWORDS = {
- "python": [
- "False",
- "None",
- "True",
- "and",
- "as",
- "assert",
- "async",
- "await",
- "break",
- "class",
- "continue",
- "def",
- "del",
- "elif",
- "else",
- "except",
- "finally",
- "for",
- "from",
- "global",
- "if",
- "import",
- "in",
- "is",
- "lambda",
- "nonlocal",
- "not",
- "or",
- "pass",
- "pprint",
- "print",
- "raise",
- "return",
- "try",
- "while",
- "with",
- "yield",
- ],
- "javascript": [
- "abstract",
- "arguments",
- "await",
- "boolean",
- "break",
- "byte",
- "case",
- "catch",
- "char",
- "class",
- "const",
- "continue",
- "debugger",
- "default",
- "delete",
- "do",
- "double",
- "else",
- "enum",
- "eval",
- "export",
- "extends",
- "false",
- "final",
- "finally",
- "float",
- "for",
- "function",
- "goto",
- "if",
- "implements",
- "import",
- "in",
- "instanceof",
- "int",
- "interface",
- "let",
- "long",
- "native",
- "new",
- "null",
- "package",
- "private",
- "protected",
- "public",
- "return",
- "short",
- "static",
- "super",
- "switch",
- "synchronized",
- "this",
- "throw",
- "throws",
- "transient",
- "true",
- "try",
- "typeof",
- "var",
- "void",
- "volatile",
- "while",
- "with",
- "yield",
- ],
- "php": [
- "abstract",
- "and",
- "array",
- "as",
- "break",
- "callable",
- "case",
- "catch",
- "class",
- "clone",
- "const",
- "continue",
- "declare",
- "default",
- "die",
- "do",
- "echo",
- "else",
- "elseif",
- "empty",
- "enddeclare",
- "endfor",
- "endforeach",
- "endif",
- "endswitch",
- "endwhile",
- "eval",
- "exit",
- "extends",
- "final",
- "for",
- "foreach",
- "function",
- "global",
- "goto",
- "if",
- "implements",
- "include",
- "include_once",
- "instanceof",
- "insteadof",
- "interface",
- "isset",
- "list",
- "namespace",
- "new",
- "or",
- "print",
- "print_r",
- "private",
- "protected",
- "public",
- "require",
- "require_once",
- "return",
- "static",
- "syslog",
- "switch",
- "throw",
- "trait",
- "try",
- "unset",
- "use",
- "var",
- "var_dump",
- "while",
- "xor",
- "yield",
- ],
- "bash": [
- "if",
- "then",
- "echo",
- "else",
- "elif",
- "fi",
- "case",
- "esac",
- "for",
- "select",
- "while",
- "until",
- "do",
- "done",
- "in",
- "function",
- "time",
- "coproc",
- ],
- "html": [
- "DOCTYPE",
- "html",
- "head",
- "title",
- "meta",
- "link",
- "style",
- "script",
- "body",
- "div",
- "span",
- "p",
- "a",
- "img",
- "ul",
- "ol",
- "li",
- "table",
- "tr",
- "td",
- "th",
- "form",
- "input",
- "button",
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "br",
- "hr",
- ],
- "css": [
- "color",
- "background",
- "background-color",
- "border",
- "margin",
- "padding",
- "width",
- "height",
- "font",
- "font-size",
- "font-weight",
- "display",
- "position",
- "top",
- "left",
- "right",
- "bottom",
- "float",
- "clear",
- "overflow",
- "z-index",
- "opacity",
- ],
- }
-
- @staticmethod
- def get_language_patterns(language: str) -> list:
- """Get highlighting patterns for a language."""
- patterns = []
-
- keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
-
- if language.lower() in ["python", "bash", "php"]:
- # Comments (#)
- patterns.append((r"#.*$", "comment"))
-
- if language.lower() in ["javascript", "php", "css"]:
- # Comments (//)
- patterns.append((r"//.*$", "comment"))
- # Multi-line comments (/* */)
- patterns.append((r"/\*.*?\*/", "comment"))
-
- if language.lower() in ["html", "xml"]:
- # HTML/XML tags
- patterns.append((r"<[^>]+>", "tag"))
- # HTML comments
- patterns.append((r"", "comment"))
-
- # Numbers
- patterns.append((r"\b\d+\.?\d*\b", "number"))
-
- # Keywords
- for keyword in keywords:
- patterns.append((r"\b" + keyword + r"\b", "keyword"))
-
- # Do strings last so they override any of the above (e.g reserved keywords in strings)
-
- # Strings (double quotes)
- patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
-
- # Strings (single quotes)
- patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
-
- return patterns
-
- @staticmethod
- def get_format_for_type(
- format_type: str, base_format: QTextCharFormat
- ) -> QTextCharFormat:
- """Get text format for a specific syntax type."""
- fmt = QTextCharFormat(base_format)
-
- if format_type == "keyword":
- fmt.setForeground(QColor(86, 156, 214)) # Blue
- fmt.setFontWeight(QFont.Weight.Bold)
- elif format_type == "string":
- fmt.setForeground(QColor(206, 145, 120)) # Orange
- elif format_type == "comment":
- fmt.setForeground(QColor(106, 153, 85)) # Green
- fmt.setFontItalic(True)
- elif format_type == "number":
- fmt.setForeground(QColor(181, 206, 168)) # Light green
- elif format_type == "tag":
- fmt.setForeground(QColor(78, 201, 176)) # Cyan
-
- return fmt
-
-
-class CodeBlockMetadata:
- """Stores metadata about code blocks (language, etc.) for a document."""
-
- def __init__(self):
- self._block_languages: Dict[int, str] = {} # block_number -> language
-
- def set_language(self, block_number: int, language: str):
- """Set the language for a code block."""
- self._block_languages[block_number] = language.lower()
-
- def get_language(self, block_number: int) -> Optional[str]:
- """Get the language for a code block."""
- return self._block_languages.get(block_number)
-
- def serialize(self) -> str:
- """Serialize metadata to a string."""
- # Store as JSON-like format in a comment at the end
- if not self._block_languages:
- return ""
-
- items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
- return "\n"
-
- def deserialize(self, text: str):
- """Deserialize metadata from text."""
- self._block_languages.clear()
-
- # Look for metadata comment at the end
- match = re.search(r"", text)
- if match:
- pairs = match.group(1).split(",")
- for pair in pairs:
- if ":" in pair:
- block_num, lang = pair.split(":", 1)
- try:
- self._block_languages[int(block_num)] = lang
- except ValueError:
- pass
-
- def clear_language(self, block_number: int):
- """Remove any stored language for a given block, if present."""
- self._block_languages.pop(block_number, None)
diff --git a/bouquin/db.py b/bouquin/db.py
index f0d5b5f..c6846e9 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -5,15 +5,13 @@ import datetime as _dt
import hashlib
import html
import json
-import mimetypes
import re
+
from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Sequence, Tuple
-
-import markdown
-from sqlcipher3 import Binary
from sqlcipher3 import dbapi2 as sqlite
+from typing import List, Sequence, Tuple, Dict
+
from . import strings
@@ -31,35 +29,6 @@ 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
@@ -92,38 +61,10 @@ 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
@@ -251,119 +192,6 @@ class DBManager:
ON time_log(project_id);
CREATE INDEX IF NOT EXISTS ix_time_log_activity
ON time_log(activity_id);
-
- CREATE TABLE IF NOT EXISTS reminders (
- id INTEGER PRIMARY KEY,
- text TEXT NOT NULL,
- time_str TEXT NOT NULL, -- HH:MM
- reminder_type TEXT NOT NULL, -- once|daily|weekdays|weekly
- weekday INTEGER, -- 0-6 for weekly (0=Mon)
- date_iso TEXT, -- for once type
- active INTEGER NOT NULL DEFAULT 1, -- 0=inactive, 1=active
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- );
-
- 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()
@@ -401,37 +229,25 @@ class DBManager:
).fetchone()
return row[0] if row else ""
- def search_entries(self, text: str) -> list[tuple[str, str, str, str, str | None]]:
+ def search_entries(self, text: str) -> list[str]:
"""
Search for entries by term or tag name.
- 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)
+ This only works against the latest version of the page.
"""
cur = self.conn.cursor()
q = text.strip()
- if not q:
- return []
-
pattern = f"%{q.lower()}%"
- results: list[tuple[str, str, str, str, str | None]] = []
-
- # --- Pages: content or tag matches ---------------------------------
- page_rows = cur.execute(
+ rows = cur.execute(
"""
- SELECT DISTINCT p.date AS date_iso, v.content
+ SELECT DISTINCT p.date, 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 ?
@@ -441,54 +257,7 @@ class DBManager:
""",
(pattern, pattern),
).fetchall()
-
- 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
+ return [(r[0], r[1]) for r in rows]
def dates_with_content(self) -> list[str]:
"""
@@ -597,17 +366,6 @@ class DBManager:
(version_id, date_iso),
)
- def delete_version(self, *, version_id: int) -> bool | None:
- """
- Delete a specific version by version_id.
- """
- cur = self.conn.cursor()
- with self.conn:
- cur.execute(
- "DELETE FROM versions WHERE id=?;",
- (version_id,),
- )
-
# ------------------------- Export logic here ------------------------#
def get_all_entries(self) -> List[Entry]:
"""
@@ -653,33 +411,14 @@ class DBManager:
'',
'',
f"{html.escape(title)}",
- "",
+ "",
"",
f"
{html.escape(title)}
",
]
for d, c in entries:
- body_html = markdown.markdown(
- c,
- extensions=[
- "extra",
- "nl2br",
- ],
- output_format="html5",
- )
-
parts.append(
- f""
- f""
- f"{body_html}"
- f""
+ f"{c}"
)
parts.append("")
@@ -903,12 +642,11 @@ class DBManager:
def delete_tag(self, tag_id: int) -> None:
"""
- Delete a tag entirely (removes it from all pages and documents).
+ Delete a tag entirely (removes it from all pages).
"""
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]:
@@ -974,7 +712,7 @@ class DBManager:
# 2 & 3) total revisions + page with most revisions + per-date counts
total_revisions = 0
- page_most_revisions: str | None = None
+ page_most_revisions = None
page_most_revisions_count = 0
revisions_by_date: Dict[_dt.date, int] = {}
@@ -996,8 +734,12 @@ class DBManager:
page_most_revisions_count = c
page_most_revisions = date_iso
- d = _dt.date.fromisoformat(date_iso)
- revisions_by_date[d] = c
+ try:
+ d = _dt.date.fromisoformat(date_iso)
+ revisions_by_date[d] = c
+ except ValueError:
+ # Ignore malformed dates
+ pass
# 4) total words + per-date words (current version only)
entries = self.get_all_entries()
@@ -1007,10 +749,14 @@ class DBManager:
for date_iso, content in entries:
wc = self._count_words(content or "")
total_words += wc
- d = _dt.date.fromisoformat(date_iso)
- words_by_date[d] = wc
+ try:
+ d = _dt.date.fromisoformat(date_iso)
+ words_by_date[d] = wc
+ except ValueError:
+ pass
# 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
@@ -1031,119 +777,6 @@ 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,
@@ -1155,18 +788,6 @@ 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 ---------------------
@@ -1178,14 +799,6 @@ 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:
@@ -1319,8 +932,7 @@ class DBManager:
t.activity_id,
a.name AS activity_name,
t.minutes,
- t.note,
- t.created_at AS created_at
+ t.note
FROM time_log t
JOIN projects p ON p.id = t.project_id
JOIN activities a ON a.id = t.activity_id
@@ -1342,7 +954,6 @@ class DBManager:
r["activity_name"],
r["minutes"],
r["note"],
- r["created_at"],
)
)
return result
@@ -1352,8 +963,8 @@ class DBManager:
project_id: int,
start_date_iso: str,
end_date_iso: str,
- granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
- ) -> list[tuple[str, str, str, int]]:
+ granularity: str = "day", # 'day' | 'week' | 'month'
+ ) -> list[tuple[str, str, int]]:
"""
Return (time_period, activity_name, total_minutes) tuples between start and end
for a project, grouped by period and activity.
@@ -1361,54 +972,7 @@ 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":
@@ -1417,11 +981,13 @@ 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
@@ -1433,113 +999,8 @@ 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["project_name"],
- r["bucket"],
- r["activity_name"],
- "",
- r["total_minutes"],
- )
+ (r["bucket"], r["activity_name"], r["note"], r["total_minutes"])
for r in rows
]
@@ -1547,898 +1008,3 @@ class DBManager:
if self.conn is not None:
self.conn.close()
self.conn = None
-
- # ------------------------- Reminders logic here ------------------------#
- def save_reminder(self, reminder) -> int:
- """Save or update a reminder. Returns the reminder ID."""
- cur = self.conn.cursor()
- if reminder.id:
- # Update existing
- cur.execute(
- """
- UPDATE reminders
- SET text = ?, time_str = ?, reminder_type = ?,
- weekday = ?, date_iso = ?, active = ?
- WHERE id = ?
- """,
- (
- reminder.text,
- reminder.time_str,
- reminder.reminder_type.value,
- reminder.weekday,
- reminder.date_iso,
- 1 if reminder.active else 0,
- reminder.id,
- ),
- )
- self.conn.commit()
- return reminder.id
- else:
- # Insert new
- cur.execute(
- """
- INSERT INTO reminders (text, time_str, reminder_type, weekday, date_iso, active)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (
- reminder.text,
- reminder.time_str,
- reminder.reminder_type.value,
- reminder.weekday,
- reminder.date_iso,
- 1 if reminder.active else 0,
- ),
- )
- self.conn.commit()
- return cur.lastrowid
-
- def get_all_reminders(self):
- """Get all reminders."""
- from .reminders import Reminder, ReminderType
-
- cur = self.conn.cursor()
- rows = cur.execute(
- """
- SELECT id, text, time_str, reminder_type, weekday, date_iso, active
- FROM reminders
- ORDER BY time_str
- """
- ).fetchall()
-
- result = []
- for r in rows:
- result.append(
- Reminder(
- id=r["id"],
- text=r["text"],
- time_str=r["time_str"],
- reminder_type=ReminderType(r["reminder_type"]),
- weekday=r["weekday"],
- date_iso=r["date_iso"],
- active=bool(r["active"]),
- )
- )
- return result
-
- def update_reminder_active(self, reminder_id: int, active: bool) -> None:
- """Update the active status of a reminder."""
- cur = self.conn.cursor()
- cur.execute(
- "UPDATE reminders SET active = ? WHERE id = ?",
- (1 if active else 0, reminder_id),
- )
- self.conn.commit()
-
- def delete_reminder(self, reminder_id: int) -> None:
- """Delete a reminder."""
- 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 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
deleted file mode 100644
index fd7313e..0000000
--- a/bouquin/document_utils.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-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
deleted file mode 100644
index 9f5a40f..0000000
--- a/bouquin/documents.py
+++ /dev/null
@@ -1,601 +0,0 @@
-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 99a1fcd..ae0206b 100644
--- a/bouquin/find_bar.py
+++ b/bouquin/find_bar.py
@@ -1,15 +1,20 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
-from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
+from PySide6.QtGui import (
+ QShortcut,
+ QTextCursor,
+ QTextCharFormat,
+ QTextDocument,
+)
from PySide6.QtWidgets import (
- QCheckBox,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QPushButton,
- QTextEdit,
QWidget,
+ QHBoxLayout,
+ QLineEdit,
+ QLabel,
+ QPushButton,
+ QCheckBox,
+ QTextEdit,
)
from . import strings
diff --git a/bouquin/fonts/DejaVu.license b/bouquin/fonts/DejaVu.license
deleted file mode 100644
index 8d71958..0000000
--- a/bouquin/fonts/DejaVu.license
+++ /dev/null
@@ -1,187 +0,0 @@
-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
deleted file mode 100644
index e5f7eec..0000000
Binary files a/bouquin/fonts/DejaVuSans.ttf and /dev/null differ
diff --git a/bouquin/fonts/Noto.license b/bouquin/fonts/Noto.license
deleted file mode 100644
index c37cc47..0000000
--- a/bouquin/fonts/Noto.license
+++ /dev/null
@@ -1,93 +0,0 @@
-Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-https://openfontlicense.org
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/bouquin/fonts/NotoSansSymbols2-Regular.ttf b/bouquin/fonts/NotoSansSymbols2-Regular.ttf
deleted file mode 100644
index 7816268..0000000
Binary files a/bouquin/fonts/NotoSansSymbols2-Regular.ttf and /dev/null differ
diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py
index c145cce..8429f5e 100644
--- a/bouquin/history_dialog.py
+++ b/bouquin/history_dialog.py
@@ -1,29 +1,21 @@
from __future__ import annotations
-import difflib
-import html as _html
-import re
+import difflib, re, html as _html
from datetime import datetime
-
-from PySide6.QtCore import QDate, Qt, Slot
+from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
- QAbstractItemView,
- QCalendarWidget,
QDialog,
- QDialogButtonBox,
+ QVBoxLayout,
QHBoxLayout,
- QLabel,
QListWidget,
QListWidgetItem,
- QMessageBox,
QPushButton,
- QTabWidget,
+ QMessageBox,
QTextBrowser,
- QVBoxLayout,
+ QTabWidget,
)
from . import strings
-from .theme import ThemeManager
def _markdown_to_text(s: str) -> str:
@@ -77,33 +69,19 @@ 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, themes: ThemeManager | None = None
- ):
+ def __init__(self, db, date_iso: str, parent=None):
super().__init__(parent)
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
self._db = db
self._date = date_iso
- self._themes = themes
self._versions = [] # list[dict] from DB
self._current_id = None # id of current
root = QVBoxLayout(self)
- # --- Top: date label + change-date button
- date_row = QHBoxLayout()
- self.date_label = QLabel(strings._("date_label").format(date=date_iso))
- date_row.addWidget(self.date_label)
- date_row.addStretch(1)
- self.change_date_btn = QPushButton(strings._("change_date"))
- self.change_date_btn.clicked.connect(self._on_change_date_clicked)
- date_row.addWidget(self.change_date_btn)
- root.addLayout(date_row)
-
# Top: list of versions
top = QHBoxLayout()
self.list = QListWidget()
- self.list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.list.setMinimumSize(500, 650)
self.list.currentItemChanged.connect(self._on_select)
top.addWidget(self.list, 1)
@@ -126,64 +104,14 @@ class HistoryDialog(QDialog):
row.addStretch(1)
self.btn_revert = QPushButton(strings._("history_dialog_revert_to_selected"))
self.btn_revert.clicked.connect(self._revert)
- self.btn_delete = QPushButton(strings._("history_dialog_delete"))
- self.btn_delete.clicked.connect(self._delete)
self.btn_close = QPushButton(strings._("close"))
self.btn_close.clicked.connect(self.reject)
row.addWidget(self.btn_revert)
- row.addWidget(self.btn_delete)
row.addWidget(self.btn_close)
root.addLayout(row)
self._load_versions()
- @Slot()
- def _on_change_date_clicked(self) -> None:
- """Let the user choose a different date and reload entries."""
-
- # Start from current dialog date; fall back to today if invalid
- current_qdate = QDate.fromString(self._date, Qt.ISODate)
- if not current_qdate.isValid():
- current_qdate = QDate.currentDate()
-
- dlg = QDialog(self)
- dlg.setWindowTitle(strings._("select_date_title"))
-
- layout = QVBoxLayout(dlg)
-
- calendar = QCalendarWidget(dlg)
- calendar.setSelectedDate(current_qdate)
- layout.addWidget(calendar)
- # Apply the same theming as the main sidebar calendar
- if self._themes is not None:
- self._themes.register_calendar(calendar)
-
- buttons = QDialogButtonBox(
- QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
- )
- buttons.accepted.connect(dlg.accept)
- buttons.rejected.connect(dlg.reject)
- layout.addWidget(buttons)
-
- if dlg.exec() != QDialog.Accepted:
- return
-
- new_qdate = calendar.selectedDate()
- new_iso = new_qdate.toString(Qt.ISODate)
- if new_iso == self._date:
- # No change
- return
-
- # Update state
- self._date = new_iso
-
- # Update window title and header label
- self.setWindowTitle(strings._("for").format(date=new_iso))
- self.date_label.setText(strings._("date_label").format(date=new_iso))
-
- # Reload entries for the newly selected date
- self._load_versions()
-
# --- Data/UX helpers ---
def _load_versions(self):
# [{id,version_no,created_at,note,is_current}]
@@ -217,24 +145,20 @@ class HistoryDialog(QDialog):
@Slot()
def _on_select(self):
- selected_items = self.list.selectedItems()
item = self.list.currentItem()
- if not item or len(selected_items) > 1:
+ if not item:
self.preview.clear()
self.diff.clear()
self.btn_revert.setEnabled(False)
return
-
sel_id = item.data(Qt.UserRole)
sel = self._db.get_version(version_id=sel_id)
self.preview.setMarkdown(sel["content"])
# Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
-
- # Enable revert and delete buttons only if selecting a non-current version
+ # Enable revert only if selecting a non-current version
self.btn_revert.setEnabled(sel_id != self._current_id)
- self.btn_delete.setEnabled(sel_id != self._current_id)
@Slot()
def _revert(self):
@@ -251,19 +175,3 @@ class HistoryDialog(QDialog):
)
return
self.accept()
-
- @Slot()
- def _delete(self):
- selected_items = self.list.selectedItems()
- for item in selected_items:
- sel_id = item.data(Qt.UserRole)
- if sel_id == self._current_id:
- return
- try:
- self._db.delete_version(version_id=sel_id)
- except Exception as e:
- QMessageBox.critical(
- self, strings._("history_dialog_delete_failed"), str(e)
- )
- return
- return self._load_versions()
diff --git a/bouquin/icons/bouquin.svg b/bouquin/icons/bouquin.svg
deleted file mode 100644
index b282050..0000000
--- a/bouquin/icons/bouquin.svg
+++ /dev/null
@@ -1,53 +0,0 @@
-
diff --git a/bouquin/invoices.py b/bouquin/invoices.py
deleted file mode 100644
index 18071d6..0000000
--- a/bouquin/invoices.py
+++ /dev/null
@@ -1,1445 +0,0 @@
-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 sqlcipher3 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}
-
-
-
-
-
-
-
-
ITEMS AND DESCRIPTION
-
QTY/HRS
-
PRICE
-
AMOUNT ({currency})
-
- {item_rows_html}
-
-
-
-
-
-
-
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)
-
- close_btn = QPushButton(strings._("close"))
- close_btn.clicked.connect(self.accept)
- btn_row.addWidget(close_btn)
-
- root.addLayout(btn_row)
-
- 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 866f682..67942ab 100644
--- a/bouquin/key_prompt.py
+++ b/bouquin/key_prompt.py
@@ -4,13 +4,13 @@ from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
- QDialogButtonBox,
- QFileDialog,
+ QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
- QVBoxLayout,
+ QDialogButtonBox,
+ QFileDialog,
)
from . import strings
@@ -99,9 +99,8 @@ class KeyPrompt(QDialog):
def db_path(self) -> Path | None:
"""Return the chosen DB path (or None if unchanged/not shown)."""
- p = self._db_path
if self.path_edit is not None:
text = self.path_edit.text().strip()
if text:
- p = Path(text)
- return p
+ return Path(text)
+ return self._db_path
diff --git a/bouquin/keys/mig5.asc b/bouquin/keys/mig5.asc
deleted file mode 100644
index 81d5fc7..0000000
--- a/bouquin/keys/mig5.asc
+++ /dev/null
@@ -1,109 +0,0 @@
------BEGIN PGP PUBLIC KEY BLOCK-----
-
-mQINBGQiioEBEAD2hJIaDsfkURHpA9KUXQQezeNhSiUcIheT3vP7Tb8nU2zkIgdy
-gvwvuUcXKjUn22q+paqbQu+skYEjtLEFo59ZlS2VOQ6f9ukTGu2O6HWqFWncH3Vv
-Pf0UeitNOoWi+qA14mtC7c/SxuHtMG4hmlHILGZg9mlSZfpt7oyczFtV7YG9toRe
-gvyM8h2BRSi3EXigsymVMgpYcW3bESVxOnNJdNEFP8fKzR9Bu7rc99abRPm5p6gw
-cYo9FAdLoiE8QcNU79hQ5UTAULWXFo3hduQfAs3y0f+g8FGJZUF40Gb8YJDtarRA
-J7B9/XdfDNDZE00/QxV2gUGbLVTbVjqn6dKhEOTfuvSmfQxqNNy2a1ewpJrNnsvh
-XGvSzZVLNy/c4CEROisRqDCa8xUb/snnHy7gGEuD5DXqQL3wnbTXu92N8gVxLegS
-fr9NW2I6/eXWrlXhWJdP5ZH9yq7FVkWha2gTByP6bcxDBvQCzKyYg4JbY9bQDtJf
-z7W2W9V6QHMiGJ9/ApfgTjKn0peiouGS8GGCPqLLyVGblEIJmSfEU+0BPq9PurRH
-RR/T7E4wVi3bgOfj9G5Z8dMBWh5BzN7PqxQvO1lCx7ZZteNkt/wXglLHB0eghnD0
-BCxuZ7lN12NW+lTf9s/kc0PS8YgZ0/AIFv45PHX1sVcxXizT49HQUbHa1wARAQAB
-tBpNaWd1ZWwgSmFjcSA8bWlnQG1pZzUubmV0PokCVAQTAQoAPhYhBACugXwkoQwl
-QEYanB183gI020WNBQJkIoqBAhsDBQkFo5qABQsJCAcDBRUKCQgLBRYCAwEAAh4B
-AheAAAoJEB183gI020WN+2AQALJ58Qr4P3/lON50ulG/RgIYxXlPnyy4Ai1bDJiI
-t3pLOWGQkGza6lw07rEh8Bs6w9sQ7WrpfzLRaYgqhfkBNbMtim8hRNZUuE/8O+v3
-k9GRVYCe9RWazKhno+RljJy4TaqiqBeGxnryDJWxk8O4dXmQAnsFPF09xNpktgOC
-mGbclA+rM8dY3bgq5wJ5Bh10zW4psfoAT1wFYX/oV19vlHbhRx3bavoWDS4lmXYv
-oWy9xwacDVoZYcbGPif3xbMbttdKH7ijf+asM3wYUsIrHeOPdHl+YK45e6AGdjwL
-mvp0P4YQo8Yk3yfH3L/km/no8rwcrPbk7+lX06x2GEjOiM2OIKAZYMZnL0BREgt4
-XsD2hcQpuowxHmI2X2CHk8TnPhAXyNdX7Ss/geQ6Zx/q1Ts+mhhfQVa9AIRS+HDm
-LURQRdZKBD1mB2hJsuF2WCyczuJ8jhBc+wSX/WXnQHLi2cG3OAC1udxrdDIckWb8
-4CojEbk05cnMLR3dPV/g1JeXunib569RNTAijaTr39VRBZepYJX/sO46iag2+0A4
-q41FgId2BwUS3GoyaIFZc5+MwLn65uYMgbIkfVlNkWEujoWV/aVLMrRa0udq4ZRE
-ymPU8pfMhEWb3uvYCv+ed7sVxsVUMWeuQpyBQuPP1qlIzmsrEkRKryYH+ij4Vzri
-OWvbuQINBGQiizkBEAC07TI3uqEUWKivXf5Mg1HnMAhpmv6yimr8PDfh3Dczy0eP
-oCB6iq5wKCjYsp12E3kv3dcW4Ox8T+5U/B5ZP2lro63yeLSORUSz+jMq27rgtGmV
-QFZNdKkzBzfPyzjKiZz4KaYE7Pn6v15In65SRqwqAXYUTkEoii+Ykk32qzZWIVCR
-ixpRQGbBi+/XipONp8KCQANOSWSzTf8s7U1y4yhW1yCeUOK67LsSRlCtBpDWD7ki
-MfX/nzSQyaXHDOrhkfVshU8eiln2Qf3mYg8gJmfFOb0zILhvCf3Sk312GtdxJo1m
-B95TrDY8/7+1+l0wVrTq69tJXjQjBSmk1PBvNthSXCvuADnF8NxQlQuZtyI+rC4T
-VInuLTr58YrmRIbGzOrFz+z6c532SB9F2PZvezjJ8LPDGCwW8dM6ADQxIw5cV0YE
-hb5liFpeIX/NOnd1kus8Q6jyS0vzFqfgZC9kBFUTaXBM+mpDg1GYB4WS7baBQn3P
-Z+7wvcN7VkfSBT2B79gJK0vfutJWBuK3p2435/KkD4PcAm6uBYL52b+Za06PQfgu
-GaKxXRLREq/KCbYm4IKBkD8HRH9dmdd2U8YsApNWQ/oAHCfWvimhYUD9YOJimDwp
-hX7FkaF/xHdi1/8hG8h2lok4cCtbaZPGXAUKuKHDhDFAI/OiIgv4nxq+A5kzfwAR
-AQABiQRyBBgBCgAmFiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQiizkCGwIFCQWj
-moACQAkQHXzeAjTbRY3BdCAEGQEKAB0WIQQ4BFZaXvpsEa/aDlNZs/DCQTXGqQUC
-ZCKLOQAKCRBZs/DCQTXGqTv6D/9eFMA3ReSg1sfPsyEFj9JiJ3H3aOJX5R5/2xdI
-QZLTjH0iapgGm3h8v+bFdr4+y3xWHpcaxBJsccyOZxzr0xjr+qt5t6OZrE+e1pQh
-Hw/Kt7m5SiCmbGM6I3aECv8zU4EpGUf/FXLcaBaot4eR4uPRjBLatngzLw+5Mjk1
-ZBjmyA5OaAqQzrDXPhFBItsSlHJeBOrpbzqxdjQi2AHD+L50itgfsoDOfVtmELZN
-heW7xn83U2iqgu3bEq4Ug8lqh2KVBHELoxErQR+wTAIxgj/CwhVDQdrKhQ4ypbLh
-O/oPlMmGFcBoMhCATNWitdqQUu7EHAECGyWCns8hm1OksqHMnbNhOzmRkl18UroZ
-a1CJPFpeaEC25U37+yPEUiG4dJE8iiZAfyjv0AN1TbXzov5g9g/Xz+BmVALtOYBJ
-fWKH/aTg5CU2GY9ts+bYDz+mli39h7FQQfcW+zjVWft2P4R7FvG0DBEJkbyw053R
-++CEO1ARsMyygy2ukwkA06nYPlbaH5wEpQl2NV5PeYt66eU4epgL7y89/DhOSBig
-JJJk+OASEh3o7rC/EkrlF/GQD8ZwO1oBO11ueDft7QU6P/TAzNqyywqZiy76kzdw
-1qU77vhXlGtZQCuxbfgvpLin1ivhOaR/6gfDmsfUlSne5kp+uUrgoRhhEc/krOci
-fGSFcutPD/4pziVea31UcngwJRo/s9AfHkjviVMpGJIQo3vtejq53UQu8yWWc/uW
-G5z+pxOuK3QdTjtzrmOiCGj1bWZ+I33K+fBbZcf7C+o4HV9KaexW1db3wBtwUFWO
-7TFezkBDaKbgxgaryh1+RcetQP7cdN2Chcy0EWf10S8/N8whj2ZyAcIuIoT8wM7i
-xWmnQRiI2l2+7AhQfqGFUk+PEYRvRyRtjF8X9buYVBh/9rFrScH6aK+gicCcU1gJ
-Zpc51QEDDSfAYF6wV8pWnILKcXqdDZhEh1hnTUitUL9mlZEaenGjSPCtcGVg3s9l
-CuXJij89s74IyfCdjJsmy9K5GxQyhUJb0nyy5wOpGPGmDueTiP32JuXOxNeEp+gY
-3rxygMNzAmL2QjLajLpE6kj+mEMBYSTWyni1W7c5i0PnOsi22yXV+2W+XaeC+9Pm
-424uM8e2Y0+C9lI6AqDziL58fP2V6FxJTpbzBxANqKwSh5N0we1Cfw/ZPC0LyebZ
-KbmPcNoSoqaOYXo3h0LFsDL2aA0PTJroAV1p/xxVoxDeGkX+hJXh+6ErVhEOb+gv
-+LiUabBFtHTa7yPVtQWLFWf4njFQIytt8iDTpFDfK1OApe25xilrTRZT147KtKwL
-5tDl33hFKbspcqALa7ozwE1Tr8/yrddainGQSIfx4CAfk8P5aqi19LkCDQRkIotT
-ARAAxjaJMoCvKYNWaJ5m9K9KsfoKss8CXiy3SEhbcqh/Yy4osiODjoWjS+lsz58G
-uyPphLXjdhIn9DWPnYKKoV7sB1y2RTCLsZ9jJaqHBL3e+gL78zS8hNHcq3HxWEwb
-SYRHr8pBKWL7/X4m+2cuMC/wnK+QWIGB4S03yMZGMbC8GTfuj6tdO4GZYfCGVWHi
-gv1ERGaArlqmXk+TkQQmTUpfhdqNBKWllZK56/oUMDNGsRrgEP8TzU4z+YbJK0FJ
-7V9dY1j28K8oqLDgA+/aiLv2gpS+qsmowMhxKN/axvF+FCZbGS3+/h4subZMIcbI
-xxDHSPqPgA+f0GQHIHsy9gELMQtkXTP5xzZuoDGX+F2LFb68wHd3jCNpfFVEfTP2
-8CcyLbjciyY8wod6WLa7q0VNDlSGEXH5thaNnidCwynNCF+NaFQMVf027jThp6S/
-nWtUZFPCMGx9jj8mbopkSsfF7E9fErRtCI8dAnmcE/ottvueAN7Q3XAUlsilLM8M
-HhkSZobaUBynewcEIpHSY4vOfRWnhQI60WGfD7x7dMuIakao9euSg9g/u7WMCV6U
-ShElJdYdpZA/H/jMFb17zuH9yp5cGNNMeUP2WvEWtUHA36nGI4+oE3SszOSRF4+E
-YAozF6Hh1MrC/hXe3NShoDq68hG5e1SsndLZ1B9Gt/nAqiEAEQEAAYkCPAQYAQoA
-JhYhBACugXwkoQwlQEYanB183gI020WNBQJkIotTAhsMBQkFo5qAAAoJEB183gI0
-20WNldAP/17KozqrwUA8mlYU3zpc/P0HdBtL/rn5Fx87MZ2E8RPuVMyNg6I4KoU5
-Kmh0vy6cL8vG7fqYXM1ieiy9wTMxiGaWDL7QZY3LBXQ2mFfGd2rAAhwloTEcPn6i
-Ro/X0C5aBGGy5iACOfpRA774XsNQG6cgBY/Jq0/D2Jom78Vv0k3H0oD1L5BrRO/H
-5L9TriBW9el4F/USpaQDjR/KiSfsBr6HLpht1OQJ+21kUbGgvse7DdTtZeK4q3wR
-1v4OV9EX1m09WUL+7Cra1OFSc9bZ0fcVY98zGXm8LTtipiBc//ZrDjMutRdOj4ct
-RHDiKHBEYFxHGeAj87Xwc9q6ph2MspjXS4qHVJRWtyx5DQcrf6gY3bH73SByhOXj
-SVDpfeDvO4BpQ+8q4d9AjcGa6NqGTXR8P5Y8jnZG68buwGstBbz2J2fHBs0SrBMg
-3T6HSB3z4gD/WkPE8bT/9oMpSLD0mdHQAYJviOa39rRGII6Jzkd1EL9tVDU9QenX
-hVx2v3ZWL8Iq1Bm8zwiDAGsiHcHmxY8sQmfuwWQdYXhxXBcG0kBNKz+158uyFr9u
-Skp8e1INBDShReAQuQ5PAGBIrZ5aElPaK/2puNeAmd3cholvpeu0CuEaxpLi0Tq3
-y/xhPPFMdZ4llt90sotKeYnHmvsYUJe2on8afl9bwotz8On484vVuQINBGQii2cB
-EAC/YnmAiKO05oN129GedPTDrvJk6PbXHUYb5UtNisAwLVXeKSpo5OWyckDZ1IoV
-9xvOdH+TWJvgX5x7gPZoD9COYHfMQRZeysZ89wCocH55PsAwmvjM87rAKLbkyZl8
-sehgsri09amBlMoSeTVN49U5lt9EZWVKZeACtDk9D86OX7r154NM7uSxvQVeydth
-Bj/Rdh15RUfsKTZYxmzZ/1x3FnHzOLTDkX5QmBIBlthVN2IaT8U8pfKpoStOlBza
-j1MdrdhtkDH4YAFi2X9KlkoP3Z2fYCefVcLJw+k3D8nwPyXmGuJhG0oHsPyesQGz
-FSnIM6ZWhqh76yS1EQxK125NKu9FeHJBAEOg0RISpe/LhNNLjUQ0dC9gRx9l+p46
-hIMUXwMPNENMFihNqP4tRLvF/0KI1oj7634rei+dZKWuja6yk/QaOcztmcyS2Aca
-n3llExISb3beNncQHaAYg8ADHR+852RZQ81yUFUF7yrxclSJmF5zO4fJAedacClA
-FuGnQvIQZv01YULOtDn3fTq8eY912VZx+SxpO2IwTObYCdnSBHigQBp13UTcg5WV
-HhmfwJKI328GaPkBa0eIqxc5gR7X6PmrLvxlCbrMC9IHjlwd203eKMhqRoIJYXEv
-Ebsx02Zceh4tMH9RDH2XNpHLt604rCLJTReRORXsAH/zBQARAQABiQI8BBgBCgAm
-FiEEAK6BfCShDCVARhqcHXzeAjTbRY0FAmQii2cCGyAFCQWjmoAACgkQHXzeAjTb
-RY1TiA/+N4dIfoHMsEZ53DwrbRqDXlzTfkfqWd99tE72Lecsns2ih4/4wHOgzV7z
-SV6002SZK/PHRYikmxSSxmoNbx5yNMp9vI8j031YShAJd6QU+NVjY3oB4ivF6wRa
-vP2OYO0vamwTw54e5quKmg+ZntFhWY55YNWCqqcYZdHI4GtvbhsCEuS/ceZ1XoXY
-xbtaNJHAn5yG+/VLNu2fiAiu+e4+xEQ2UjV8rC60MU9tZafMbALlHUXGDY0tUCzv
-/BF3GDQk3dxN+fEBnassVXgZm30dOB2XqVIF5g+l6iufmT9WcDTbnXyYbEBRVTJ1
-DpTbmtwUpuYdSX41NPPojK3XcesP+PR8x7tWU7AEWzV827I4sx54HjJVMj2TWSGB
-X+xDgthbqqtm1VZPNL2yHJzxHgIPqo6iQLaAGphR/L+ULFeJnFNjgOatt7vcG7pr
-ZVLK1Kq+gc0X+73grlm89XC5R3mNFNOUMWXJ7YniqzCzsTiOwyGP40pvY1vP8v61
-509UcUjfXyIhls6vAl1jo/BA0jLuUODQ9P4QqWm4wy7MzMfWBmWKsaubCiiHuala
-rXFaJVtIgM/bl089klXVzxD3Beo0PCnuU/6qBgkM6ulS+/wxqU7chW6ClHwdY8U0
-NU3X/uocFtQrI3WLcE0vMc0IHa8VjDb8r6ztC9Vsti6iPMdScOM=
-=IfFs
------END PGP PUBLIC KEY BLOCK-----
diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json
index 6c13e42..9b70cfd 100644
--- a/bouquin/locales/en.json
+++ b/bouquin/locales/en.json
@@ -5,6 +5,7 @@
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error",
+ "database_path": "Database path",
"database_maintenance": "Database maintenance",
"database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
@@ -22,17 +23,19 @@
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
"error": "Error",
"success": "Success",
- "close": "&Close",
+ "close": "Close",
"find": "Find",
"file": "File",
- "locale": "Language",
+ "locale": "Locale",
"locale_restart": "Please restart the application to load the new language.",
"settings": "Settings",
"theme": "Theme",
"system": "System",
"light": "Light",
"dark": "Dark",
+ "behaviour": "Behaviour",
"never": "Never",
+ "browse": "Browse",
"close_tab": "Close tab",
"previous": "Previous",
"previous_day": "Previous day",
@@ -40,9 +43,9 @@
"next_day": "Next day",
"today": "Today",
"show": "Show",
- "edit": "Edit",
- "delete": "Delete",
"history": "History",
+ "view_history": "View History",
+ "export": "Export",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
"export_complete": "Export complete",
@@ -51,8 +54,6 @@
"backup_complete": "Backup complete",
"backup_failed": "Backup failed",
"quit": "Quit",
- "cancel": "Cancel",
- "save": "Save",
"help": "Help",
"saved": "Saved",
"saved_to": "Saved to",
@@ -60,20 +61,6 @@
"couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug",
"version": "Version",
- "update": "Update",
- "check_for_updates": "Check for updates",
- "could_not_check_for_updates": "Could not check for updates:\n",
- "update_server_returned_an_empty_version_string": "Update server returned an empty version string",
- "you_are_running_the_latest_version": "You are running the latest version:\n",
- "there_is_a_new_version_available": "There is a new version available:\n",
- "download_the_appimage": "Download the AppImage?",
- "downloading": "Downloading",
- "download_cancelled": "Download cancelled",
- "failed_to_download_update": "Failed to download update:\n",
- "could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
- "could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
- "gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
- "downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate",
"current": "current",
"selected": "selected",
@@ -84,14 +71,11 @@
"find_bar_match_case": "Match case",
"history_dialog_preview": "Preview",
"history_dialog_diff": "Diff",
- "history_dialog_revert_to_selected": "&Revert to selected",
+ "history_dialog_revert_to_selected": "Revert to selected",
"history_dialog_revert_failed": "Revert failed",
- "history_dialog_delete": "&Delete revision",
- "history_dialog_delete_failed": "Could not delete revision",
"key_prompt_enter_key": "Enter key",
- "lock_overlay_locked": "Locked",
+ "lock_overlay_locked_due_to_inactivity": "Locked due to inactivity",
"lock_overlay_unlock": "Unlock",
- "main_window_lock_screen_accessibility": "&Lock screen",
"main_window_ready": "Ready",
"main_window_save_a_version": "Save a version",
"main_window_settings_accessible_flag": "Settin&gs",
@@ -102,8 +86,7 @@
"open_in_new_tab": "Open in new tab",
"autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
- "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
- "move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
+ "move_yesterdays_unchecked_todos_to_today_on_startup": "Move yesterday's unchecked TODOs to today on startup",
"insert_images": "Insert images",
"images": "Images",
"reopen_failed": "Re-open failed",
@@ -115,23 +98,15 @@
"backup_encrypted_notebook": "Backup encrypted notebook",
"enter_a_name_for_this_version": "Enter a name for this version",
"new_version_i_saved_at": "New version I saved at",
- "appearance": "Appearance",
- "security": "Security",
- "features": "Features",
- "database": "Database",
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
"lock_screen_when_idle": "Lock screen when idle",
- "autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
- "font_size": "Font size",
- "font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
+ "autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.'nSet to 0 (never) to never lock.",
"search_for_notes_here": "Search for notes here",
"toolbar_format": "Format",
"toolbar_bold": "Bold",
"toolbar_italic": "Italic",
"toolbar_strikethrough": "Strikethrough",
"toolbar_normal_paragraph_text": "Normal paragraph text",
- "toolbar_font_smaller": "Smaller text",
- "toolbar_font_larger": "Larger text",
"toolbar_bulleted_list": "Bulleted list",
"toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block",
@@ -140,12 +115,20 @@
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
+ "main_window_manage_tags_accessible_flag": "Manage &Tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
+ "tag_name": "Tag name",
+ "tag_color_hex": "Hex colour",
"color_hex": "Colour",
"date": "Date",
- "page_or_document": "Page / Document",
+ "pick_color": "Pick colour",
+ "invalid_color_title": "Invalid colour",
+ "invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
+ "add": "Add",
+ "remove": "Remove",
+ "ok": "OK",
"add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
@@ -155,11 +138,6 @@
"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",
@@ -170,18 +148,7 @@
"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,8 +156,8 @@
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
- "reminder": "Reminder",
"set_reminder": "Set reminder prompt",
+ "set_reminder_prompt": "Enter a time",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
@@ -209,18 +176,10 @@
"add_project": "Add project",
"add_time_entry": "Add time entry",
"time_period": "Time period",
- "dont_group": "Don't group",
- "by_activity": "by activity",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
"date_range": "Date range",
- "custom_range": "Custom",
- "last_week": "Last week",
- "this_week": "This week",
- "this_month": "This month",
- "this_year": "This year",
- "all_projects": "All projects",
"delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?",
@@ -230,11 +189,11 @@
"delete_time_entry": "Delete time entry",
"group_by": "Group by",
"hours": "Hours",
- "created_at": "Created at",
"invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project",
+ "label_key": "Label",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
@@ -249,27 +208,17 @@
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
- "reporting": "Reporting",
- "reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report",
- "add_activity_title": "Add activity",
- "add_activity_label": "Add an activity",
- "rename_activity_label": "Rename activity",
- "add_project_title": "Add project",
"add_project_label": "Add a project",
- "rename_activity_title": "Rename this activity",
- "rename_project_label": "Rename project",
- "rename_project_title": "Rename this project",
+ "add_activity_label": "Add an activity",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a project",
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
- "date_label": "Date: {date}",
- "change_date": "Change date",
- "select_date_title": "Select date",
- "for": "For {date}",
+ "time_log_date_label": "Time log date: {date}",
+ "time_log_for": "Time log for {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
@@ -278,6 +227,7 @@
"time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
+ "title_key": "title",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",
@@ -288,145 +238,5 @@
"export_csv_error_message": "Could not write CSV file:\n{error}",
"export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed",
- "export_pdf_error_message": "Could not write PDF file:\n{error}",
- "enable_tags_feature": "Enable Tags",
- "enable_time_log_feature": "Enable Time Logging",
- "enable_reminders_feature": "Enable Reminders",
- "reminders_webhook_section_title": "Send Reminders to a webhook",
- "reminders_webhook_url_label":"Webhook URL",
- "reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
- "enable_documents_feature": "Enable storing of documents",
- "pomodoro_time_log_default_text": "Focus session",
- "toolbar_pomodoro_timer": "Time-logging timer",
- "set_code_language": "Set code language",
- "cut": "Cut",
- "copy": "Copy",
- "paste": "Paste",
- "start": "Start",
- "pause": "Pause",
- "resume": "Resume",
- "stop_and_log": "Stop and log",
- "manage_reminders": "Manage Reminders",
- "upcoming_reminders": "Upcoming Reminders",
- "no_upcoming_reminders": "No upcoming reminders",
- "once": "once",
- "daily": "daily",
- "weekdays": "weekdays",
- "weekly": "weekly",
- "add_reminder": "Add Reminder",
- "set_reminder": "Set Reminder",
- "edit_reminder": "Edit Reminder",
- "delete_reminder": "Delete Reminder",
- "delete_reminders": "Delete Reminders",
- "deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
- "this_is_a_reminder_of_type": "Note: This is a reminder of type",
- "this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
- "reminder": "Reminder",
- "reminders": "Reminders",
- "time": "Time",
- "once": "Once",
- "every_day": "Every day",
- "every_weekday": "Every weekday (Mon-Fri)",
- "every_week": "Every week",
- "every_fortnight": "Every 2 weeks",
- "every_month": "Every month (same date)",
- "every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
- "week_in_month": "Week in month",
- "fortnightly": "Fortnightly",
- "monthly_same_date": "Monthly (same date)",
- "monthly_nth_weekday": "Monthly (nth weekday)",
- "repeat": "Repeat",
- "monday": "Monday",
- "tuesday": "Tuesday",
- "wednesday": "Wednesday",
- "thursday": "Thursday",
- "friday": "Friday",
- "saturday": "Saturday",
- "sunday": "Sunday",
- "monday_short": "Mon",
- "tuesday_short": "Tue",
- "wednesday_short": "Wed",
- "thursday_short": "Thu",
- "friday_short": "Fri",
- "saturday_short": "Sat",
- "sunday_short": "Sun",
- "day": "Day",
- "text": "Text",
- "type": "Type",
- "active": "Active",
- "actions": "Actions",
- "edit_code_block": "Edit code block",
- "delete_code_block": "Delete code block",
- "search_result_heading_document": "Document",
- "toolbar_documents": "Documents Manager",
- "project_documents_title": "Project documents",
- "documents_col_file": "File",
- "documents_col_description": "Description",
- "documents_col_added": "Added",
- "documents_col_path": "Path",
- "documents_col_tags": "Tags",
- "documents_col_size": "Size",
- "documents_add": "&Add",
- "documents_add_document": "Add a document",
- "documents_open": "&Open",
- "documents_delete": "&Delete",
- "documents_no_project_selected": "Please choose a project first.",
- "documents_file_filter_all": "All files (*)",
- "documents_add_failed": "Could not add document: {error}",
- "documents_open_failed": "Could not open document: {error}",
- "documents_missing_file": "The file does not exist:\n{path}",
- "documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
- "documents_search_label": "Search",
- "documents_search_placeholder": "Type to search documents (all projects)",
- "todays_documents": "Documents from this day",
- "todays_documents_none": "No documents yet.",
- "manage_invoices": "Manage Invoices",
- "create_invoice": "Create Invoice",
- "invoice_amount": "Amount",
- "invoice_apply_tax": "Apply Tax",
- "invoice_client_address": "Client Address",
- "invoice_client_company": "Client Company",
- "invoice_client_email": "Client E-mail",
- "invoice_client_name": "Client Contact",
- "invoice_currency": "Currency",
- "invoice_dialog_title": "Create Invoice",
- "invoice_due_date": "Due Date",
- "invoice_hourly_rate": "Hourly Rate",
- "invoice_hours": "Hours",
- "invoice_issue_date": "Issue Date",
- "invoice_mode_detailed": "Detailed mode",
- "invoice_mode_summary": "Summary mode",
- "invoice_number": "Invoice Number",
- "invoice_save_and_export": "Save and export",
- "invoice_save_pdf_title": "Save PDF",
- "invoice_subtotal": "Subtotal",
- "invoice_summary_default_desc": "Consultant services for the month of",
- "invoice_summary_desc": "Summary description",
- "invoice_summary_hours": "Summary hours",
- "invoice_tax": "Tax details",
- "invoice_tax_label": "Tax type",
- "invoice_tax_rate": "Tax rate",
- "invoice_tax_total": "Tax total",
- "invoice_total": "Total",
- "invoice_paid_at": "Paid on",
- "invoice_payment_note": "Payment notes",
- "invoice_project_required_title": "Project required",
- "invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
- "invoice_need_report_title": "Report required",
- "invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
- "invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
- "invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
- "enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
- "invoice_company_profile": "Business Profile",
- "invoice_company_name": "Business Name",
- "invoice_company_address": "Address",
- "invoice_company_phone": "Phone",
- "invoice_company_email": "E-mail",
- "invoice_company_tax_id": "Tax number",
- "invoice_company_payment_details": "Payment details",
- "invoice_company_logo": "Logo",
- "invoice_company_logo_choose": "Choose logo",
- "invoice_company_logo_set": "Logo has been set",
- "invoice_company_logo_not_set": "Logo not set",
- "invoice_number_unique": "Invoice number must be unique. This invoice number already exists."
+ "export_pdf_error_message": "Could not write PDF file:\n{error}"
}
diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json
index f77ebb1..0949130 100644
--- a/bouquin/locales/fr.json
+++ b/bouquin/locales/fr.json
@@ -1,10 +1,11 @@
{
"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_reopen_failed_after_rekey": "Échec de la réouverture après modification de la 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_key_incorrect": "La clé est peut-être incorrecte",
"db_database_error": "Erreur de base de données",
+ "database_path": "Chemin de la base de données",
"database_maintenance": "Maintenance de la base de données",
"database_compact": "Compacter la base de données",
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
@@ -16,9 +17,9 @@
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
"key_mismatch": "Les clés ne correspondent pas",
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
- "empty_key": "La clé est vide",
+ "empty_key": "Clé est vide",
"empty_key_explanation": "La clé ne peut pas être vide.",
- "key_changed": "La clé a été modifiée",
+ "key_changed": "Clé modifiée",
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
"error": "Erreur",
"success": "Succès",
@@ -26,14 +27,15 @@
"find": "Rechercher",
"file": "Fichier",
"locale": "Langue",
- "locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
+ "locale_restart": "Veuillez redémarrer l’application pour appliquer la nouvelle langue.",
"settings": "Paramètres",
"theme": "Thème",
"system": "Système",
"light": "Clair",
"dark": "Sombre",
+ "behaviour": "Comportement",
"never": "Jamais",
- "close_tab": "Fermer l'onglet",
+ "browse": "Parcourir",
"previous": "Précédent",
"previous_day": "Jour précédent",
"next": "Suivant",
@@ -41,94 +43,69 @@
"today": "Aujourd'hui",
"show": "Afficher",
"history": "Historique",
+ "view_history": "Afficher l'historique",
+ "export": "Exporter",
"export_accessible_flag": "E&xporter",
"export_entries": "Exporter les entrées",
"export_complete": "Exportation terminée",
- "export_failed": "Échec de l'exportation",
+ "export_failed": "Échec de l’exportation",
"backup": "Sauvegarder",
"backup_complete": "Sauvegarde terminée",
"backup_failed": "Échec de la sauvegarde",
"quit": "Quitter",
- "cancel": "Annuler",
- "save": "Enregistrer",
"help": "Aide",
"saved": "Enregistré",
"saved_to": "Enregistré dans",
"documentation": "Documentation",
- "couldnt_open": "Impossible d'ouvrir",
+ "couldnt_open": "Impossible d’ouvrir",
"report_a_bug": "Signaler un bug",
"version": "Version",
- "update": "Mise à jour",
- "check_for_updates": "Rechercher des mises à jour",
- "could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
- "update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
- "you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
- "there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
- "download_the_appimage": "Télécharger l'AppImage ?",
- "downloading": "Téléchargement en cours",
- "download_cancelled": "Téléchargement annulé",
- "failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
- "could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
- "could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
- "gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
- "downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
"navigate": "Naviguer",
"current": "actuel",
"selected": "sélectionné",
"find_on_page": "Rechercher dans la page",
- "find_next": "Rechercher le suivant",
- "find_previous": "Rechercher le précédent",
+ "find_next": "Rechercher suivant",
+ "find_previous": "Rechercher 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_locked_due_to_inactivity": "Verrouillé pour cause d’inactivité",
"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",
+ "unlock_encrypted_notebook_explanation": "Saisissez 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_yesterdays_unchecked_todos_to_today_on_startup": "Au démarrage, déplacer les TODO non cochés d’hier vers aujourd’hui",
"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.",
+ "unencrypted_export_warning": "L’export de la base de données ne sera pas chiffré !\nÊtes-vous sûr de vouloir continuer ?'nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu’Export.",
"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",
+ "new_version_i_saved_at": "Nouvelle version que j’ai enregistrée à",
+ "save_key_warning": "Si vous ne voulez pas que l’on vous demande votre clé de chiffrement, cochez ceci pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
+ "lock_screen_when_idle": "Verrouiller l’écran en cas d’inactivité",
+ "autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée ; vous devrez alors ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
+ "search_for_notes_here": "Recherchez des notes",
"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_normal_paragraph_text": "Texte normale",
"toolbar_bulleted_list": "Liste à puces",
"toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code",
@@ -137,154 +114,24 @@
"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",
+ "add_tag_placeholder": "Ajouter une étiquette et appuyez sur Entrée",
+ "tag_browser_title": "Navigateur de étiquettes",
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
+ "tag_name": "Nom de l'étiquette",
+ "tag_color_hex": "Couleur hexadécimale",
"color_hex": "Couleur",
"date": "Date",
+ "pick_color": "Choisir la couleur",
+ "invalid_color_title": "Couleur invalide",
+ "invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.",
+ "add": "Ajouter",
+ "remove": "Supprimer",
+ "ok": "OK",
"add_a_tag": "Ajouter une étiquette",
"edit_tag_name": "Modifier le nom de l'étiquette",
"new_tag_name": "Nouveau nom de l'étiquette :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
- "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
- "statistics": "Statistiques",
- "main_window_statistics_accessible_flag": "Stat&istiques",
- "stats_pages_with_content": "Pages avec contenu (version actuelle)",
- "stats_total_revisions": "Nombre total de révisions",
- "stats_page_most_revisions": "Page avec le plus de révisions",
- "stats_total_words": "Nombre total de mots (versions actuelles)",
- "stats_unique_tags": "Étiquettes uniques",
- "stats_page_most_tags": "Page avec le plus d'étiquettes",
- "stats_activity_heatmap": "Carte de chaleur d'activité",
- "stats_heatmap_metric": "Colorer selon",
- "stats_metric_words": "Mots",
- "stats_metric_revisions": "Révisions",
- "stats_no_data": "Aucune statistique disponible pour le moment.",
- "select_notebook": "Sélectionner un bouquin",
- "bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
- "bug_report_placeholder": "Saisissez votre rapport de bug ici",
- "bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
- "bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
- "bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
- "send": "Envoyer",
- "reminder": "Rappel",
- "set_reminder": "Définir le rappel",
- "reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
- "invalid_time_title": "Heure invalide",
- "invalid_time_message": "Veuillez saisir une heure au format HH:MM",
- "dismiss": "Ignorer",
- "toolbar_alarm": "Régler l'alarme de rappel",
- "activities": "Activités",
- "activity": "Activité",
- "note": "Note",
- "activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
- "activity_delete_error_title": "Problème lors de la suppression de l'activité",
- "activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
- "activity_rename_error_title": "Problème lors du renommage de l'activité",
- "activity_required_message": "Un nom d'activité est requis",
- "activity_required_title": "Nom d'activité requis",
- "add_activity": "Ajouter une activité",
- "add_project": "Ajouter un projet",
- "add_time_entry": "Ajouter une entrée de temps",
- "time_period": "Période",
- "by_day": "par jour",
- "by_month": "par mois",
- "by_week": "par semaine",
- "date_range": "Plage de dates",
- "delete_activity": "Supprimer l'activité",
- "delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
- "delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
- "delete_project": "Supprimer le projet",
- "delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
- "delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
- "delete_time_entry": "Supprimer l'entrée de temps",
- "group_by": "Grouper par",
- "hours": "Heures",
- "invalid_activity_message": "L'activité est invalide",
- "invalid_activity_title": "Activité invalide",
- "invalid_project_message": "Le projet est invalide",
- "invalid_project_title": "Projet invalide",
- "manage_activities": "Gérer les activités",
- "manage_projects": "Gérer les projets",
- "manage_projects_activities": "Gérer les activités du projet",
- "open_time_log": "Ouvrir le journal de temps",
- "project": "Projet",
- "project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
- "project_delete_error_title": "Problème lors de la suppression du projet",
- "project_rename_error_message": "Un problème est survenu lors du renommage du projet",
- "project_rename_error_title": "Problème lors du renommage du projet",
- "project_required_message": "Un projet est requis",
- "project_required_title": "Projet requis",
- "projects": "Projets",
- "rename_activity": "Renommer l'activité",
- "rename_project": "Renommer le projet",
- "run_report": "Exécuter le rapport",
- "add_activity_title": "Ajouter une activité",
- "add_activity_label": "Ajouter une activité",
- "rename_activity_label": "Renommer l'activité",
- "add_project_title": "Ajouter un projet",
- "add_project_label": "Ajouter un projet",
- "rename_activity_title": "Renommer cette activité",
- "rename_project_label": "Renommer le projet",
- "rename_project_title": "Renommer ce projet",
- "select_activity_message": "Sélectionner une activité",
- "select_activity_title": "Sélectionner une activité",
- "select_project_message": "Sélectionner un projet",
- "select_project_title": "Sélectionner un projet",
- "time_log": "Journal de temps",
- "time_log_collapsed_hint": "Journal de temps",
- "time_log_date_label": "Date du journal de temps : {date}",
- "time_log_for": "Journal de temps pour {date}",
- "time_log_no_date": "Journal de temps",
- "time_log_no_entries": "Aucune entrée de temps pour l'instant",
- "time_log_report": "Rapport de temps",
- "time_log_report_title": "Journal de temps pour {project}",
- "time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
- "time_log_total_hours": "Total pour la journée : {hours:.2f}h",
- "time_log_with_total": "Journal de temps ({hours:.2f}h)",
- "update_time_entry": "Mettre à jour l'entrée de temps",
- "time_report_total": "Total : {hours:.2f} heures",
- "no_report_title": "Aucun rapport",
- "no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
- "total": "Total",
- "export_csv": "Exporter en CSV",
- "export_csv_error_title": "Échec de l'exportation",
- "export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
- "export_pdf": "Exporter en PDF",
- "export_pdf_error_title": "Échec de l'exportation PDF",
- "export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
- "enable_tags_feature": "Activer les étiquettes",
- "enable_time_log_feature": "Activer le journal de temps",
- "enable_reminders_feature": "Activer les rappels",
- "pomodoro_time_log_default_text": "Session de concentration",
- "toolbar_pomodoro_timer": "Minuteur de suivi du temps",
- "set_code_language": "Définir le langage du code",
- "cut": "Couper",
- "copy": "Copier",
- "paste": "Coller",
- "start": "Démarrer",
- "pause": "Pause",
- "resume": "Reprendre",
- "stop_and_log": "Arrêter et enregistrer",
- "once": "une fois",
- "daily": "quotidien",
- "weekdays": "jours de semaine",
- "weekly": "hebdomadaire",
- "edit_reminder": "Modifier le rappel",
- "time": "Heure",
- "once": "Une fois (aujourd'hui)",
- "every_day": "Tous les jours",
- "every_weekday": "Tous les jours de semaine (lun-ven)",
- "every_week": "Toutes les semaines",
- "repeat": "Répéter",
- "monday": "Lundi",
- "tuesday": "Mardi",
- "wednesday": "Mercredi",
- "thursday": "Jeudi",
- "friday": "Vendredi",
- "saturday": "Samedi",
- "sunday": "Dimanche",
- "day": "Jour"
+ "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà"
}
diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json
index 6be0955..b5006bb 100644
--- a/bouquin/locales/it.json
+++ b/bouquin/locales/it.json
@@ -3,8 +3,9 @@
"db_issues_reported": "problema/i segnalato/i",
"db_reopen_failed_after_rekey": "Riapertura fallita dopo il cambio chiave",
"db_version_id_does_not_belong_to_the_given_date": "version_id non appartiene alla data indicata",
- "db_database_error": "Errore del database",
"db_key_incorrect": "La chiave è probabilmente errata",
+ "db_database_error": "Errore del database",
+ "database_path": "Percorso del database",
"database_maintenance": "Manutenzione del database",
"database_compact": "Compatta il database",
"database_compact_explanation": "La compattazione esegue VACUUM sul database. Può aiutare a ridurne le dimensioni.",
@@ -32,7 +33,9 @@
"system": "Sistema",
"light": "Chiaro",
"dark": "Scuro",
+ "behaviour": "Comportamento",
"never": "Mai",
+ "browse": "Sfoglia",
"previous": "Precedente",
"previous_day": "Giorno precedente",
"next": "Successivo",
@@ -40,6 +43,8 @@
"today": "Oggi",
"show": "Mostra",
"history": "Cronologia",
+ "view_history": "Visualizza cronologia",
+ "export": "Esporta",
"export_accessible_flag": "&Esporta",
"export_entries": "Esporta voci",
"export_complete": "Esportazione completata",
@@ -68,10 +73,10 @@
"history_dialog_revert_to_selected": "Ripristina alla versione selezionata",
"history_dialog_revert_failed": "Ripristino fallito",
"key_prompt_enter_key": "Inserisci la chiave",
- "lock_overlay_locked": "Bloccato",
+ "lock_overlay_locked_due_to_inactivity": "Bloccato per inattività",
"lock_overlay_unlock": "Sblocca",
"main_window_ready": "Pronto",
- "main_window_save_a_version": "Salva versione",
+ "main_window_save_a_version": "Salva una versione",
"main_window_settings_accessible_flag": "Impo&stazioni",
"set_an_encryption_key": "Imposta una chiave di crittografia",
"set_an_encryption_key_explanation": "Bouquin cripta i tuoi dati.\n\nCrea una passphrase sicura per criptare il blocco note.\n\nPuoi sempre cambiarla in seguito!",
@@ -80,7 +85,7 @@
"open_in_new_tab": "Apri in una nuova scheda",
"autosave": "salvataggio automatico",
"unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo",
- "move_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati a oggi all'avvio",
+ "move_yesterdays_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati di ieri a oggi all'avvio",
"insert_images": "Inserisci immagini",
"images": "Immagini",
"reopen_failed": "Riapertura fallita",
@@ -111,51 +116,21 @@
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
+ "tag_name": "Nome del tag",
+ "tag_color_hex": "Colore esadecimale",
"color_hex": "Colore",
"date": "Data",
+ "pick_color": "Scegli colore",
+ "invalid_color_title": "Colore non valido",
+ "invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.",
+ "add": "Aggiungi",
+ "remove": "Rimuovi",
+ "ok": "OK",
"add_a_tag": "Aggiungi un tag",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",
"change_color": "Cambia colore",
"delete_tag": "Elimina tag",
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.",
- "tag_already_exists_with_that_name": "Esiste già un tag con questo nome",
- "cancel": "Annulla",
- "select_notebook": "Seleziona blocco note",
- "save": "Salva",
- "history_dialog_delete": "Cancella versione",
- "check_for_updates": "Controlla aggiornamenti",
- "close": "Chiudi",
- "send": "Invia",
- "time_log": "Registro Attività",
- "time_log_no_entries": "Nessuna Attività",
- "close_tab": "Chiudi scheda",
- "toolbar_font_smaller": "Rimpicciolisci testo",
- "toolbar_font_larger": "Ingrandisci testo",
- "toolbar_alarm": "Imposta promemoria",
- "statistics": "Statistiche",
- "main_window_statistics_accessible_flag": "Stat&istiche",
- "main_window_lock_screen_accessibility": "B&locca Schermo",
- "font_size": "Dimensione carattere",
- "font_size_explanation": "Cambiare questo valore camberà la dimensione di tutto il testo in tutte le schede. Dimensione di titoli e blocchi di codice rimarranno invariati",
- "enable_tags_feature": "Abilita Tags",
- "enable_time_log_feature": "Abilita Traccuamento del tempo",
- "appearance": "Interfaccia",
- "features": "Funzionalità",
- "security": "Sicurezza",
- "bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.",
- "bug_report_placeholder": "Scrivi la tua segnalazione qui",
- "update": "Aggiornamento",
- "you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n",
- "cut": "Taglia",
- "copy": "Copia",
- "paste": "Incolla",
- "monday": "Lunedì",
- "tuesday": "Martedì",
- "wednesday": "Mercoledì",
- "thursday": "Giovedì",
- "friday": "Venerdì",
- "saturday": "Sabato",
- "sunday": "Domenica",
- "day": "Giorno"
+ "tag_already_exists_with_that_name": "Esiste già un tag con questo nome"
}
diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py
index 90c12a8..61a52e5 100644
--- a/bouquin/lock_overlay.py
+++ b/bouquin/lock_overlay.py
@@ -1,7 +1,7 @@
from __future__ import annotations
-from PySide6.QtCore import QEvent, Qt
-from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
+from PySide6.QtCore import Qt, QEvent
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from . import strings
from .theme import ThemeManager
@@ -21,13 +21,12 @@ class LockOverlay(QWidget):
lay = QVBoxLayout(self)
lay.addStretch(1)
- msg = QLabel(strings._("lock_overlay_locked"), self)
+ msg = QLabel(strings._("lock_overlay_locked_due_to_inactivity"), self)
msg.setObjectName("lockLabel")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton(strings._("lock_overlay_unlock"), self)
self._btn.setObjectName("unlockButton")
- self._btn.setShortcut("Ctrl+Shift+U")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
diff --git a/bouquin/main.py b/bouquin/main.py
index 6883755..693917e 100644
--- a/bouquin/main.py
+++ b/bouquin/main.py
@@ -1,26 +1,18 @@
from __future__ import annotations
import sys
-from pathlib import Path
-
-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 .main_window import MainWindow
from .theme import Theme, ThemeConfig, ThemeManager
+from . import strings
def main():
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG)
- # Icon
- BASE_DIR = Path(__file__).resolve().parent
- ICON_PATH = BASE_DIR / "icons" / "bouquin.svg"
- icon = QIcon(str(ICON_PATH))
- app.setWindowIcon(icon)
s = get_settings()
theme_str = s.value("ui/theme", "system")
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 9b812b4..b5dab36 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -1,22 +1,23 @@
from __future__ import annotations
import datetime
+import importlib.metadata
import os
-import re
import sys
-from pathlib import Path
+import re
+from pathlib import Path
from PySide6.QtCore import (
QDate,
- QDateTime,
- QEvent,
- QSettings,
- QSignalBlocker,
- Qt,
- QTime,
QTimer,
- QUrl,
+ Qt,
+ QSettings,
Slot,
+ QUrl,
+ QEvent,
+ QSignalBlocker,
+ QDateTime,
+ QTime,
)
from PySide6.QtGui import (
QAction,
@@ -27,48 +28,46 @@ 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,
+ QInputDialog,
+ 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, ReminderWebHook
from .save_dialog import SaveDialog
from .search import Search
-from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
+from .settings import APP_ORG, APP_NAME, 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
+from .time_log import TimeLogWidget, TimeReportDialog
from .toolbar import ToolBar
-from .version_check import VersionChecker
class MainWindow(QMainWindow):
@@ -78,7 +77,6 @@ class MainWindow(QMainWindow):
self.setMinimumSize(1000, 650)
self.themes = themes # Store the themes manager
- self.version_checker = VersionChecker(self)
self.cfg = load_db_config()
if not os.path.exists(self.cfg.path):
@@ -94,8 +92,6 @@ class MainWindow(QMainWindow):
else:
self._try_connect()
- self.settings = QSettings(APP_ORG, APP_NAME)
-
# ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget()
self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
@@ -107,22 +103,12 @@ class MainWindow(QMainWindow):
self.search.openDateRequested.connect(self._load_selected_date)
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
- # Features
- self.time_log = TimeLogWidget(self.db, themes=self.themes)
+ self.time_log = TimeLogWidget(self.db)
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
# when the main window is resized.
left_panel = QWidget()
@@ -130,9 +116,6 @@ class MainWindow(QMainWindow):
left_layout.setContentsMargins(8, 8, 8, 8)
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)
@@ -220,30 +203,34 @@ class MainWindow(QMainWindow):
act_save.triggered.connect(lambda: self._save_current(explicit=True))
file_menu.addAction(act_save)
act_history = QAction("&" + strings._("history"), self)
- act_history.setShortcut("Ctrl+Shift+H")
+ act_history.setShortcut("Ctrl+H")
act_history.setShortcutContext(Qt.ApplicationShortcut)
act_history.triggered.connect(self._open_history)
file_menu.addAction(act_history)
act_settings = QAction(strings._("main_window_settings_accessible_flag"), self)
- act_settings.setShortcut("Ctrl+Shift+.")
+ act_settings.setShortcut("Ctrl+G")
act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings)
act_export = QAction(strings._("export_accessible_flag"), self)
- act_export.setShortcut("Ctrl+Shift+E")
+ act_export.setShortcut("Ctrl+E")
act_export.triggered.connect(self._export)
file_menu.addAction(act_export)
act_backup = QAction("&" + strings._("backup"), self)
act_backup.setShortcut("Ctrl+Shift+B")
act_backup.triggered.connect(self._backup)
file_menu.addAction(act_backup)
+ act_tags = QAction(strings._("main_window_manage_tags_accessible_flag"), self)
+ act_tags.setShortcut("Ctrl+T")
+ act_tags.triggered.connect(self.tags._open_manager)
+ file_menu.addAction(act_tags)
act_stats = QAction(strings._("main_window_statistics_accessible_flag"), self)
- act_stats.setShortcut("Ctrl+Shift+S")
+ act_stats.setShortcut("Shift+Ctrl+S")
act_stats.triggered.connect(self._open_statistics)
file_menu.addAction(act_stats)
- act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self)
- act_lock.setShortcut("Ctrl+Shift+L")
- act_lock.triggered.connect(self._enter_lock)
- file_menu.addAction(act_lock)
+ act_time_report = QAction(strings._("time_log_report"), self)
+ act_time_report.setShortcut("Ctrl+Shift+L")
+ act_time_report.triggered.connect(self._open_time_report)
+ file_menu.addAction(act_time_report)
file_menu.addSeparator()
act_quit = QAction("&" + strings._("quit"), self)
act_quit.setShortcut("Ctrl+Q")
@@ -301,19 +288,19 @@ class MainWindow(QMainWindow):
# Help menu with drop-down
help_menu = mb.addMenu("&" + strings._("help"))
act_docs = QAction(strings._("documentation"), self)
- act_docs.setShortcut("Ctrl+Shift+D")
+ act_docs.setShortcut("Ctrl+D")
act_docs.setShortcutContext(Qt.ApplicationShortcut)
act_docs.triggered.connect(self._open_docs)
help_menu.addAction(act_docs)
self.addAction(act_docs)
act_bugs = QAction(strings._("report_a_bug"), self)
- act_bugs.setShortcut("Ctrl+Shift+R")
+ act_bugs.setShortcut("Ctrl+R")
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
act_bugs.triggered.connect(self._open_bugs)
help_menu.addAction(act_bugs)
self.addAction(act_bugs)
act_version = QAction(strings._("version"), self)
- act_version.setShortcut("Ctrl+Shift+V")
+ act_version.setShortcut("Ctrl+V")
act_version.setShortcutContext(Qt.ApplicationShortcut)
act_version.triggered.connect(self._open_version)
help_menu.addAction(act_version)
@@ -329,28 +316,17 @@ class MainWindow(QMainWindow):
self._reminder_timers: list[QTimer] = []
# First load + mark dates in calendar with content
- if not self._load_unchecked_todos():
+ if not self._load_yesterday_todos():
self._load_selected_date()
self._refresh_calendar_marks()
- # Hide tags and time log widgets if not enabled
- if not self.cfg.tags:
- self.tags.hide()
- if not self.cfg.time_log:
- self.time_log.hide()
- self.toolBar.actTimer.setVisible(False)
- 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.settings = QSettings(APP_ORG, APP_NAME)
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()
@@ -358,15 +334,6 @@ class MainWindow(QMainWindow):
# Build any alarms for *today* from stored markdown
self._rebuild_reminders_for_today()
- # Rollover unchecked todos automatically when the calendar day changes
- self._day_change_timer = QTimer(self)
- self._day_change_timer.setSingleShot(True)
- self._day_change_timer.timeout.connect(self._on_day_changed)
- self._schedule_next_day_change()
-
- # Ensure toolbar is definitely visible
- self.toolBar.setVisible(True)
-
@property
def editor(self) -> MarkdownEditor | None:
"""Get the currently active editor."""
@@ -394,7 +361,7 @@ class MainWindow(QMainWindow):
else:
error = str(e)
QMessageBox.critical(self, strings._("db_database_error"), error)
- return False
+ sys.exit(1)
def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
"""
@@ -497,7 +464,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):
@@ -520,9 +487,6 @@ class MainWindow(QMainWindow):
editor = MarkdownEditor(self.themes)
- # Apply user's preferred font size
- self._apply_font_size(editor)
-
# Set up the editor's event connections
editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
editor.cursorPositionChanged.connect(self._sync_toolbar)
@@ -817,228 +781,45 @@ class MainWindow(QMainWindow):
today = QDate.currentDate()
self._create_new_tab(today)
- def _rollover_target_date(self, day: QDate) -> QDate:
- """
- Given a 'new day' (system date), return the date we should move
- unfinished todos *to*.
+ def _load_yesterday_todos(self):
+ if not self.cfg.move_todos:
+ return
+ yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
+ text = self.db.get_entry(yesterday_str)
+ unchecked_items = []
- 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)
- return day.addDays(8 - dow) # 6 -> +2, 7 -> +1 (next Monday)
- return day
+ # Split into lines and find unchecked checkbox items
+ lines = text.split("\n")
+ remaining_lines = []
- def _schedule_next_day_change(self) -> None:
- """
- Schedule a one-shot timer to fire shortly after the next midnight.
- """
- now = QDateTime.currentDateTime()
- tomorrow = now.date().addDays(1)
- # A couple of minutes after midnight to be safe
- next_run = QDateTime(tomorrow, QTime(0, 2))
- msecs = max(60_000, now.msecsTo(next_run)) # at least 1 minute
- self._day_change_timer.start(msecs)
-
- @Slot()
- def _on_day_changed(self) -> None:
- """
- Called when we've crossed into a new calendar day (according to the timer).
- Re-runs the rollover logic and refreshes the UI.
- """
- # Make the calendar show the *real* new day first
- today = QDate.currentDate()
- with QSignalBlocker(self.calendar):
- self.calendar.setSelectedDate(today)
-
- # Same logic as on startup
- if not self._load_unchecked_todos():
- self._load_selected_date()
-
- self._refresh_calendar_marks()
- self._rebuild_reminders_for_today()
- self._schedule_next_day_change()
-
- def _load_unchecked_todos(self, days_back: int = 7) -> bool:
- """
- Move unchecked checkbox items from the last `days_back` days
- into the rollover target date (today, or next Monday if today
- is a weekend).
-
- Returns True if any items were moved, False otherwise.
- """
- if not getattr(self.cfg, "move_todos", False):
- return False
-
- if not getattr(self, "db", None):
- return False
-
- today = QDate.currentDate()
- target_date = self._rollover_target_date(today)
- target_iso = target_date.toString("yyyy-MM-dd")
-
- # Regexes for markdown headings and checkboxes
- heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
- unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+")
-
- 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 _insert_todos_under_heading(
- target_lines: list[str],
- heading_level: int,
- heading_text: str,
- todos: list[str],
- ) -> list[str]:
- """Ensure a heading exists and append todos 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() == ""
+ for line in lines:
+ # Check for unchecked markdown checkboxes: - [ ] or - [☐]
+ if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
+ r"^\s*-\s*\[☐\]\s+", line
):
- insert_at -= 1
-
- for todo in todos:
- target_lines.insert(insert_at, todo)
- insert_at += 1
-
- return target_lines
-
- # Collect moved todos as (heading_info, item_text)
- # heading_info is either None or (level, heading_text)
- moved_items: list[tuple[tuple[int, str] | None, str]] = []
- any_moved = False
-
- # Look back N days (yesterday = 1, up to `days_back`)
- for delta in range(1, days_back + 1):
- src_date = today.addDays(-delta)
- src_iso = src_date.toString("yyyy-MM-dd")
- text = self.db.get_entry(src_iso)
- if not text:
- continue
-
- lines = text.split("\n")
- remaining_lines: list[str] = []
- moved_from_this_day = False
- current_heading: tuple[int, str] | None = None
-
- for line in lines:
- # 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
- remaining_lines.append(line)
- continue
-
- # Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
- if unchecked_re.match(line):
- item_text = unchecked_re.sub("", line)
- moved_items.append((current_heading, item_text))
- moved_from_this_day = True
- any_moved = True
- else:
- remaining_lines.append(line)
-
- if moved_from_this_day:
- modified_text = "\n".join(remaining_lines)
- # Save the cleaned-up source day
- self.db.save_new_version(
- src_iso,
- modified_text,
- strings._("unchecked_checkbox_items_moved_to_next_day"),
- )
-
- if not any_moved:
- return False
-
- # --- Merge all moved items into the *target* date ---
-
- target_text = self.db.get_entry(target_iso) or ""
- target_lines = target_text.split("\n") if target_text else []
-
- by_heading: dict[tuple[int, str], list[str]] = {}
- plain_items: list[str] = []
-
- for heading_info, item_text in moved_items:
- todo_line = f"- [ ] {item_text}"
- if heading_info is None:
- # No heading above this checkbox in the source: behave as before
- plain_items.append(todo_line)
+ # Extract the text after the checkbox
+ item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
+ unchecked_items.append(f"- [ ] {item_text}")
else:
- by_heading.setdefault(heading_info, []).append(todo_line)
+ # Keep all other lines
+ remaining_lines.append(line)
- # First insert all items that have headings
- for (level, heading_text), todos in by_heading.items():
- target_lines = _insert_todos_under_heading(
- target_lines, level, heading_text, todos
+ # Save modified content back if we moved items
+ if unchecked_items:
+ modified_text = "\n".join(remaining_lines)
+ self.db.save_new_version(
+ yesterday_str,
+ modified_text,
+ strings._("unchecked_checkbox_items_moved_to_next_day"),
)
- # Then append all items without headings at the end, like before
- if plain_items:
- if target_lines and target_lines[-1].strip():
- target_lines.append("") # one blank line before the "unsectioned" todos
- target_lines.extend(plain_items)
+ # Join unchecked items into markdown format
+ unchecked_str = "\n".join(unchecked_items) + "\n"
- 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
+ # Load the unchecked items into the current editor
+ self._load_selected_date(False, unchecked_str)
+ else:
+ return False
def _on_date_changed(self):
"""
@@ -1133,22 +914,21 @@ class MainWindow(QMainWindow):
pass
# ----------------- Some theme helpers -------------------#
- def _apply_font_size(self, editor: MarkdownEditor) -> None:
- """Apply the saved font size to a newly created editor."""
- size = self.cfg.font_size
- editor.qfont.setPointSize(size)
- editor.setFont(editor.qfont)
- self.cfg.font_size = size
- # save size to settings
- cfg = load_db_config()
- cfg.font_size = self.cfg.font_size
- 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]):
@@ -1217,10 +997,6 @@ class MainWindow(QMainWindow):
self._tb_numbers = lambda: self._call_editor("toggle_numbers")
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
tb.boldRequested.connect(self._tb_bold)
tb.italicRequested.connect(self._tb_italic)
@@ -1231,12 +1007,8 @@ class MainWindow(QMainWindow):
tb.numbersRequested.connect(self._tb_numbers)
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)
- tb.fontSizeSmallerRequested.connect(self._tb_font_smaller)
self._toolbar_bound = True
@@ -1282,68 +1054,57 @@ class MainWindow(QMainWindow):
self.toolBar.actBullets.setChecked(bool(bullets_on))
self.toolBar.actNumbers.setChecked(bool(numbers_on))
- def _change_font_size(self, delta: int) -> None:
- """Change font size for all editor tabs and save the setting."""
- old_size = self.cfg.font_size
- new_size = old_size + delta
-
- self.cfg.font_size = new_size
- # save size to settings
- cfg = load_db_config()
- cfg.font_size = self.cfg.font_size
- save_db_config(cfg)
-
- # Apply font size change to all open editors
- self._apply_font_size_to_all_tabs(new_size)
-
- def _apply_font_size_to_all_tabs(self, size: int) -> None:
- for i in range(self.tab_widget.count()):
- ed = self.tab_widget.widget(i)
- if not isinstance(ed, MarkdownEditor):
- continue
- ed.qfont.setPointSize(size)
- ed.setFont(ed.qfont)
-
- def _on_font_larger_requested(self) -> None:
- self._change_font_size(+1)
-
- def _on_font_smaller_requested(self) -> None:
- self._change_font_size(-1)
-
# ----------- Alarms handler ------------#
def _on_alarm_requested(self):
- self.upcoming_reminders._add_reminder()
+ """Create a one-shot reminder based on the current line in the editor."""
+ editor = getattr(self, "editor", None)
+ if editor is None:
+ return
- def _on_timer_requested(self):
- """Toggle the embedded Pomodoro timer for the current line."""
- action = self.toolBar.actTimer
+ # Use the current line in the markdown editor as the reminder text
+ try:
+ editor.get_current_line_text().strip()
+ except AttributeError:
+ c = editor.textCursor()
+ c.block().text().strip()
- # 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
+ # Ask user for a time today in HH:MM format
+ time_str, ok = QInputDialog.getText(
+ self,
+ strings._("set_reminder"),
+ strings._("set_reminder_prompt") + " (HH:MM)",
+ )
+ if not ok or not time_str.strip():
+ return
- # 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")
+ try:
+ hour, minute = map(int, time_str.strip().split(":", 1))
+ except ValueError:
+ QMessageBox.warning(
+ self,
+ strings._("invalid_time_title"),
+ strings._("invalid_time_message"),
+ )
+ return
- # Get current date
- date_iso = self.editor.current_date.toString("yyyy-MM-dd")
+ t = QTime(hour, minute)
+ if not t.isValid():
+ QMessageBox.warning(
+ self,
+ strings._("invalid_time_title"),
+ strings._("invalid_time_message"),
+ )
+ return
- # 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()
+ # Normalise to HH:MM
+ time_str = f"{t.hour():02d}:{t.minute():02d}"
- def _send_reminder_webhook(self, text: str):
- if self.cfg.reminders and self.cfg.reminders_webhook_url:
- reminder_webhook = ReminderWebHook(text)
- reminder_webhook._send()
+ # Insert / update ⏰ in the editor text
+ if hasattr(editor, "insert_alarm_marker"):
+ editor.insert_alarm_marker(time_str)
+
+ # Rebuild timers, but only if this page is for "today"
+ self._rebuild_reminders_for_today()
def _show_flashing_reminder(self, text: str):
"""
@@ -1362,7 +1123,6 @@ class MainWindow(QMainWindow):
dlg = QDialog(self)
dlg.setWindowTitle(strings._("reminder"))
dlg.setModal(True)
- dlg.setMinimumWidth(400)
layout = QVBoxLayout(dlg)
label = QLabel(text)
@@ -1462,14 +1222,6 @@ 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"):
@@ -1477,7 +1229,7 @@ class MainWindow(QMainWindow):
else:
date_iso = self._current_date_iso()
- dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
+ dlg = HistoryDialog(self.db, date_iso, self)
if dlg.exec() == QDialog.Accepted:
# refresh editor + calendar (head pointer may have changed)
self._load_selected_date(date_iso)
@@ -1504,8 +1256,6 @@ 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"""
@@ -1570,31 +1320,13 @@ 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)
# Persist once
save_db_config(self.cfg)
+
# Apply idle setting immediately (restart the timer with new interval if it changed)
self._apply_idle_minutes(self.cfg.idle_minutes)
- # Apply font size to all tabs
- self._apply_font_size_to_all_tabs(self.cfg.font_size)
# If the DB path changed, reconnect
if self.cfg.path != old_path:
@@ -1609,27 +1341,6 @@ class MainWindow(QMainWindow):
self._load_selected_date()
self._refresh_calendar_marks()
- # Show or hide the tags and time_log features depending on what the settings are now.
- self.tags.hide() if not self.cfg.tags else self.tags.show()
- if not self.cfg.time_log:
- self.time_log.hide()
- self.toolBar.actTimer.setVisible(False)
- else:
- self.time_log.show()
- self.toolBar.actTimer.setVisible(True)
- if not self.cfg.reminders:
- self.upcoming_reminders.hide()
- self.toolBar.actAlarm.setVisible(False)
- 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 --------------- #
def _open_statistics(self):
@@ -1647,6 +1358,11 @@ class MainWindow(QMainWindow):
dlg._heatmap.date_clicked.connect(on_date_clicked)
dlg.exec()
+ # ------------ Timesheet report handler --------------- #
+ def _open_time_report(self):
+ dlg = TimeReportDialog(self.db, self)
+ dlg.exec()
+
# ------------ Window positioning --------------- #
def _restore_window_position(self):
geom = self.settings.value("main/geometry", None)
@@ -1795,7 +1511,9 @@ class MainWindow(QMainWindow):
dlg.exec()
def _open_version(self):
- self.version_checker.show_version_dialog()
+ version = importlib.metadata.version("bouquin")
+ version_formatted = f"{APP_NAME} {version}"
+ QMessageBox.information(self, strings._("version"), version_formatted)
# ----------------- Idle handlers ----------------- #
def _apply_idle_minutes(self, minutes: int):
@@ -1847,8 +1565,6 @@ class MainWindow(QMainWindow):
tb.hide()
self._lock_overlay.show()
self._lock_overlay.raise_()
- lock_msg = strings._("lock_overlay_locked")
- self.setWindowTitle(f"{APP_NAME} ({lock_msg})")
@Slot()
def _on_unlock_clicked(self):
@@ -1875,7 +1591,6 @@ class MainWindow(QMainWindow):
tb.show()
self._idle_timer.start()
QTimer.singleShot(0, self._focus_editor_now)
- self.setWindowTitle(APP_NAME)
# ----------------- Close handlers ----------------- #
def closeEvent(self, event):
diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py
index 3d30889..7fea40c 100644
--- a/bouquin/markdown_editor.py
+++ b/bouquin/markdown_editor.py
@@ -3,30 +3,24 @@ 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.QtWidgets import QDialog, QTextEdit
+from PySide6.QtCore import Qt, QRect, QTimer, QUrl
+from PySide6.QtWidgets import QTextEdit
-from . import strings
-from .code_block_editor_dialog import CodeBlockEditorDialog
-from .markdown_highlighter import MarkdownHighlighter
from .theme import ThemeManager
+from .markdown_highlighter import MarkdownHighlighter
class MarkdownEditor(QTextEdit):
@@ -46,25 +40,10 @@ class MarkdownEditor(QTextEdit):
# We accept plain text, not rich text (markdown is plain text)
self.setAcceptRichText(False)
- # Load in our preferred fonts
- base_dir = Path(__file__).resolve().parent
-
- # Load regular text font (primary)
- regular_font_path = base_dir / "fonts" / "DejaVuSans.ttf"
- regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path))
-
- # 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]
-
- # Use the regular Noto Sans family as the editor font
- regular_families = QFontDatabase.applicationFontFamilies(regular_font_id)
- if regular_families:
- self.text_font_family = regular_families[0]
- self.qfont = QFont(self.text_font_family, 11)
- self.setFont(self.qfont)
+ # Normal text
+ font = QFont()
+ font.setPointSize(10)
+ self.setFont(font)
self._apply_line_spacing() # 1.25× initial spacing
@@ -79,12 +58,7 @@ class MarkdownEditor(QTextEdit):
self._BULLET_STORAGE = "-"
# Install syntax highlighter
- self.highlighter = MarkdownHighlighter(self.document(), theme_manager, self)
-
- # Initialize code block metadata
- from .code_highlighter import CodeBlockMetadata
-
- self._code_metadata = CodeBlockMetadata()
+ self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
# Track current list type for smart enter handling
self._last_enter_was_empty = False
@@ -92,20 +66,6 @@ class MarkdownEditor(QTextEdit):
# Track if we're currently updating text programmatically
self._updating = False
- # 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)
@@ -122,36 +82,15 @@ 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
- )
+ self.highlighter = MarkdownHighlighter(self.document(), self.theme_manager)
self._apply_line_spacing()
self._apply_code_block_spacing()
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
- def setFont(self, font: QFont) -> None: # type: ignore[override]
- """
- Ensure that whenever the base editor font changes, our highlighter
- re-computes checkbox / bullet formats.
- """
- # Keep qfont in sync
- self.qfont = QFont(font)
- super().setFont(self.qfont)
-
- # If the highlighter is already attached, let it rebuild its formats
- highlighter = getattr(self, "highlighter", None)
- if highlighter is not None:
- refresh = getattr(highlighter, "refresh_for_font_change", None)
- if callable(refresh):
- refresh()
-
def showEvent(self, e):
super().showEvent(e)
# First time the widget is shown, Qt may rebuild layout once more.
@@ -214,290 +153,47 @@ class MarkdownEditor(QTextEdit):
b = b.previous()
return inside
- def _update_code_block_row_backgrounds(self) -> None:
- """Paint a full-width background behind each fenced ``` code block."""
-
+ def _update_code_block_row_backgrounds(self):
+ """Paint a full-width background for each line that is in a fenced code block."""
doc = self.document()
if doc is None:
return
- if not hasattr(self, "highlighter") or self.highlighter is None:
- return
-
+ sels = []
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:
- 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
+ inside = not inside
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 + 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
+ self.setExtraSelections(others + sels)
def _apply_line_spacing(self, height: float = 125.0):
"""Apply proportional line spacing to the whole document."""
@@ -519,8 +215,8 @@ class MarkdownEditor(QTextEdit):
def _apply_code_block_spacing(self):
"""
- Make all fenced code-block lines (including ``` fences) single-spaced
- and give them a solid background.
+ Make all fenced code-block lines (including ``` fences) single-spaced.
+ Call this AFTER _apply_line_spacing().
"""
doc = self.document()
if doc is None:
@@ -529,8 +225,6 @@ class MarkdownEditor(QTextEdit):
cursor = QTextCursor(doc)
cursor.beginEditBlock()
- bg_brush = self.highlighter.code_block_format.background()
-
inside = False
block = doc.begin()
while block.isValid():
@@ -539,22 +233,14 @@ class MarkdownEditor(QTextEdit):
is_fence = stripped.startswith("```")
is_code_line = is_fence or inside
- fmt = block.blockFormat()
-
if is_code_line:
- # Single spacing for code lines
+ fmt = block.blockFormat()
fmt.setLineHeight(
0.0,
QTextBlockFormat.LineHeightTypes.SingleHeight.value,
)
- # 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)
+ cursor.setPosition(block.position())
+ cursor.setBlockFormat(fmt)
if is_fence:
inside = not inside
@@ -563,30 +249,6 @@ 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
@@ -607,12 +269,6 @@ class MarkdownEditor(QTextEdit):
text,
)
- # Append code block metadata if present
- if hasattr(self, "_code_metadata"):
- metadata_str = self._code_metadata.serialize()
- if metadata_str:
- text = text.rstrip() + "\n\n" + metadata_str
-
return text
def _extract_images_to_markdown(self) -> str:
@@ -651,16 +307,6 @@ class MarkdownEditor(QTextEdit):
def from_markdown(self, markdown_text: str):
"""Load markdown text into the editor."""
- # Extract and load code block metadata if present
- from .code_highlighter import CodeBlockMetadata
-
- if not hasattr(self, "_code_metadata"):
- self._code_metadata = CodeBlockMetadata()
-
- self._code_metadata.deserialize(markdown_text)
- # Remove metadata comment from displayed text
- markdown_text = re.sub(r"\s*\s*$", "", markdown_text)
-
# Convert markdown checkboxes to Unicode for display
display_text = markdown_text.replace(
f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
@@ -745,12 +391,46 @@ class MarkdownEditor(QTextEdit):
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
cursor.insertImage(img_format)
+ def insert_alarm_marker(self, time_str: str) -> None:
+ """
+ Append or replace an ⏰ HH:MM marker on the current line.
+ time_str is expected to be 'HH:MM'.
+ """
+ cursor = self.textCursor()
+ block = cursor.block()
+ line = block.text()
+
+ # Strip any existing ⏰ HH:MM at the end of the line
+ new_line = re.sub(r"\s*⏰\s*\d{1,2}:\d{2}\s*$", "", line).rstrip()
+
+ # Append the new marker
+ new_line = f"{new_line} ⏰ {time_str}"
+
+ # --- : only replace the block's text, not its newline ---
+ block_start = block.position()
+ block_end = block_start + len(line)
+
+ bc = QTextCursor(self.document())
+ bc.beginEditBlock()
+ bc.setPosition(block_start)
+ bc.setPosition(block_end, QTextCursor.KeepAnchor)
+ bc.insertText(new_line)
+ bc.endEditBlock()
+
+ # Move cursor to end of the edited line
+ cursor.setPosition(block.position() + len(new_line))
+ self.setTextCursor(cursor)
+
def _get_current_line(self) -> str:
"""Get the text of the current line."""
cursor = self.textCursor()
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
return cursor.selectedText()
+ def get_current_line_text(self) -> str:
+ """Public wrapper used by MainWindow for reminders."""
+ return self._get_current_line()
+
def _list_prefix_length_for_block(self, block) -> int:
"""Return the length (in chars) of the visual list prefix for the given
block (including leading indentation), or 0 if it's not a list item.
@@ -780,75 +460,6 @@ 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).
@@ -863,7 +474,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} ")
@@ -905,77 +516,37 @@ class MarkdownEditor(QTextEdit):
def keyPressEvent(self, event):
"""Handle special key events for markdown editing."""
- c = self.textCursor()
- block = c.block()
- in_code = self._is_inside_code_block(block)
- is_fence_line = block.text().strip().startswith("```")
-
- # --- NEW: 3rd backtick shortcut → open code block dialog ---
- # 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):
+ # --- Auto-close code fences when typing the 3rd backtick at line start ---
+ if event.text() == "`":
+ c = self.textCursor()
+ block = c.block()
line = block.text()
pos_in_block = c.position() - block.position()
+
+ # text before caret on this line
before = line[:pos_in_block]
- # "before" currently contains whatever's before the *third* backtick.
- # We trigger only when the line is (whitespace + "``") before the caret.
+ # 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() == "``":
- 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()
+ start = (
+ block.position() + pos_in_block - 2
+ ) # start of the two backticks
- # Move caret to where the code block should start
- c.setPosition(start)
- self.setTextCursor(c)
+ edit = QTextCursor(self.document())
+ edit.beginEditBlock()
+ edit.setPosition(start)
+ edit.setPosition(start + 2, QTextCursor.KeepAnchor)
+ edit.insertText("```\n\n```\n")
+ edit.endEditBlock()
- # Now behave exactly like the > toolbar button
- self.apply_code()
+ # place caret on the blank line between the fences
+ new_pos = start + 4 # after "```\n"
+ c.setPosition(new_pos)
+ self.setTextCursor(c)
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
-
- 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
-
- # --- Step out of a code block with Down at EOF ---
+ # Step out of a code block with Down at EOF
if event.key() == Qt.Key.Key_Down:
c = self.textCursor()
b = c.block()
@@ -986,8 +557,7 @@ 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)
@@ -1008,8 +578,7 @@ 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)
@@ -1055,7 +624,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:
@@ -1179,11 +748,22 @@ class MarkdownEditor(QTextEdit):
return
- # Inside a code block (but not on a fence): open the popup editor
+ # Inside a code block (but not on a fence): newline stays code-style
if block_state == 1:
- if not self._edit_code_block(current_block):
- # Fallback if something is malformed
- super().keyPressEvent(event)
+ super().keyPressEvent(event)
+ return
+
+ # Auto-insert an extra blank line after headings (#, ##, ###)
+ # when pressing Enter at the end of the line.
+ if re.match(r"^#{1,3}\s+", stripped) and pos_in_block >= len(line_text):
+ cursor.beginEditBlock()
+ # First blank line: visual separator between heading and body
+ cursor.insertBlock()
+ # Second blank line: where body text will start (caret ends here)
+ cursor.insertBlock()
+ cursor.endEditBlock()
+
+ self.setTextCursor(cursor)
return
# Check for list continuation
@@ -1210,7 +790,7 @@ class MarkdownEditor(QTextEdit):
return
else:
# Not empty - continue the list
- self._last_enter_was_empty = True
+ self._last_enter_was_empty = False
# Insert newline and continue the list
super().keyPressEvent(event)
@@ -1227,13 +807,6 @@ class MarkdownEditor(QTextEdit):
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:
@@ -1247,12 +820,6 @@ 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
@@ -1272,13 +839,7 @@ 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
@@ -1317,45 +878,9 @@ class MarkdownEditor(QTextEdit):
if icon:
# absolute document position of the icon
doc_pos = block.position() + i
- r_icon = char_rect_at(doc_pos, icon)
+ r = char_rect_at(doc_pos, icon)
- # --- 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):
+ if r.contains(pt):
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
new_icon = (
self._CHECK_CHECKED_DISPLAY
@@ -1367,15 +892,10 @@ 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
@@ -1386,27 +906,6 @@ 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 ------------------------
def apply_weight(self):
@@ -1478,106 +977,86 @@ class MarkdownEditor(QTextEdit):
self.setFocus()
def apply_code(self):
- """
- 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()
+ """Insert a fenced code block, or navigate fences without creating inline backticks."""
+ c = self.textCursor()
doc = self.document()
- if doc is None:
+
+ 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()
return
- block = cursor.block()
+ block = c.block()
+ line = block.text()
+ pos_in_block = c.position() - block.position()
+ stripped = line.strip()
- # --- 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)
+ # 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()
return
- # --- 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")
+ # Outside any block → create a clean template on its own lines (never inline)
+ start_pos = c.position()
+ before = line[:pos_in_block]
edit = QTextCursor(doc)
edit.beginEditBlock()
- # Remove selection (if any) so we can insert the new fenced block
- edit.setPosition(start_pos)
- 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
+ # If there is text before the caret on the line, start the block on a new line
lead_break = "\n" if before else ""
- insert_str = f"{lead_break}```\n{code_text}\n```\n"
-
+ # Insert the block; trailing newline guarantees you can Down-arrow out later
+ insert = f"{lead_break}```\n\n```\n"
edit.setPosition(start_pos)
- edit.insertText(insert_str)
+ edit.insertText(insert)
edit.endEditBlock()
- # Find the opening fence block we just inserted
- open_block = doc.findBlock(start_pos + len(lead_break))
+ # Put caret on the blank line inside the block
+ c.setPosition(start_pos + len(lead_break) + 4) # after "```\n"
+ self.setTextCursor(c)
- # 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()
+ if hasattr(self, "_update_code_block_row_backgrounds"):
+ self._update_code_block_row_backgrounds()
- 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
+ # tighten spacing for the new code block
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()
@@ -1746,100 +1225,3 @@ class MarkdownEditor(QTextEdit):
cursor = self.textCursor()
cursor.insertImage(img_format)
cursor.insertText("\n") # Add newline after image
-
- # ========== Context Menu Support ==========
-
- def contextMenuEvent(self, event):
- """Override context menu to add custom actions."""
- from PySide6.QtGui import QAction
- from PySide6.QtWidgets import QMenu
-
- menu = QMenu(self)
- cursor = self.cursorForPosition(event.pos())
-
- # Check if we're in a code block
- block = cursor.block()
- if self._is_inside_code_block(block):
- # Add language selection submenu
- lang_menu = menu.addMenu(strings._("set_code_language"))
-
- languages = [
- "bash",
- "css",
- "html",
- "javascript",
- "php",
- "python",
- ]
- for lang in languages:
- action = QAction(lang.capitalize(), self)
- action.triggered.connect(
- lambda checked, l=lang: self._set_code_block_language(block, l)
- )
- lang_menu.addAction(action)
-
- 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()
-
- # Add standard context menu actions
- if self.textCursor().hasSelection():
- menu.addAction(strings._("cut"), self.cut)
- menu.addAction(strings._("copy"), self.copy)
-
- menu.addAction(strings._("paste"), self.paste)
-
- menu.exec(event.globalPos())
-
- def _set_code_block_language(self, block, language: str):
- """Set the language for a code block and store metadata."""
- if not hasattr(self, "_code_metadata"):
- from .code_highlighter import CodeBlockMetadata
-
- self._code_metadata = CodeBlockMetadata()
-
- # Find the opening fence block for this code block
- fence_block = block
- while fence_block.isValid() and not fence_block.text().strip().startswith(
- "```"
- ):
- fence_block = fence_block.previous()
-
- if fence_block.isValid():
- self._code_metadata.set_language(fence_block.blockNumber(), language)
- # Trigger rehighlight
- self.highlighter.rehighlight()
-
- def get_current_line_text(self) -> str:
- """Get the text of the current line."""
- 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 bb308d5..9fcbcc3 100644
--- a/bouquin/markdown_highlighter.py
+++ b/bouquin/markdown_highlighter.py
@@ -6,7 +6,6 @@ from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
- QFontMetrics,
QGuiApplication,
QPalette,
QSyntaxHighlighter,
@@ -14,18 +13,15 @@ from PySide6.QtGui import (
QTextDocument,
)
-from .theme import Theme, ThemeManager
+from .theme import ThemeManager, Theme
class MarkdownHighlighter(QSyntaxHighlighter):
"""Live syntax highlighter for markdown that applies formatting as you type."""
- def __init__(
- self, document: QTextDocument, theme_manager: ThemeManager, editor=None
- ):
+ def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
super().__init__(document)
self.theme_manager = theme_manager
- self._editor = editor # Reference to the MarkdownEditor
self._setup_formats()
# Recompute formats whenever the app theme changes
self.theme_manager.themeChanged.connect(self._on_theme_changed)
@@ -34,14 +30,6 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self._setup_formats()
self.rehighlight()
- def refresh_for_font_change(self) -> None:
- """
- Called when the editor's base font changes (zoom / settings).
- It rebuilds any formats that depend on the editor font metrics.
- """
- self._setup_formats()
- self.rehighlight()
-
def _setup_formats(self):
"""Setup text formats for different markdown elements."""
@@ -78,18 +66,17 @@ 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 for codeblocks
- code_bg = pal.color(QPalette.AlternateBase)
- code_fg = pal.color(QPalette.Text)
+ # In dark mode, use a darker panel-like background
+ bg = pal.color(QPalette.AlternateBase)
+ fg = pal.color(QPalette.Text)
else:
- # Light mode: keep the existing light gray for code blocks
- code_bg = QColor(245, 245, 245)
- code_fg = QColor( # pragma: no cover
+ # Light mode: keep the existing light gray
+ bg = QColor(245, 245, 245)
+ fg = QColor(
0, 0, 0
) # avoiding using QPalette.Text as it can be white on macOS
-
- self.code_block_format.setBackground(code_bg)
- self.code_block_format.setForeground(code_fg)
+ self.code_block_format.setBackground(bg)
+ self.code_block_format.setForeground(fg)
# Headings
self.h1_format = QTextCharFormat()
@@ -111,58 +98,27 @@ 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
+ # Base size from the document/editor font
+ doc = self.document()
+ base_font = doc.defaultFont() if doc is not None else QGuiApplication.font()
+ base_size = base_font.pointSizeF()
+ if base_size <= 0:
+ base_size = 10.0 # fallback
+ # Checkboxes: make them a bit bigger so they stand out
self.checkbox_format = QTextCharFormat()
+ self.checkbox_format.setFontPointSize(base_size * 1.3)
self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle)
# Bullets
self.bullet_format = QTextCharFormat()
-
- # Use Symbols font for checkbox and bullet glyphs if present
- if self._editor is not None and hasattr(self._editor, "symbols_font_family"):
- base_font = QFont(self._editor.qfont) # copy of editor font
- symbols_font = QFont(self._editor.symbols_font_family)
- symbols_font.setPointSizeF(base_font.pointSizeF())
-
- base_metrics = QFontMetrics(base_font)
- sym_metrics = QFontMetrics(symbols_font)
-
- # If Symbols glyphs are noticeably shorter than the text,
- # scale them up so the visual heights roughly match.
- if sym_metrics.height() > 0:
- ratio = base_metrics.height() / sym_metrics.height()
- if ratio > 1.05: # more than ~5% smaller
- ratio = min(ratio, 1.4) # Oh, Tod, Tod. Don't overdo it.
- symbols_font.setPointSizeF(symbols_font.pointSizeF() * ratio)
-
- self.checkbox_format.setFont(symbols_font)
- self.bullet_format.setFont(symbols_font)
+ self.bullet_format.setFontPointSize(base_size * 1.2)
# Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat()
- # Use the editor background color so they blend in
- hidden = QColor(text_bg)
- hidden.setAlpha(0)
- self.syntax_format.setForeground(hidden)
# Make the markers invisible by setting font size to 0.1 points
self.syntax_format.setFontPointSize(0.1)
+ # Also make them very faint in case they still show
+ self.syntax_format.setForeground(QColor(250, 250, 250))
def _overlay_range(
self, start: int, length: int, overlay_fmt: QTextCharFormat
@@ -201,36 +157,6 @@ class MarkdownHighlighter(QSyntaxHighlighter):
if in_code_block:
# inside code: apply block bg and language rules
self.setFormat(0, len(text), self.code_block_format)
-
- # Try to apply language-specific highlighting
- if self._editor and hasattr(self._editor, "_code_metadata"):
- from .code_highlighter import CodeHighlighter
-
- # Find the opening fence block
- prev_block = self.currentBlock().previous()
- fence_block_num = None
- temp_inside = in_code_block
-
- while prev_block.isValid():
- if prev_block.text().strip().startswith("```"):
- temp_inside = not temp_inside
- if not temp_inside:
- fence_block_num = prev_block.blockNumber()
- break
- prev_block = prev_block.previous()
-
- if fence_block_num is not None:
- language = self._editor._code_metadata.get_language(fence_block_num)
- if language:
- patterns = CodeHighlighter.get_language_patterns(language)
- for pattern, syntax_type in patterns:
- for match in re.finditer(pattern, text):
- start, end = match.span()
- fmt = CodeHighlighter.get_format_for_type(
- syntax_type, self.code_block_format
- )
- self.setFormat(start, end - start, fmt)
-
self.setCurrentBlockState(1)
return
@@ -289,7 +215,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
):
start, end = m.span()
if any(_overlaps((start, end), occ) for occ in occupied):
- continue # pragma: no cover
+ continue
content_start, content_end = start + 2, end - 2
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
@@ -301,12 +227,12 @@ class MarkdownHighlighter(QSyntaxHighlighter):
):
start, end = m.span()
if any(_overlaps((start, end), occ) for occ in occupied):
- continue # pragma: no cover
+ continue
# avoid stealing a single marker that is part of a double
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
- continue # pragma: no cover
+ continue
if end < len(text) and text[end : end + 1] in ("*", "_"):
- continue # pragma: no cover
+ continue
content_start, content_end = start + 1, end - 1
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
@@ -356,15 +282,6 @@ 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
deleted file mode 100644
index e66c1f4..0000000
--- a/bouquin/pomodoro_timer.py
+++ /dev/null
@@ -1,207 +0,0 @@
-from __future__ import annotations
-
-import math
-from typing import Optional
-
-from PySide6.QtCore import Qt, QTimer, Signal, Slot
-from PySide6.QtWidgets import (
- QFrame,
- QHBoxLayout,
- QLabel,
- QPushButton,
- QVBoxLayout,
- QWidget,
-)
-
-from . import strings
-from .db import DBManager
-from .time_log import TimeLogDialog
-
-
-class PomodoroTimer(QFrame):
- """A simple timer for tracking work time on a specific task."""
-
- timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
-
- def __init__(self, task_text: str, parent: Optional[QWidget] = None):
- super().__init__(parent)
-
- self._task_text = task_text
- self._elapsed_seconds = 0
- self._running = False
-
- layout = QVBoxLayout(self)
-
- # Task label
- task_label = QLabel(task_text)
- task_label.setWordWrap(True)
- layout.addWidget(task_label)
-
- # Timer display
- self.time_label = QLabel("00:00:00")
- font = self.time_label.font()
- font.setPointSize(20)
- font.setBold(True)
- self.time_label.setFont(font)
- self.time_label.setAlignment(Qt.AlignCenter)
- layout.addWidget(self.time_label)
-
- # Control buttons
- btn_layout = QHBoxLayout()
-
- self.start_pause_btn = QPushButton(strings._("start"))
- self.start_pause_btn.clicked.connect(self._toggle_timer)
- btn_layout.addWidget(self.start_pause_btn)
-
- self.stop_btn = QPushButton(strings._("stop_and_log"))
- self.stop_btn.clicked.connect(self._stop_and_log)
- self.stop_btn.setEnabled(False)
- btn_layout.addWidget(self.stop_btn)
-
- layout.addLayout(btn_layout)
-
- # Internal timer (ticks every second)
- self._timer = QTimer(self)
- self._timer.timeout.connect(self._tick)
-
- @Slot()
- def _toggle_timer(self):
- """Start or pause the timer."""
- if self._running:
- # Pause
- self._running = False
- self._timer.stop()
- self.start_pause_btn.setText(strings._("resume"))
- else:
- # Start/Resume
- self._running = True
- self._timer.start(1000) # 1 second
- self.start_pause_btn.setText(strings._("pause"))
- self.stop_btn.setEnabled(True)
-
- @Slot()
- def _tick(self):
- """Update the elapsed time display."""
- self._elapsed_seconds += 1
- self._update_display()
-
- def _update_display(self):
- """Update the time display label."""
- hours = self._elapsed_seconds // 3600
- minutes = (self._elapsed_seconds % 3600) // 60
- seconds = self._elapsed_seconds % 60
- self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
-
- @Slot()
- def _stop_and_log(self):
- """Stop the timer and emit signal to open time log."""
- if self._running:
- self._running = False
- self._timer.stop()
-
- self.timerStopped.emit(self._elapsed_seconds, self._task_text)
- self.close()
-
-
-class PomodoroManager:
- """Manages Pomodoro timers and integrates with time log."""
-
- def __init__(self, db: DBManager, parent_window):
- self._db = db
- self._parent = parent_window
- self._active_timer: Optional[PomodoroTimer] = None
-
- def start_timer_for_line(self, line_text: str, date_iso: str):
- """
- Start a new timer for the given line of text and embed it into the
- TimeLogWidget in the main window sidebar.
- """
- # Cancel any existing timer first
- self.cancel_timer()
-
- # The timer lives inside the TimeLogWidget in the sidebar
- time_log_widget = getattr(self._parent, "time_log", None)
- if time_log_widget is None:
- return
-
- self._active_timer = PomodoroTimer(line_text, time_log_widget)
- self._active_timer.timerStopped.connect(
- lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
- )
-
- # Ask the TimeLogWidget to own and display the widget
- if hasattr(time_log_widget, "show_pomodoro_widget"):
- time_log_widget.show_pomodoro_widget(self._active_timer)
- else:
- # Fallback - just attach it as a child widget
- self._active_timer.setParent(time_log_widget)
- self._active_timer.show()
-
- def cancel_timer(self):
- """Cancel any running timer without logging and remove it from the sidebar."""
- if not self._active_timer:
- return
-
- time_log_widget = getattr(self._parent, "time_log", None)
- if time_log_widget is not None and hasattr(
- time_log_widget, "clear_pomodoro_widget"
- ):
- time_log_widget.clear_pomodoro_widget()
- else:
- # Fallback if the widget API doesn't exist
- self._active_timer.setParent(None)
-
- self._active_timer.deleteLater()
- self._active_timer = None
-
- def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
- """Handle timer stop - open time log dialog with pre-filled data."""
- # Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes)
- quarter_hours = math.ceil(elapsed_seconds / 900)
- hours = quarter_hours * 0.25
-
- # Ensure minimum of 0.25 hours
- if hours < 0.25:
- hours = 0.25
-
- # Untoggle the toolbar button without retriggering the slot
- tool_bar = getattr(self._parent, "toolBar", None)
- if tool_bar is not None and hasattr(tool_bar, "actTimer"):
- action = tool_bar.actTimer
- was_blocked = action.blockSignals(True)
- try:
- action.setChecked(False)
- finally:
- action.blockSignals(was_blocked)
-
- # Remove the embedded widget
- self.cancel_timer()
-
- # Open time log dialog
- dlg = TimeLogDialog(
- self._db,
- date_iso,
- self._parent,
- True,
- themes=self._parent.themes,
- close_after_add=True,
- )
-
- # Pre-fill the hours
- dlg.hours_spin.setValue(hours)
-
- # Pre-fill the note with task text
- dlg.note.setText(task_text)
-
- # Show the dialog
- dlg.exec()
-
- time_log_widget = getattr(self._parent, "time_log", None)
- if time_log_widget is not None:
- # Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only:
- # reload the summary so the TimeLogWidget in sidebar updates its totals
- time_log_widget._reload_summary()
- if not time_log_widget.toggle_btn.isChecked():
- time_log_widget.summary_label.setText(
- strings._("time_log_collapsed_hint")
- )
diff --git a/bouquin/reminders.py b/bouquin/reminders.py
deleted file mode 100644
index 6d8b0a1..0000000
--- a/bouquin/reminders.py
+++ /dev/null
@@ -1,917 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from enum import Enum
-from typing import Optional
-
-from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
-from PySide6.QtWidgets import (
- QAbstractItemView,
- QComboBox,
- QDateEdit,
- QDialog,
- QFormLayout,
- QFrame,
- QHBoxLayout,
- QHeaderView,
- QLineEdit,
- QListWidget,
- QListWidgetItem,
- QMessageBox,
- QPushButton,
- QSizePolicy,
- QSpinBox,
- QStyle,
- QTableWidget,
- QTableWidgetItem,
- QTimeEdit,
- QToolButton,
- QVBoxLayout,
- QWidget,
-)
-
-from . import strings
-from .db import DBManager
-from .settings import load_db_config
-
-import requests
-
-
-class ReminderType(Enum):
- ONCE = strings._("once")
- DAILY = strings._("daily")
- WEEKDAYS = strings._("weekdays") # Mon-Fri
- WEEKLY = strings._("weekly") # specific day of week
- FORTNIGHTLY = strings._("fortnightly") # every 2 weeks
- MONTHLY_DATE = strings._("monthly_same_date") # same calendar date
- MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday
-
-
-@dataclass
-class Reminder:
- id: Optional[int]
- text: str
- time_str: str # HH:MM
- reminder_type: ReminderType
- weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type)
- active: bool = True
- date_iso: Optional[str] = None # For ONCE type
-
-
-class ReminderDialog(QDialog):
- """Dialog for creating/editing reminders with recurrence support."""
-
- def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None):
- super().__init__(parent)
- self._db = db
- self._reminder = reminder
-
- self.setWindowTitle(
- strings._("set_reminder") if not reminder else strings._("edit_reminder")
- )
- self.setMinimumWidth(400)
-
- layout = QVBoxLayout(self)
- self.form = QFormLayout()
-
- # Reminder text
- self.text_edit = QLineEdit()
- if reminder:
- self.text_edit.setText(reminder.text)
- self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
-
- # Date
- self.date_edit = QDateEdit()
- self.date_edit.setCalendarPopup(True)
- self.date_edit.setDisplayFormat("yyyy-MM-dd")
-
- if reminder and reminder.date_iso:
- d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if d.isValid():
- self.date_edit.setDate(d)
- else:
- self.date_edit.setDate(QDate.currentDate())
- else:
- self.date_edit.setDate(QDate.currentDate())
-
- self.form.addRow("&" + strings._("date") + ":", self.date_edit)
-
- # Time
- self.time_edit = QTimeEdit()
- self.time_edit.setDisplayFormat("HH:mm")
- if reminder:
- parts = reminder.time_str.split(":")
- self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
- else:
- # Default to 5 minutes in the future
- future = QTime.currentTime().addSecs(5 * 60)
- self.time_edit.setTime(future)
- self.form.addRow("&" + strings._("time") + ":", self.time_edit)
-
- # Recurrence type
- self.type_combo = QComboBox()
- self.type_combo.addItem(strings._("once"), ReminderType.ONCE)
- self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
- self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
- self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
- self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY)
- self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE)
- self.type_combo.addItem(
- strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY
- )
-
- if reminder:
- for i in range(self.type_combo.count()):
- if self.type_combo.itemData(i) == reminder.reminder_type:
- self.type_combo.setCurrentIndex(i)
- break
-
- self.type_combo.currentIndexChanged.connect(self._on_type_changed)
- self.form.addRow("&" + strings._("repeat") + ":", self.type_combo)
-
- # Weekday selector (for weekly reminders)
- self.weekday_combo = QComboBox()
- days = [
- strings._("monday"),
- strings._("tuesday"),
- strings._("wednesday"),
- strings._("thursday"),
- strings._("friday"),
- strings._("saturday"),
- strings._("sunday"),
- ]
- for i, day in enumerate(days):
- self.weekday_combo.addItem(day, i)
-
- if reminder and reminder.weekday is not None:
- self.weekday_combo.setCurrentIndex(reminder.weekday)
- else:
- self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1)
-
- self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
- day_label = self.form.labelForField(self.weekday_combo)
- day_label.setVisible(False)
-
- self.nth_spin = QSpinBox()
- self.nth_spin.setRange(1, 5) # up to 5th Monday, etc.
- self.nth_spin.setValue(1)
- # If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso
- if (
- reminder
- and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
- and reminder.date_iso
- ):
- anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if anchor.isValid():
- nth_index = (anchor.day() - 1) // 7 # 0-based
- self.nth_spin.setValue(nth_index + 1)
-
- self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin)
- nth_label = self.form.labelForField(self.nth_spin)
- nth_label.setVisible(False)
- self.nth_spin.setVisible(False)
-
- layout.addLayout(self.form)
-
- # Buttons
- btn_layout = QHBoxLayout()
- btn_layout.addStretch()
-
- save_btn = QPushButton("&" + strings._("save"))
- save_btn.clicked.connect(self.accept)
- save_btn.setDefault(True)
- btn_layout.addWidget(save_btn)
-
- cancel_btn = QPushButton("&" + strings._("cancel"))
- cancel_btn.clicked.connect(self.reject)
- btn_layout.addWidget(cancel_btn)
-
- layout.addLayout(btn_layout)
-
- self._on_type_changed()
-
- def _on_type_changed(self):
- """Show/hide weekday / nth selectors based on reminder type."""
- reminder_type = self.type_combo.currentData()
-
- show_weekday = reminder_type in (
- ReminderType.WEEKLY,
- ReminderType.MONTHLY_NTH_WEEKDAY,
- )
- self.weekday_combo.setVisible(show_weekday)
- day_label = self.form.labelForField(self.weekday_combo)
- day_label.setVisible(show_weekday)
-
- show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
- nth_label = self.form.labelForField(self.nth_spin)
- self.nth_spin.setVisible(show_nth)
- nth_label.setVisible(show_nth)
-
- # For new reminders, when switching to a type that uses a weekday,
- # snap the weekday to match the currently selected date.
- if reminder_type in (
- ReminderType.WEEKLY,
- ReminderType.MONTHLY_NTH_WEEKDAY,
- ) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
- dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
- if 0 <= dow < self.weekday_combo.count():
- self.weekday_combo.setCurrentIndex(dow)
-
- def get_reminder(self) -> Reminder:
- """Get the configured reminder."""
- reminder_type = self.type_combo.currentData()
- time_obj = self.time_edit.time()
- time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
-
- weekday = None
- if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY):
- weekday = self.weekday_combo.currentData()
-
- date_iso = None
- anchor_date = self.date_edit.date()
-
- if reminder_type == ReminderType.ONCE:
- # Fire once, on the chosen calendar date at the chosen time
- date_iso = anchor_date.toString("yyyy-MM-dd")
-
- elif reminder_type == ReminderType.FORTNIGHTLY:
- # Anchor: the chosen calendar date. Every 14 days from this date.
- date_iso = anchor_date.toString("yyyy-MM-dd")
-
- elif reminder_type == ReminderType.MONTHLY_DATE:
- # Anchor: the chosen calendar date. "Same date each month"
- date_iso = anchor_date.toString("yyyy-MM-dd")
-
- elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
- # Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
- weekday = self.weekday_combo.currentData()
- nth_index = self.nth_spin.value() - 1 # 0-based
-
- first = QDate(anchor_date.year(), anchor_date.month(), 1)
- target_dow = weekday + 1 # Qt: Monday=1
- offset = (target_dow - first.dayOfWeek() + 7) % 7
- anchor = first.addDays(offset + nth_index * 7)
-
- # If nth weekday doesn't exist in this month, fall back to the last such weekday
- if anchor.month() != anchor_date.month():
- anchor = anchor.addDays(-7)
-
- date_iso = anchor.toString("yyyy-MM-dd")
-
- return Reminder(
- id=self._reminder.id if self._reminder else None,
- text=self.text_edit.text(),
- time_str=time_str,
- reminder_type=reminder_type,
- weekday=weekday,
- active=self._reminder.active if self._reminder else True,
- date_iso=date_iso,
- )
-
-
-class UpcomingRemindersWidget(QFrame):
- """Collapsible widget showing upcoming reminders for today and next 7 days."""
-
- reminderTriggered = Signal(str) # Emits reminder text
-
- def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
- super().__init__(parent)
- self._db = db
-
- self.setFrameShape(QFrame.StyledPanel)
- self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
-
- # Header with toggle button
- self.toggle_btn = QToolButton()
- self.toggle_btn.setText(strings._("upcoming_reminders"))
- self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
- self.toggle_btn.setCheckable(True)
- self.toggle_btn.setChecked(False)
- self.toggle_btn.setArrowType(Qt.RightArrow)
- self.toggle_btn.clicked.connect(self._on_toggle)
-
- self.add_btn = QToolButton()
- self.add_btn.setText("⏰")
- self.add_btn.setToolTip(strings._("add_reminder"))
- self.add_btn.setAutoRaise(True)
- self.add_btn.clicked.connect(self._add_reminder)
-
- self.manage_btn = QToolButton()
- self.manage_btn.setIcon(
- self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
- )
- self.manage_btn.setToolTip(strings._("manage_reminders"))
- self.manage_btn.setAutoRaise(True)
- self.manage_btn.clicked.connect(self._manage_reminders)
-
- header = QHBoxLayout()
- header.setContentsMargins(0, 0, 0, 0)
- header.addWidget(self.toggle_btn)
- header.addStretch()
- header.addWidget(self.add_btn)
- header.addWidget(self.manage_btn)
-
- # Body with reminder list
- self.body = QWidget()
- body_layout = QVBoxLayout(self.body)
- body_layout.setContentsMargins(0, 4, 0, 0)
- body_layout.setSpacing(2)
-
- self.reminder_list = QListWidget()
- self.reminder_list.setMaximumHeight(200)
- self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
- self.reminder_list.itemDoubleClicked.connect(self._edit_reminder)
- self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu)
- self.reminder_list.customContextMenuRequested.connect(
- self._show_reminder_context_menu
- )
- body_layout.addWidget(self.reminder_list)
-
- self.body.setVisible(False)
-
- main = QVBoxLayout(self)
- main.setContentsMargins(0, 0, 0, 0)
- main.addLayout(header)
- main.addWidget(self.body)
-
- # Timer to check and fire reminders
- #
- # We tick once per second, but only hit the DB when the clock is
- # exactly on a :00 second. That way a reminder for HH:MM fires at
- # HH:MM:00, independent of when it was created.
- self._tick_timer = QTimer(self)
- self._tick_timer.setInterval(1000) # 1 second
- self._tick_timer.timeout.connect(self._on_tick)
- self._tick_timer.start()
-
- # Also check once on startup so we don't miss reminders that
- # should have fired a moment ago when the app wasn't running.
- QTimer.singleShot(0, self._check_reminders)
-
- def _on_tick(self) -> None:
- """Called every second; run reminder check only on exact minute boundaries."""
- now = QDateTime.currentDateTime()
- if now.time().second() == 0:
- # Only do the heavier DB work once per minute, at HH:MM:00,
- # so reminders are aligned to the clock and not to when they
- # were created.
- self._check_reminders(now)
-
- def __del__(self):
- """Cleanup timers when widget is destroyed."""
- try:
- if hasattr(self, "_tick_timer") and self._tick_timer:
- self._tick_timer.stop()
- except Exception:
- pass # Ignore any cleanup errors
-
- def _on_toggle(self, checked: bool):
- """Toggle visibility of reminder list."""
- self.body.setVisible(checked)
- self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
- if checked:
- self.refresh()
-
- def refresh(self):
- """Reload and display upcoming reminders."""
- # Guard: Check if database connection is valid
- if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
- return
-
- self.reminder_list.clear()
-
- reminders = self._db.get_all_reminders()
- now = QDateTime.currentDateTime()
- today = QDate.currentDate()
-
- # Get reminders for the next 7 days
- upcoming = []
- for i in range(8): # Today + 7 days
- check_date = today.addDays(i)
-
- for reminder in reminders:
- if not reminder.active:
- continue
-
- if self._should_fire_on_date(reminder, check_date):
- # Parse time
- hour, minute = map(int, reminder.time_str.split(":"))
- dt = QDateTime(check_date, QTime(hour, minute))
-
- # Skip past reminders
- if dt < now:
- continue
-
- upcoming.append((dt, reminder))
-
- # Sort by datetime
- upcoming.sort(key=lambda x: x[0])
-
- # Display
- for dt, reminder in upcoming[:20]: # Show max 20
- date_str = dt.date().toString("ddd MMM d")
- time_str = dt.time().toString("HH:mm")
-
- item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}")
- item.setData(Qt.UserRole, reminder)
- self.reminder_list.addItem(item)
-
- if not upcoming:
- item = QListWidgetItem(strings._("no_upcoming_reminders"))
- item.setFlags(Qt.NoItemFlags)
- self.reminder_list.addItem(item)
-
- def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
- """Check if a reminder should fire on a given date."""
- rtype = reminder.reminder_type
-
- if rtype == ReminderType.ONCE:
- if reminder.date_iso:
- return date.toString("yyyy-MM-dd") == reminder.date_iso
- return False
-
- if rtype == ReminderType.DAILY:
- return True
-
- if rtype == ReminderType.WEEKDAYS:
- # Monday=1, Sunday=7
- return 1 <= date.dayOfWeek() <= 5
-
- if rtype == ReminderType.WEEKLY:
- # Qt: Monday=1, reminder: Monday=0
- return date.dayOfWeek() - 1 == reminder.weekday
-
- if rtype == ReminderType.FORTNIGHTLY:
- if not reminder.date_iso:
- return False
- anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if not anchor.isValid() or date < anchor:
- return False
- days = anchor.daysTo(date)
- return days % 14 == 0
-
- if rtype == ReminderType.MONTHLY_DATE:
- if not reminder.date_iso:
- return False
- anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if not anchor.isValid():
- return False
- anchor_day = anchor.day()
- # Clamp to the last day of this month (for 29/30/31)
- first_of_month = QDate(date.year(), date.month(), 1)
- last_of_month = first_of_month.addMonths(1).addDays(-1)
- target_day = min(anchor_day, last_of_month.day())
- return date.day() == target_day
-
- if rtype == ReminderType.MONTHLY_NTH_WEEKDAY:
- if not reminder.date_iso or reminder.weekday is None:
- return False
-
- anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if not anchor.isValid():
- return False
-
- # Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.)
- anchor_n = (anchor.day() - 1) // 7
- target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7)
-
- # Compute the anchor_n-th target weekday in this month
- first = QDate(date.year(), date.month(), 1)
- offset = (target_dow - first.dayOfWeek() + 7) % 7
- candidate = first.addDays(offset + anchor_n * 7)
-
- # If that nth weekday doesn't exist this month (e.g. 5th Monday), skip
- if candidate.month() != date.month():
- return False
-
- return date == candidate
-
- return False
-
- def _check_reminders(self, now: QDateTime | None = None):
- """
- Check and trigger due reminders.
-
- This uses absolute clock time, so a reminder for HH:MM will fire
- when the system clock reaches HH:MM:00, independent of when the
- reminder was created.
- """
- # Guard: Check if database connection is valid
- if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
- return
-
- if now is None:
- now = QDateTime.currentDateTime()
-
- today = now.date()
- reminders = self._db.get_all_reminders()
-
- # Small grace window (in seconds) so we still fire reminders if
- # the app was just opened or the event loop was briefly busy.
- GRACE_WINDOW_SECS = 120 # 2 minutes
-
- for reminder in reminders:
- if not reminder.active:
- continue
-
- if not self._should_fire_on_date(reminder, today):
- continue
-
- # Parse time: stored as "HH:MM", we treat that as HH:MM:00
- hour, minute = map(int, reminder.time_str.split(":"))
- target = QDateTime(today, QTime(hour, minute, 0))
-
- # Skip if this reminder is still in the future
- if now < target:
- continue
-
- # How long ago should this reminder have fired?
- seconds_late = target.secsTo(now) # target -> now
-
- if 0 <= seconds_late <= GRACE_WINDOW_SECS:
- # Check if we haven't already fired this occurrence
- if not hasattr(self, "_fired_reminders"):
- self._fired_reminders = {}
-
- reminder_key = (reminder.id, target.toString())
-
- if reminder_key in self._fired_reminders:
- continue
-
- # Mark as fired and emit
- self._fired_reminders[reminder_key] = now
- self.reminderTriggered.emit(reminder.text)
-
- # For ONCE reminders, deactivate after firing
- if reminder.reminder_type == ReminderType.ONCE:
- self._db.update_reminder_active(reminder.id, False)
- self.refresh() # Refresh the list to show deactivated reminder
-
- @Slot()
- def _add_reminder(self):
- """Open dialog to add a new reminder."""
- dlg = ReminderDialog(self._db, self)
- if dlg.exec() == QDialog.Accepted:
- reminder = dlg.get_reminder()
- self._db.save_reminder(reminder)
- self.refresh()
-
- @Slot(QListWidgetItem)
- def _edit_reminder(self, item: QListWidgetItem):
- """Edit an existing reminder."""
- reminder = item.data(Qt.UserRole)
- if not reminder:
- return
-
- dlg = ReminderDialog(self._db, self, reminder)
- if dlg.exec() == QDialog.Accepted:
- updated = dlg.get_reminder()
- self._db.save_reminder(updated)
- self.refresh()
-
- @Slot()
- def _show_reminder_context_menu(self, pos):
- """Show context menu for reminder list item(s)."""
- selected_items = self.reminder_list.selectedItems()
- if not selected_items:
- return
-
- from PySide6.QtGui import QAction
- from PySide6.QtWidgets import QMenu
-
- menu = QMenu(self)
-
- # Only show Edit if single item selected
- if len(selected_items) == 1:
- reminder = selected_items[0].data(Qt.UserRole)
- if reminder:
- edit_action = QAction(strings._("edit"), self)
- edit_action.triggered.connect(
- lambda: self._edit_reminder(selected_items[0])
- )
- menu.addAction(edit_action)
-
- # Delete option for any selection
- if len(selected_items) == 1:
- delete_text = strings._("delete")
- else:
- delete_text = (
- strings._("delete")
- + f" {len(selected_items)} "
- + strings._("reminders")
- )
-
- delete_action = QAction(delete_text, self)
- delete_action.triggered.connect(lambda: self._delete_selected_reminders())
- menu.addAction(delete_action)
-
- menu.exec(self.reminder_list.mapToGlobal(pos))
-
- def _delete_selected_reminders(self):
- """Delete all selected reminders (handling duplicates)."""
- selected_items = self.reminder_list.selectedItems()
- if not selected_items:
- return
-
- # Collect unique reminder IDs
- unique_reminders = {}
- for item in selected_items:
- reminder = item.data(Qt.UserRole)
- if reminder and reminder.id not in unique_reminders:
- unique_reminders[reminder.id] = reminder
-
- if not unique_reminders:
- return
-
- # Confirmation message
- if len(unique_reminders) == 1:
- reminder = list(unique_reminders.values())[0]
- msg = (
- strings._("delete")
- + " "
- + strings._("reminder")
- + f" '{reminder.text}'?"
- )
- if reminder.reminder_type != ReminderType.ONCE:
- msg += (
- "\n\n"
- + strings._("this_is_a_reminder_of_type")
- + f" '{reminder.reminder_type.value}'. "
- + strings._("deleting_it_will_remove_all_future_occurrences")
- )
- else:
- msg = (
- strings._("delete")
- + f"{len(unique_reminders)} "
- + strings._("reminders")
- + " ?\n\n"
- + strings._("this_will_delete_the_actual_reminders")
- )
-
- reply = QMessageBox.question(
- self,
- strings._("delete_reminders"),
- msg,
- QMessageBox.Yes | QMessageBox.No,
- QMessageBox.No,
- )
-
- if reply == QMessageBox.Yes:
- for reminder_id in unique_reminders:
- self._db.delete_reminder(reminder_id)
- self.refresh()
-
- def _delete_reminder(self, reminder):
- """Delete a single reminder after confirmation."""
- msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
- if reminder.reminder_type != ReminderType.ONCE:
- msg += (
- "\n\n"
- + strings._("this_is_a_reminder_of_type")
- + f" '{reminder.reminder_type.value}'. "
- + strings._("deleting_it_will_remove_all_future_occurrences")
- )
-
- reply = QMessageBox.question(
- self,
- strings._("delete_reminder"),
- msg,
- QMessageBox.Yes | QMessageBox.No,
- QMessageBox.No,
- )
-
- if reply == QMessageBox.Yes:
- self._db.delete_reminder(reminder.id)
- self.refresh()
-
- @Slot()
- def _manage_reminders(self):
- """Open dialog to manage all reminders."""
- dlg = ManageRemindersDialog(self._db, self)
- dlg.exec()
- self.refresh()
-
-
-class ManageRemindersDialog(QDialog):
- """Dialog for managing all reminders."""
-
- def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
- super().__init__(parent)
- self._db = db
-
- self.setWindowTitle(strings._("manage_reminders"))
- self.setMinimumSize(700, 500)
-
- layout = QVBoxLayout(self)
-
- # Reminder list table
- self.table = QTableWidget()
- self.table.setColumnCount(6)
- self.table.setHorizontalHeaderLabels(
- [
- strings._("text"),
- strings._("date"),
- strings._("time"),
- strings._("type"),
- strings._("active"),
- strings._("actions"),
- ]
- )
- self.table.horizontalHeader().setStretchLastSection(False)
- self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
- self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
- self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
- layout.addWidget(self.table)
-
- # Buttons
- btn_layout = QHBoxLayout()
-
- add_btn = QPushButton(strings._("add_reminder"))
- add_btn.clicked.connect(self._add_reminder)
- btn_layout.addWidget(add_btn)
-
- btn_layout.addStretch()
-
- close_btn = QPushButton(strings._("close"))
- close_btn.clicked.connect(self.accept)
- btn_layout.addWidget(close_btn)
-
- layout.addLayout(btn_layout)
-
- self._load_reminders()
-
- def _load_reminders(self):
- """Load all reminders into the table."""
-
- # Guard: Check if database connection is valid
- if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
- return
-
- reminders = self._db.get_all_reminders()
- self.table.setRowCount(len(reminders))
-
- for row, reminder in enumerate(reminders):
- # Text
- text_item = QTableWidgetItem(reminder.text)
- text_item.setData(Qt.UserRole, reminder)
- self.table.setItem(row, 0, text_item)
-
- # Date
- date_display = ""
- if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso:
- d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if d.isValid():
- date_display = d.toString("yyyy-MM-dd")
- else:
- date_display = reminder.date_iso
-
- date_item = QTableWidgetItem(date_display)
- self.table.setItem(row, 1, date_item)
-
- # Time
- time_item = QTableWidgetItem(reminder.time_str)
- self.table.setItem(row, 2, time_item)
-
- # Type
- base_type_strs = {
- ReminderType.ONCE: "Once",
- ReminderType.DAILY: "Daily",
- ReminderType.WEEKDAYS: "Weekdays",
- ReminderType.WEEKLY: "Weekly",
- ReminderType.FORTNIGHTLY: "Fortnightly",
- ReminderType.MONTHLY_DATE: "Monthly (date)",
- ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
- }
- type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
-
- # Short day names we can reuse
- days_short = [
- strings._("monday_short"),
- strings._("tuesday_short"),
- strings._("wednesday_short"),
- strings._("thursday_short"),
- strings._("friday_short"),
- strings._("saturday_short"),
- strings._("sunday_short"),
- ]
-
- if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
- # Show something like: Monthly (3rd Mon)
- day_name = ""
- if reminder.weekday is not None and 0 <= reminder.weekday < len(
- days_short
- ):
- day_name = days_short[reminder.weekday]
-
- nth_label = ""
- if reminder.date_iso:
- anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
- if anchor.isValid():
- nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
- ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
- if 0 <= nth_index < len(ordinals):
- nth_label = ordinals[nth_index]
-
- parts = []
- if nth_label:
- parts.append(nth_label)
- if day_name:
- parts.append(day_name)
-
- if parts:
- type_str = f"Monthly ({' '.join(parts)})"
- # else: fall back to the generic "Monthly (nth weekday)"
-
- else:
- # For weekly / fortnightly types, still append the day name
- if (
- reminder.reminder_type
- in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY)
- and reminder.weekday is not None
- and 0 <= reminder.weekday < len(days_short)
- ):
- type_str += f" ({days_short[reminder.weekday]})"
-
- type_item = QTableWidgetItem(type_str)
- self.table.setItem(row, 3, type_item)
-
- # Active
- active_item = QTableWidgetItem("✓" if reminder.active else "✗")
- self.table.setItem(row, 4, active_item)
-
- # Actions
- actions_widget = QWidget()
- actions_layout = QHBoxLayout(actions_widget)
- actions_layout.setContentsMargins(2, 2, 2, 2)
-
- edit_btn = QPushButton(strings._("edit"))
- edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
- actions_layout.addWidget(edit_btn)
-
- delete_btn = QPushButton(strings._("delete"))
- delete_btn.clicked.connect(
- lambda checked, r=reminder: self._delete_reminder(r)
- )
- actions_layout.addWidget(delete_btn)
-
- self.table.setCellWidget(row, 5, actions_widget)
-
- def _add_reminder(self):
- """Add a new reminder."""
- dlg = ReminderDialog(self._db, self)
- if dlg.exec() == QDialog.Accepted:
- reminder = dlg.get_reminder()
- self._db.save_reminder(reminder)
- self._load_reminders()
-
- def _edit_reminder(self, reminder):
- """Edit an existing reminder."""
- dlg = ReminderDialog(self._db, self, reminder)
- if dlg.exec() == QDialog.Accepted:
- updated = dlg.get_reminder()
- self._db.save_reminder(updated)
- self._load_reminders()
-
- def _delete_reminder(self, reminder):
- """Delete a reminder."""
- reply = QMessageBox.question(
- self,
- strings._("delete_reminder"),
- strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?",
- QMessageBox.Yes | QMessageBox.No,
- QMessageBox.No,
- )
-
- if reply == QMessageBox.Yes:
- self._db.delete_reminder(reminder.id)
- self._load_reminders()
-
-
-class ReminderWebHook:
- def __init__(self, text):
- self.text = text
- self.cfg = load_db_config()
-
- def _send(self):
- payload: dict[str, str] = {
- "reminder": self.text,
- }
-
- url = self.cfg.reminders_webhook_url
- secret = self.cfg.reminders_webhook_secret
-
- _headers = {}
- if secret:
- _headers["X-Bouquin-Secret"] = secret
-
- if url:
- try:
- requests.post(
- url,
- json=payload,
- timeout=10,
- headers=_headers,
- )
- except Exception:
- # We did our best
- pass
diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py
index 528896b..bc40cc7 100644
--- a/bouquin/save_dialog.py
+++ b/bouquin/save_dialog.py
@@ -2,8 +2,13 @@ from __future__ import annotations
import datetime
-from PySide6.QtGui import QFontMetrics
-from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
+from PySide6.QtWidgets import (
+ QDialog,
+ QVBoxLayout,
+ QLabel,
+ QLineEdit,
+ QDialogButtonBox,
+)
from . import strings
@@ -17,24 +22,13 @@ class SaveDialog(QDialog):
Used for explicitly saving a new version of a page.
"""
super().__init__(parent)
-
self.setWindowTitle(strings._("enter_a_name_for_this_version"))
-
v = QVBoxLayout(self)
v.addWidget(QLabel(strings._("enter_a_name_for_this_version")))
-
self.note = QLineEdit()
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- text = strings._("new_version_i_saved_at") + f" {now}"
- self.note.setText(text)
+ self.note.setText(strings._("new_version_i_saved_at") + f" {now}")
v.addWidget(self.note)
-
- # make dialog wide enough for the line edit text
- fm = QFontMetrics(self.note.font())
- text_width = fm.horizontalAdvance(text) + 20
- self.note.setMinimumWidth(text_width)
- self.adjustSize()
-
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
diff --git a/bouquin/search.py b/bouquin/search.py
index 7dd7f7f..95a94de 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, str, str, str | None]
+Row = Tuple[str, str]
class Search(QWidget):
@@ -52,27 +52,9 @@ class Search(QWidget):
lay.addWidget(self.results)
def _open_selected(self, item: QListWidgetItem):
- 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)
+ date_str = item.data(Qt.ItemDataRole.UserRole)
+ if date_str:
+ self.openDateRequested.emit(date_str)
def _search(self, text: str):
"""
@@ -98,28 +80,28 @@ class Search(QWidget):
self.resultDatesChanged.emit([]) # clear highlights
return
- # 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.resultDatesChanged.emit(sorted({d for d, _ in rows}))
self.results.show()
- 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)
-
+ 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) ----
container = QWidget()
outer = QVBoxLayout(container)
- outer.setContentsMargins(0, 0, 0, 0)
+ outer.setContentsMargins(8, 6, 8, 6)
outer.setSpacing(2)
- # ---- Heading (date for pages, "Document" for docs) ----
- heading = QLabel(title)
- heading.setStyleSheet("font-weight:bold;")
- outer.addWidget(heading)
+ # 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)
- # ---- Preview row ----
+ # Preview row with optional ellipses
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
@@ -135,9 +117,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)
@@ -145,22 +127,9 @@ class Search(QWidget):
# ---- Add to list ----
item = QListWidgetItem()
- 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.setData(Qt.ItemDataRole.UserRole, date_str)
item.setSizeHint(container.sizeHint())
+
self.results.addItem(item)
self.results.setItemWidget(item, container)
diff --git a/bouquin/settings.py b/bouquin/settings.py
index fde863d..6578237 100644
--- a/bouquin/settings.py
+++ b/bouquin/settings.py
@@ -1,7 +1,6 @@
from __future__ import annotations
from pathlib import Path
-
from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig
@@ -42,34 +41,14 @@ 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(
path=path,
key=key,
idle_minutes=idle,
theme=theme,
move_todos=move_todos,
- move_todos_include_weekends=move_todos_include_weekends,
- tags=tags,
- time_log=time_log,
- reminders=reminders,
- reminders_webhook_url=reminders_webhook_url,
- reminders_webhook_secret=reminders_webhook_secret,
- documents=documents,
- invoicing=invoicing,
locale=locale,
- font_size=font_size,
)
@@ -80,13 +59,4 @@ 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 bec0627..7a9c73a 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -2,37 +2,33 @@ 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,
- QHBoxLayout,
QLabel,
- QLineEdit,
- QMessageBox,
+ QHBoxLayout,
+ QVBoxLayout,
QPushButton,
+ QDialogButtonBox,
QRadioButton,
QSizePolicy,
QSpinBox,
- QTabWidget,
- QTextEdit,
- QToolButton,
- QVBoxLayout,
- QWidget,
+ QMessageBox,
)
+from PySide6.QtCore import Qt, Slot
+from PySide6.QtGui import QPalette
+
-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
+from .key_prompt import KeyPrompt
+
+from . import strings
class SettingsDialog(QDialog):
@@ -43,45 +39,14 @@ class SettingsDialog(QDialog):
self._db = db
self.key = ""
- self.current_settings = load_db_config()
-
- self.setMinimumWidth(600)
+ form = QFormLayout()
+ form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
+ self.setMinimumWidth(560)
self.setSizeGripEnabled(True)
- # --- Tabs ----------------------------------------------------------
- tabs = QTabWidget()
- tabs.setTabPosition(QTabWidget.North)
- tabs.setDocumentMode(True)
- tabs.setMovable(False)
+ self.current_settings = load_db_config()
- tabs.addTab(self._create_appearance_page(cfg), strings._("appearance"))
- tabs.addTab(self._create_features_page(), strings._("features"))
- tabs.addTab(self._create_security_page(cfg), strings._("security"))
- tabs.addTab(self._create_database_page(), strings._("database"))
-
- # --- Buttons -------------------------------------------------------
- bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
- bb.accepted.connect(self._save)
- bb.rejected.connect(self.reject)
-
- # Root layout
- root = QVBoxLayout(self)
- root.setContentsMargins(12, 12, 12, 12)
- root.setSpacing(8)
- root.addWidget(tabs)
- root.addWidget(bb, 0, Qt.AlignRight)
-
- # ------------------------------------------------------------------ #
- # Pages
- # ------------------------------------------------------------------ #
-
- def _create_appearance_page(self, cfg: DBConfig) -> QWidget:
- page = QWidget()
- layout = QVBoxLayout(page)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(12)
-
- # --- Theme group --------------------------------------------------
+ # Add theme selection
theme_group = QGroupBox(strings._("theme"))
theme_layout = QVBoxLayout(theme_group)
@@ -89,6 +54,7 @@ class SettingsDialog(QDialog):
self.theme_light = QRadioButton(strings._("light"))
self.theme_dark = QRadioButton(strings._("dark"))
+ # Load current theme from settings
current_theme = self.current_settings.theme
if current_theme == Theme.DARK.value:
self.theme_dark.setChecked(True)
@@ -101,255 +67,53 @@ class SettingsDialog(QDialog):
theme_layout.addWidget(self.theme_light)
theme_layout.addWidget(self.theme_dark)
- # font size row
- font_row = QHBoxLayout()
- self.font_heading = QLabel(strings._("font_size"))
- self.font_size = QSpinBox()
- self.font_size.setRange(1, 24)
- self.font_size.setSingleStep(1)
- self.font_size.setAccelerated(True)
- self.font_size.setValue(getattr(cfg, "font_size", 11))
- font_row.addWidget(self.font_heading)
- font_row.addWidget(self.font_size)
- font_row.addStretch()
- theme_layout.addLayout(font_row)
+ form.addRow(theme_group)
- # explanation
- self.font_size_label = QLabel(strings._("font_size_explanation"))
- self.font_size_label.setWordWrap(True)
- self.font_size_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
- pal = self.font_size_label.palette()
- self.font_size_label.setForegroundRole(QPalette.PlaceholderText)
- self.font_size_label.setPalette(pal)
-
- font_exp_row = QHBoxLayout()
- font_exp_row.setContentsMargins(24, 0, 0, 0)
- font_exp_row.addWidget(self.font_size_label)
- theme_layout.addLayout(font_exp_row)
-
- layout.addWidget(theme_group)
-
- # --- Locale group -------------------------------------------------
+ # Locale settings
locale_group = QGroupBox(strings._("locale"))
locale_layout = QVBoxLayout(locale_group)
+ locale_layout.setContentsMargins(12, 8, 12, 12)
+ locale_layout.setSpacing(6)
self.locale_combobox = QComboBox()
self.locale_combobox.addItems(strings._AVAILABLE)
self.locale_combobox.setCurrentText(self.current_settings.locale)
locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft)
+ # Explanation for locale
self.locale_label = QLabel(strings._("locale_restart"))
self.locale_label.setWordWrap(True)
self.locale_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ # make it look secondary
lpal = self.locale_label.palette()
self.locale_label.setForegroundRole(QPalette.PlaceholderText)
self.locale_label.setPalette(lpal)
- loc_row = QHBoxLayout()
- loc_row.setContentsMargins(24, 0, 0, 0)
- loc_row.addWidget(self.locale_label)
- locale_layout.addLayout(loc_row)
+ locale_row = QHBoxLayout()
+ locale_row.setContentsMargins(24, 0, 0, 0)
+ locale_row.addWidget(self.locale_label)
+ locale_layout.addLayout(locale_row)
+ form.addRow(locale_group)
- layout.addWidget(locale_group)
- layout.addStretch()
- return page
-
- def _create_features_page(self) -> QWidget:
- page = QWidget()
- layout = QVBoxLayout(page)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(12)
-
- features_group = QGroupBox(strings._("features"))
- features_layout = QVBoxLayout(features_group)
+ # Add Behaviour
+ behaviour_group = QGroupBox(strings._("behaviour"))
+ behaviour_layout = QVBoxLayout(behaviour_group)
self.move_todos = QCheckBox(
- strings._("move_unchecked_todos_to_today_on_startup")
+ strings._("move_yesterdays_unchecked_todos_to_today_on_startup")
)
self.move_todos.setChecked(self.current_settings.move_todos)
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())
+ behaviour_layout.addWidget(self.move_todos)
+ form.addRow(behaviour_group)
- 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)
- features_layout.addWidget(self.tags)
-
- self.time_log = QCheckBox(strings._("enable_time_log_feature"))
- self.time_log.setChecked(self.current_settings.time_log)
- self.time_log.setCursor(Qt.PointingHandCursor)
- features_layout.addWidget(self.time_log)
-
- self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
- invoicing_enabled = getattr(self.current_settings, "invoicing", False)
- self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
- self.invoicing.setCursor(Qt.PointingHandCursor)
- features_layout.addWidget(self.invoicing)
- # Invoicing only if time_log is enabled
- if not self.current_settings.time_log:
- self.invoicing.setChecked(False)
- self.invoicing.setEnabled(False)
- self.time_log.toggled.connect(self._on_time_log_toggled)
-
- # --- Reminders feature + webhook options -------------------------
- self.reminders = QCheckBox(strings._("enable_reminders_feature"))
- self.reminders.setChecked(self.current_settings.reminders)
- self.reminders.toggled.connect(self._on_reminders_toggled)
- self.reminders.setCursor(Qt.PointingHandCursor)
- features_layout.addWidget(self.reminders)
-
- # Container for reminder-specific options, indented under the checkbox
- self.reminders_options_container = QWidget()
- reminders_options_layout = QVBoxLayout(self.reminders_options_container)
- reminders_options_layout.setContentsMargins(24, 0, 0, 0)
- reminders_options_layout.setSpacing(4)
-
- self.reminders_options_toggle = QToolButton()
- self.reminders_options_toggle.setText(
- strings._("reminders_webhook_section_title")
- )
- self.reminders_options_toggle.setCheckable(True)
- self.reminders_options_toggle.setChecked(False)
- self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
- self.reminders_options_toggle.setArrowType(Qt.RightArrow)
- self.reminders_options_toggle.clicked.connect(
- self._on_reminders_options_toggled
- )
-
- toggle_row = QHBoxLayout()
- toggle_row.addWidget(self.reminders_options_toggle)
- toggle_row.addStretch()
- reminders_options_layout.addLayout(toggle_row)
-
- # Actual options (labels + QLineEdits)
- self.reminders_options_widget = QWidget()
- options_form = QFormLayout(self.reminders_options_widget)
- options_form.setContentsMargins(0, 0, 0, 0)
- options_form.setSpacing(4)
-
- self.reminders_webhook_url = QLineEdit(
- self.current_settings.reminders_webhook_url or ""
- )
- self.reminders_webhook_secret = QLineEdit(
- self.current_settings.reminders_webhook_secret or ""
- )
- self.reminders_webhook_secret.setEchoMode(QLineEdit.Password)
-
- options_form.addRow(
- strings._("reminders_webhook_url_label") + ":",
- self.reminders_webhook_url,
- )
- options_form.addRow(
- strings._("reminders_webhook_secret_label") + ":",
- self.reminders_webhook_secret,
- )
-
- reminders_options_layout.addWidget(self.reminders_options_widget)
-
- features_layout.addWidget(self.reminders_options_container)
-
- self.reminders_options_container.setVisible(self.reminders.isChecked())
- self.reminders_options_widget.setVisible(False)
-
- self.documents = QCheckBox(strings._("enable_documents_feature"))
- self.documents.setChecked(self.current_settings.documents)
- self.documents.setCursor(Qt.PointingHandCursor)
- features_layout.addWidget(self.documents)
-
- layout.addWidget(features_group)
-
- # --- Invoicing / company profile section -------------------------
- self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
- invoicing_layout = QFormLayout(self.invoicing_group)
-
- profile = self._db.get_company_profile() or (
- None,
- None,
- None,
- None,
- None,
- None,
- None,
- )
- name, address, phone, email, tax_id, payment_details, logo_bytes = profile
-
- self.company_name_edit = QLineEdit(name or "")
- self.company_address_edit = QTextEdit(address or "")
- self.company_phone_edit = QLineEdit(phone or "")
- self.company_email_edit = QLineEdit(email or "")
- self.company_tax_id_edit = QLineEdit(tax_id or "")
- self.company_payment_details_edit = QTextEdit()
- self.company_payment_details_edit.setPlainText(payment_details or "")
-
- invoicing_layout.addRow(
- strings._("invoice_company_name") + ":", self.company_name_edit
- )
- invoicing_layout.addRow(
- strings._("invoice_company_address") + ":", self.company_address_edit
- )
- invoicing_layout.addRow(
- strings._("invoice_company_phone") + ":", self.company_phone_edit
- )
- invoicing_layout.addRow(
- strings._("invoice_company_email") + ":", self.company_email_edit
- )
- invoicing_layout.addRow(
- strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
- )
- invoicing_layout.addRow(
- strings._("invoice_company_payment_details") + ":",
- self.company_payment_details_edit,
- )
-
- # Logo picker - store bytes on self._logo_bytes
- self._logo_bytes = logo_bytes
- logo_row = QHBoxLayout()
- self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
- if logo_bytes:
- self.logo_label.setText(strings._("invoice_company_logo_set"))
- logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
- logo_btn.clicked.connect(self._on_choose_logo)
- logo_row.addWidget(self.logo_label)
- logo_row.addWidget(logo_btn)
- invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
-
- # Show/hide this whole block based on invoicing checkbox
- self.invoicing_group.setVisible(self.invoicing.isChecked())
- self.invoicing.toggled.connect(self.invoicing_group.setVisible)
-
- layout.addWidget(self.invoicing_group)
-
- layout.addStretch()
- return page
-
- def _create_security_page(self, cfg: DBConfig) -> QWidget:
- page = QWidget()
- layout = QVBoxLayout(page)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(12)
-
- # --- Encryption group ---------------------------------------------
+ # Encryption settings
enc_group = QGroupBox(strings._("encryption"))
enc = QVBoxLayout(enc_group)
+ enc.setContentsMargins(12, 8, 12, 12)
+ enc.setSpacing(6)
+ # Checkbox to remember key
self.save_key_btn = QCheckBox(strings._("remember_key"))
self.key = self.current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key))
@@ -357,15 +121,17 @@ class SettingsDialog(QDialog):
self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
+ # Explanation for remembering key
self.save_key_label = QLabel(strings._("save_key_warning"))
self.save_key_label.setWordWrap(True)
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ # make it look secondary
pal = self.save_key_label.palette()
self.save_key_label.setForegroundRole(QPalette.PlaceholderText)
self.save_key_label.setPalette(pal)
exp_row = QHBoxLayout()
- exp_row.setContentsMargins(24, 0, 0, 0)
+ exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
exp_row.addWidget(self.save_key_label)
enc.addLayout(exp_row)
@@ -374,59 +140,62 @@ class SettingsDialog(QDialog):
line.setFrameShadow(QFrame.Sunken)
enc.addWidget(line)
+ # Change key button
self.rekey_btn = QPushButton(strings._("change_encryption_key"))
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.rekey_btn.clicked.connect(self._change_key)
+
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
- layout.addWidget(enc_group)
+ form.addRow(enc_group)
- # --- Idle lock group ----------------------------------------------
+ # Privacy settings
priv_group = QGroupBox(strings._("lock_screen_when_idle"))
priv = QVBoxLayout(priv_group)
+ priv.setContentsMargins(12, 8, 12, 12)
+ priv.setSpacing(6)
self.idle_spin = QSpinBox()
self.idle_spin.setRange(0, 240)
self.idle_spin.setSingleStep(1)
self.idle_spin.setAccelerated(True)
self.idle_spin.setSuffix(" min")
- self.idle_spin.setSpecialValueText(strings._("never"))
+ self.idle_spin.setSpecialValueText(strings._("Never"))
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
-
+ # Explanation for idle option (autolock)
self.idle_spin_label = QLabel(strings._("autolock_explanation"))
self.idle_spin_label.setWordWrap(True)
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ # make it look secondary
spal = self.idle_spin_label.palette()
self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText)
self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout()
- spin_row.setContentsMargins(24, 0, 0, 0)
+ spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
spin_row.addWidget(self.idle_spin_label)
priv.addLayout(spin_row)
- layout.addWidget(priv_group)
- layout.addStretch()
- return page
-
- def _create_database_page(self) -> QWidget:
- page = QWidget()
- layout = QVBoxLayout(page)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(12)
+ form.addRow(priv_group)
+ # Maintenance settings
maint_group = QGroupBox(strings._("database_maintenance"))
maint = QVBoxLayout(maint_group)
+ maint.setContentsMargins(12, 8, 12, 12)
+ maint.setSpacing(6)
self.compact_btn = QPushButton(strings._("database_compact"))
self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.compact_btn.clicked.connect(self._compact_btn_clicked)
+
maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
+ # Explanation for compacting button
self.compact_label = QLabel(strings._("database_compact_explanation"))
self.compact_label.setWordWrap(True)
self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ # make it look secondary
cpal = self.compact_label.palette()
self.compact_label.setForegroundRole(QPalette.PlaceholderText)
self.compact_label.setPalette(cpal)
@@ -436,15 +205,22 @@ class SettingsDialog(QDialog):
maint_row.addWidget(self.compact_label)
maint.addLayout(maint_row)
- layout.addWidget(maint_group)
- layout.addStretch()
- return page
+ form.addRow(maint_group)
- # ------------------------------------------------------------------ #
- # Save settings
- # ------------------------------------------------------------------ #
+ # Buttons
+ bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
+ bb.accepted.connect(self._save)
+ bb.rejected.connect(self.reject)
+
+ # Root layout (adjust margins/spacing a bit)
+ v = QVBoxLayout(self)
+ v.setContentsMargins(12, 12, 12, 12)
+ v.setSpacing(10)
+ v.addLayout(form)
+ v.addWidget(bb, 0, Qt.AlignRight)
def _save(self):
+ # Save the selected theme into QSettings
if self.theme_dark.isChecked():
selected_theme = Theme.DARK
elif self.theme_light.isChecked():
@@ -460,92 +236,13 @@ 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._("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 5f58767..7a644bd 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 QSize, Qt, Signal
-from PySide6.QtGui import QBrush, QColor, QPainter, QPen
+from PySide6.QtCore import Qt, QSize, Signal
+from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtWidgets import (
- QComboBox,
QDialog,
+ QVBoxLayout,
QFormLayout,
+ QLabel,
QGroupBox,
QHBoxLayout,
- QLabel,
+ QComboBox,
QScrollArea,
- QSizePolicy,
- QVBoxLayout,
QWidget,
+ QSizePolicy,
)
from . import strings
from .db import DBManager
-from .settings import load_db_config
+
# ---------- Activity heatmap ----------
@@ -98,7 +98,7 @@ class DateHeatmap(QWidget):
def minimumSizeHint(self) -> QSize:
sz = self.sizeHint()
- return QSize(min(380, sz.width()), sz.height())
+ return QSize(min(300, sz.width()), sz.height())
def paintEvent(self, event):
super().paintEvent(event)
@@ -150,7 +150,7 @@ class DateHeatmap(QWidget):
fm = painter.fontMetrics()
# --- weekday labels on left -------------------------------------
- # Python's weekday(): Monday=0 ... Sunday=6
+ # Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
for dow in range(7):
@@ -171,7 +171,7 @@ class DateHeatmap(QWidget):
prev_month = None
for week in range(weeks):
date = self._start + _dt.timedelta(days=week * 7)
- if date > self._end: # pragma: no cover
+ if date > self._end:
break
if prev_month == date.month:
@@ -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,9 +248,7 @@ class StatisticsDialog(QDialog):
self._db = db
self.setWindowTitle(strings._("statistics"))
- self.setMinimumWidth(650)
- self.setMinimumHeight(650)
-
+ self.setMinimumWidth(600)
root = QVBoxLayout(self)
(
@@ -264,212 +262,50 @@ 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()
- self.cfg = load_db_config()
+ # --- Numeric summary at the top ----------------------------------
+ form = QFormLayout()
+ root.addLayout(form)
- # 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(
+ form.addRow(
strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)),
)
- pages_form.addRow(
+ form.addRow(
strings._("stats_total_revisions"),
QLabel(str(total_revisions)),
)
if page_most_revisions:
- pages_form.addRow(
+ form.addRow(
strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
)
else:
- pages_form.addRow(
- strings._("stats_page_most_revisions"),
- QLabel("—"),
- )
+ form.addRow(strings._("stats_page_most_revisions"), QLabel("—"))
- pages_form.addRow(
+ form.addRow(
strings._("stats_total_words"),
QLabel(str(total_words)),
)
- root.addWidget(pages_group)
+ # Unique tag names
+ form.addRow(
+ strings._("stats_unique_tags"),
+ QLabel(str(unique_tags)),
+ )
- # --- Tags ---------------------------------------------------------
- if self.cfg.tags:
- tags_group = QGroupBox(strings._("stats_group_tags"))
- tags_form = QFormLayout(tags_group)
-
- tags_form.addRow(
- strings._("stats_unique_tags"),
- QLabel(str(unique_tags)),
+ if page_most_tags:
+ form.addRow(
+ strings._("stats_page_most_tags"),
+ QLabel(f"{page_most_tags} ({page_most_tags_count})"),
)
+ else:
+ form.addRow(strings._("stats_page_most_tags"), QLabel("—"))
- 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
- ):
+ # --- Heatmap with switcher ---------------------------------------
+ if words_by_date or revisions_by_date:
group = QGroupBox(strings._("stats_activity_heatmap"))
group_layout = QVBoxLayout(group)
@@ -478,30 +314,14 @@ 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",
- )
- 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",
- )
+ self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
combo_row.addWidget(self.metric_combo)
combo_row.addStretch(1)
group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap()
+ self._words_by_date = words_by_date
+ self._revisions_by_date = revisions_by_date
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@@ -518,19 +338,11 @@ 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 71e838b..eff0e18 100644
--- a/bouquin/strings.py
+++ b/bouquin/strings.py
@@ -1,5 +1,5 @@
-import json
from importlib.resources import files
+import json
# Get list of locales
root = files("bouquin") / "locales"
diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py
index 210f7d3..a5d12d0 100644
--- a/bouquin/tag_browser.py
+++ b/bouquin/tag_browser.py
@@ -1,22 +1,21 @@
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,
- QVBoxLayout,
+ QPushButton,
+ QLabel,
+ QColorDialog,
+ QMessageBox,
+ QInputDialog,
)
-from sqlcipher3.dbapi2 import IntegrityError
-from . import strings
from .db import DBManager
-from .settings import load_db_config
+from . import strings
+from sqlcipher3.dbapi2 import IntegrityError
class TagBrowserDialog(QDialog):
@@ -26,7 +25,6 @@ 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")
)
@@ -40,18 +38,9 @@ class TagBrowserDialog(QDialog):
layout.addWidget(instructions)
self.tree = QTreeWidget()
- 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.setHeaderLabels(
+ [strings._("tag"), strings._("color_hex"), strings._("date")]
+ )
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 100)
self.tree.itemActivated.connect(self._on_item_activated)
@@ -130,7 +119,6 @@ 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])
@@ -139,21 +127,6 @@ 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
@@ -180,25 +153,12 @@ class TagBrowserDialog(QDialog):
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
- item_type = data.get("type")
-
- if item_type == "page":
+ if data.get("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 7ac4ad4..423bd06 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,
- QToolButton,
- QVBoxLayout,
- QWidget,
+ QCompleter,
)
from . import strings
diff --git a/bouquin/theme.py b/bouquin/theme.py
index 87b77f9..305f249 100644
--- a/bouquin/theme.py
+++ b/bouquin/theme.py
@@ -1,12 +1,10 @@
from __future__ import annotations
-
from dataclasses import dataclass
from enum import Enum
-from weakref import WeakSet
-
-from PySide6.QtCore import QObject, Qt, Signal
-from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat
+from PySide6.QtGui import QPalette, QColor, QGuiApplication
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
+from PySide6.QtCore import QObject, Signal
+from weakref import WeakSet
class Theme(Enum):
@@ -176,14 +174,6 @@ 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 05d7e98..9ff5da4 100644
--- a/bouquin/time_log.py
+++ b/bouquin/time_log.py
@@ -2,49 +2,45 @@ from __future__ import annotations
import csv
import html
-from collections import defaultdict
-from datetime import datetime
-from typing import Optional
-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,
- QDialogButtonBox,
- QDoubleSpinBox,
- QFileDialog,
- QFormLayout,
- QFrame,
- QHBoxLayout,
- QHeaderView,
- QInputDialog,
- QLabel,
- QLineEdit,
- QListWidget,
- QListWidgetItem,
- QMessageBox,
- QPushButton,
- QSizePolicy,
- QStyle,
- QTableWidget,
- QTableWidgetItem,
- QTabWidget,
- QToolButton,
- QVBoxLayout,
- QWidget,
-)
+from collections import defaultdict
+from typing import Optional
from sqlcipher3.dbapi2 import IntegrityError
-from . import strings
+from PySide6.QtCore import Qt, QDate, QUrl
+from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
+from PySide6.QtPrintSupport import QPrinter
+from PySide6.QtWidgets import (
+ QDialog,
+ QFrame,
+ QVBoxLayout,
+ QHBoxLayout,
+ QWidget,
+ QFileDialog,
+ QFormLayout,
+ QLabel,
+ QComboBox,
+ QLineEdit,
+ QDoubleSpinBox,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QAbstractItemView,
+ QHeaderView,
+ QTabWidget,
+ QListWidget,
+ QListWidgetItem,
+ QDateEdit,
+ QMessageBox,
+ QCompleter,
+ QToolButton,
+ QSizePolicy,
+ QStyle,
+ QInputDialog,
+)
+
from .db import DBManager
-from .settings import load_db_config
-from .theme import ThemeManager
+from . import strings
class TimeLogWidget(QFrame):
@@ -53,18 +49,9 @@ class TimeLogWidget(QFrame):
Shown in the left sidebar above the Tags widget.
"""
- remindersChanged = Signal()
-
- def __init__(
- self,
- db: DBManager,
- themes: ThemeManager | None = None,
- parent: QWidget | None = None,
- ):
+ def __init__(self, db: DBManager, 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)
@@ -79,21 +66,6 @@ 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)
@@ -106,8 +78,6 @@ 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
@@ -119,8 +89,6 @@ 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)
@@ -136,40 +104,8 @@ 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)
@@ -219,27 +155,7 @@ class TimeLogWidget(QFrame):
if not self._current_date:
return
- 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 = TimeLogDialog(self._db, self._current_date, self)
dlg.exec()
# Always refresh summary + header totals
@@ -252,49 +168,30 @@ 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,
- log_entry_only: bool | None = False,
- themes: ThemeManager | None = None,
- close_after_add: bool | None = False,
- ):
+ def __init__(self, db: DBManager, date_iso: str, parent=None):
super().__init__(parent)
self._db = db
- self._themes = themes
self._date_iso = date_iso
self._current_entry_id: Optional[int] = None
- self.cfg = load_db_config()
- # Guard flag used when repopulating the table so we don't treat
+ # Guard flag used when repopulating the table so we don’t treat
# programmatic item changes as user edits.
self._reloading_entries: bool = False
- self.total_hours = 0
-
- self.close_after_add = close_after_add
-
- self.setWindowTitle(strings._("for").format(date=date_iso))
+ self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
self.resize(900, 600)
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: date label
+ root.addWidget(QLabel(strings._("time_log_date_label").format(date=date_iso)))
# --- Project / activity / hours row
form = QFormLayout()
@@ -328,7 +225,6 @@ 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)
@@ -342,21 +238,24 @@ 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(5)
+ self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("note"),
strings._("hours"),
- strings._("created_at"),
]
)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
@@ -365,7 +264,6 @@ 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)
@@ -373,21 +271,10 @@ class TimeLogDialog(QDialog):
self.table.itemChanged.connect(self._on_table_item_changed)
root.addWidget(self.table, 1)
- # --- Total time, Reporting and Close button
+ # --- 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 = QPushButton("&" + strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)
@@ -397,12 +284,6 @@ 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:
@@ -434,16 +315,11 @@ 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)
@@ -452,68 +328,15 @@ 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()
@@ -561,8 +384,6 @@ class TimeLogDialog(QDialog):
)
self._reload_entries()
- if self.close_after_add:
- self.close()
def _on_row_selected(self) -> None:
items = self.table.selectedItems()
@@ -608,7 +429,7 @@ class TimeLogDialog(QDialog):
# Ignore changes that come from _reload_entries().
return
- if item is None: # pragma: no cover
+ if item is None:
return
row = item.row()
@@ -619,7 +440,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
@@ -751,7 +572,7 @@ class TimeCodeManagerDialog(QDialog):
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
- close_btn = QPushButton(strings._("close"))
+ close_btn = QPushButton("&" + strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)
@@ -828,7 +649,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"),
@@ -1006,21 +827,17 @@ 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, str, str, int]] = []
+ self._last_rows: list[tuple[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)
@@ -1028,62 +845,30 @@ 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()
- 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._("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 = QDateEdit(today.addDays(-7))
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)
@@ -1103,18 +888,13 @@ 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(5)
+ self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(
[
- strings._("project"),
strings._("time_period"),
strings._("activity"),
strings._("note"),
@@ -1124,9 +904,8 @@ 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(
- 4, QHeaderView.ResizeToContents
+ 3, QHeaderView.ResizeToContents
)
root.addWidget(self.table, 1)
@@ -1137,189 +916,44 @@ class TimeReportDialog(QDialog):
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
- close_btn = QPushButton(strings._("close"))
+ close_btn = QPushButton("&" + strings._("close"))
close_btn.clicked.connect(self.accept)
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 == "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
- self._configure_table_columns(gran)
+ rows = self._db.time_report(proj_id, start, end, gran)
- rows_for_table: list[tuple[str, str, str, str, int]] = []
+ self._last_rows = rows
+ self._last_total_minutes = sum(r[3] for r in 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
- ):
+ self.table.setRowCount(len(rows))
+ for i, (time_period, activity_name, note, minutes) in enumerate(rows):
hrs = minutes / 60.0
- 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))
+ 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 == "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
- 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)
- )
+ self.total_label.setText(
+ strings._("time_report_total").format(hours=total_hours)
+ )
def _export_csv(self):
if not self._last_rows:
@@ -1338,52 +972,30 @@ 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
- 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)
+ writer.writerow(
+ [
+ strings._("time_period"),
+ strings._("activity"),
+ strings._("note"),
+ strings._("hours"),
+ ]
+ )
# Data rows
- for (
- project,
- time_period,
- activity_name,
- note,
- minutes,
- ) in self._last_rows:
+ for time_period, activity_name, note, minutes in self._last_rows:
hours = minutes / 60.0
- 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)
+ writer.writerow([time_period, activity_name, note, f"{hours:.2f}"])
# Blank line + total
total_hours = self._last_total_minutes / 60.0
writer.writerow([])
- total_row = [""] * len(header)
- total_row[0] = strings._("total")
- total_row[-1] = f"{total_hours:.2f}"
- writer.writerow(total_row)
+ writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
except OSError as exc:
QMessageBox.warning(
self,
@@ -1408,23 +1020,18 @@ class TimeReportDialog(QDialog):
)
if not filename:
return
- if not filename.endswith(".pdf"):
- filename = f"{filename}.pdf"
- # ---------- 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
+ # ---------- 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
- buckets = sorted(per_bucket_minutes.keys())
+ periods = sorted(per_period_minutes.keys())
chart_w, chart_h = 800, 220
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
chart.fill(Qt.white)
- if buckets:
+ if periods:
painter = QPainter(chart)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
@@ -1452,9 +1059,9 @@ class TimeReportDialog(QDialog):
# Border
painter.drawRect(left, top, width, height)
- max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0
+ max_hours = max(per_period_minutes[p] for p in periods) / 60.0
if max_hours > 0:
- n = len(buckets)
+ n = len(periods)
bar_spacing = width / max(1, n)
bar_width = bar_spacing * 0.6
@@ -1479,11 +1086,11 @@ class TimeReportDialog(QDialog):
painter.setBrush(QColor(80, 140, 200))
painter.setPen(Qt.NoPen)
- for i, label in enumerate(buckets):
- hours = per_bucket_minutes[label] / 60.0
+ for i, period in enumerate(periods):
+ hours = per_period_minutes[period] / 60.0
bar_h = int((hours / max_hours) * (height - 10))
if bar_h <= 0:
- continue # pragma: no cover
+ continue
x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2)
@@ -1493,7 +1100,7 @@ class TimeReportDialog(QDialog):
# X labels after bars, in black
painter.setPen(Qt.black)
- for i, label in enumerate(buckets):
+ for i, period in enumerate(periods):
x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2)
painter.drawText(
@@ -1502,7 +1109,7 @@ class TimeReportDialog(QDialog):
int(bar_width),
20,
Qt.AlignHCenter | Qt.AlignTop,
- label,
+ period,
)
finally:
painter.end()
@@ -1511,54 +1118,23 @@ 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_key = getattr(self, "_last_gran", "day")
- gran_label = html.escape(self._last_gran_label or "")
+ gran = html.escape(self._last_gran_label or "")
total_hours = self._last_total_minutes / 60.0
- # Table rows
+ # Table rows (period, activity, hours)
row_html_parts: list[str] = []
- if gran_key == "activity":
- for project, _period, activity, _note, minutes in self._last_rows:
- hours = minutes / 60.0
- row_html_parts.append(
- "