Compare commits

..

27 commits
0.1.0 ... main

Author SHA1 Message Date
329e016f8d
More fixes for shortcuts, move Rekey button to bottom of settings 2025-11-02 19:29:13 +11:00
b71e7ea1e3
Tweak UI for diff 2025-11-02 19:15:55 +11:00
82069053be
Refactor schema to support versioning of pages. Add HistoryDialog and diff with ability to revert. 2025-11-02 17:02:03 +11:00
cf9102939f
Update screenshot 2025-11-02 15:48:13 +11:00
ef50c8911e
Add auto-lock feature and 'report a bug' 2025-11-02 15:42:42 +11:00
c4091d4cee
Add Help menu with documentation link 2025-11-02 13:11:05 +11:00
c9db440c85
Update screenshot 2025-11-02 12:51:04 +11:00
bed3729206
Bump version 2025-11-02 12:50:14 +11:00
fb4a9e5e27
Add export options 2025-11-02 12:49:19 +11:00
6cae652643
Abbreviated toolbar symbols and add tooltips 2025-11-02 11:56:28 +11:00
43bbe971eb
Add ability to save the key to avoid being prompted for it 2025-11-02 11:44:22 +11:00
4f773e1c1b
Detect fresh install and guide the user to set an encryption passphrase so they know why they're prompted 2025-11-02 11:13:52 +11:00
327e7882b5
Clickable URL links 2025-11-02 11:00:00 +11:00
c4f99f9b2b
Remember app window position on screen 2025-11-02 10:59:43 +11:00
baf9b41f44
Reduce size of preview. Fix jumping to the appropriate day in calendar when clicking on a search result 2025-11-02 10:21:42 +11:00
39c0cb61da
Fix set/unset of bold text 2025-11-02 10:15:27 +11:00
f9d92811dc
Remove explicit cipher_compatability PRAGMA 2025-11-02 10:04:34 +11:00
ff3f5fcf3a
Update screenshot 2025-11-01 17:52:53 +11:00
e146a92b31
Bump version 2025-11-01 17:44:58 +11:00
53e99af912
Add Search ability 2025-11-01 17:44:23 +11:00
72862f9a4f
Update test to account for HTML 2025-11-01 16:45:59 +11:00
e0d7826fe0
Switch to HTML (QTextEdit) and a style toolbar 2025-11-01 16:41:57 +11:00
50fee4ec78
Add repository url to pyproject.toml 2025-10-31 16:55:51 +11:00
cc9453997e
Add shortcut for Settings (Ctrl+E) so as not to collide with Ctrl+S (Save) 2025-10-31 16:50:53 +11:00
3db384e7e4
Add CHANGELOG.md 2025-10-31 16:47:18 +11:00
f778afd268
Add ability to change the key 2025-10-31 16:46:42 +11:00
0caf0efeef
Allow jumping to today 2025-10-31 16:34:20 +11:00
17 changed files with 1825 additions and 214 deletions

39
CHANGELOG.md Normal file
View file

@ -0,0 +1,39 @@
# 0.1.6
* Fix shortcuts for next/previous day to not collide with Normal text (Ctrl+N)
# 0.1.5
* Refactor schema to support versioning of pages. Add HistoryDialog and diff with ability to revert.
# 0.1.4
* Add auto-lock of app (configurable in Settings, defaults to 15 minutes)
* Add 'Report a bug' to Help nav
# 0.1.3
* Fix bold toggle
* Improvements to preview size in search results
* Make URLs highlighted and clickable (Ctrl+click)
* Explain the purpose of the encryption key for first-time use
* Support saving the encryption key to the settings file to avoid being prompted (off by default)
* Abbreviated toolbar symbols to keep things tidier. Add tooltips
* Add ability to export the database to different formats
* Add Documentation/Help menu
# 0.1.2
* Switch from Markdown to HTML via QTextEdit, with a toolbar
* Add search ability
* Fix Settings shortcut and change nav menu from 'File' to 'Application'
# 0.1.1
* Add ability to change the key
* Add ability to jump to today's date
* Add shortcut for Settings (Ctrl+E) so as not to collide with Ctrl+S (Save)
# 0.1.0
* Initial release.

View file

@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
for SQLite3. This means that the underlying database for the notebook is encrypted at rest. for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
To increase security, the SQLCipher key is requested when the app is opened, and is not written To increase security, the SQLCipher key is requested when the app is opened, and is not written
to disk. 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.
@ -19,24 +19,23 @@ There is deliberately no network connectivity or syncing intended.
## Features ## 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 * Every 'page' is linked to the calendar day
* Basic markdown * All changes are version controlled, with ability to view/diff versions and revert
* Automatic periodic saving (or explicitly save) * Text is HTML with basic styling
* Navigating from one day to the next automatically saves
* Basic keyboard shortcuts
* Transparent integrity checking of the database when it opens
## Yet to do
* Search * Search
* Taxonomy/tagging * Automatic periodic saving (or explicitly save)
* Ability to change the SQLCipher key * Transparent integrity checking of the database when it opens
* Export to other formats (plaintext, json, sql etc) * 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
## How to install ## How to install
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
### From source ### From source
* Clone this repo or download the tarball from the releases page * Clone this repo or download the tarball from the releases page
@ -48,7 +47,7 @@ There is deliberately no network connectivity or syncing intended.
* Download the whl and run it * Download the whl and run it
### From PyPi ### From PyPi/pip
* `pip install bouquin` * `pip install bouquin`

View file

