Early work on tags

This commit is contained in:
Miguel Jacq 2025-11-13 20:37:02 +11:00
parent 8cd9538a50
commit 0a04b25fe5
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 520 additions and 9 deletions

View file

@ -1,18 +1,29 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import hashlib
import html import html
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple from typing import List, Sequence, Tuple, Iterable
from . import strings from . import strings
Entry = Tuple[str, str] 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 @dataclass
class DBConfig: class DBConfig:
@ -82,7 +93,6 @@ class DBManager:
# Always keep FKs on # Always keep FKs on
cur.execute("PRAGMA foreign_keys = ON;") cur.execute("PRAGMA foreign_keys = ON;")
# Create new versioned schema if missing (< 0.1.5)
cur.executescript( cur.executescript(
""" """
CREATE TABLE IF NOT EXISTS pages ( 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 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 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() self.conn.commit()
@ -142,25 +170,35 @@ class DBManager:
def search_entries(self, text: str) -> list[str]: def search_entries(self, text: str) -> list[str]:
""" """
Search for entries by term. This only works against the latest Search for entries by term or tag name.
version of the page. This only works against the latest version of the page.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
pattern = f"%{text}%" q = text.strip()
pattern = f"%{q.lower()}%"
rows = cur.execute( rows = cur.execute(
""" """
SELECT p.date, v.content SELECT DISTINCT p.date, v.content
FROM pages AS p FROM pages AS p
JOIN versions AS v JOIN versions AS v
ON v.id = p.current_version_id 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) <> '' 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; ORDER BY p.date DESC;
""", """,
(pattern,), (pattern, pattern),
).fetchall() ).fetchall()
return [(r[0], r[1]) for r in rows] return [(r[0], r[1]) for r in rows]
def dates_with_content(self) -> list[str]: def dates_with_content(self) -> list[str]:
""" """
Find all entries and return the dates of them. Find all entries and return the dates of them.
@ -386,6 +424,142 @@ class DBManager:
except Exception as e: except Exception as e:
print(f"{strings._('error')}: {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: def close(self) -> None:
if self.conn is not None: if self.conn is not None:
self.conn.close() self.conn.close()

View file

@ -110,5 +110,13 @@
"toolbar_numbered_list": "Numbered list", "toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block", "toolbar_code_block": "Code block",
"toolbar_heading": "Heading", "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."
} }

View file

@ -56,6 +56,7 @@ from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog from .settings_dialog import SettingsDialog
from . import strings from . import strings
from .tags_widget import PageTagsWidget
from .toolbar import ToolBar from .toolbar import ToolBar
from .theme import ThemeManager from .theme import ThemeManager
@ -93,6 +94,8 @@ class MainWindow(QMainWindow):
self.search.openDateRequested.connect(self._load_selected_date) self.search.openDateRequested.connect(self._load_selected_date)
self.search.resultDatesChanged.connect(self._on_search_dates_changed) 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 # 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()
@ -100,6 +103,7 @@ class MainWindow(QMainWindow):
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar) left_layout.addWidget(self.calendar)
left_layout.addWidget(self.search) left_layout.addWidget(self.search)
left_layout.addWidget(self.tags)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# Create tab widget to hold multiple editors # Create tab widget to hold multiple editors
@ -498,6 +502,12 @@ class MainWindow(QMainWindow):
with QSignalBlocker(self.calendar): with QSignalBlocker(self.calendar):
self.calendar.setSelectedDate(editor.current_date) 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 # Reconnect toolbar to new active editor
self._sync_toolbar() self._sync_toolbar()
@ -628,6 +638,10 @@ class MainWindow(QMainWindow):
# Keep tabs sorted by date # Keep tabs sorted by date
self._reorder_tabs_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): def _load_date_into_editor(self, date: QDate, extra_data=False):
"""Load a specific date's content into a given editor.""" """Load a specific date's content into a given editor."""
date_iso = date.toString("yyyy-MM-dd") date_iso = date.toString("yyyy-MM-dd")

134
bouquin/tags_dialog.py Normal file
View file

@ -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()

181
bouquin/tags_widget.py Normal file
View file

@ -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()