Add Search ability

This commit is contained in:
Miguel Jacq 2025-11-01 17:44:23 +11:00
parent 72862f9a4f
commit 53e99af912
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
9 changed files with 224 additions and 9 deletions

View file

@ -1,6 +1,7 @@
# 0.1.2 # 0.1.2
* Switch from Markdown to HTML via QTextEdit, with a toolbar * Switch from Markdown to HTML via QTextEdit, with a toolbar
* Add search ability
* Fix Settings shortcut and change nav menu from 'File' to 'Application' * Fix Settings shortcut and change nav menu from 'File' to 'Application'
# 0.1.1 # 0.1.1

View file

@ -20,16 +20,15 @@ There is deliberately no network connectivity or syncing intended.
## Features ## Features
* Every 'page' is linked to the calendar day * Every 'page' is linked to the calendar day
* Basic markdown * Text is HTML with basic styling
* Search
* Automatic periodic saving (or explicitly save) * 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 * Transparent integrity checking of the database when it opens
* Rekey the database (change the password)
## Yet to do ## Yet to do
* Search
* Taxonomy/tagging * Taxonomy/tagging
* Export to other formats (plaintext, json, sql etc) * 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 * Download the whl and run it
### From PyPi ### From PyPi/pip
* `pip install bouquin` * `pip install bouquin`

View file

@ -100,6 +100,12 @@ class DBManager:
) )
self.conn.commit() 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]: def dates_with_content(self) -> list[str]:
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor, QColor,
QFont, QFont,

View file

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

View file

@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
from .db import DBManager from .db import DBManager
from .editor import Editor from .editor import Editor
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
from .search import Search
from .settings import APP_NAME, load_db_config, save_db_config from .settings import APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog from .settings_dialog import SettingsDialog
from .toolbar import ToolBar from .toolbar import ToolBar
@ -44,12 +45,16 @@ class MainWindow(QMainWindow):
self.calendar.setGridVisible(True) self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed) self.calendar.selectionChanged.connect(self._on_date_changed)
self.search = Search(self.db)
self.search.openDateRequested.connect(self._load_selected_date)
# Lock the calendar to the left panel at the top to stop it stretching # Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized. # when the main window is resized.
left_panel = QWidget() left_panel = QWidget()
left_layout = QVBoxLayout(left_panel) left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop) left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
left_layout.addStretch(1) left_layout.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
@ -187,7 +192,8 @@ class MainWindow(QMainWindow):
d = self.calendar.selectedDate() d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
def _load_selected_date(self): def _load_selected_date(self, date_iso=False):
if not date_iso:
date_iso = self._current_date_iso() date_iso = self._current_date_iso()
try: try:
text = self.db.get_entry(date_iso) text = self.db.get_entry(date_iso)

195
bouquin/search.py Normal file
View file

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

View file

@ -91,7 +91,9 @@ class SettingsDialog(QDialog):
return return
try: try:
self._db.rekey(new_key) 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: except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")

View file

@ -1,7 +1,10 @@
from __future__ import annotations
from PySide6.QtCore import Signal, Qt from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QFont, QAction from PySide6.QtGui import QFont, QAction
from PySide6.QtWidgets import QToolBar from PySide6.QtWidgets import QToolBar
class ToolBar(QToolBar): class ToolBar(QToolBar):
boldRequested = Signal(QFont.Weight) boldRequested = Signal(QFont.Weight)
italicRequested = Signal() italicRequested = Signal()