@ -1,15 +1,23 @@
from __future__ import annotations from __future__ import annotations
import csv
import html
import json
import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple
Entry = Tuple[str, str]
@dataclass @dataclass
class DBConfig: class DBConfig:
path: Path path: Path
key: str key: str
idle_minutes: int = 15 # 0 = never lock
class DBManager: class DBManager:
@ -18,14 +26,17 @@ class DBManager:
self.conn: sqlite.Connection | None = None self.conn: sqlite.Connection | None = None
def connect(self) -> bool: def connect(self) -> bool:
"""
Open, decrypt and install schema on the database.
"""
# Ensure parent dir exists # Ensure parent dir exists
self.cfg.path.parent.mkdir(parents=True, exist_ok=True) self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite.connect(str(self.cfg.path)) self.conn = sqlite.connect(str(self.cfg.path))
self.conn.row_factory = sqlite.Row
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(f"PRAGMA key = '{self.cfg.key}';") cur.execute(f"PRAGMA key = '{self.cfg.key}';")
cur.execute("PRAGMA cipher_compatibility = 4;") cur.execute("PRAGMA foreign_keys = ON;")
cur.execute("PRAGMA journal_mode = WAL;") cur.execute("PRAGMA journal_mode = WAL;").fetchone()
self.conn.commit()
try: try:
self._integrity_ok() self._integrity_ok()
except Exception: except Exception:
@ -36,15 +47,18 @@ class DBManager:
return True return True
def _integrity_ok(self) -> bool: def _integrity_ok(self) -> bool:
"""
Runs the cipher_integrity_check PRAGMA on the database.
"""
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute("PRAGMA cipher_integrity_check;") cur.execute("PRAGMA cipher_integrity_check;")
rows = cur.fetchall() rows = cur.fetchall()
# OK # OK: nothing returned
if not rows: if not rows:
return return
# Not OK # Not OK: rows of problems returned
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None) details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
raise sqlite.IntegrityError( raise sqlite.IntegrityError(
"SQLCipher integrity check failed" "SQLCipher integrity check failed"
@ -52,39 +66,384 @@ class DBManager:
) )
def _ensure_schema(self) -> None: def _ensure_schema(self) -> None:
"""
Install the expected schema on the database.
We also handle upgrades here.
"""
cur = self.conn.cursor() cur = self.conn.cursor()
# 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 (
date TEXT PRIMARY KEY, -- yyyy-MM-dd
current_version_id INTEGER,
FOREIGN KEY(current_version_id) REFERENCES versions(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS versions (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL, -- FK to pages.date
version_no INTEGER NOT NULL, -- 1,2,3 per date
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
note TEXT,
content TEXT NOT NULL,
FOREIGN KEY(date) REFERENCES pages(date) ON DELETE CASCADE
);
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);
"""
)
# 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( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS entries ( UPDATE pages
date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd SET current_version_id = (
content TEXT NOT NULL SELECT v.id FROM versions v
WHERE v.date = pages.date AND v.version_no = 1
); );
""" """
) )
cur.execute("PRAGMA user_version = 1;") cur.execute("DROP TABLE IF EXISTS entries;")
self.conn.commit() self.conn.commit()
def get_entry(self, date_iso: str) -> str: def rekey(self, new_key: str) -> None:
"""
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() cur = self.conn.cursor()
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,)) # Change the encryption key of the currently open database
row = cur.fetchone() cur.execute(f"PRAGMA rekey = '{new_key}';")
self.conn.commit()
# Close and reopen with the new key to verify and restore PRAGMAs
self.conn.close()
self.conn = None
self.cfg.key = new_key
if not self.connect():
raise sqlite.Error("Re-open failed after rekey")
def get_entry(self, date_iso: str) -> str:
"""
Get a single entry by its date.
"""
cur = self.conn.cursor()
row = cur.execute(
"""
SELECT v.content
FROM pages p
JOIN versions v ON v.id = p.current_version_id
WHERE p.date = ?;
""",
(date_iso,),
).fetchone()
return row[0] if row else "" return row[0] if row else ""
def upsert_entry(self, date_iso: str, content: str) -> None: def upsert_entry(self, date_iso: str, content: str) -> None:
cur = self.conn.cursor()
cur.execute(
""" """
INSERT INTO entries(date, content) VALUES(?, ?) Insert or update an entry.
ON CONFLICT(date) DO UPDATE SET content = excluded.content; """
# Make a new version and set it as current
self.save_new_version(date_iso, content, note=None, set_current=True)
def search_entries(self, text: str) -> list[str]:
"""
Search for entries by term. This only works against the latest
version of the page.
"""
cur = self.conn.cursor()
pattern = f"%{text}%"
rows = cur.execute(
"""
SELECT p.date, v.content
FROM pages AS p
JOIN versions AS v
ON v.id = p.current_version_id
WHERE TRIM(v.content) <> ''
AND v.content LIKE LOWER(?) ESCAPE '\\'
ORDER BY p.date DESC;
""", """,
(date_iso, content), (pattern,),
) ).fetchall()
self.conn.commit() return [(r[0], r[1]) for r in rows]
def dates_with_content(self) -> list[str]: def dates_with_content(self) -> list[str]:
"""
Find all entries and return the dates of them.
This is used to mark the calendar days in bold if they contain entries.
"""
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") rows = cur.execute(
return [r[0] for r in cur.fetchall()] """
SELECT p.date
FROM pages p
JOIN versions v ON v.id = p.current_version_id
WHERE TRIM(v.content) <> ''
ORDER BY p.date;
"""
).fetchall()
return [r[0] for r in rows]
# ------------------------- Versioning logic here ------------------------#
def save_new_version(
self,
date_iso: str,
content: str,
note: str | None = None,
set_current: bool = True,
) -> tuple[int, int]:
"""
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
cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
# Next version number
row = cur.execute(
"SELECT COALESCE(MAX(version_no), 0) AS maxv FROM versions WHERE date=?;",
(date_iso,),
).fetchone()
next_ver = int(row["maxv"]) + 1
# Insert the version
cur.execute(
"INSERT INTO versions(date, version_no, content, note) "
"VALUES (?,?,?,?);",
(date_iso, next_ver, content, note),
)
ver_id = cur.lastrowid
if set_current:
cur.execute(
"UPDATE pages SET current_version_id=? WHERE date=?;",
(ver_id, date_iso),
)
return ver_id, next_ver
def list_versions(self, date_iso: str) -> list[dict]:
"""
Returns history for a given date (newest first), including which one is current.
Each item: {id, version_no, created_at, note, is_current}
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT v.id, v.version_no, v.created_at, v.note,
CASE WHEN v.id = p.current_version_id THEN 1 ELSE 0 END AS is_current
FROM versions v
LEFT JOIN pages p ON p.date = v.date
WHERE v.date = ?
ORDER BY v.version_no DESC;
""",
(date_iso,),
).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:
"""
Fetch a specific version by (date, version_no) OR 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()
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:
"""
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")
with self.conn:
cur.execute(
"UPDATE pages SET current_version_id=? WHERE date=?;",
(version_id, date_iso),
)
# ------------------------- Export logic here ------------------------#
def get_all_entries(self) -> List[Entry]:
"""
Get all entries. Used for exports.
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT p.date, v.content
FROM pages p
JOIN versions v ON v.id = p.current_version_id
ORDER BY p.date;
"""
).fetchall()
return [(r[0], r[1]) for r in rows]
def export_json(
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
) -> 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=(",", ":"))
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
# 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)<br\\s*/?>")
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 <style> and <script> blocks *including their contents*
s = STYLE_SCRIPT_RE.sub("", s)
# 2) Remove HTML comments
s = COMMENT_RE.sub("", s)
# 3) Turn some block-ish boundaries into newlines before removing tags
s = BR_RE.sub("\n", s)
s = BLOCK_END_RE.sub("\n", s)
# 4) Drop remaining tags
s = TAG_RE.sub("", s)
# 5) Unescape entities (&nbsp; etc.)
s = _html.unescape(s)
# 6) Tidy whitespace
s = WS_ENDS_RE.sub("\n", s)
s = MULTINEWLINE_RE.sub("\n\n", s)
return s.strip()
with open(file_path, "w", encoding="utf-8") as f:
for i, (d, c) in enumerate(entries):
body = _strip(c) if strip_html else c
f.write(f"{d}\n{body}\n")
if i < len(entries) - 1:
f.write(separator)
def export_html(
self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
) -> None:
parts = [
"<!doctype html>",
'<html lang="en">',
'<meta charset="utf-8">',
f"<title>{html.escape(title)}</title>",
"<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
"article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
"<body>",
f"<h1>{html.escape(title)}</h1>",
]
for d, c in entries:
parts.append(
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
)
parts.append("</body></html>")
with open(file_path, "w", encoding="utf-8") as f:
f.write("\n".join(parts))
def export_by_extension(self, file_path: str) -> None:
entries = self.get_all_entries()
ext = os.path.splitext(file_path)[1].lower()
if ext == ".json":
self.export_json(entries, file_path)
elif ext == ".csv":
self.export_csv(entries, file_path)
elif ext == ".txt":
self.export_txt(entries, file_path)
elif ext in {".html", ".htm"}:
self.export_html(entries, file_path)
else:
raise ValueError(f"Unsupported extension: {ext}")
def close(self) -> None: def close(self) -> None:
if self.conn is not None: if self.conn is not None:

248
bouquin/editor.py Normal file
View file

@ -0,0 +1,248 @@
from __future__ import annotations
from PySide6.QtGui import (
QColor,
QDesktopServices,
QFont,
QFontDatabase,
QTextCharFormat,
QTextCursor,
QTextListFormat,
QTextBlockFormat,
)
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
from PySide6.QtWidgets import QTextEdit
class Editor(QTextEdit):
linkActivated = Signal(str)
_URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
self.setTabStopDistance(tab_w)
self.setTextInteractionFlags(
Qt.TextInteractionFlag.TextEditorInteraction
| Qt.TextInteractionFlag.LinksAccessibleByMouse
| Qt.TextInteractionFlag.LinksAccessibleByKeyboard
)
self.setAcceptRichText(True)
# Turn raw URLs into anchors
self._linkifying = False
self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True)
def _linkify_document(self):
if self._linkifying:
return
self._linkifying = True
doc = self.document()
cur = QTextCursor(doc)
cur.beginEditBlock()
block = doc.begin()
while block.isValid():
text = block.text()
it = self._URL_RX.globalMatch(text)
while it.hasNext():
m = it.next()
start = block.position() + m.capturedStart()
end = start + m.capturedLength()
cur.setPosition(start)
cur.setPosition(end, QTextCursor.KeepAnchor)
fmt = cur.charFormat()
if fmt.isAnchor(): # already linkified; skip
continue
href = m.captured(0)
if href.startswith("www."):
href = "https://" + href
fmt.setAnchor(True)
# Qt 6: use setAnchorHref; for compatibility, also set names.
try:
fmt.setAnchorHref(href)
except AttributeError:
fmt.setAnchorNames([href])
fmt.setFontUnderline(True)
fmt.setForeground(Qt.blue)
cur.setCharFormat(fmt)
block = block.next()
cur.endEditBlock()
self._linkifying = False
def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
href = self.anchorAt(e.pos())
if href:
QDesktopServices.openUrl(QUrl.fromUserInput(href))
self.linkActivated.emit(href)
return
super().mouseReleaseEvent(e)
def mouseMoveEvent(self, e):
if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
self.viewport().setCursor(Qt.PointingHandCursor)
else:
self.viewport().setCursor(Qt.IBeamCursor)
super().mouseMoveEvent(e)
def keyPressEvent(self, e):
key = e.key()
# Pre-insert: stop link/format bleed for “word boundary” keys
if key in (Qt.Key_Space, Qt.Key_Tab):
self._break_anchor_for_next_char()
return super().keyPressEvent(e)
# When pressing Enter/return key, insert first, then neutralise the empty blocks inline format
if key in (Qt.Key_Return, Qt.Key_Enter):
super().keyPressEvent(e) # create the new (possibly empty) paragraph
# If we're on an empty block, clear the insertion char format so the
# *next* Enter will create another new line (not consume the press to reset formatting).
c = self.textCursor()
block = c.block()
if block.length() == 1:
self._clear_insertion_char_format()
return
return super().keyPressEvent(e)
def _clear_insertion_char_format(self):
"""Reset inline typing format (keeps lists, alignment, margins, etc.)."""
nf = QTextCharFormat()
self.setCurrentCharFormat(nf)
def _break_anchor_for_next_char(self):
c = self.textCursor()
fmt = c.charFormat()
if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
# clone, then strip just the link-specific bits so the next char is plain text
nf = QTextCharFormat(fmt)
nf.setAnchor(False)
nf.setFontUnderline(False)
nf.clearForeground()
try:
nf.setAnchorHref("")
except AttributeError:
nf.setAnchorNames([])
self.setCurrentCharFormat(nf)
def merge_on_sel(self, fmt):
"""
Sets the styling on the selected characters.
"""
cursor = self.textCursor()
if not cursor.hasSelection():
cursor.select(cursor.SelectionType.WordUnderCursor)
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
@Slot()
def apply_weight(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
weight = (
QFont.Weight.Normal
if cur.fontWeight() == QFont.Weight.Bold
else QFont.Weight.Bold
)
fmt.setFontWeight(weight)
self.merge_on_sel(fmt)
@Slot()
def apply_italic(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
fmt.setFontItalic(not cur.fontItalic())
self.merge_on_sel(fmt)
@Slot()
def apply_underline(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
fmt.setFontUnderline(not cur.fontUnderline())
self.merge_on_sel(fmt)
@Slot()
def apply_strikethrough(self):
cur = self.currentCharFormat()
fmt = QTextCharFormat()
fmt.setFontStrikeOut(not cur.fontStrikeOut())
self.merge_on_sel(fmt)
@Slot()
def apply_code(self):
c = self.textCursor()
if not c.hasSelection():
c.select(c.SelectionType.BlockUnderCursor)
bf = QTextBlockFormat()
bf.setLeftMargin(12)
bf.setRightMargin(12)
bf.setTopMargin(6)
bf.setBottomMargin(6)
bf.setBackground(QColor(245, 245, 245))
bf.setNonBreakableLines(True)
cf = QTextCharFormat()
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
cf.setFont(mono)
cf.setFontFixedPitch(True)
# If the current block already looks like a code block, remove styling
cur_bf = c.blockFormat()
is_code = (
cur_bf.nonBreakableLines()
and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
)
if is_code:
# clear: margins/background/wrapping
bf = QTextBlockFormat()
cf = QTextCharFormat()
c.mergeBlockFormat(bf)
c.mergeBlockCharFormat(cf)
@Slot(int)
def apply_heading(self, size):
fmt = QTextCharFormat()
if size:
fmt.setFontWeight(QFont.Weight.Bold)
fmt.setFontPointSize(size)
else:
fmt.setFontWeight(QFont.Weight.Normal)
fmt.setFontPointSize(self.font().pointSizeF())
self.merge_on_sel(fmt)
def toggle_bullets(self):
c = self.textCursor()
lst = c.currentList()
if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
lst.remove(c.block())
return
fmt = QTextListFormat()
fmt.setStyle(QTextListFormat.Style.ListDisc)
c.createList(fmt)
def toggle_numbers(self):
c = self.textCursor()
lst = c.currentList()
if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
lst.remove(c.block())
return
fmt = QTextListFormat()
fmt.setStyle(QTextListFormat.Style.ListDecimal)
c.createList(fmt)

View file

@ -1,112 +0,0 @@
from __future__ import annotations
import re
from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor
class MarkdownHighlighter(QSyntaxHighlighter):
ST_NORMAL = 0
ST_CODE = 1
FENCE = re.compile(r"^```")
def __init__(self, document):
super().__init__(document)
base_size = document.defaultFont().pointSizeF() or 12.0
# Monospace for code
self.mono = QFont("Monospace")
self.mono.setStyleHint(QFont.TypeWriter)
# Light, high-contrast scheme for code
self.col_bg = QColor("#eef2f6") # light code bg
self.col_fg = QColor("#1f2328") # dark text
# Formats
self.fmt_h = [QTextCharFormat() for _ in range(6)]
for i, f in enumerate(self.fmt_h, start=1):
f.setFontWeight(QFont.Weight.Bold)
f.setFontPointSize(base_size + (7 - i))
self.fmt_bold = QTextCharFormat()
self.fmt_bold.setFontWeight(QFont.Weight.Bold)
self.fmt_italic = QTextCharFormat()
self.fmt_italic.setFontItalic(True)
self.fmt_quote = QTextCharFormat()
self.fmt_quote.setForeground(QColor("#6a737d"))
self.fmt_link = QTextCharFormat()
self.fmt_link.setFontUnderline(True)
self.fmt_list = QTextCharFormat()
self.fmt_list.setFontWeight(QFont.Weight.DemiBold)
self.fmt_strike = QTextCharFormat()
self.fmt_strike.setFontStrikeOut(True)
# Uniform code style
self.fmt_code = QTextCharFormat()
self.fmt_code.setFont(self.mono)
self.fmt_code.setFontPointSize(max(6.0, base_size - 1))
self.fmt_code.setBackground(self.col_bg)
self.fmt_code.setForeground(self.col_fg)
# Simple patterns
self.re_heading = re.compile(r"^(#{1,6}) +.*$")
self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_")
self.re_strike = re.compile(r"~~(.+?)~~")
self.re_inline_code = re.compile(r"`([^`]+)`")
self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)")
self.re_quote = re.compile(r"^> ?.*$")
def highlightBlock(self, text: str) -> None:
prev = self.previousBlockState()
in_code = prev == self.ST_CODE
if in_code:
# Entire line is code
self.setFormat(0, len(text), self.fmt_code)
if self.FENCE.match(text):
self.setCurrentBlockState(self.ST_NORMAL)
else:
self.setCurrentBlockState(self.ST_CODE)
return
# Starting/ending a fenced block?
if self.FENCE.match(text):
self.setFormat(0, len(text), self.fmt_code)
self.setCurrentBlockState(self.ST_CODE)
return
# --- Normal markdown styling ---
m = self.re_heading.match(text)
if m:
level = min(len(m.group(1)), 6)
self.setFormat(0, len(text), self.fmt_h[level - 1])
self.setCurrentBlockState(self.ST_NORMAL)
return
m = self.re_list.match(text)
if m:
self.setFormat(m.start(), m.end() - m.start(), self.fmt_list)
if self.re_quote.match(text):
self.setFormat(0, len(text), self.fmt_quote)
for m in self.re_inline_code.finditer(text):
self.setFormat(m.start(), m.end() - m.start(), self.fmt_code)
for m in self.re_bold.finditer(text):
self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold)
for m in self.re_italic.finditer(text):
self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic)
for m in self.re_strike.finditer(text):
self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike)
for m in self.re_link.finditer(text):
start = m.start(1) - 1
length = len(m.group(1)) + 2
self.setFormat(start, length, self.fmt_link)
self.setCurrentBlockState(self.ST_NORMAL)

179
bouquin/history_dialog.py Normal file
View file

@ -0,0 +1,179 @@
from __future__ import annotations
import difflib, re, html as _html
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QListWidget,
QListWidgetItem,
QPushButton,
QMessageBox,
QTextBrowser,
QTabWidget,
)
def _html_to_text(s: str) -> str:
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
BR_RE = re.compile(r"(?i)<br\s*/?>")
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
TAG_RE = re.compile(r"<[^>]+>")
MULTINL_RE = re.compile(r"\n{3,}")
s = STYLE_SCRIPT_RE.sub("", s)
s = COMMENT_RE.sub("", s)
s = BR_RE.sub("\n", s)
s = BLOCK_END_RE.sub("\n", s)
s = TAG_RE.sub("", s)
s = _html.unescape(s)
s = MULTINL_RE.sub("\n\n", s)
return s.strip()
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
a = _html_to_text(old_html).splitlines()
b = _html_to_text(new_html).splitlines()
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
lines = []
for line in ud:
if line.startswith("+") and not line.startswith("+++"):
lines.append(
f"<span style='color:#116329'>+ {_html.escape(line[1:])}</span>"
)
elif line.startswith("-") and not line.startswith("---"):
lines.append(
f"<span style='color:#b31d28'>- {_html.escape(line[1:])}</span>"
)
elif line.startswith("@@"):
lines.append(f"<span style='color:#6f42c1'>{_html.escape(line)}</span>")
else:
lines.append(f"<span style='color:#586069'>{_html.escape(line)}</span>")
css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }"
return f"<style>{css}</style><pre>{'<br>'.join(lines)}</pre>"
class HistoryDialog(QDialog):
"""Show versions for a date, preview, diff, and allow revert."""
def __init__(self, db, date_iso: str, parent=None):
super().__init__(parent)
self.setWindowTitle(f"History — {date_iso}")
self._db = db
self._date = date_iso
self._versions = [] # list[dict] from DB
self._current_id = None # id of current
root = QVBoxLayout(self)
# Top: list of versions
top = QHBoxLayout()
self.list = QListWidget()
self.list.setMinimumSize(500, 650)
self.list.currentItemChanged.connect(self._on_select)
top.addWidget(self.list, 1)
# Right: tabs (Preview / Diff)
self.tabs = QTabWidget()
self.preview = QTextBrowser()
self.preview.setOpenExternalLinks(True)
self.diff = QTextBrowser()
self.diff.setOpenExternalLinks(False)
self.tabs.addTab(self.preview, "Preview")
self.tabs.addTab(self.diff, "Diff")
self.tabs.setMinimumSize(500, 650)
top.addWidget(self.tabs, 2)
root.addLayout(top)
# Buttons
row = QHBoxLayout()
row.addStretch(1)
self.btn_revert = QPushButton("Revert to Selected")
self.btn_revert.clicked.connect(self._revert)
self.btn_close = QPushButton("Close")
self.btn_close.clicked.connect(self.reject)
row.addWidget(self.btn_revert)
row.addWidget(self.btn_close)
root.addLayout(row)
self._load_versions()
# --- Data/UX helpers ---
def _load_versions(self):
self._versions = self._db.list_versions(
self._date
) # [{id,version_no,created_at,note,is_current}]
self._current_id = next(
(v["id"] for v in self._versions if v["is_current"]), None
)
self.list.clear()
for v in self._versions:
label = f"v{v['version_no']}{v['created_at']}"
if v.get("note"):
label += f" · {v['note']}"
if v["is_current"]:
label += " **(current)**"
it = QListWidgetItem(label)
it.setData(Qt.UserRole, v["id"])
self.list.addItem(it)
# select the first non-current if available, else current
idx = 0
for i, v in enumerate(self._versions):
if not v["is_current"]:
idx = i
break
if self.list.count():
self.list.setCurrentRow(idx)
@Slot()
def _on_select(self):
item = self.list.currentItem()
if not item:
self.preview.clear()
self.diff.clear()
self.btn_revert.setEnabled(False)
return
sel_id = item.data(Qt.UserRole)
# Preview selected as HTML
sel = self._db.get_version(version_id=sel_id)
self.preview.setHtml(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 only if selecting a non-current
self.btn_revert.setEnabled(sel_id != self._current_id)
@Slot()
def _revert(self):
item = self.list.currentItem()
if not item:
return
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
sel = self._db.get_version(version_id=sel_id)
vno = sel["version_no"]
# Confirm
if (
QMessageBox.question(
self,
"Revert",
f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
QMessageBox.Yes | QMessageBox.No,
)
!= QMessageBox.Yes
):
return
# Flip head pointer
try:
self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e:
QMessageBox.critical(self, "Revert failed", str(e))
return
QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
self.accept() # let the caller refresh the editor

View file

@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
def __init__( def __init__(
self, self,
parent=None, parent=None,
title: str = "Unlock database", title: str = "Enter key",
message: str = "Enter SQLCipher key", message: str = "Enter key",
): ):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)

View file

@ -11,5 +11,6 @@ def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG) app.setOrganizationName(APP_ORG)
win = MainWindow(); win.show() win = MainWindow()
win.show()
sys.exit(app.exec()) sys.exit(app.exec())

View file

@ -1,26 +1,96 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from PySide6.QtCore import QDate, QTimer, Qt from pathlib import Path
from PySide6.QtGui import QAction, QFont, QTextCharFormat from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
from PySide6.QtGui import (
QAction,
QCursor,
QDesktopServices,
QFont,
QGuiApplication,
QTextCharFormat,
)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog,
QCalendarWidget, QCalendarWidget,
QDialog,
QFileDialog,
QLabel,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPlainTextEdit, QPushButton,
QSizePolicy,
QSplitter, QSplitter,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
QSizePolicy,
) )
from .db import DBManager from .db import DBManager
from .settings import APP_NAME, load_db_config, save_db_config from .editor import Editor
from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
from .highlighter import MarkdownHighlighter from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog from .settings_dialog import SettingsDialog
from .toolbar import ToolBar
class _LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable):
super().__init__(parent)
self.setObjectName("LockOverlay")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
self.setStyleSheet(
"""
#LockOverlay { background-color: #ccc; }
#LockOverlay QLabel { color: #fff; font-size: 18px; }
#LockOverlay QPushButton {
background-color: #f2f2f2;
color: #000;
padding: 6px 14px;
border: 1px solid #808080;
border-radius: 6px;
font-size: 14px;
}
#LockOverlay QPushButton:hover { background-color: #ffffff; }
#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
"""
)
lay = QVBoxLayout(self)
lay.addStretch(1)
msg = QLabel("Locked due to inactivity")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton("Unlock")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
self._btn.setDefault(True)
self._btn.clicked.connect(on_unlock)
lay.addWidget(msg, 0, Qt.AlignCenter)
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
self.hide() # start hidden
# keep overlay sized with its parent
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())
return False
def showEvent(self, e):
super().showEvent(e)
self._btn.setFocus()
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@ -30,9 +100,18 @@ class MainWindow(QMainWindow):
self.setMinimumSize(1000, 650) self.setMinimumSize(1000, 650)
self.cfg = load_db_config() self.cfg = load_db_config()
# Always prompt for the key (we never store it) if not os.path.exists(self.cfg.path):
if not self._prompt_for_key_until_valid(): # Fresh database/first time use, so guide the user re: setting a key
first_time = True
else:
first_time = False
# Prompt for the key unless it is found in config
if not self.cfg.key:
if not self._prompt_for_key_until_valid(first_time):
sys.exit(1) sys.exit(1)
else:
self._try_connect()
# ---- UI: Left fixed panel (calendar) + right editor ----------------- # ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget() self.calendar = QCalendarWidget()
@ -40,17 +119,36 @@ class MainWindow(QMainWindow):
self.calendar.setGridVisible(True) self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed) self.calendar.selectionChanged.connect(self._on_date_changed)
self.search = Search(self.db)
self.search.openDateRequested.connect(self._load_selected_date)
# Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized.
left_panel = QWidget() left_panel = QWidget()
left_layout = QVBoxLayout(left_panel) left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop) left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
left_layout.addStretch(1) left_layout.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
self.editor = QPlainTextEdit() # This is the note-taking editor
tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ") self.editor = Editor()
self.editor.setTabStopDistance(tab_w)
self.highlighter = MarkdownHighlighter(self.editor.document()) # Toolbar for controlling styling
self.toolBar = ToolBar()
self.addToolBar(self.toolBar)
# Wire toolbar intents to editor methods
self.toolBar.boldRequested.connect(self.editor.apply_weight)
self.toolBar.italicRequested.connect(self.editor.apply_italic)
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
self.toolBar.codeRequested.connect(self.editor.apply_code)
self.toolBar.headingRequested.connect(self.editor.apply_heading)
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history)
split = QSplitter() split = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
@ -62,26 +160,54 @@ class MainWindow(QMainWindow):
lay.addWidget(split) lay.addWidget(split)
self.setCentralWidget(container) self.setCentralWidget(container)
# Idle lock setup
self._idle_timer = QTimer(self)
self._idle_timer.setSingleShot(True)
self._idle_timer.timeout.connect(self._enter_lock)
self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
self._idle_timer.start()
# full-window overlay that sits on top of the central widget
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
self.centralWidget().installEventFilter(self._lock_overlay)
self._locked = False
# reset idle timer on any key press anywhere in the app
from PySide6.QtWidgets import QApplication
QApplication.instance().installEventFilter(self)
# Status bar for feedback # Status bar for feedback
self.statusBar().showMessage("Ready", 800) self.statusBar().showMessage("Ready", 800)
# Menu bar (File) # Menu bar (File)
mb = self.menuBar() mb = self.menuBar()
file_menu = mb.addMenu("&File") file_menu = mb.addMenu("&File")
act_save = QAction("&Save", self) act_save = QAction("&Save a version", self)
act_save.setShortcut("Ctrl+S") act_save.setShortcut("Ctrl+S")
act_save.triggered.connect(lambda: self._save_current(explicit=True)) act_save.triggered.connect(lambda: self._save_current(explicit=True))
file_menu.addAction(act_save) file_menu.addAction(act_save)
act_settings = QAction("&Settings", self) act_history = QAction("History", self)
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("Settin&gs", self)
act_settings.setShortcut("Ctrl+G")
act_settings.triggered.connect(self._open_settings) act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings) file_menu.addAction(act_settings)
act_export = QAction("&Export", self)
act_export.setShortcut("Ctrl+E")
act_export.triggered.connect(self._export)
file_menu.addAction(act_export)
file_menu.addSeparator() file_menu.addSeparator()
act_quit = QAction("&Quit", self) act_quit = QAction("&Quit", self)
act_quit.setShortcut("Ctrl+Q") act_quit.setShortcut("Ctrl+Q")
act_quit.triggered.connect(self.close) act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit) file_menu.addAction(act_quit)
# Navigate menu with next/previous day # Navigate menu with next/previous/today
nav_menu = mb.addMenu("&Navigate") nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self) act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P") act_prev.setShortcut("Ctrl+P")
@ -97,6 +223,28 @@ class MainWindow(QMainWindow):
nav_menu.addAction(act_next) nav_menu.addAction(act_next)
self.addAction(act_next) self.addAction(act_next)
act_today = QAction("Today", self)
act_today.setShortcut("Ctrl+T")
act_today.setShortcutContext(Qt.ApplicationShortcut)
act_today.triggered.connect(self._adjust_today)
nav_menu.addAction(act_today)
self.addAction(act_today)
# Help menu with drop-down
help_menu = mb.addMenu("&Help")
act_docs = QAction("Documentation", self)
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("Report a bug", self)
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)
# Autosave # Autosave
self._dirty = False self._dirty = False
self._save_timer = QTimer(self) self._save_timer = QTimer(self)
@ -104,12 +252,18 @@ class MainWindow(QMainWindow):
self._save_timer.timeout.connect(self._save_current) self._save_timer.timeout.connect(self._save_current)
self.editor.textChanged.connect(self._on_text_changed) self.editor.textChanged.connect(self._on_text_changed)
# First load + mark dates with content # First load + mark dates in calendar with content
self._load_selected_date() self._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
# --- DB lifecycle # Restore window position from settings
self.settings = QSettings(APP_ORG, APP_NAME)
self._restore_window_position()
def _try_connect(self) -> bool: def _try_connect(self) -> bool:
"""
Try to connect to the database.
"""
try: try:
self.db = DBManager(self.cfg) self.db = DBManager(self.cfg)
ok = self.db.connect() ok = self.db.connect()
@ -122,17 +276,29 @@ class MainWindow(QMainWindow):
return False return False
return ok return ok
def _prompt_for_key_until_valid(self) -> bool: def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
"""
Prompt for the SQLCipher key.
"""
if first_time:
title = "Set an encryption key"
message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
else:
title = "Unlock encrypted notebook"
message = "Enter your key to unlock the notebook"
while True: while True:
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook") dlg = KeyPrompt(self, title, message)
if dlg.exec() != QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
return False return False
self.cfg.key = dlg.key() self.cfg.key = dlg.key()
if self._try_connect(): if self._try_connect():
return True return True
# --- Calendar marks to indicate text exists for htat day -----------------
def _refresh_calendar_marks(self): def _refresh_calendar_marks(self):
"""
Sets a bold marker on the day to indicate that text exists
for that day.
"""
fmt_bold = QTextCharFormat() fmt_bold = QTextCharFormat()
fmt_bold.setFontWeight(QFont.Weight.Bold) fmt_bold.setFontWeight(QFont.Weight.Bold)
# Clear previous marks # Clear previous marks
@ -153,7 +319,8 @@ class MainWindow(QMainWindow):
d = self.calendar.selectedDate() d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
def _load_selected_date(self): def _load_selected_date(self, date_iso=False):
if not date_iso:
date_iso = self._current_date_iso() date_iso = self._current_date_iso()
try: try:
text = self.db.get_entry(date_iso) text = self.db.get_entry(date_iso)
@ -161,21 +328,28 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Read Error", str(e)) QMessageBox.critical(self, "Read Error", str(e))
return return
self.editor.blockSignals(True) self.editor.blockSignals(True)
self.editor.setPlainText(text) self.editor.setHtml(text)
self.editor.blockSignals(False) self.editor.blockSignals(False)
self._dirty = False self._dirty = False
# track which date the editor currently represents # track which date the editor currently represents
self._active_date_iso = date_iso self._active_date_iso = date_iso
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
self.calendar.setSelectedDate(qd)
def _on_text_changed(self): def _on_text_changed(self):
self._dirty = True self._dirty = True
self._save_timer.start(1200) # autosave after idle self._save_timer.start(10000) # autosave after idle
def _adjust_day(self, delta: int): def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous).""" """Move selection by delta days (negative for previous)."""
d = self.calendar.selectedDate().addDays(delta) d = self.calendar.selectedDate().addDays(delta)
self.calendar.setSelectedDate(d) self.calendar.setSelectedDate(d)
def _adjust_today(self):
"""Jump to today."""
today = QDate.currentDate()
self.calendar.setSelectedDate(today)
def _on_date_changed(self): def _on_date_changed(self):
""" """
When the calendar selection changes, save the previous day's note if dirty, When the calendar selection changes, save the previous day's note if dirty,
@ -192,16 +366,16 @@ class MainWindow(QMainWindow):
# Now load the newly selected date # Now load the newly selected date
self._load_selected_date() self._load_selected_date()
def _save_date(self, date_iso: str, explicit: bool = False): def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
""" """
Save editor contents into the given date. Shows status on success. Save editor contents into the given date. Shows status on success.
explicit=True means user invoked Save: show feedback even if nothing changed. explicit=True means user invoked Save: show feedback even if nothing changed.
""" """
if not self._dirty and not explicit: if not self._dirty and not explicit:
return return
text = self.editor.toPlainText() text = self.editor.toHtml()
try: try:
self.db.upsert_entry(date_iso, text) self.db.save_new_version(date_iso, text, note)
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Save Error", str(e)) QMessageBox.critical(self, "Save Error", str(e))
return return
@ -215,20 +389,57 @@ class MainWindow(QMainWindow):
) )
def _save_current(self, explicit: bool = False): def _save_current(self, explicit: bool = False):
try:
self._save_timer.stop()
except Exception:
pass
if explicit:
# Prompt for a note
dlg = SaveDialog(self)
if dlg.exec() != QDialog.Accepted:
return
note = dlg.note_text()
else:
note = "autosave"
# Delegate to _save_date for the currently selected date # Delegate to _save_date for the currently selected date
self._save_date(self._current_date_iso(), explicit) self._save_date(self._current_date_iso(), explicit, note)
try:
self._save_timer.start()
except Exception:
pass
def _open_settings(self): def _open_history(self):
dlg = SettingsDialog(self.cfg, self) date_iso = self._current_date_iso()
dlg = HistoryDialog(self.db, date_iso, self)
if dlg.exec() == QDialog.Accepted: if dlg.exec() == QDialog.Accepted:
# refresh editor + calendar (head pointer may have changed)
self._load_selected_date(date_iso)
self._refresh_calendar_marks()
# ----------- Settings handler ------------#
def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self)
if dlg.exec() != QDialog.Accepted:
return
new_cfg = dlg.config new_cfg = dlg.config
if new_cfg.path != self.cfg.path: old_path = self.cfg.path
# Save the new path to the notebook
# Update in-memory config from the dialog
self.cfg.path = new_cfg.path self.cfg.path = new_cfg.path
self.cfg.key = new_cfg.key
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
# Persist once
save_db_config(self.cfg) 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)
# If the DB path changed, reconnect
if self.cfg.path != old_path:
self.db.close() self.db.close()
# Prompt again for the key for the new path if not self._prompt_for_key_until_valid(first_time=False):
if not self._prompt_for_key_until_valid():
QMessageBox.warning( QMessageBox.warning(
self, "Reopen failed", "Could not unlock database at new path." self, "Reopen failed", "Could not unlock database at new path."
) )
@ -236,8 +447,167 @@ class MainWindow(QMainWindow):
self._load_selected_date() self._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
def closeEvent(self, event): # noqa: N802 # ------------ Window positioning --------------- #
def _restore_window_position(self):
geom = self.settings.value("main/geometry", None)
state = self.settings.value("main/windowState", None)
was_max = self.settings.value("main/maximized", False, type=bool)
if geom is not None:
self.restoreGeometry(geom)
if state is not None:
self.restoreState(state)
if not self._rect_on_any_screen(self.frameGeometry()):
self._move_to_cursor_screen_center()
else:
# First run: place window on the screen where the mouse cursor is.
self._move_to_cursor_screen_center()
# If it was maximized, do that AFTER the window exists in the event loop.
if was_max:
QTimer.singleShot(0, self.showMaximized)
def _rect_on_any_screen(self, rect):
for sc in QGuiApplication.screens():
if sc.availableGeometry().intersects(rect):
return True
return False
def _move_to_cursor_screen_center(self):
screen = (
QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
)
r = screen.availableGeometry()
# Center the window in that screens available area
self.move(r.center() - self.rect().center())
# ----------------- Export handler ----------------- #
@Slot()
def _export(self):
try: try:
self.export_dialog()
except Exception as e:
QMessageBox.critical(self, "Export failed", str(e))
def export_dialog(self) -> None:
filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
start_dir = os.path.join(os.path.expanduser("~"), "Documents")
filename, selected_filter = QFileDialog.getSaveFileName(
self, "Export entries", start_dir, filters
)
if not filename:
return # user cancelled
default_ext = {
"Text (*.txt)": ".txt",
"JSON (*.json)": ".json",
"CSV (*.csv)": ".csv",
"HTML (*.html)": ".html",
}.get(selected_filter, ".txt")
if not Path(filename).suffix:
filename += default_ext
try:
entries = self.db.get_all_entries()
if selected_filter.startswith("Text"):
self.db.export_txt(entries, filename)
elif selected_filter.startswith("JSON"):
self.db.export_json(entries, filename)
elif selected_filter.startswith("CSV"):
self.db.export_csv(entries, filename)
elif selected_filter.startswith("HTML"):
self.bd.export_html(entries, filename)
else:
self.bd.export_by_extension(entries, filename)
QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
except Exception as e:
QMessageBox.critical(self, "Export failed", str(e))
def _open_docs(self):
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
url = QUrl.fromUserInput(url_str)
if not QDesktopServices.openUrl(url):
QMessageBox.warning(
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
)
def _open_bugs(self):
url_str = "https://nr.mig5.net/forms/mig5/contact"
url = QUrl.fromUserInput(url_str)
if not QDesktopServices.openUrl(url):
QMessageBox.warning(
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
)
# Idle handlers
def _apply_idle_minutes(self, minutes: int):
minutes = max(0, int(minutes))
if not hasattr(self, "_idle_timer"):
return
if minutes == 0:
self._idle_timer.stop()
# If youre currently locked, unlock when user disables the timer:
if getattr(self, "_locked", False):
try:
self._locked = False
if hasattr(self, "_lock_overlay"):
self._lock_overlay.hide()
except Exception:
pass
else:
self._idle_timer.setInterval(minutes * 60 * 1000)
if not getattr(self, "_locked", False):
self._idle_timer.start()
def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress and not self._locked:
self._idle_timer.start()
return super().eventFilter(obj, event)
def _enter_lock(self):
if self._locked:
return
self._locked = True
if self.menuBar():
self.menuBar().setEnabled(False)
if self.statusBar():
self.statusBar().setEnabled(False)
tb = getattr(self, "toolBar", None)
if tb:
tb.setEnabled(False)
self._lock_overlay.show()
self._lock_overlay.raise_()
@Slot()
def _on_unlock_clicked(self):
try:
ok = self._prompt_for_key_until_valid(first_time=False)
except Exception as e:
QMessageBox.critical(self, "Unlock failed", str(e))
return
if ok:
self._locked = False
self._lock_overlay.hide()
if self.menuBar():
self.menuBar().setEnabled(True)
if self.statusBar():
self.statusBar().setEnabled(True)
tb = getattr(self, "toolBar", None)
if tb:
tb.setEnabled(True)
self._idle_timer.start()
# Close app handler - save window position and database
def closeEvent(self, event):
try:
# Save window position
self.settings.setValue("main/geometry", self.saveGeometry())
self.settings.setValue("main/windowState", self.saveState())
self.settings.setValue("main/maximized", self.isMaximized())
# Ensure we save any last pending edits to the db
self._save_current() self._save_current()
self.db.close() self.db.close()
except Exception: except Exception:

