diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..8c2fb8d
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI
+
+on:
+ push:
+
+jobs:
+ test:
+ runs-on: docker
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ apt-get update
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ python3-venv pipx libgl1 libxcb-cursor0 libxkbcommon-x11-0 libegl1 libdbus-1-3 \
+ libopengl0 libx11-6 libxext6 libxi6 libxrender1 libxrandr2 \
+ libxcb1 libxcb-render0 libxcb-keysyms1 libxcb-image0 libxcb-shm0 \
+ libxcb-icccm4 libxcb-xfixes0 libxcb-shape0 libxcb-randr0 libxcb-xinerama0 \
+ libxkbcommon0
+
+ - name: Install Poetry
+ run: |
+ pipx install poetry==1.8.3
+ /root/.local/bin/poetry --version
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+
+ - name: Install project deps (including test extras)
+ run: |
+ poetry install --with test
+
+ - name: Run test script
+ run: |
+ ./tests.sh
+
+ # 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
new file mode 100644
index 0000000..fbe5a7e
--- /dev/null
+++ b/.forgejo/workflows/lint.yml
@@ -0,0 +1,41 @@
+name: Lint
+
+on:
+ push:
+
+jobs:
+ test:
+ runs-on: docker
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ apt-get update
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ black pyflakes3 vulture python3-bandit
+
+ - name: Run linters
+ run: |
+ black --diff --check bouquin/*
+ black --diff --check tests/*
+ pyflakes3 bouquin/*
+ pyflakes3 tests/*
+ vulture
+ bandit -s B110 -r bouquin/
+
+ # 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
new file mode 100644
index 0000000..fad2f6f
--- /dev/null
+++ b/.forgejo/workflows/trivy.yml
@@ -0,0 +1,40 @@
+name: Trivy
+
+on:
+ schedule:
+ - cron: '0 1 * * *'
+ push:
+
+jobs:
+ test:
+ runs-on: docker
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
+ wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
+ echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
+ apt-get update
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
+
+ - name: Run trivy
+ run: |
+ trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
+
+ # 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 8652982..07c956d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,8 @@
__pycache__
.pytest_cache
dist
+.coverage
+*.db
+*.pdf
+*.csv
+*.html
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..6281daa
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,26 @@
+repos:
+ - repo: https://github.com/pycqa/flake8
+ rev: 7.3.0
+ hooks:
+ - id: flake8
+ args: ["--select=F"]
+ types: [python]
+
+ - repo: https://github.com/psf/black-pre-commit-mirror
+ rev: 25.11.0
+ hooks:
+ - id: black
+ language_version: python3
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.4.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+
+ - repo: https://github.com/PyCQA/bandit
+ rev: 1.9.2
+ hooks:
+ - id: bandit
+ files: ^bouquin/
+ args: ["-s", "B110"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f04325b..45edf09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,283 @@
+# 0.7.3
+
+ * Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
+ * Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
+
+# 0.7.2
+
+ * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
+
+# 0.7.1
+
+ * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
+ * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
+ * Invoicing should not be enabled by default
+ * Fix Reminders to fire right on the minute after adding them during runtime
+ * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
+ * Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
+
+# 0.7.0
+
+ * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
+ * Add 'Last week' to Time Report dialog range option
+ * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
+
+# 0.6.4
+
+ * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
+ * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
+ * Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
+
+# 0.6.3
+
+ * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
+ * Allow 'All Projects' for timesheet reports.
+ * Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
+ * Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
+
+# 0.6.2
+
+ * Ensure that adding a document whilst on an older date page, uses that date as its upload date
+ * Add 'Created at' to time log table.
+ * Show total hours for the day in the time log table (not just in the widget in sidebar)
+ * Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
+ * Indent tabs by 4 spaces in code block editor dialog
+
+# 0.6.1
+
+ * Consolidate some code related to opening documents using the Documents feature.
+ * Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time.
+ * More code coverage
+
+# 0.6.0
+
+ * Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature.
+ * Close time log dialog if opened via the + button from sidebar widget
+ * Only show tags in Statistics widget if tags are enabled
+ * Fix rounding up/down in Pomodoro timer to the closest 15 min interval
+
+# 0.5.5
+
+ * Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option)
+ * Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text.
+ * Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager)
+ * Ensure time log reports have an extension
+
+# 0.5.4
+
+ * Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it)
+
+# 0.5.3
+
+ * Prevent triple-click select from selecting the list item (e.g checkbox, bullet)
+ * Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues.
+ * Change History icon (again)
+ * Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it)
+ * Prevent double-click of checkbox leading to selecting/highlighting it
+ * Slightly fade the text of a checkbox line if the checkbox is checked.
+ * Fix weekend date colours being incorrect on theme change while app is running
+ * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops
+ * Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
+
+# 0.5.2
+
+ * Update icon again to remove background
+ * Adjust History icon and reorder toolbar items
+ * Try to address checkbox/bullet size issues (again)
+ * Fix HTML export of markdown (with newlines, tables and other styling preserved)
+ * Remove table tool
+
+# 0.5.1
+
+ * Try to address Noto Sans font issue that works for both numbers and checkbox/bullets.
+ * Update icon
+ * Update French translations
+ * Improve size of flashing reminder dialog
+
+# 0.5
+
+ * More Italian translations, thank you @mdaleo404
+ * Set locked status on window title when locked
+ * Don't exit on incorrect key, let it be tried again
+ * Make reminders be its own dataset rather than tied to current string.
+ * Add support for repeated reminders
+ * Make reminders be a feature that can be turned on and off
+ * Add syntax highlighting for code blocks (right-click to set it)
+ * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
+ * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
+
+# 0.4.5
+
+ * Make it possible to delete revisions
+ * Make it possible to force-lock the screen even if idle timer hasn't tripped
+ * Add shortcuts for lock and unlock of screen
+ * Other misc bug fixes
+
+# 0.4.4.1
+
+ * Adjust some widget heights/settings text wrap
+ * Adjust shortcuts
+ * History unicode symbol
+ * Icon in version dialog
+
+# 0.4.4
+
+ * Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
+ * Moving unchecked TODOs now skips placing them on weekends.
+ * Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
+ * Check for new version / download new AppImage via the Help -> Version screen.
+ * Remove extra newline after headings
+
+# 0.4.3
+
+ * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora
+
+# 0.4.2
+
+ * Improve Statistics widget height
+ * Improve SaveDialog widget width
+ * Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default)
+ * Make it possible to change regular text size
+ * Refactored Settings dialog to use tabs to reduce its size
+
+# 0.4.1
+
+ * Allow time log entries to be edited directly in their table cells
+ * Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline)
+ * Add 'Close tab' nav item and shortcut
+
+# 0.4
+
+ * Remove screenshot tool
+ * Improve width of bug report dialog
+ * Improve size of checkboxes
+ * Convert bullet - to actual unicode bullets
+ * Add alarm option to set reminders
+ * Add time logging and reporting
+
+# 0.3.2
+
+ * Add weekday letters on left axis of Statistics page
+ * Allow clicking on a date in the Statistics heatmap and have it open that page
+ * Add the ability to choose the database path at startup
+ * Add in-app bug report functionality
+
+# 0.3.1
+
+ * Make it possible to add a tag from the Tag Browser
+ * Add a statistics dialog with heatmap
+ * Remove export to .txt (just use .md)
+ * Restore link styling and clickability
+
+# 0.3
+
+ * Introduce Tags
+ * Make translations dynamically detected from the locales dir rather than hardcoded
+ * Add Italian translations (thanks @mdaleo404)
+ * Add version information in the navigation
+ * Increase line spacing between lines (except for code blocks)
+ * Prevent being able to left-click a date and have it load in current tab if it is already open in another tab
+ * Avoid second checkbox/bullet on second newline after first newline
+ * Avoid Home/left arrow jumping to the left side of a list symbol
+ * Various test additions/fixes
+
+# 0.2.1.8
+
+ * Translate all strings, add French, add locale choice in settings
+ * Fix hiding status bar (including find bar) when locked
+
+# 0.2.1.7
+
+ * Fix being able to set bold, italic and strikethrough at the same time.
+ * Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager
+ * Add AppImage
+
+# 0.2.1.6
+
+ * Some code cleanup and more coverage
+ * Improve code block styling / escaping out of the block in various scenarios
+
+# 0.2.1.5
+
+ * Go back to font size 10 (I might add a switcher later)
+ * Fix bug with not syncing the right calendar date on search (History item would then be wrong too)
+
+# 0.2.1.4
+
+ * Increase font size of normal text
+ * Fix auto-save of a tab if we are moving to another tab and it has not yet saved
+ * DRY up some code
+
+# 0.2.1.3
+
+ * Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line
+ * Fix code backticks to not show but still be able to type code easily
+
+# 0.2.1.2
+
+ * Ensure tabs are ordered by calendar date
+ * Some other code cleanups
+
+# 0.2.1.1
+
+ * Fix history preview pane to be in markdown
+ * Some other code cleanups
+
+# 0.2.1
+
+ * Introduce tabs!
+
+# 0.2.0.1
+
+ * Fix chomping images when TODO is typed and converts to a checkbox
+
+# 0.2.0
+
+ * Switch back to Markdown editor
+
+# 0.1.12.1
+
+ * Fix newline after URL keeps URL style formatting
+
+# 0.1.12
+
+ * Add find bar for searching for text in the editor
+
+# 0.1.11
+
+ * Add missing export extensions to export_by_extension
+ * Fix focusing on editor after leaving the app and returning
+ * More code coverage and removing obsolete bits of code
+
+# 0.1.10.2
+
+ * Fix for code blocks in dark mode
+
+# 0.1.10.1
+
+ * Small bugfix for a debug message left in
+
+# 0.1.10
+
+ * Improve search results window and highlight in calendar when there are matches.
+ * Fix styling issue with text that comes after a URL, so it doesn't appear as part of the URL.
+ * Add ability to export to Markdown (and fix heading styles)
+ * Represent in the History diff pane when an image was the thing that changed
+ * Support theme choice in settings (light/dark/system)
+ * Add Checkboxes in the editor. Typing 'TODO' at the start of a line will auto-convert into a checkbox.
+ * Add option to automatically move yesterday's unchecked TODOs to today on startup
+
+# 0.1.9
+
+ * More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be
+ possible to set both H1 and H2 at once)
+ * Fix small bug in export of HTML or arbitrary extension
+ * Add plaintext SQLite3 Export option
+ * Add Backup option (database remains encrypted with SQLCipher)
+ * Add ability to run VACUUM (compact) on the database in settings
+ * Add ability to store images in the page
+ * Lots more tests, over 80% coverage
+
# 0.1.8
* Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed
diff --git a/README.md b/README.md
index ea1cbdc..da87442 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,16 @@
# Bouquin
+
+

+
## Introduction
-Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
+Bouquin ("Book-ahn") is a notebook and planner application written in Python, Qt and SQLCipher.
+
+It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
+search, reminders and time logging for those of us who need to keep track of not just TODOs, but
+also how long we spent on them.
It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -11,30 +18,76 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
To increase security, the SQLCipher key is requested when the app is opened, and is not written
to disk unless the user configures it to be in the settings.
-There is deliberately no network connectivity or syncing intended.
+There is deliberately no network connectivity or syncing intended, other than the option to send a bug
+report from within the app, or optionally to check for new versions to upgrade to.
-## Screenshot
+## Screenshots
-
+### General view
+
+

+
-## Features
+### 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
- * Every 'page' is linked to the calendar day
- * All changes are version controlled, with ability to view/diff versions and revert
- * Text is HTML with basic styling
- * Search
+ * All changes are version controlled, with ability to view/diff versions, revert or delete revisions
+ * Automatic rendering of basic Markdown syntax
+ * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
+ * Images are supported
+ * Search all pages, or find text on current page
+ * Add and manage tags
* Automatic periodic saving (or explicitly save)
- * Transparent integrity checking of the database when it opens
* Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password)
- * Export the database to json, txt, html or csv
+ * Export the database to json, html, csv, markdown or .sql (for sqlite3)
+ * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
+ * Dark and light 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.
+ * English, French and Italian locales provided
+ * Ability to set reminder alarms (which will be flashed as the reminder)
+ * Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
+ * Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
## How to install
-Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
+Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
+
+If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
+
+### From PyPi/pip
+
+ * `pip install bouquin`
+
+### From AppImage
+
+ * Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it.
### From source
@@ -47,14 +100,9 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
* Download the whl and run it
-### From PyPi/pip
-
- * `pip install bouquin`
-
-
## How to run the tests
* Clone the repo
* Ensure you have poetry installed
* Run `poetry install --with test`
- * Run `poetry run pytest -vvv`
+ * Run `./tests.sh`
diff --git a/bouquin.desktop b/bouquin.desktop
new file mode 100644
index 0000000..aa519c3
--- /dev/null
+++ b/bouquin.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Type=Application
+Name=Bouquin
+Exec=Bouquin.AppImage
+Icon=bouquin
+Categories=Office
diff --git a/bouquin/__init__.py b/bouquin/__init__.py
index c28a133..e69de29 100644
--- a/bouquin/__init__.py
+++ b/bouquin/__init__.py
@@ -1 +0,0 @@
-from .main import main
diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py
new file mode 100644
index 0000000..0743985
--- /dev/null
+++ b/bouquin/bug_report_dialog.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import importlib.metadata
+
+import requests
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QLabel,
+ QMessageBox,
+ QTextEdit,
+ QVBoxLayout,
+)
+
+from . import strings
+
+BUG_REPORT_HOST = "https://nr.mig5.net"
+ROUTE = "forms/bouquin/bugs"
+
+
+class BugReportDialog(QDialog):
+ """
+ Dialog to collect a bug report
+ """
+
+ MAX_CHARS = 5000
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle(strings._("report_a_bug"))
+
+ layout = QVBoxLayout(self)
+
+ header = QLabel(strings._("bug_report_explanation"))
+ header.setWordWrap(True)
+ layout.addWidget(header)
+
+ self.text_edit = QTextEdit()
+ self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
+ layout.addWidget(self.text_edit)
+
+ self.text_edit.textChanged.connect(self._enforce_max_length)
+
+ # Buttons: Cancel / Send
+ button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
+ button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
+ button_box.accepted.connect(self._send)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ self.setMinimumWidth(560)
+
+ self.text_edit.setFocus()
+
+ # ------------Helpers ------------ #
+
+ def _enforce_max_length(self):
+ text = self.text_edit.toPlainText()
+ if len(text) <= self.MAX_CHARS:
+ return
+
+ # Remember cursor position
+ cursor = self.text_edit.textCursor()
+ pos = cursor.position()
+
+ # Trim and restore without re-entering this slot
+ self.text_edit.blockSignals(True)
+ self.text_edit.setPlainText(text[: self.MAX_CHARS])
+ self.text_edit.blockSignals(False)
+
+ cursor.setPosition(pos)
+ self.text_edit.setTextCursor(cursor)
+
+ def _send(self):
+ text = self.text_edit.toPlainText().strip()
+ if not text:
+ QMessageBox.warning(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_empty"),
+ )
+ return
+
+ # Get current app version
+ version = importlib.metadata.version("bouquin")
+
+ payload: dict[str, str] = {
+ "message": text,
+ "version": version,
+ }
+
+ # POST as JSON
+ try:
+ resp = requests.post(
+ f"{BUG_REPORT_HOST}/{ROUTE}",
+ json=payload,
+ timeout=10,
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_send_failed") + f"\n{e}",
+ )
+ return
+
+ if resp.status_code == 201:
+ QMessageBox.information(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_sent_ok"),
+ )
+ self.accept()
+ else:
+ QMessageBox.critical(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
+ )
diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py
new file mode 100644
index 0000000..8df348d
--- /dev/null
+++ b/bouquin/code_block_editor_dialog.py
@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+from PySide6.QtCore import QRect, QSize, Qt
+from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
+from PySide6.QtWidgets import (
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QLabel,
+ QPlainTextEdit,
+ QVBoxLayout,
+ QWidget,
+)
+
+from . import strings
+
+
+class _LineNumberArea(QWidget):
+ def __init__(self, editor: "CodeEditorWithLineNumbers"):
+ super().__init__(editor)
+ self._editor = editor
+
+ def sizeHint(self) -> QSize: # type: ignore[override]
+ return QSize(self._editor.line_number_area_width(), 0)
+
+ def paintEvent(self, event): # type: ignore[override]
+ self._editor.line_number_area_paint_event(event)
+
+
+class CodeEditorWithLineNumbers(QPlainTextEdit):
+ """QPlainTextEdit with a non-selectable line-number gutter on the left."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._line_number_area = _LineNumberArea(self)
+
+ self.blockCountChanged.connect(self._update_line_number_area_width)
+ self.updateRequest.connect(self._update_line_number_area)
+ self.cursorPositionChanged.connect(self._line_number_area.update)
+
+ self._update_line_number_area_width()
+ self._update_tab_stop_width()
+
+ # ---- layout / sizing -------------------------------------------------
+
+ def setFont(self, font: QFont) -> None: # type: ignore[override]
+ """Ensure tab width stays at 4 spaces when the font changes."""
+ super().setFont(font)
+ self._update_tab_stop_width()
+
+ def _update_tab_stop_width(self) -> None:
+ """Set tab width to 4 spaces."""
+ metrics = QFontMetrics(self.font())
+ # Tab width = width of 4 space characters
+ self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
+
+ def line_number_area_width(self) -> int:
+ # Enough digits for large-ish code blocks.
+ digits = max(2, len(str(max(1, self.blockCount()))))
+ fm = QFontMetrics(self._line_number_font())
+ return fm.horizontalAdvance("9" * digits) + 8
+
+ def _line_number_font(self) -> QFont:
+ """Font to use for line numbers (slightly smaller than main text)."""
+ font = self.font()
+ if font.pointSize() > 0:
+ font.setPointSize(font.pointSize() - 1)
+ else:
+ # fallback for pixel-sized fonts
+ font.setPointSizeF(font.pointSizeF() * 0.9)
+ return font
+
+ def _update_line_number_area_width(self) -> None:
+ margin = self.line_number_area_width()
+ self.setViewportMargins(margin, 0, 0, 0)
+
+ def resizeEvent(self, event): # type: ignore[override]
+ super().resizeEvent(event)
+ cr = self.contentsRect()
+ self._line_number_area.setGeometry(
+ QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
+ )
+
+ def _update_line_number_area(self, rect, dy) -> None:
+ if dy:
+ self._line_number_area.scroll(0, dy)
+ else:
+ self._line_number_area.update(
+ 0, rect.y(), self._line_number_area.width(), rect.height()
+ )
+
+ if rect.contains(self.viewport().rect()):
+ self._update_line_number_area_width()
+
+ # ---- painting --------------------------------------------------------
+
+ def line_number_area_paint_event(self, event) -> None:
+ painter = QPainter(self._line_number_area)
+ painter.fillRect(event.rect(), self.palette().base())
+
+ # Use a slightly smaller font for numbers
+ painter.setFont(self._line_number_font())
+
+ # Faded colour: same blend used for completed-task text in
+ # MarkdownHighlighter (text colour towards background).
+ pal = self.palette()
+ text_fg = pal.color(QPalette.Text)
+ text_bg = pal.color(QPalette.Base)
+ t = 0.55 # same factor as completed_task_format
+ faded = QColor(
+ int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
+ int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
+ int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
+ )
+ painter.setPen(faded)
+
+ block = self.firstVisibleBlock()
+ block_number = block.blockNumber()
+ top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
+ bottom = top + self.blockBoundingRect(block).height()
+ fm = self.fontMetrics()
+ line_height = fm.height()
+ right_margin = self._line_number_area.width() - 4
+
+ while block.isValid() and top <= event.rect().bottom():
+ if block.isVisible() and bottom >= event.rect().top():
+ number = str(block_number + 1)
+ painter.setPen(self.palette().text().color())
+ painter.drawText(
+ 0,
+ int(top),
+ right_margin,
+ line_height,
+ Qt.AlignRight | Qt.AlignVCenter,
+ number,
+ )
+
+ block = block.next()
+ top = bottom
+ bottom = top + self.blockBoundingRect(block).height()
+ block_number += 1
+
+
+class CodeBlockEditorDialog(QDialog):
+ def __init__(
+ self, code: str, language: str | None, parent=None, allow_delete: bool = False
+ ):
+ super().__init__(parent)
+ self.setWindowTitle(strings._("edit_code_block"))
+
+ self.setMinimumSize(650, 650)
+ self._code_edit = CodeEditorWithLineNumbers(self)
+ self._code_edit.setPlainText(code)
+
+ # Track whether the user clicked "Delete"
+ self._delete_requested = False
+
+ # Language selector (optional)
+ self._lang_combo = QComboBox(self)
+ languages = [
+ "",
+ "bash",
+ "css",
+ "html",
+ "javascript",
+ "php",
+ "python",
+ ]
+ self._lang_combo.addItems(languages)
+ if language and language in languages:
+ self._lang_combo.setCurrentText(language)
+
+ # Buttons
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
+ parent=self,
+ )
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+
+ if allow_delete:
+ delete_btn = buttons.addButton(
+ strings._("delete_code_block"),
+ QDialogButtonBox.ButtonRole.DestructiveRole,
+ )
+ delete_btn.clicked.connect(self._on_delete_clicked)
+
+ layout = QVBoxLayout(self)
+ layout.addWidget(QLabel(strings._("locale") + ":", self))
+ layout.addWidget(self._lang_combo)
+ layout.addWidget(self._code_edit)
+ layout.addWidget(buttons)
+
+ def _on_delete_clicked(self) -> None:
+ """Mark this dialog as 'delete requested' and close as Accepted."""
+ self._delete_requested = True
+ self.accept()
+
+ def was_deleted(self) -> bool:
+ """Return True if the user chose to delete the code block."""
+ return self._delete_requested
+
+ def code(self) -> str:
+ return self._code_edit.toPlainText()
+
+ def language(self) -> str | None:
+ text = self._lang_combo.currentText().strip()
+ return text or None
diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py
new file mode 100644
index 0000000..74ef6d4
--- /dev/null
+++ b/bouquin/code_highlighter.py
@@ -0,0 +1,373 @@
+from __future__ import annotations
+
+import re
+from typing import Dict, Optional
+
+from PySide6.QtGui import QColor, QFont, QTextCharFormat
+
+
+class CodeHighlighter:
+ """Syntax highlighter for different programming languages."""
+
+ # Language keywords
+ KEYWORDS = {
+ "python": [
+ "False",
+ "None",
+ "True",
+ "and",
+ "as",
+ "assert",
+ "async",
+ "await",
+ "break",
+ "class",
+ "continue",
+ "def",
+ "del",
+ "elif",
+ "else",
+ "except",
+ "finally",
+ "for",
+ "from",
+ "global",
+ "if",
+ "import",
+ "in",
+ "is",
+ "lambda",
+ "nonlocal",
+ "not",
+ "or",
+ "pass",
+ "pprint",
+ "print",
+ "raise",
+ "return",
+ "try",
+ "while",
+ "with",
+ "yield",
+ ],
+ "javascript": [
+ "abstract",
+ "arguments",
+ "await",
+ "boolean",
+ "break",
+ "byte",
+ "case",
+ "catch",
+ "char",
+ "class",
+ "const",
+ "continue",
+ "debugger",
+ "default",
+ "delete",
+ "do",
+ "double",
+ "else",
+ "enum",
+ "eval",
+ "export",
+ "extends",
+ "false",
+ "final",
+ "finally",
+ "float",
+ "for",
+ "function",
+ "goto",
+ "if",
+ "implements",
+ "import",
+ "in",
+ "instanceof",
+ "int",
+ "interface",
+ "let",
+ "long",
+ "native",
+ "new",
+ "null",
+ "package",
+ "private",
+ "protected",
+ "public",
+ "return",
+ "short",
+ "static",
+ "super",
+ "switch",
+ "synchronized",
+ "this",
+ "throw",
+ "throws",
+ "transient",
+ "true",
+ "try",
+ "typeof",
+ "var",
+ "void",
+ "volatile",
+ "while",
+ "with",
+ "yield",
+ ],
+ "php": [
+ "abstract",
+ "and",
+ "array",
+ "as",
+ "break",
+ "callable",
+ "case",
+ "catch",
+ "class",
+ "clone",
+ "const",
+ "continue",
+ "declare",
+ "default",
+ "die",
+ "do",
+ "echo",
+ "else",
+ "elseif",
+ "empty",
+ "enddeclare",
+ "endfor",
+ "endforeach",
+ "endif",
+ "endswitch",
+ "endwhile",
+ "eval",
+ "exit",
+ "extends",
+ "final",
+ "for",
+ "foreach",
+ "function",
+ "global",
+ "goto",
+ "if",
+ "implements",
+ "include",
+ "include_once",
+ "instanceof",
+ "insteadof",
+ "interface",
+ "isset",
+ "list",
+ "namespace",
+ "new",
+ "or",
+ "print",
+ "print_r",
+ "private",
+ "protected",
+ "public",
+ "require",
+ "require_once",
+ "return",
+ "static",
+ "syslog",
+ "switch",
+ "throw",
+ "trait",
+ "try",
+ "unset",
+ "use",
+ "var",
+ "var_dump",
+ "while",
+ "xor",
+ "yield",
+ ],
+ "bash": [
+ "if",
+ "then",
+ "echo",
+ "else",
+ "elif",
+ "fi",
+ "case",
+ "esac",
+ "for",
+ "select",
+ "while",
+ "until",
+ "do",
+ "done",
+ "in",
+ "function",
+ "time",
+ "coproc",
+ ],
+ "html": [
+ "DOCTYPE",
+ "html",
+ "head",
+ "title",
+ "meta",
+ "link",
+ "style",
+ "script",
+ "body",
+ "div",
+ "span",
+ "p",
+ "a",
+ "img",
+ "ul",
+ "ol",
+ "li",
+ "table",
+ "tr",
+ "td",
+ "th",
+ "form",
+ "input",
+ "button",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "br",
+ "hr",
+ ],
+ "css": [
+ "color",
+ "background",
+ "background-color",
+ "border",
+ "margin",
+ "padding",
+ "width",
+ "height",
+ "font",
+ "font-size",
+ "font-weight",
+ "display",
+ "position",
+ "top",
+ "left",
+ "right",
+ "bottom",
+ "float",
+ "clear",
+ "overflow",
+ "z-index",
+ "opacity",
+ ],
+ }
+
+ @staticmethod
+ def get_language_patterns(language: str) -> list:
+ """Get highlighting patterns for a language."""
+ patterns = []
+
+ keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
+
+ if language.lower() in ["python", "bash", "php"]:
+ # Comments (#)
+ patterns.append((r"#.*$", "comment"))
+
+ if language.lower() in ["javascript", "php", "css"]:
+ # Comments (//)
+ patterns.append((r"//.*$", "comment"))
+ # Multi-line comments (/* */)
+ patterns.append((r"/\*.*?\*/", "comment"))
+
+ if language.lower() in ["html", "xml"]:
+ # HTML/XML tags
+ patterns.append((r"<[^>]+>", "tag"))
+ # HTML comments
+ patterns.append((r"", "comment"))
+
+ # Numbers
+ patterns.append((r"\b\d+\.?\d*\b", "number"))
+
+ # Keywords
+ for keyword in keywords:
+ patterns.append((r"\b" + keyword + r"\b", "keyword"))
+
+ # Do strings last so they override any of the above (e.g reserved keywords in strings)
+
+ # Strings (double quotes)
+ patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
+
+ # Strings (single quotes)
+ patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
+
+ return patterns
+
+ @staticmethod
+ def get_format_for_type(
+ format_type: str, base_format: QTextCharFormat
+ ) -> QTextCharFormat:
+ """Get text format for a specific syntax type."""
+ fmt = QTextCharFormat(base_format)
+
+ if format_type == "keyword":
+ fmt.setForeground(QColor(86, 156, 214)) # Blue
+ fmt.setFontWeight(QFont.Weight.Bold)
+ elif format_type == "string":
+ fmt.setForeground(QColor(206, 145, 120)) # Orange
+ elif format_type == "comment":
+ fmt.setForeground(QColor(106, 153, 85)) # Green
+ fmt.setFontItalic(True)
+ elif format_type == "number":
+ fmt.setForeground(QColor(181, 206, 168)) # Light green
+ elif format_type == "tag":
+ fmt.setForeground(QColor(78, 201, 176)) # Cyan
+
+ return fmt
+
+
+class CodeBlockMetadata:
+ """Stores metadata about code blocks (language, etc.) for a document."""
+
+ def __init__(self):
+ self._block_languages: Dict[int, str] = {} # block_number -> language
+
+ def set_language(self, block_number: int, language: str):
+ """Set the language for a code block."""
+ self._block_languages[block_number] = language.lower()
+
+ def get_language(self, block_number: int) -> Optional[str]:
+ """Get the language for a code block."""
+ return self._block_languages.get(block_number)
+
+ def serialize(self) -> str:
+ """Serialize metadata to a string."""
+ # Store as JSON-like format in a comment at the end
+ if not self._block_languages:
+ return ""
+
+ items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
+ return "\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 37b65f1..f0d5b5f 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -1,16 +1,88 @@
from __future__ import annotations
import csv
+import datetime as _dt
+import hashlib
import html
import json
-import os
-
+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
+
+from . import strings
Entry = Tuple[str, str]
+TagRow = Tuple[int, str, str]
+ProjectRow = Tuple[int, str] # (id, name)
+ActivityRow = Tuple[int, str] # (id, name)
+TimeLogRow = Tuple[
+ int, # id
+ str, # page_date (yyyy-MM-dd)
+ int,
+ str, # project_id, project_name
+ int,
+ str, # activity_id, activity_name
+ 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
+ "#FFDFBA", # soft orange
+ "#FFFFBA", # soft yellow
+ "#BAFFC9", # soft green
+ "#BAE1FF", # soft blue
+ "#E0BAFF", # soft purple
+ "#FFC4B3", # soft coral
+ "#FFD8B1", # soft peach
+ "#FFF1BA", # soft light yellow
+ "#E9FFBA", # soft lime
+ "#CFFFE5", # soft mint
+ "#BAFFF5", # soft aqua
+ "#BAF0FF", # soft cyan
+ "#C7E9FF", # soft sky blue
+ "#C7CEFF", # soft periwinkle
+ "#F0BAFF", # soft lavender pink
+ "#FFBAF2", # soft magenta
+ "#FFD1F0", # soft pink
+ "#EBD5C7", # soft beige
+ "#EAEAEA", # soft gray
+]
@dataclass
@@ -18,9 +90,40 @@ class DBConfig:
path: Path
key: str
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
@@ -61,8 +164,12 @@ class DBManager:
# Not OK: rows of problems returned
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
raise sqlite.IntegrityError(
- "SQLCipher integrity check failed"
- + (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
+ strings._("db_sqlcipher_integrity_check_failed")
+ + (
+ f": {details}"
+ if details
+ else f" ({len(rows)} {strings._('db_issues_reported')})"
+ )
)
def _ensure_schema(self) -> None:
@@ -74,7 +181,6 @@ class DBManager:
# Always keep FKs on
cur.execute("PRAGMA foreign_keys = ON;")
- # Create new versioned schema if missing (< 0.1.5)
cur.executescript(
"""
CREATE TABLE IF NOT EXISTS pages (
@@ -95,33 +201,171 @@ class DBManager:
CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
+
+ CREATE TABLE IF NOT EXISTS tags (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ color TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name);
+
+ CREATE TABLE IF NOT EXISTS page_tags (
+ page_date TEXT NOT NULL, -- FK to pages.date
+ tag_id INTEGER NOT NULL, -- FK to tags.id
+ PRIMARY KEY (page_date, tag_id),
+ FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE,
+ FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
+ );
+
+ CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id);
+
+ CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE IF NOT EXISTS activities (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE IF NOT EXISTS time_log (
+ id INTEGER PRIMARY KEY,
+ page_date TEXT NOT NULL, -- FK to pages.date (yyyy-MM-dd)
+ project_id INTEGER NOT NULL, -- FK to projects.id
+ activity_id INTEGER NOT NULL, -- FK to activities.id
+ minutes INTEGER NOT NULL, -- duration in minutes
+ note TEXT,
+ created_at TEXT NOT NULL DEFAULT (
+ strftime('%Y-%m-%dT%H:%M:%fZ','now')
+ ),
+ FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE RESTRICT,
+ FOREIGN KEY(activity_id) REFERENCES activities(id) ON DELETE RESTRICT
+ );
+
+ CREATE INDEX IF NOT EXISTS ix_time_log_date
+ ON time_log(page_date);
+ CREATE INDEX IF NOT EXISTS ix_time_log_project
+ 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)
+ );
"""
)
-
- # If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
- pre_0_1_5 = cur.execute(
- "SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
- ).fetchone()
- pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
-
- if pre_0_1_5 and pages_empty:
- # Seed pages and versions (all as version 1)
- cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
- cur.execute(
- "INSERT INTO versions(date, version_no, content) "
- "SELECT date, 1, content FROM entries;"
- )
- # Point head to v1 for each page
- cur.execute(
- """
- UPDATE pages
- SET current_version_id = (
- SELECT v.id FROM versions v
- WHERE v.date = pages.date AND v.version_no = 1
- );
- """
- )
- cur.execute("DROP TABLE IF EXISTS entries;")
self.conn.commit()
def rekey(self, new_key: str) -> None:
@@ -129,8 +373,6 @@ class DBManager:
Change the SQLCipher passphrase in-place, then reopen the connection
with the new key to verify.
"""
- if self.conn is None:
- raise RuntimeError("Database is not connected")
cur = self.conn.cursor()
# Change the encryption key of the currently open database
cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone()
@@ -141,7 +383,7 @@ class DBManager:
self.conn = None
self.cfg.key = new_key
if not self.connect():
- raise sqlite.Error("Re-open failed after rekey")
+ raise sqlite.Error(strings._("db_reopen_failed_after_rekey"))
def get_entry(self, date_iso: str) -> str:
"""
@@ -159,33 +401,94 @@ class DBManager:
).fetchone()
return row[0] if row else ""
- def upsert_entry(self, date_iso: str, content: str) -> None:
+ def search_entries(self, text: str) -> list[tuple[str, str, str, str, str | None]]:
"""
- Insert or update an entry.
- """
- # Make a new version and set it as current
- self.save_new_version(date_iso, content, note=None, set_current=True)
+ Search for entries by term or tag name.
+ Returns both pages and documents.
- def search_entries(self, text: str) -> list[str]:
- """
- Search for entries by term. This only works against the latest
- version of the page.
+ kind = "page" or "document"
+ key = date_iso (page) or str(doc_id) (document)
+ title = heading for the result ("YYYY-MM-DD" or "Document")
+ text = source text for the snippet
+ aux = extra info (file_name for documents, else None)
"""
cur = self.conn.cursor()
- pattern = f"%{text}%"
- rows = cur.execute(
+ 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(
"""
- SELECT p.date, v.content
+ SELECT DISTINCT p.date AS date_iso, v.content
FROM pages AS p
JOIN versions AS v
ON v.id = p.current_version_id
+ LEFT JOIN page_tags pt
+ ON pt.page_date = p.date
+ LEFT JOIN tags t
+ ON t.id = pt.tag_id
WHERE TRIM(v.content) <> ''
- AND v.content LIKE LOWER(?) ESCAPE '\\'
+ AND (
+ LOWER(v.content) LIKE ?
+ OR LOWER(COALESCE(t.name, '')) LIKE ?
+ )
ORDER BY p.date DESC;
""",
- (pattern,),
+ (pattern, pattern),
).fetchall()
- return [(r[0], r[1]) for r in rows]
+
+ for r in page_rows:
+ date_iso = r["date_iso"]
+ content = r["content"]
+ results.append(("page", date_iso, date_iso, content, None))
+
+ # --- Documents: file name, description, or tag matches -------------
+ doc_rows = cur.execute(
+ """
+ SELECT DISTINCT
+ d.id AS doc_id,
+ d.file_name AS file_name,
+ d.uploaded_at AS uploaded_at,
+ COALESCE(d.description, '') AS description,
+ COALESCE(t.name, '') AS tag_name
+ FROM project_documents AS d
+ LEFT JOIN document_tags AS dt
+ ON dt.document_id = d.id
+ LEFT JOIN tags AS t
+ ON t.id = dt.tag_id
+ WHERE
+ LOWER(d.file_name) LIKE ?
+ OR LOWER(COALESCE(d.description, '')) LIKE ?
+ OR LOWER(COALESCE(t.name, '')) LIKE ?
+ ORDER BY LOWER(d.file_name);
+ """,
+ (pattern, pattern, pattern),
+ ).fetchall()
+
+ for r in doc_rows:
+ doc_id = r["doc_id"]
+ file_name = r["file_name"]
+ description = r["description"] or ""
+ uploaded_at = r["uploaded_at"]
+ # Simple snippet source: file name + description
+ text_src = f"{file_name}\n{description}".strip()
+
+ results.append(
+ (
+ "document",
+ str(doc_id),
+ strings._("search_result_heading_document") + f" ({uploaded_at})",
+ text_src,
+ file_name,
+ )
+ )
+
+ return results
def dates_with_content(self) -> list[str]:
"""
@@ -197,7 +500,8 @@ class DBManager:
"""
SELECT p.date
FROM pages p
- JOIN versions v ON v.id = p.current_version_id
+ JOIN versions v
+ ON v.id = p.current_version_id
WHERE TRIM(v.content) <> ''
ORDER BY p.date;
"""
@@ -216,8 +520,6 @@ class DBManager:
Append a new version for this date. Returns (version_id, version_no).
If set_current=True, flips the page head to this new version.
"""
- if self.conn is None:
- raise RuntimeError("Database is not connected")
with self.conn: # transaction
cur = self.conn.cursor()
# Ensure page row exists
@@ -261,68 +563,33 @@ class DBManager:
).fetchall()
return [dict(r) for r in rows]
- def get_version(
- self,
- *,
- date_iso: str | None = None,
- version_no: int | None = None,
- version_id: int | None = None,
- ) -> dict | None:
+ def get_version(self, *, version_id: int) -> dict | None:
"""
- Fetch a specific version by (date, version_no) OR by version_id.
+ Fetch a specific version by version_id.
Returns a dict with keys: id, date, version_no, created_at, note, content.
"""
cur = self.conn.cursor()
- if version_id is not None:
- row = cur.execute(
- "SELECT id, date, version_no, created_at, note, content "
- "FROM versions WHERE id=?;",
- (version_id,),
- ).fetchone()
- else:
- if date_iso is None or version_no is None:
- raise ValueError(
- "Provide either version_id OR (date_iso and version_no)"
- )
- row = cur.execute(
- "SELECT id, date, version_no, created_at, note, content "
- "FROM versions WHERE date=? AND version_no=?;",
- (date_iso, version_no),
- ).fetchone()
+ row = cur.execute(
+ "SELECT id, date, version_no, created_at, note, content "
+ "FROM versions WHERE id=?;",
+ (version_id,),
+ ).fetchone()
return dict(row) if row else None
- def revert_to_version(
- self,
- date_iso: str,
- *,
- version_no: int | None = None,
- version_id: int | None = None,
- ) -> None:
+ def revert_to_version(self, date_iso: str, version_id: int) -> None:
"""
Point the page head (pages.current_version_id) to an existing version.
- Fast revert: no content is rewritten.
"""
- if self.conn is None:
- raise RuntimeError("Database is not connected")
cur = self.conn.cursor()
- if version_id is None:
- if version_no is None:
- raise ValueError("Provide version_no or version_id")
- row = cur.execute(
- "SELECT id FROM versions WHERE date=? AND version_no=?;",
- (date_iso, version_no),
- ).fetchone()
- if row is None:
- raise ValueError("Version not found for this date")
- version_id = int(row["id"])
- else:
- # Ensure that version_id belongs to the given date
- row = cur.execute(
- "SELECT date FROM versions WHERE id=?;", (version_id,)
- ).fetchone()
- if row is None or row["date"] != date_iso:
- raise ValueError("version_id does not belong to the given date")
+ # Ensure that version_id belongs to the given date
+ row = cur.execute(
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
+ ).fetchone()
+ if row is None or row["date"] != date_iso:
+ raise ValueError(
+ strings._("db_version_id_does_not_belong_to_the_given_date")
+ )
with self.conn:
cur.execute(
@@ -330,6 +597,17 @@ 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]:
"""
@@ -346,106 +624,1821 @@ class DBManager:
).fetchall()
return [(r[0], r[1]) for r in rows]
- def export_json(
- self, entries: Sequence[Entry], file_path: str, pretty: bool = True
- ) -> None:
+ def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
"""
Export to json.
"""
data = [{"date": d, "content": c} for d, c in entries]
with open(file_path, "w", encoding="utf-8") as f:
- if pretty:
- json.dump(data, f, ensure_ascii=False, indent=2)
- else:
- json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
+ json.dump(data, f, ensure_ascii=False, indent=2)
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
+ """
+ Export pages to CSV.
+ """
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow(["date", "content"]) # header
writer.writerows(entries)
- def export_txt(
- self,
- entries: Sequence[Entry],
- file_path: str,
- separator: str = "\n\n— — — — —\n\n",
- strip_html: bool = True,
- ) -> None:
- import re, html as _html
-
- # Precompiled patterns
- STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?\1>")
- COMMENT_RE = re.compile(r"", re.S)
- BR_RE = re.compile(r"(?i)
")
- BLOCK_END_RE = re.compile(r"(?i)(p|div|section|article|li|h[1-6])\\s*>")
- TAG_RE = re.compile(r"<[^>]+>")
- WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
- MULTINEWLINE_RE = re.compile(r"\\n{3,}")
-
- def _strip(s: str) -> str:
- # 1) Remove ",
+ "",
"",
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""
+ f""
+ f""
)
parts.append("