diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 86bf338..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,39 +0,0 @@ -# 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. diff --git a/README.md b/README.md index ea1cbdc..8b20e14 100644 --- a/README.md +++ b/README.md @@ -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. To increase security, the SQLCipher key is requested when the app is opened, and is not written -to disk unless the user configures it to be in the settings. +to disk. There is deliberately no network connectivity or syncing intended. @@ -19,23 +19,24 @@ There is deliberately no network connectivity or syncing intended. ## Features - * Data is encrypted at rest - * Encryption key is prompted for and never stored, unless user chooses to via Settings * Every 'page' is linked to the calendar day - * All changes are version controlled, with ability to view/diff versions and revert - * Text is HTML with basic styling - * Search + * Basic markdown * Automatic periodic saving (or explicitly save) + * Navigating from one day to the next automatically saves + * Basic keyboard shortcuts * 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 + + +## Yet to do + + * Search + * Taxonomy/tagging + * Ability to change the SQLCipher key + * Export to other formats (plaintext, json, sql etc) ## How to install -Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions). - ### From source * Clone this repo or download the tarball from the releases page @@ -47,7 +48,7 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o * Download the whl and run it -### From PyPi/pip +### From PyPi * `pip install bouquin` diff --git a/bouquin/db.py b/bouquin/db.py index df5aa62..1ea60fa 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1,23 +1,15 @@ from __future__ import annotations -import csv -import html -import json -import os - from dataclasses import dataclass from pathlib import Path -from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple -Entry = Tuple[str, str] +from sqlcipher3 import dbapi2 as sqlite @dataclass class DBConfig: path: Path key: str - idle_minutes: int = 15 # 0 = never lock class DBManager: @@ -26,17 +18,14 @@ 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 foreign_keys = ON;") - cur.execute("PRAGMA journal_mode = WAL;").fetchone() + cur.execute("PRAGMA cipher_compatibility = 4;") + cur.execute("PRAGMA journal_mode = WAL;") + self.conn.commit() try: self._integrity_ok() except Exception: @@ -47,18 +36,15 @@ 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: nothing returned + # OK if not rows: return - # Not OK: rows of problems returned + # Not OK details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None) raise sqlite.IntegrityError( "SQLCipher integrity check failed" @@ -66,384 +52,39 @@ class DBManager: ) def _ensure_schema(self) -> None: - """ - Install the expected schema on the database. - We also handle upgrades here. - """ 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( + cur.execute( """ - 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 entries ( + date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd + content TEXT NOT 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( - """ - 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;") + cur.execute("PRAGMA user_version = 1;") self.conn.commit() - 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() - # Change the encryption key of the currently open database - 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() + cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,)) + row = cur.fetchone() return row[0] if row else "" def upsert_entry(self, date_iso: str, content: str) -> None: - """ - Insert or update an entry. - """ - # Make a new version and set it as current - self.save_new_version(date_iso, content, note=None, set_current=True) - - 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( + 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; + INSERT INTO entries(date, content) VALUES(?, ?) + ON CONFLICT(date) DO UPDATE SET content = excluded.content; """, - (pattern,), - ).fetchall() - return [(r[0], r[1]) for r in rows] + (date_iso, content), + ) + self.conn.commit() 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() - 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] - - # ------------------------- 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)[^>]*>.*?") - 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"<[^>]+>") - WS_ENDS_RE = re.compile(r"[ \\t]+\\n") - MULTINEWLINE_RE = re.compile(r"\\n{3,}") - - def _strip(s: str) -> str: - # 1) Remove ", - "", - f"

{html.escape(title)}