35
bouquin/save_dialog.py Normal file
View file

@ -0,0 +1,35 @@
from __future__ import annotations
import datetime
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QDialogButtonBox,
)
class SaveDialog(QDialog):
def __init__(
self,
parent=None,
title: str = "Enter a name for this version",
message: str = "Enter a name for this version?",
):
super().__init__(parent)
self.setWindowTitle(title)
v = QVBoxLayout(self)
v.addWidget(QLabel(message))
self.note = QLineEdit()
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.note.setText(f"New version I saved at {now}")
v.addWidget(self.note)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
v.addWidget(bb)
def note_text(self) -> str:
return self.note.text()

195
bouquin/search.py Normal file
View file

@ -0,0 +1,195 @@
from __future__ import annotations
import re
from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QHBoxLayout,
QVBoxLayout,
QWidget,
)
# type: rows are (date_iso, content)
Row = Tuple[str, str]
class Search(QWidget):
"""Encapsulates the search UI + logic and emits a signal when a result is chosen."""
openDateRequested = Signal(str)
def __init__(self, db, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
self.search = QLineEdit()
self.search.setPlaceholderText("Search for notes here")
self.search.textChanged.connect(self._search)
self.results = QListWidget()
self.results.setUniformItemSizes(False)
self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
self.results.itemClicked.connect(self._open_selected)
self.results.hide()
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
lay.addWidget(self.search)
lay.addWidget(self.results)
def _open_selected(self, item: QListWidgetItem):
date_str = item.data(Qt.ItemDataRole.UserRole)
if date_str:
self.openDateRequested.emit(date_str)
def _search(self, text: str):
"""
Search for the supplied text in the database.
For all rows found, populate the results widget with a clickable preview.
"""
q = text.strip()
if not q:
self.results.clear()
self.results.hide()
return
try:
rows: Iterable[Row] = self._db.search_entries(q)
except Exception:
# be quiet on DB errors here; caller can surface if desired
rows = []
self._populate_results(q, rows)
def _populate_results(self, query: str, rows: Iterable[Row]):
self.results.clear()
rows = list(rows)
if not rows:
self.results.hide()
return
self.results.show()
for date_str, content in rows:
# Build an HTML fragment around the match and whether to show ellipses
frag_html, left_ell, right_ell = 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(8, 6, 8, 6)
outer.setSpacing(2)
# Date label (plain text)
date_lbl = QLabel(date_str)
date_lbl.setTextFormat(Qt.TextFormat.PlainText)
date_f = date_lbl.font()
date_f.setPointSizeF(date_f.pointSizeF() - 1)
date_lbl.setFont(date_f)
date_lbl.setStyleSheet("color:#666;")
outer.addWidget(date_lbl)
# Preview row with optional ellipses
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(4)
if left_ell:
left = QLabel("")
left.setStyleSheet("color:#888;")
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
preview = QLabel()
preview.setTextFormat(Qt.TextFormat.RichText)
preview.setWordWrap(True)
preview.setOpenExternalLinks(True)
preview.setText(
frag_html
if frag_html
else "<span style='color:#888'>(no preview)</span>"
)
h.addWidget(preview, 1)
if right_ell:
right = QLabel("")
right.setStyleSheet("color:#888;")
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
outer.addWidget(row)
# ---- Add to list ----
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, date_str)
item.setSizeHint(container.sizeHint())
self.results.addItem(item)
self.results.setItemWidget(item, container)
# --- Snippet/highlight helpers -----------------------------------------
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
doc = QTextDocument()
doc.setHtml(html_src)
plain = doc.toPlainText()
if not plain:
return "", False, False
tokens = [t for t in re.split(r"\s+", query.strip()) if t]
L = len(plain)
# Find first occurrence (phrase first, then earliest token)
idx, mlen = -1, 0
if tokens:
lower = plain.lower()
phrase = " ".join(tokens).lower()
j = lower.find(phrase)
if j >= 0:
idx, mlen = j, len(phrase)
else:
for t in tokens:
tj = lower.find(t.lower())
if tj >= 0 and (idx < 0 or tj < idx):
idx, mlen = tj, len(t)
# Compute window
if idx < 0:
start, end = 0, min(L, maxlen)
else:
start = max(0, min(idx - radius, max(0, L - maxlen)))
end = min(L, max(idx + mlen + radius, start + maxlen))
# Bold all token matches that fall inside [start, end)
if tokens:
lower = plain.lower()
fmt = QTextCharFormat()
fmt.setFontWeight(QFont.Weight.Bold)
for t in tokens:
t_low = t.lower()
pos = start
while True:
k = lower.find(t_low, pos)
if k == -1 or k >= end:
break
c = QTextCursor(doc)
c.setPosition(k)
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
c.mergeCharFormat(fmt)
pos = k + len(t)
# Select the window and export as HTML fragment
c = QTextCursor(doc)
c.setPosition(start)
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
fragment_html = (
c.selection().toHtml()
) # preserves original styles + our bolding
return fragment_html, start > 0, end < L

