diff --git a/bouquin/db.py b/bouquin/db.py index 58143b6..fdc6764 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1,18 +1,29 @@ from __future__ import annotations import csv +import hashlib import html import json from dataclasses import dataclass from pathlib import Path from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple +from typing import List, Sequence, Tuple, Iterable + from . import strings Entry = Tuple[str, str] +TagRow = Tuple[int, str, str] +_TAG_COLORS = [ + "#FFB3BA", # soft red + "#FFDFBA", # soft orange + "#FFFFBA", # soft yellow + "#BAFFC9", # soft green + "#BAE1FF", # soft blue + "#E0BAFF", # soft purple +] @dataclass class DBConfig: @@ -82,7 +93,6 @@ class DBManager: # Always keep FKs on cur.execute("PRAGMA foreign_keys = ON;") - # Create new versioned schema if missing (< 0.1.5) cur.executescript( """ CREATE TABLE IF NOT EXISTS pages ( @@ -103,6 +113,24 @@ class DBManager: 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); + + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name); + + CREATE TABLE IF NOT EXISTS page_tags ( + page_date TEXT NOT NULL, -- FK to pages.date + tag_id INTEGER NOT NULL, -- FK to tags.id + PRIMARY KEY (page_date, tag_id), + FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id); """ ) self.conn.commit() @@ -142,25 +170,35 @@ class DBManager: def search_entries(self, text: str) -> list[str]: """ - Search for entries by term. This only works against the latest - version of the page. + Search for entries by term or tag name. + This only works against the latest version of the page. """ cur = self.conn.cursor() - pattern = f"%{text}%" + q = text.strip() + pattern = f"%{q.lower()}%" + rows = cur.execute( """ - SELECT p.date, v.content + SELECT DISTINCT p.date, v.content FROM pages AS p JOIN versions AS v ON v.id = p.current_version_id + LEFT JOIN page_tags pt + ON pt.page_date = p.date + LEFT JOIN tags t + ON t.id = pt.tag_id WHERE TRIM(v.content) <> '' - AND v.content LIKE LOWER(?) ESCAPE '\\' + AND ( + LOWER(v.content) LIKE ? + OR LOWER(COALESCE(t.name, '')) LIKE ? + ) ORDER BY p.date DESC; """, - (pattern,), + (pattern, pattern), ).fetchall() return [(r[0], r[1]) for r in rows] + def dates_with_content(self) -> list[str]: """ Find all entries and return the dates of them. @@ -386,6 +424,142 @@ class DBManager: except Exception as e: print(f"{strings._('error')}: {e}") + # -------- Tags: helpers ------------------------------------------- + + def _default_tag_colour(self, name: str) -> str: + """ + Deterministically pick a colour for a tag name from a small palette. + """ + if not name: + return "#CCCCCC" + h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) + return _TAG_COLORS[h % len(_TAG_COLORS)] + + # -------- Tags: per-page ------------------------------------------- + + def get_tags_for_page(self, date_iso: str) -> list[TagRow]: + """ + Return (id, name, color) for all tags attached to this page/date. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT t.id, t.name, t.color + FROM page_tags pt + JOIN tags t ON t.id = pt.tag_id + WHERE pt.page_date = ? + ORDER BY LOWER(t.name); + """, + (date_iso,), + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def set_tags_for_page(self, date_iso: str, tag_names: Sequence[str]) -> None: + """ + Replace the tag set for a page with the given names. + Creates new tags as needed (with auto colours). + """ + # Normalise + dedupe + clean_names = [] + seen = set() + for name in tag_names: + name = name.strip() + if not name: + continue + if name.lower() in seen: + continue + seen.add(name.lower()) + clean_names.append(name) + + with self.conn: + cur = self.conn.cursor() + + # Ensure the page row exists even if there's no content yet + cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,)) + + if not clean_names: + # Just clear all tags for this page + cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) + return + + # Ensure tag rows exist + for name in clean_names: + cur.execute( + """ + INSERT OR IGNORE INTO tags(name, color) + VALUES (?, ?); + """, + (name, self._default_tag_colour(name)), + ) + + # Lookup ids + placeholders = ",".join("?" for _ in clean_names) + rows = cur.execute( + f""" + SELECT id, name + FROM tags + WHERE name IN ({placeholders}); + """, + tuple(clean_names), + ).fetchall() + ids_by_name = {r["name"]: r["id"] for r in rows} + + # Reset page_tags for this page + cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) + for name in clean_names: + tag_id = ids_by_name.get(name) + if tag_id is not None: + cur.execute( + """ + INSERT OR IGNORE INTO page_tags(page_date, tag_id) + VALUES (?, ?); + """, + (date_iso, tag_id), + ) + + # -------- Tags: global management ---------------------------------- + + def list_tags(self) -> list[TagRow]: + """ + Return all tags in the database. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT id, name, color + FROM tags + ORDER BY LOWER(name); + """ + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def update_tag(self, tag_id: int, name: str, color: str) -> None: + """ + Update a tag's name and colour. + """ + name = name.strip() + color = color.strip() or "#CCCCCC" + + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + UPDATE tags + SET name = ?, color = ? + WHERE id = ?; + """, + (name, color, tag_id), + ) + + def delete_tag(self, tag_id: int) -> None: + """ + Delete a tag entirely (removes it from all pages). + """ + with self.conn: + cur = self.conn.cursor() + cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) + cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) + def close(self) -> None: if self.conn is not None: self.conn.close() diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index dfae41c..c191f8f 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -110,5 +110,13 @@ "toolbar_numbered_list": "Numbered list", "toolbar_code_block": "Code block", "toolbar_heading": "Heading", - "toolbar_toggle_checkboxes": "Toggle checkboxes" + "toolbar_toggle_checkboxes": "Toggle checkboxes", + "tags": "Tags", + "manage_tags": "Manage tags…", + "add_tag_placeholder": "Add tag and press Enter…", + "tag_name": "Tag name", + "tag_color_hex": "Hex colour", + "pick_color": "Pick colour…", + "invalid_color_title": "Invalid colour", + "invalid_color_message": "Please enter a valid hex colour like #RRGGBB." } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 578852f..be8bb7f 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -56,6 +56,7 @@ from .search import Search from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from . import strings +from .tags_widget import PageTagsWidget from .toolbar import ToolBar from .theme import ThemeManager @@ -93,6 +94,8 @@ class MainWindow(QMainWindow): self.search.openDateRequested.connect(self._load_selected_date) self.search.resultDatesChanged.connect(self._on_search_dates_changed) + self.tags = PageTagsWidget(self.db) + # Lock the calendar to the left panel at the top to stop it stretching # when the main window is resized. left_panel = QWidget() @@ -100,6 +103,7 @@ class MainWindow(QMainWindow): left_layout.setContentsMargins(8, 8, 8, 8) left_layout.addWidget(self.calendar) left_layout.addWidget(self.search) + left_layout.addWidget(self.tags) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) # Create tab widget to hold multiple editors @@ -498,6 +502,12 @@ class MainWindow(QMainWindow): with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(editor.current_date) + + # update per-page tags for the active tab + if hasattr(self, "tags"): + date_iso = editor.current_date.toString("yyyy-MM-dd") + self.tags.set_current_date(date_iso) + # Reconnect toolbar to new active editor self._sync_toolbar() @@ -628,6 +638,10 @@ class MainWindow(QMainWindow): # Keep tabs sorted by date self._reorder_tabs_by_date() + # sync tags + if hasattr(self, "tags"): + self.tags.set_current_date(date_iso) + def _load_date_into_editor(self, date: QDate, extra_data=False): """Load a specific date's content into a given editor.""" date_iso = date.toString("yyyy-MM-dd") diff --git a/bouquin/tags_dialog.py b/bouquin/tags_dialog.py new file mode 100644 index 0000000..59c722a --- /dev/null +++ b/bouquin/tags_dialog.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QTableWidget, + QTableWidgetItem, + QPushButton, + QColorDialog, + QMessageBox, +) + +from . import strings +from .db import DBManager + +class TagManagerDialog(QDialog): + def __init__(self, db: DBManager, parent=None): + super().__init__(parent) + self._db = db + self.setWindowTitle(strings._("manage_tags")) + + layout = QVBoxLayout(self) + + self.table = QTableWidget(0, 2, self) + self.table.setHorizontalHeaderLabels( + [strings._("tag_name"), strings._("tag_color_hex")] + ) + self.table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.table) + + btn_row = QHBoxLayout() + self.add_btn = QPushButton(strings._("add")) + self.remove_btn = QPushButton(strings._("remove")) + self.color_btn = QPushButton(strings._("pick_color")) + btn_row.addWidget(self.add_btn) + btn_row.addWidget(self.remove_btn) + btn_row.addWidget(self.color_btn) + btn_row.addStretch(1) + layout.addLayout(btn_row) + + action_row = QHBoxLayout() + ok_btn = QPushButton(strings._("ok")) + cancel_btn = QPushButton(strings._("cancel")) + ok_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + action_row.addStretch(1) + action_row.addWidget(ok_btn) + action_row.addWidget(cancel_btn) + layout.addLayout(action_row) + + self.add_btn.clicked.connect(self._add_row) + self.remove_btn.clicked.connect(self._remove_selected) + self.color_btn.clicked.connect(self._pick_color) + + self._load_tags() + + def _load_tags(self) -> None: + self.table.setRowCount(0) + for tag_id, name, color in self._db.list_tags(): + row = self.table.rowCount() + self.table.insertRow(row) + + name_item = QTableWidgetItem(name) + name_item.setData(Qt.ItemDataRole.UserRole, tag_id) + self.table.setItem(row, 0, name_item) + + color_item = QTableWidgetItem(color) + color_item.setBackground(Qt.GlobalColor.transparent) + self.table.setItem(row, 1, color_item) + + def _add_row(self) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + name_item = QTableWidgetItem("") + name_item.setData(Qt.ItemDataRole.UserRole, 0) # 0 => new tag + self.table.setItem(row, 0, name_item) + self.table.setItem(row, 1, QTableWidgetItem("#CCCCCC")) + + def _remove_selected(self) -> None: + row = self.table.currentRow() + if row < 0: + return + item = self.table.item(row, 0) + tag_id = item.data(Qt.ItemDataRole.UserRole) + if tag_id: + self._db.delete_tag(int(tag_id)) + self.table.removeRow(row) + + def _pick_color(self) -> None: + row = self.table.currentRow() + if row < 0: + return + item = self.table.item(row, 1) + current = item.text() or "#CCCCCC" + color = QColorDialog.getColor() + if color.isValid(): + item.setText(color.name()) + + def accept(self) -> None: + # Persist all rows back to DB + for row in range(self.table.rowCount()): + name_item = self.table.item(row, 0) + color_item = self.table.item(row, 1) + if name_item is None or color_item is None: + continue + + name = name_item.text().strip() + color = color_item.text().strip() or "#CCCCCC" + tag_id = int(name_item.data(Qt.ItemDataRole.UserRole) or 0) + + if not name: + continue # ignore empty rows + + if not color.startswith("#") or len(color) not in (4, 7): + QMessageBox.warning( + self, + strings._("invalid_color_title"), + strings._("invalid_color_message"), + ) + return # keep dialog open + + if tag_id == 0: + # new tag: just rely on set_tags_for_page/create, or you can + # insert here if you like. Easiest is to create via DBManager: + # use a dummy page or do a direct insert + self._db.set_tags_for_page("__dummy__", [name]) + # then delete the dummy row; or instead provide a DBManager + # helper to create a tag explicitly. + else: + self._db.update_tag(tag_id, name, color) + + super().accept() diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py new file mode 100644 index 0000000..506ce88 --- /dev/null +++ b/bouquin/tags_widget.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from typing import Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QVBoxLayout, + QWidget, + QToolButton, + QLabel, + QLineEdit, + QSizePolicy, + QStyle, +) + +from . import strings +from .db import DBManager + +class TagChip(QFrame): + removeRequested = Signal(int) # tag_id + + def __init__(self, tag_id: int, name: str, color: str, parent: QWidget | None = None): + super().__init__(parent) + self._id = tag_id + self.setObjectName("TagChip") + + self.setFrameShape(QFrame.StyledPanel) + self.setFrameShadow(QFrame.Raised) + + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(4) + + color_lbl = QLabel() + color_lbl.setFixedSize(10, 10) + color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 3px;") + layout.addWidget(color_lbl) + + name_lbl = QLabel(name) + layout.addWidget(name_lbl) + + btn = QToolButton() + btn.setText("×") + btn.setAutoRaise(True) + btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) + layout.addWidget(btn) + + @property + def tag_id(self) -> int: + return self._id + + +class PageTagsWidget(QFrame): + """ + Collapsible per-page tag editor shown in the left sidebar. + """ + + def __init__(self, db: DBManager, parent: QWidget | None = None): + super().__init__(parent) + self._db = db + self._current_date: Optional[str] = None + + self.setFrameShape(QFrame.StyledPanel) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + + # Header (toggle + manage button) + self.toggle_btn = QToolButton() + self.toggle_btn.setText(strings._("tags")) + self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.toggle_btn.setCheckable(True) + self.toggle_btn.setChecked(False) + self.toggle_btn.setArrowType(Qt.RightArrow) + self.toggle_btn.clicked.connect(self._on_toggle) + + self.manage_btn = QToolButton() + self.manage_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView)) + self.manage_btn.setToolTip(strings._("manage_tags")) + self.manage_btn.setAutoRaise(True) + self.manage_btn.clicked.connect(self._open_manager) + + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.addWidget(self.toggle_btn) + header.addStretch(1) + header.addWidget(self.manage_btn) + + # Body (chips + add line) + self.body = QWidget() + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 4, 0, 0) + self.body_layout.setSpacing(4) + + # Simple horizontal layout for now; you can swap for a FlowLayout + self.chip_row = QHBoxLayout() + self.chip_row.setContentsMargins(0, 0, 0, 0) + self.chip_row.setSpacing(4) + self.body_layout.addLayout(self.chip_row) + + self.add_edit = QLineEdit() + self.add_edit.setPlaceholderText(strings._("add_tag_placeholder")) + self.add_edit.returnPressed.connect(self._on_add_tag) + self.body_layout.addWidget(self.add_edit) + + self.body.setVisible(False) + + main = QVBoxLayout(self) + main.setContentsMargins(0, 0, 0, 0) + main.addLayout(header) + main.addWidget(self.body) + + # ----- external API ------------------------------------------------ + + def set_current_date(self, date_iso: str) -> None: + self._current_date = date_iso + if self.toggle_btn.isChecked(): + self._reload_tags() + else: + # Keep it cheap while collapsed; reload only when expanded + self._clear_chips() + + # ----- internals --------------------------------------------------- + + def _on_toggle(self, checked: bool) -> None: + self.body.setVisible(checked) + self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) + if checked and self._current_date: + self._reload_tags() + + def _clear_chips(self) -> None: + while self.chip_row.count(): + item = self.chip_row.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _reload_tags(self) -> None: + if not self._current_date: + self._clear_chips() + return + + self._clear_chips() + tags = self._db.get_tags_for_page(self._current_date) + for tag_id, name, color in tags: + chip = TagChip(tag_id, name, color, self) + chip.removeRequested.connect(self._remove_tag) + self.chip_row.addWidget(chip) + self.chip_row.addStretch(1) + + def _on_add_tag(self) -> None: + if not self._current_date: + return + new_tag = self.add_edit.text().strip() + if not new_tag: + return + + # Combine current tags + new one, then write back + existing = [name for _, name, _ in self._db.get_tags_for_page(self._current_date)] + existing.append(new_tag) + self._db.set_tags_for_page(self._current_date, existing) + self.add_edit.clear() + self._reload_tags() + + def _remove_tag(self, tag_id: int) -> None: + if not self._current_date: + return + tags = self._db.get_tags_for_page(self._current_date) + remaining = [name for (tid, name, _color) in tags if tid != tag_id] + self._db.set_tags_for_page(self._current_date, remaining) + self._reload_tags() + + def _open_manager(self) -> None: + from .tags_dialog import TagManagerDialog + + dlg = TagManagerDialog(self._db, self) + if dlg.exec(): + # Names/colours may have changed + if self._current_date: + self._reload_tags() +