", - ] - for d, c in entries: - parts.append( - f"
{c}
" - ) - parts.append("") - - 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}") + cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") + return [r[0] for r in cur.fetchall()] def close(self) -> None: if self.conn is not None: diff --git a/bouquin/editor.py b/bouquin/editor.py deleted file mode 100644 index eb3b664..0000000 --- a/bouquin/editor.py +++ /dev/null @@ -1,248 +0,0 @@ -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 block’s 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) diff --git a/bouquin/highlighter.py b/bouquin/highlighter.py new file mode 100644 index 0000000..456dfa2 --- /dev/null +++ b/bouquin/highlighter.py @@ -0,0 +1,112 @@ +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) diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py deleted file mode 100644 index 1c906ac..0000000 --- a/bouquin/history_dialog.py +++ /dev/null @@ -1,179 +0,0 @@ -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) - 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 diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index 095093c..1fe8dee 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -14,8 +14,8 @@ class KeyPrompt(QDialog): def __init__( self, parent=None, - title: str = "Enter key", - message: str = "Enter key", + title: str = "Unlock database", + message: str = "Enter SQLCipher key", ): super().__init__(parent) self.setWindowTitle(title) diff --git a/bouquin/main.py b/bouquin/main.py index 3e5f90b..9beb4d9 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -11,6 +11,5 @@ def main(): app = QApplication(sys.argv) app.setApplicationName(APP_NAME) app.setOrganizationName(APP_ORG) - win = MainWindow() - win.show() + win = MainWindow(); win.show() sys.exit(app.exec()) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 7d2fbfc..394ccb9 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1,96 +1,26 @@ from __future__ import annotations -import os import sys -from pathlib import Path -from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent -from PySide6.QtGui import ( - QAction, - QCursor, - QDesktopServices, - QFont, - QGuiApplication, - QTextCharFormat, -) +from PySide6.QtCore import QDate, QTimer, Qt +from PySide6.QtGui import QAction, QFont, QTextCharFormat from PySide6.QtWidgets import ( - QCalendarWidget, QDialog, - QFileDialog, - QLabel, + QCalendarWidget, QMainWindow, QMessageBox, - QPushButton, - QSizePolicy, + QPlainTextEdit, QSplitter, QVBoxLayout, QWidget, + QSizePolicy, ) from .db import DBManager -from .editor import Editor -from .history_dialog import HistoryDialog +from .settings import APP_NAME, load_db_config, save_db_config 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 .highlighter import MarkdownHighlighter 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): @@ -100,18 +30,9 @@ class MainWindow(QMainWindow): self.setMinimumSize(1000, 650) self.cfg = load_db_config() - if not os.path.exists(self.cfg.path): - # 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) - else: - self._try_connect() + # Always prompt for the key (we never store it) + if not self._prompt_for_key_until_valid(): + sys.exit(1) # ---- UI: Left fixed panel (calendar) + right editor ----------------- self.calendar = QCalendarWidget() @@ -119,36 +40,17 @@ class MainWindow(QMainWindow): self.calendar.setGridVisible(True) 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_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(8, 8, 8, 8) left_layout.addWidget(self.calendar, alignment=Qt.AlignTop) - left_layout.addWidget(self.search, alignment=Qt.AlignBottom) left_layout.addStretch(1) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) - # This is the note-taking editor - self.editor = Editor() - - # 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) + self.editor = QPlainTextEdit() + tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ") + self.editor.setTabStopDistance(tab_w) + self.highlighter = MarkdownHighlighter(self.editor.document()) split = QSplitter() split.addWidget(left_panel) @@ -160,54 +62,26 @@ class MainWindow(QMainWindow): lay.addWidget(split) 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 self.statusBar().showMessage("Ready", 800) # Menu bar (File) mb = self.menuBar() file_menu = mb.addMenu("&File") - act_save = QAction("&Save a version", self) + act_save = QAction("&Save", 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 = QAction("&Settings", self) act_settings.triggered.connect(self._open_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() act_quit = QAction("&Quit", self) act_quit.setShortcut("Ctrl+Q") act_quit.triggered.connect(self.close) file_menu.addAction(act_quit) - # Navigate menu with next/previous/today + # Navigate menu with next/previous day nav_menu = mb.addMenu("&Navigate") act_prev = QAction("Previous Day", self) act_prev.setShortcut("Ctrl+P") @@ -223,28 +97,6 @@ class MainWindow(QMainWindow): nav_menu.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 self._dirty = False self._save_timer = QTimer(self) @@ -252,18 +104,12 @@ class MainWindow(QMainWindow): self._save_timer.timeout.connect(self._save_current) self.editor.textChanged.connect(self._on_text_changed) - # First load + mark dates in calendar with content + # First load + mark dates with content self._load_selected_date() self._refresh_calendar_marks() - # Restore window position from settings - self.settings = QSettings(APP_ORG, APP_NAME) - self._restore_window_position() - + # --- DB lifecycle def _try_connect(self) -> bool: - """ - Try to connect to the database. - """ try: self.db = DBManager(self.cfg) ok = self.db.connect() @@ -276,29 +122,17 @@ class MainWindow(QMainWindow): return False return ok - 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" + def _prompt_for_key_until_valid(self) -> bool: while True: - dlg = KeyPrompt(self, title, message) + dlg = KeyPrompt(self, message="Enter a key to unlock the notebook") if dlg.exec() != QDialog.Accepted: return False self.cfg.key = dlg.key() if self._try_connect(): return True + # --- Calendar marks to indicate text exists for htat day ----------------- 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.setFontWeight(QFont.Weight.Bold) # Clear previous marks @@ -319,37 +153,29 @@ class MainWindow(QMainWindow): d = self.calendar.selectedDate() return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" - def _load_selected_date(self, date_iso=False): - if not date_iso: - date_iso = self._current_date_iso() + def _load_selected_date(self): + date_iso = self._current_date_iso() try: text = self.db.get_entry(date_iso) except Exception as e: QMessageBox.critical(self, "Read Error", str(e)) return self.editor.blockSignals(True) - self.editor.setHtml(text) + self.editor.setPlainText(text) self.editor.blockSignals(False) self._dirty = False # track which date the editor currently represents self._active_date_iso = date_iso - qd = QDate.fromString(date_iso, "yyyy-MM-dd") - self.calendar.setSelectedDate(qd) def _on_text_changed(self): self._dirty = True - self._save_timer.start(10000) # autosave after idle + self._save_timer.start(1200) # autosave after idle def _adjust_day(self, delta: int): """Move selection by delta days (negative for previous).""" d = self.calendar.selectedDate().addDays(delta) self.calendar.setSelectedDate(d) - def _adjust_today(self): - """Jump to today.""" - today = QDate.currentDate() - self.calendar.setSelectedDate(today) - def _on_date_changed(self): """ When the calendar selection changes, save the previous day's note if dirty, @@ -366,16 +192,16 @@ class MainWindow(QMainWindow): # Now load the newly selected date self._load_selected_date() - def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"): + def _save_date(self, date_iso: str, explicit: bool = False): """ Save editor contents into the given date. Shows status on success. explicit=True means user invoked Save: show feedback even if nothing changed. """ if not self._dirty and not explicit: return - text = self.editor.toHtml() + text = self.editor.toPlainText() try: - self.db.save_new_version(date_iso, text, note) + self.db.upsert_entry(date_iso, text) except Exception as e: QMessageBox.critical(self, "Save Error", str(e)) return @@ -389,225 +215,29 @@ 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, note) - try: - self._save_timer.start() - except Exception: - pass + self._save_date(self._current_date_iso(), explicit) - 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: - return + dlg = SettingsDialog(self.cfg, self) + if dlg.exec() == QDialog.Accepted: + new_cfg = dlg.config + if new_cfg.path != self.cfg.path: + # Save the new path to the notebook + self.cfg.path = new_cfg.path + save_db_config(self.cfg) + self.db.close() + # Prompt again for the key for the new path + if not self._prompt_for_key_until_valid(): + QMessageBox.warning( + self, "Reopen failed", "Could not unlock database at new path." + ) + return + self._load_selected_date() + self._refresh_calendar_marks() - new_cfg = dlg.config - old_path = self.cfg.path - - # Update in-memory config from the dialog - 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) - - # 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() - if not self._prompt_for_key_until_valid(first_time=False): - QMessageBox.warning( - self, "Reopen failed", "Could not unlock database at new path." - ) - return - 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) - 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 screen’s available area - self.move(r.center() - self.rect().center()) - - # ----------------- Export handler ----------------- # - @Slot() - def _export(self): + def closeEvent(self, event): # noqa: N802 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 you’re 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.db.close() except Exception: diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py deleted file mode 100644 index 5e4095e..0000000 --- a/bouquin/save_dialog.py +++ /dev/null @@ -1,35 +0,0 @@ -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/search.py b/bouquin/search.py deleted file mode 100644 index 8cd2fd5..0000000 --- a/bouquin/search.py +++ /dev/null @@ -1,195 +0,0 @@ -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 "(no preview)" - ) - 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 diff --git a/bouquin/settings.py b/bouquin/settings.py index fc92394..508e12f 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -21,13 +21,9 @@ def get_settings() -> QSettings: def load_db_config() -> DBConfig: s = get_settings() path = Path(s.value("db/path", str(default_db_path()))) - key = s.value("db/key", "") - idle = s.value("db/idle_minutes", 15, type=int) - return DBConfig(path=path, key=key, idle_minutes=idle) + return DBConfig(path=path, key="") def save_db_config(cfg: DBConfig) -> None: s = get_settings() s.setValue("db/path", str(cfg.path)) - s.setValue("db/key", str(cfg.key)) - s.setValue("db/idle_minutes", str(cfg.idle_minutes)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index c981388..790c4e0 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -3,12 +3,8 @@ from __future__ import annotations from pathlib import Path from PySide6.QtWidgets import ( - QCheckBox, QDialog, QFormLayout, - QFrame, - QGroupBox, - QLabel, QHBoxLayout, QVBoxLayout, QWidget, @@ -17,29 +13,21 @@ from PySide6.QtWidgets import ( QFileDialog, QDialogButtonBox, QSizePolicy, - QSpinBox, - QMessageBox, ) -from PySide6.QtCore import Qt, Slot -from PySide6.QtGui import QPalette - -from .db import DBConfig, DBManager -from .settings import load_db_config, save_db_config -from .key_prompt import KeyPrompt +from .db import DBConfig +from .settings import save_db_config class SettingsDialog(QDialog): - def __init__(self, cfg: DBConfig, db: DBManager, parent=None): + def __init__(self, cfg: DBConfig, parent=None): super().__init__(parent) self.setWindowTitle("Settings") self._cfg = DBConfig(path=cfg.path, key="") - self._db = db - self.key = "" form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) - self.setMinimumWidth(560) + self.setMinimumWidth(520) self.setSizeGripEnabled(True) self.path_edit = QLineEdit(str(self._cfg.path)) @@ -56,94 +44,13 @@ class SettingsDialog(QDialog): h.setStretch(1, 0) 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.accepted.connect(self._save) bb.rejected.connect(self.reject) - # Root layout (adjust margins/spacing a bit) v = QVBoxLayout(self) - v.setContentsMargins(12, 12, 12, 12) - v.setSpacing(10) v.addLayout(form) - v.addWidget(bb, 0, Qt.AlignRight) + v.addWidget(bb) def _browse(self): p, _ = QFileDialog.getSaveFileName( @@ -156,53 +63,10 @@ class SettingsDialog(QDialog): self.path_edit.setText(p) def _save(self): - 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(), - ) + self._cfg = DBConfig(path=Path(self.path_edit.text()), key="") save_db_config(self._cfg) 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 def config(self) -> DBConfig: return self._cfg diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py deleted file mode 100644 index 241e15c..0000000 --- a/bouquin/toolbar.py +++ /dev/null @@ -1,157 +0,0 @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index 85c063f..1be51b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,10 @@ [tool.poetry] name = "bouquin" -version = "0.1.7" +version = "0.1.0" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" license = "GPL-3.0-or-later" -repository = "https://git.mig5.net/mig5/bouquin" [tool.poetry.dependencies] python = ">=3.9,<3.14" diff --git a/screenshot.png b/screenshot.png index e0843e5..a0d47bf 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/tests/test_ui.py b/tests/test_ui.py index 280a01a..5df04bc 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -30,11 +30,11 @@ def test_manual_save_current_day(patched_main_window, qtbot): win, *_ = patched_main_window # Type into the editor and save - win.editor.setHtml("Test note") + win.editor.setPlainText("Test note") win._save_current(explicit=True) # call directly to avoid waiting timers day = win._current_date_iso() - assert "Test note" in win.db.get_entry(day) + assert win.db.get_entry(day) == "Test note" 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 d1 = win.calendar.selectedDate() d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}" - win.editor.setHtml("Notes day 1") + win.editor.setPlainText("Notes day 1") # Trigger a day change (this path calls _on_date_changed via signal) d2 = d1.addDays(1) win.calendar.setSelectedDate(d2) # After changing, previous day should be saved; editor now shows day 2 content (empty) - assert "Notes day 1" in win.db.get_entry(d1_iso) + assert win.db.get_entry(d1_iso) == "Notes day 1" assert win.editor.toPlainText() == ""