diff --git a/CHANGELOG.md b/CHANGELOG.md index c54582d..06ef835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 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 diff --git a/README.md b/README.md index b874668..3307543 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,15 @@ There is deliberately no network connectivity or syncing intended. ## Features * Every 'page' is linked to the calendar day - * Basic markdown + * Text is HTML with basic styling + * Search * 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 + * Rekey the database (change the password) ## Yet to do - * Search * Taxonomy/tagging * Export to other formats (plaintext, json, sql etc) @@ -47,7 +46,7 @@ There is deliberately no network connectivity or syncing intended. * Download the whl and run it -### From PyPi +### From PyPi/pip * `pip install bouquin` diff --git a/bouquin/db.py b/bouquin/db.py index 15cc4c9..c75847e 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -100,6 +100,12 @@ class DBManager: ) self.conn.commit() + def search_entries(self, text: str) -> list[str]: + cur = self.conn.cursor() + pattern = f"%{text}%" + cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)) + return [r for r in cur.fetchall()] + def dates_with_content(self) -> list[str]: cur = self.conn.cursor() cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") diff --git a/bouquin/editor.py b/bouquin/editor.py index bf40a6b..7fe55c0 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from PySide6.QtGui import ( QColor, QFont, diff --git a/bouquin/main.py b/bouquin/main.py index 9beb4d9..3e5f90b 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -11,5 +11,6 @@ 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 359481e..bceaa8d 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -22,6 +22,7 @@ from PySide6.QtWidgets import ( from .db import DBManager from .editor import Editor from .key_prompt import KeyPrompt +from .search import Search from .settings import APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .toolbar import ToolBar @@ -44,12 +45,16 @@ 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) @@ -187,8 +192,9 @@ 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 = self._current_date_iso() + def _load_selected_date(self, date_iso=False): + if not date_iso: + date_iso = self._current_date_iso() try: text = self.db.get_entry(date_iso) except Exception as e: diff --git a/bouquin/search.py b/bouquin/search.py new file mode 100644 index 0000000..8177905 --- /dev/null +++ b/bouquin/search.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import re +from typing import Iterable, Tuple + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument +from PySide6.QtWidgets import ( + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QHBoxLayout, + QVBoxLayout, + QWidget, +) + +# type: rows are (date_iso, content) +Row = Tuple[str, str] + + +class Search(QWidget): + """Encapsulates the search UI + logic and emits a signal when a result is chosen.""" + + openDateRequested = Signal(str) + + def __init__(self, db, parent: QWidget | None = None): + super().__init__(parent) + self._db = db + + self.search = QLineEdit() + self.search.setPlaceholderText("Search for notes here") + self.search.textChanged.connect(self._search) + + self.results = QListWidget() + self.results.setUniformItemSizes(False) + self.results.setSelectionMode(self.results.SelectionMode.SingleSelection) + self.results.itemClicked.connect(self._open_selected) + self.results.hide() + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + lay.addWidget(self.search) + lay.addWidget(self.results) + + def _open_selected(self, item: QListWidgetItem): + date_str = item.data(Qt.ItemDataRole.UserRole) + if date_str: + self.openDateRequested.emit(date_str) + + def _search(self, text: str): + """ + Search for the supplied text in the database. + For all rows found, populate the results widget with a clickable preview. + """ + q = text.strip() + if not q: + self.results.clear() + self.results.hide() + return + + try: + rows: Iterable[Row] = self._db.search_entries(q) + except Exception: + # be quiet on DB errors here; caller can surface if desired + rows = [] + + self._populate_results(q, rows) + + def _populate_results(self, query: str, rows: Iterable[Row]): + self.results.clear() + rows = list(rows) + if not rows: + self.results.hide() + return + + self.results.show() + + for date_str, content in rows: + # Build an HTML fragment around the match and whether to show ellipses + frag_html, left_ell, right_ell = self._make_html_snippet( + content, query, radius=60, maxlen=180 + ) + + # ---- 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) # keep links in your HTML clickable + 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_dialog.py b/bouquin/settings_dialog.py index ca2514c..a59e1c6 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -91,7 +91,9 @@ class SettingsDialog(QDialog): return try: self._db.rekey(new_key) - QMessageBox.information(self, "Key changed", "The database key was updated.") + QMessageBox.information( + self, "Key changed", "The database key was updated." + ) except Exception as e: QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 471fd8e..93c7ee3 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,10 @@ +from __future__ import annotations + from PySide6.QtCore import Signal, Qt from PySide6.QtGui import QFont, QAction from PySide6.QtWidgets import QToolBar + class ToolBar(QToolBar): boldRequested = Signal(QFont.Weight) italicRequested = Signal()