View file

@ -21,9 +21,13 @@ def get_settings() -> QSettings:
def load_db_config() -> DBConfig: def load_db_config() -> DBConfig:
s = get_settings() s = get_settings()
path = Path(s.value("db/path", str(default_db_path()))) path = Path(s.value("db/path", str(default_db_path())))
return DBConfig(path=path, key="") key = s.value("db/key", "")
idle = s.value("db/idle_minutes", 15, type=int)
return DBConfig(path=path, key=key, idle_minutes=idle)
def save_db_config(cfg: DBConfig) -> None: def save_db_config(cfg: DBConfig) -> None:
s = get_settings() s = get_settings()
s.setValue("db/path", str(cfg.path)) s.setValue("db/path", str(cfg.path))
s.setValue("db/key", str(cfg.key))
s.setValue("db/idle_minutes", str(cfg.idle_minutes))

View file

@ -3,8 +3,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox,
QDialog, QDialog,
QFormLayout, QFormLayout,
QFrame,
QGroupBox,
QLabel,
QHBoxLayout, QHBoxLayout,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@ -13,21 +17,29 @@ from PySide6.QtWidgets import (
QFileDialog, QFileDialog,
QDialogButtonBox, QDialogButtonBox,
QSizePolicy, QSizePolicy,
QSpinBox,
QMessageBox,
) )
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from .db import DBConfig
from .settings import save_db_config from .db import DBConfig, DBManager
from .settings import load_db_config, save_db_config
from .key_prompt import KeyPrompt
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
def __init__(self, cfg: DBConfig, parent=None): def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Settings") self.setWindowTitle("Settings")
self._cfg = DBConfig(path=cfg.path, key="") self._cfg = DBConfig(path=cfg.path, key="")
self._db = db
self.key = ""
form = QFormLayout() form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.setMinimumWidth(520) self.setMinimumWidth(560)
self.setSizeGripEnabled(True) self.setSizeGripEnabled(True)
self.path_edit = QLineEdit(str(self._cfg.path)) self.path_edit = QLineEdit(str(self._cfg.path))
@ -44,13 +56,94 @@ class SettingsDialog(QDialog):
h.setStretch(1, 0) h.setStretch(1, 0)
form.addRow("Database path", path_row) form.addRow("Database path", path_row)
# Encryption settings
enc_group = QGroupBox("Encryption and Privacy")
enc = QVBoxLayout(enc_group)
enc.setContentsMargins(12, 8, 12, 12)
enc.setSpacing(6)
# Checkbox to remember key
self.save_key_btn = QCheckBox("Remember key")
current_settings = load_db_config()
self.key = current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor)
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(
"If you don't want to be prompted for your encryption key, check this to remember it. "
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
)
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()
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
self.save_key_label.setPalette(pal)
exp_row = QHBoxLayout()
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)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
enc.addWidget(line)
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("Never")
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
# Explanation for idle option (autolock)
self.idle_spin_label = QLabel(
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
"Set to 0 (never) to never lock."
)
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()
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout()
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
spin_row.addWidget(self.idle_spin_label)
enc.addLayout(spin_row)
line2 = QFrame()
line2.setFrameShape(QFrame.HLine)
line2.setFrameShadow(QFrame.Sunken)
enc.addWidget(line2)
# Change key button
self.rekey_btn = QPushButton("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)
# Put the group into the form so it spans the full width nicely
form.addRow(enc_group)
# Buttons
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save) bb.accepted.connect(self._save)
bb.rejected.connect(self.reject) bb.rejected.connect(self.reject)
# Root layout (adjust margins/spacing a bit)
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.setContentsMargins(12, 12, 12, 12)
v.setSpacing(10)
v.addLayout(form) v.addLayout(form)
v.addWidget(bb) v.addWidget(bb, 0, Qt.AlignRight)
def _browse(self): def _browse(self):
p, _ = QFileDialog.getSaveFileName( p, _ = QFileDialog.getSaveFileName(
@ -63,10 +156,53 @@ class SettingsDialog(QDialog):
self.path_edit.setText(p) self.path_edit.setText(p)
def _save(self): def _save(self):
self._cfg = DBConfig(path=Path(self.path_edit.text()), key="") key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig(
path=Path(self.path_edit.text()),
key=key_to_save,
idle_minutes=self.idle_spin.value(),
)
save_db_config(self._cfg) save_db_config(self._cfg)
self.accept() self.accept()
def _change_key(self):
p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
if p1.exec() != QDialog.Accepted:
return
new_key = p1.key()
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
if p2.exec() != QDialog.Accepted:
return
if new_key != p2.key():
QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
return
if not new_key:
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
return
try:
self._db.rekey(new_key)
QMessageBox.information(
self, "Key changed", "The notebook was re-encrypted with the new key!"
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
@Slot(bool)
def save_key_btn_clicked(self, checked: bool):
if checked:
if not self.key:
p1 = KeyPrompt(
self, title="Enter your key", message="Enter the encryption key"
)
if p1.exec() != QDialog.Accepted:
self.save_key_btn.blockSignals(True)
self.save_key_btn.setChecked(False)
self.save_key_btn.blockSignals(False)
return
self.key = p1.key() or ""
else:
self.key = ""
@property @property
def config(self) -> DBConfig: def config(self) -> DBConfig:
return self._cfg return self._cfg

157
bouquin/toolbar.py Normal file
View file

@ -0,0 +1,157 @@
from __future__ import annotations
from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
from PySide6.QtWidgets import QToolBar
class ToolBar(QToolBar):
boldRequested = Signal()
italicRequested = Signal()
underlineRequested = Signal()
strikeRequested = Signal()
codeRequested = Signal()
headingRequested = Signal(int)
bulletsRequested = Signal()
numbersRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal()
def __init__(self, parent=None):
super().__init__("Format", parent)
self.setObjectName("Format")
self.setToolButtonStyle(Qt.ToolButtonTextOnly)
self._build_actions()
self._apply_toolbar_styles()
def _build_actions(self):
self.actBold = QAction("Bold", self)
self.actBold.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested)
self.actItalic = QAction("Italic", self)
self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested)
self.actUnderline = QAction("Underline", self)
self.actUnderline.setShortcut(QKeySequence.Underline)
self.actUnderline.triggered.connect(self.underlineRequested)
self.actStrike = QAction("Strikethrough", self)
self.actStrike.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested)
self.actCode = QAction("Inline code", self)
self.actCode.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested)
# Headings
self.actH1 = QAction("Heading 1", self)
self.actH2 = QAction("Heading 2", self)
self.actH3 = QAction("Heading 3", self)
self.actNormal = QAction("Normal text", self)
self.actH1.setShortcut("Ctrl+1")
self.actH2.setShortcut("Ctrl+2")
self.actH3.setShortcut("Ctrl+3")
self.actNormal.setShortcut("Ctrl+O")
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
# Lists
self.actBullets = QAction("Bulleted list", self)
self.actBullets.triggered.connect(self.bulletsRequested)
self.actNumbers = QAction("Numbered list", self)
self.actNumbers.triggered.connect(self.numbersRequested)
# Alignment
self.actAlignL = QAction("Align left", self)
self.actAlignC = QAction("Align center", self)
self.actAlignR = QAction("Align right", self)
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
self.actAlignC.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignHCenter)
)
self.actAlignR.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignRight)
)
# History button
self.actHistory = QAction("History", self)
self.actHistory.triggered.connect(self.historyRequested)
self.addActions(
[
self.actBold,
self.actItalic,
self.actUnderline,
self.actStrike,
self.actCode,
self.actH1,
self.actH2,
self.actH3,
self.actNormal,
self.actBullets,
self.actNumbers,
self.actAlignL,
self.actAlignC,
self.actAlignR,
self.actHistory,
]
)
def _apply_toolbar_styles(self):
self._style_letter_button(self.actBold, "B", bold=True)
self._style_letter_button(self.actItalic, "I", italic=True)
self._style_letter_button(self.actUnderline, "U", underline=True)
self._style_letter_button(self.actStrike, "S", strike=True)
# Monospace look for code; use a fixed font
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._style_letter_button(self.actCode, "</>", custom_font=code_font)
# Headings
self._style_letter_button(self.actH1, "H1")
self._style_letter_button(self.actH2, "H2")
self._style_letter_button(self.actH3, "H3")
self._style_letter_button(self.actNormal, "N")
# Lists
self._style_letter_button(self.actBullets, "")
self._style_letter_button(self.actNumbers, "1.")
# Alignment
self._style_letter_button(self.actAlignL, "L")
self._style_letter_button(self.actAlignC, "C")
self._style_letter_button(self.actAlignR, "R")
# History
self._style_letter_button(self.actHistory, "View History")
def _style_letter_button(
self,
action: QAction,
text: str,
*,
bold: bool = False,
italic: bool = False,
underline: bool = False,
strike: bool = False,
custom_font: QFont | None = None,
):
btn = self.widgetForAction(action)
if not btn:
return
btn.setText(text)
f = custom_font if custom_font is not None else QFont(btn.font())
if custom_font is None:
f.setBold(bold)
f.setItalic(italic)
f.setUnderline(underline)
f.setStrikeOut(strike)
btn.setFont(f)
# Keep accessibility/tooltip readable
btn.setToolTip(action.text())
btn.setAccessibleName(action.text())

