Early work on tags
This commit is contained in:
parent
8cd9538a50
commit
0a04b25fe5
5 changed files with 520 additions and 9 deletions
190
bouquin/db.py
190
bouquin/db.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
134
bouquin/tags_dialog.py
Normal 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
181
bouquin/tags_widget.py
Normal 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()
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue