diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2c84f..4234e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 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) diff --git a/README.md b/README.md index 4bf621a..404c4e8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ There is deliberately no network connectivity or syncing intended. * Search * Automatic periodic saving (or explicitly save) * Transparent integrity checking of the database when it opens + * Automatic locking of the app after a period of inactivity (default 15 min) * Rekey the database (change the password) * Export the database to json, txt, html or csv diff --git a/bouquin/db.py b/bouquin/db.py index 39226f5..df5aa62 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -26,14 +26,17 @@ class DBManager: self.conn: sqlite.Connection | None = None def connect(self) -> bool: + """ + Open, decrypt and install schema on the database. + """ # Ensure parent dir exists self.cfg.path.parent.mkdir(parents=True, exist_ok=True) self.conn = sqlite.connect(str(self.cfg.path)) self.conn.row_factory = sqlite.Row cur = self.conn.cursor() cur.execute(f"PRAGMA key = '{self.cfg.key}';") - cur.execute("PRAGMA journal_mode = WAL;") - self.conn.commit() + cur.execute("PRAGMA foreign_keys = ON;") + cur.execute("PRAGMA journal_mode = WAL;").fetchone() try: self._integrity_ok() except Exception: @@ -44,15 +47,18 @@ class DBManager: return True def _integrity_ok(self) -> bool: + """ + Runs the cipher_integrity_check PRAGMA on the database. + """ cur = self.conn.cursor() cur.execute("PRAGMA cipher_integrity_check;") rows = cur.fetchall() - # OK + # OK: nothing returned if not rows: 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) raise sqlite.IntegrityError( "SQLCipher integrity check failed" @@ -60,16 +66,62 @@ class DBManager: ) def _ensure_schema(self) -> None: + """ + Install the expected schema on the database. + We also handle upgrades here. + """ cur = self.conn.cursor() - cur.execute( + # 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 entries ( - date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd - content TEXT NOT NULL + 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); """ ) - cur.execute("PRAGMA user_version = 1;") + + # 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: @@ -92,42 +144,214 @@ class DBManager: 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() - cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,)) - row = cur.fetchone() + 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 "" def upsert_entry(self, date_iso: str, content: str) -> None: - cur = self.conn.cursor() - cur.execute( - """ - INSERT INTO entries(date, content) VALUES(?, ?) - ON CONFLICT(date) DO UPDATE SET content = excluded.content; - """, - (date_iso, content), - ) - self.conn.commit() + """ + Insert or update an entry. + """ + # Make a new version and set it as current + self.save_new_version(date_iso, content, note=None, set_current=True) 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}%" - return cur.execute( - "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,) + 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; + """, + (pattern,), ).fetchall() + return [(r[0], r[1]) for r in rows] 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.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") - return [r[0] for r in cur.fetchall()] + rows = cur.execute( + """ + 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] - def get_all_entries(self) -> List[Entry]: + # ------------------------- 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 date, content FROM entries ORDER BY date").fetchall() - return [(row["date"], row["content"]) for row in rows] + 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: diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py new file mode 100644 index 0000000..b4f3bcd --- /dev/null +++ b/bouquin/history_dialog.py @@ -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)[^>]*>.*?") + COMMENT_RE = re.compile(r"", re.S) + BR_RE = re.compile(r"(?i)") + BLOCK_END_RE = re.compile(r"(?i)") + 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"+ {_html.escape(line[1:])}" + ) + elif line.startswith("-") and not line.startswith("---"): + lines.append( + f"- {_html.escape(line[1:])}" + ) + elif line.startswith("@@"): + lines.append(f"{_html.escape(line)}") + else: + lines.append(f"{_html.escape(line)}") + css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }" + return f"
{'
'.join(lines)}
" + + +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 vs current) + 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 vs current") + 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 diff --git a/bouquin/main_window.py b/bouquin/main_window.py index d7726ca..7d2fbfc 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -29,7 +29,9 @@ from PySide6.QtWidgets import ( from .db import DBManager from .editor import Editor +from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt +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 @@ -146,6 +148,7 @@ class MainWindow(QMainWindow): 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.addWidget(left_panel) @@ -181,10 +184,15 @@ class MainWindow(QMainWindow): # Menu bar (File) mb = self.menuBar() file_menu = mb.addMenu("&File") - act_save = QAction("&Save", self) + act_save = QAction("&Save a version", self) act_save.setShortcut("Ctrl+S") act_save.triggered.connect(lambda: self._save_current(explicit=True)) file_menu.addAction(act_save) + 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) @@ -330,7 +338,7 @@ class MainWindow(QMainWindow): def _on_text_changed(self): 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): """Move selection by delta days (negative for previous).""" @@ -358,7 +366,7 @@ class MainWindow(QMainWindow): # Now load the newly 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. explicit=True means user invoked Save: show feedback even if nothing changed. @@ -367,7 +375,7 @@ class MainWindow(QMainWindow): return text = self.editor.toHtml() try: - self.db.upsert_entry(date_iso, text) + self.db.save_new_version(date_iso, text, note) except Exception as e: QMessageBox.critical(self, "Save Error", str(e)) return @@ -381,9 +389,34 @@ class MainWindow(QMainWindow): ) 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 - 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_history(self): + date_iso = self._current_date_iso() + dlg = HistoryDialog(self.db, date_iso, self) + if dlg.exec() == QDialog.Accepted: + # refresh editor + calendar (head pointer may have changed) + self._load_selected_date(date_iso) + self._refresh_calendar_marks() + + # ----------- Settings handler ------------# def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) if dlg.exec() != QDialog.Accepted: @@ -414,6 +447,7 @@ class MainWindow(QMainWindow): self._load_selected_date() self._refresh_calendar_marks() + # ------------ Window positioning --------------- # def _restore_window_position(self): geom = self.settings.value("main/geometry", None) state = self.settings.value("main/windowState", None) @@ -447,6 +481,7 @@ class MainWindow(QMainWindow): # Center the window in that screen’s available area self.move(r.center() - self.rect().center()) + # ----------------- Export handler ----------------- # @Slot() def _export(self): try: diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py new file mode 100644 index 0000000..5e4095e --- /dev/null +++ b/bouquin/save_dialog.py @@ -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() diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 182b527..1304acf 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -15,6 +15,7 @@ class ToolBar(QToolBar): bulletsRequested = Signal() numbersRequested = Signal() alignRequested = Signal(Qt.AlignmentFlag) + historyRequested = Signal() def __init__(self, parent=None): super().__init__("Format", parent) @@ -76,6 +77,10 @@ class ToolBar(QToolBar): lambda: self.alignRequested.emit(Qt.AlignRight) ) + # History button + self.actHistory = QAction("History", self) + self.actHistory.triggered.connect(self.historyRequested) + self.addActions( [ self.actBold, @@ -92,6 +97,7 @@ class ToolBar(QToolBar): self.actAlignL, self.actAlignC, self.actAlignR, + self.actHistory, ] ) @@ -120,6 +126,9 @@ class ToolBar(QToolBar): 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, diff --git a/pyproject.toml b/pyproject.toml index 37d4413..1797db1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.1.4" +version = "0.1.5" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/screenshot.png b/screenshot.png index be2ec9f..e0843e5 100644 Binary files a/screenshot.png and b/screenshot.png differ