Add Search ability
This commit is contained in:
parent
72862f9a4f
commit
53e99af912
9 changed files with 224 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) <> '';")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
195
bouquin/search.py
Normal 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
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue