diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..87b67ff
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,37 @@
+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
+
diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml
new file mode 100644
index 0000000..5bb3794
--- /dev/null
+++ b/.forgejo/workflows/lint.yml
@@ -0,0 +1,27 @@
+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/
diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml
new file mode 100644
index 0000000..18ced32
--- /dev/null
+++ b/.forgejo/workflows/trivy.yml
@@ -0,0 +1,26 @@
+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 .
diff --git a/.gitignore b/.gitignore
index 2352872..851b242 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ __pycache__
.pytest_cache
dist
.coverage
+*.db
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 203a3f2..7839225 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,159 @@
+# 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
diff --git a/README.md b/README.md
index a70013d..5cf77e5 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,16 @@
# Bouquin
+
+

+
## Introduction
-Bouquin ("Book-ahn") 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,42 +18,78 @@ 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
+
+

+
-
+### History panes
+
+

+

+
-## Features
+### 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
+ * 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
+ * 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, csv, markdown or .sql (for sqlite3)
+ * 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 themes
+ * Dark and light theme support
* Automatically generate checkboxes when typing 'TODO'
- * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
+ * 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
## 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).
+
+It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
+
+If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
### From PyPi/pip
* `pip install bouquin`
+### From AppImage
+
+ * Download the Bouquin.AppImage from the Releases page, make it executable with `chmod +x`, and run it.
+
### From source
* Clone this repo or download the tarball from the releases page
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/bug_report_dialog.py b/bouquin/bug_report_dialog.py
new file mode 100644
index 0000000..9cc727c
--- /dev/null
+++ b/bouquin/bug_report_dialog.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import importlib.metadata
+
+import requests
+
+from PySide6.QtWidgets import (
+ QDialog,
+ QVBoxLayout,
+ QLabel,
+ QTextEdit,
+ QDialogButtonBox,
+ QMessageBox,
+)
+
+from . import strings
+
+
+BUG_REPORT_HOST = "https://nr.mig5.net"
+ROUTE = "forms/bouquin/bugs"
+
+
+class BugReportDialog(QDialog):
+ """
+ Dialog to collect a bug report
+ """
+
+ MAX_CHARS = 5000
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle(strings._("report_a_bug"))
+
+ layout = QVBoxLayout(self)
+
+ header = QLabel(strings._("bug_report_explanation"))
+ header.setWordWrap(True)
+ layout.addWidget(header)
+
+ self.text_edit = QTextEdit()
+ self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
+ layout.addWidget(self.text_edit)
+
+ self.text_edit.textChanged.connect(self._enforce_max_length)
+
+ # Buttons: Cancel / Send
+ button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
+ button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole)
+ button_box.accepted.connect(self._send)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ self.setMinimumWidth(560)
+
+ self.text_edit.setFocus()
+
+ # ------------Helpers ------------ #
+
+ def _enforce_max_length(self):
+ text = self.text_edit.toPlainText()
+ if len(text) <= self.MAX_CHARS:
+ return
+
+ # Remember cursor position
+ cursor = self.text_edit.textCursor()
+ pos = cursor.position()
+
+ # Trim and restore without re-entering this slot
+ self.text_edit.blockSignals(True)
+ self.text_edit.setPlainText(text[: self.MAX_CHARS])
+ self.text_edit.blockSignals(False)
+
+ cursor.setPosition(pos)
+ self.text_edit.setTextCursor(cursor)
+
+ def _send(self):
+ text = self.text_edit.toPlainText().strip()
+ if not text:
+ QMessageBox.warning(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_empty"),
+ )
+ return
+
+ # Get current app version
+ version = importlib.metadata.version("bouquin")
+
+ payload: dict[str, str] = {
+ "message": text,
+ "version": version,
+ }
+
+ # POST as JSON
+ try:
+ resp = requests.post(
+ f"{BUG_REPORT_HOST}/{ROUTE}",
+ json=payload,
+ timeout=10,
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_send_failed") + f"\n{e}",
+ )
+ return
+
+ if resp.status_code == 201:
+ QMessageBox.information(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_sent_ok"),
+ )
+ self.accept()
+ else:
+ QMessageBox.critical(
+ self,
+ strings._("report_a_bug"),
+ strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
+ )
diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py
new file mode 100644
index 0000000..e462574
--- /dev/null
+++ b/bouquin/code_highlighter.py
@@ -0,0 +1,367 @@
+from __future__ import annotations
+
+import re
+from typing import Optional, Dict
+
+from PySide6.QtGui import QColor, QTextCharFormat, QFont
+
+
+class CodeHighlighter:
+ """Syntax highlighter for different programming languages."""
+
+ # Language keywords
+ KEYWORDS = {
+ "python": [
+ "False",
+ "None",
+ "True",
+ "and",
+ "as",
+ "assert",
+ "async",
+ "await",
+ "break",
+ "class",
+ "continue",
+ "def",
+ "del",
+ "elif",
+ "else",
+ "except",
+ "finally",
+ "for",
+ "from",
+ "global",
+ "if",
+ "import",
+ "in",
+ "is",
+ "lambda",
+ "nonlocal",
+ "not",
+ "or",
+ "pass",
+ "print",
+ "raise",
+ "return",
+ "try",
+ "while",
+ "with",
+ "yield",
+ ],
+ "javascript": [
+ "abstract",
+ "arguments",
+ "await",
+ "boolean",
+ "break",
+ "byte",
+ "case",
+ "catch",
+ "char",
+ "class",
+ "const",
+ "continue",
+ "debugger",
+ "default",
+ "delete",
+ "do",
+ "double",
+ "else",
+ "enum",
+ "eval",
+ "export",
+ "extends",
+ "false",
+ "final",
+ "finally",
+ "float",
+ "for",
+ "function",
+ "goto",
+ "if",
+ "implements",
+ "import",
+ "in",
+ "instanceof",
+ "int",
+ "interface",
+ "let",
+ "long",
+ "native",
+ "new",
+ "null",
+ "package",
+ "private",
+ "protected",
+ "public",
+ "return",
+ "short",
+ "static",
+ "super",
+ "switch",
+ "synchronized",
+ "this",
+ "throw",
+ "throws",
+ "transient",
+ "true",
+ "try",
+ "typeof",
+ "var",
+ "void",
+ "volatile",
+ "while",
+ "with",
+ "yield",
+ ],
+ "php": [
+ "abstract",
+ "and",
+ "array",
+ "as",
+ "break",
+ "callable",
+ "case",
+ "catch",
+ "class",
+ "clone",
+ "const",
+ "continue",
+ "declare",
+ "default",
+ "die",
+ "do",
+ "echo",
+ "else",
+ "elseif",
+ "empty",
+ "enddeclare",
+ "endfor",
+ "endforeach",
+ "endif",
+ "endswitch",
+ "endwhile",
+ "eval",
+ "exit",
+ "extends",
+ "final",
+ "for",
+ "foreach",
+ "function",
+ "global",
+ "goto",
+ "if",
+ "implements",
+ "include",
+ "include_once",
+ "instanceof",
+ "insteadof",
+ "interface",
+ "isset",
+ "list",
+ "namespace",
+ "new",
+ "or",
+ "print",
+ "print_r",
+ "private",
+ "protected",
+ "public",
+ "require",
+ "require_once",
+ "return",
+ "static",
+ "syslog",
+ "switch",
+ "throw",
+ "trait",
+ "try",
+ "unset",
+ "use",
+ "var",
+ "while",
+ "xor",
+ "yield",
+ ],
+ "bash": [
+ "if",
+ "then",
+ "echo",
+ "else",
+ "elif",
+ "fi",
+ "case",
+ "esac",
+ "for",
+ "select",
+ "while",
+ "until",
+ "do",
+ "done",
+ "in",
+ "function",
+ "time",
+ "coproc",
+ ],
+ "html": [
+ "DOCTYPE",
+ "html",
+ "head",
+ "title",
+ "meta",
+ "link",
+ "style",
+ "script",
+ "body",
+ "div",
+ "span",
+ "p",
+ "a",
+ "img",
+ "ul",
+ "ol",
+ "li",
+ "table",
+ "tr",
+ "td",
+ "th",
+ "form",
+ "input",
+ "button",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "br",
+ "hr",
+ ],
+ "css": [
+ "color",
+ "background",
+ "background-color",
+ "border",
+ "margin",
+ "padding",
+ "width",
+ "height",
+ "font",
+ "font-size",
+ "font-weight",
+ "display",
+ "position",
+ "top",
+ "left",
+ "right",
+ "bottom",
+ "float",
+ "clear",
+ "overflow",
+ "z-index",
+ "opacity",
+ ],
+ }
+
+ @staticmethod
+ def get_language_patterns(language: str) -> list:
+ """Get highlighting patterns for a language."""
+ patterns = []
+
+ keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
+
+ if language.lower() in ["python", "bash", "php"]:
+ # Comments (#)
+ patterns.append((r"#.*$", "comment"))
+
+ if language.lower() in ["javascript", "php", "css"]:
+ # Comments (//)
+ patterns.append((r"//.*$", "comment"))
+ # Multi-line comments (/* */)
+ patterns.append((r"/\*.*?\*/", "comment"))
+
+ if language.lower() in ["html", "xml"]:
+ # HTML/XML tags
+ patterns.append((r"<[^>]+>", "tag"))
+ # HTML comments
+ patterns.append((r"", "comment"))
+
+ # Numbers
+ patterns.append((r"\b\d+\.?\d*\b", "number"))
+
+ # Keywords
+ for keyword in keywords:
+ patterns.append((r"\b" + keyword + r"\b", "keyword"))
+
+ # Do strings last so they override any of the above (e.g reserved keywords in strings)
+
+ # Strings (double quotes)
+ patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
+
+ # Strings (single quotes)
+ patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
+
+ return patterns
+
+ @staticmethod
+ def get_format_for_type(
+ format_type: str, base_format: QTextCharFormat
+ ) -> QTextCharFormat:
+ """Get text format for a specific syntax type."""
+ fmt = QTextCharFormat(base_format)
+
+ if format_type == "keyword":
+ fmt.setForeground(QColor(86, 156, 214)) # Blue
+ fmt.setFontWeight(QFont.Weight.Bold)
+ elif format_type == "string":
+ fmt.setForeground(QColor(206, 145, 120)) # Orange
+ elif format_type == "comment":
+ fmt.setForeground(QColor(106, 153, 85)) # Green
+ fmt.setFontItalic(True)
+ elif format_type == "number":
+ fmt.setForeground(QColor(181, 206, 168)) # Light green
+ elif format_type == "tag":
+ fmt.setForeground(QColor(78, 201, 176)) # Cyan
+
+ return fmt
+
+
+class CodeBlockMetadata:
+ """Stores metadata about code blocks (language, etc.) for a document."""
+
+ def __init__(self):
+ self._block_languages: Dict[int, str] = {} # block_number -> language
+
+ def set_language(self, block_number: int, language: str):
+ """Set the language for a code block."""
+ self._block_languages[block_number] = language.lower()
+
+ def get_language(self, block_number: int) -> Optional[str]:
+ """Get the language for a code block."""
+ return self._block_languages.get(block_number)
+
+ def serialize(self) -> str:
+ """Serialize metadata to a string."""
+ # Store as JSON-like format in a comment at the end
+ if not self._block_languages:
+ return ""
+
+ items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
+ return ""
+
+ 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
diff --git a/bouquin/db.py b/bouquin/db.py
index b6c937b..ba6b6ce 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -1,17 +1,58 @@
from __future__ import annotations
import csv
+import datetime as _dt
+import hashlib
import html
import json
-import os
+import markdown
+import re
from dataclasses import dataclass
-from markdownify import markdownify as md
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
-from typing import List, Sequence, Tuple
+from typing import List, Sequence, Tuple, Dict
+
+
+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
+]
+
+_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
@@ -21,6 +62,11 @@ class DBConfig:
idle_minutes: int = 15 # 0 = never lock
theme: str = "system"
move_todos: bool = False
+ tags: bool = True
+ time_log: bool = True
+ reminders: bool = True
+ locale: str = "en"
+ font_size: int = 11
class DBManager:
@@ -64,8 +110,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:
@@ -77,7 +127,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 (
@@ -98,33 +147,72 @@ 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);
"""
)
-
- # 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:
@@ -132,8 +220,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()
@@ -144,7 +230,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:
"""
@@ -164,22 +250,31 @@ class DBManager:
def search_entries(self, text: str) -> list[str]:
"""
- Search for entries by term. This only works against the latest
- version of the page.
+ Search for entries by term or tag name.
+ This only works against the latest version of the page.
"""
cur = self.conn.cursor()
- pattern = f"%{text}%"
+ q = text.strip()
+ pattern = f"%{q.lower()}%"
+
rows = cur.execute(
"""
- SELECT p.date, 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
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]
@@ -193,7 +288,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;
"""
@@ -212,8 +308,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
@@ -281,7 +375,9 @@ class DBManager:
"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")
+ raise ValueError(
+ strings._("db_version_id_does_not_belong_to_the_given_date")
+ )
with self.conn:
cur.execute(
@@ -289,6 +385,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]:
"""
@@ -323,52 +430,6 @@ class DBManager:
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:
- """
- Strip the HTML from the latest version of the pages
- and save to a text file.
- """
- 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("