View file

@ -1,10 +1,11 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.1.0" version = "0.1.7"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://git.mig5.net/mig5/bouquin"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.9,<3.14" python = ">=3.9,<3.14"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

View file

@ -30,11 +30,11 @@ def test_manual_save_current_day(patched_main_window, qtbot):
win, *_ = patched_main_window win, *_ = patched_main_window
# Type into the editor and save # Type into the editor and save
win.editor.setPlainText("Test note") win.editor.setHtml("Test note")
win._save_current(explicit=True) # call directly to avoid waiting timers win._save_current(explicit=True) # call directly to avoid waiting timers
day = win._current_date_iso() day = win._current_date_iso()
assert win.db.get_entry(day) == "Test note" assert "Test note" in win.db.get_entry(day)
def test_switch_day_saves_previous(patched_main_window, qtbot): def test_switch_day_saves_previous(patched_main_window, qtbot):
@ -45,13 +45,13 @@ def test_switch_day_saves_previous(patched_main_window, qtbot):
# Write on Day 1 # Write on Day 1
d1 = win.calendar.selectedDate() d1 = win.calendar.selectedDate()
d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}" d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}"
win.editor.setPlainText("Notes day 1") win.editor.setHtml("Notes day 1")
# Trigger a day change (this path calls _on_date_changed via signal) # Trigger a day change (this path calls _on_date_changed via signal)
d2 = d1.addDays(1) d2 = d1.addDays(1)
win.calendar.setSelectedDate(d2) win.calendar.setSelectedDate(d2)
# After changing, previous day should be saved; editor now shows day 2 content (empty) # After changing, previous day should be saved; editor now shows day 2 content (empty)
assert win.db.get_entry(d1_iso) == "Notes day 1" assert "Notes day 1" in win.db.get_entry(d1_iso)
assert win.editor.toPlainText() == "" assert win.editor.toPlainText() == ""