This commit is contained in:
Miguel Jacq 2025-11-14 13:18:58 +11:00
parent df7ae0b42d
commit 5e283ecf17
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
8 changed files with 294 additions and 21 deletions

View file

@ -25,6 +25,7 @@ _TAG_COLORS = [
"#E0BAFF", # soft purple "#E0BAFF", # soft purple
] ]
@dataclass @dataclass
class DBConfig: class DBConfig:
path: Path path: Path
@ -198,7 +199,6 @@ class DBManager:
).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.
@ -560,6 +560,28 @@ class DBManager:
cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,))
cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,))
def get_pages_for_tag(self, tag_name: str) -> list[Entry]:
"""
Return (date, content) for pages that have the given tag.
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT p.date, v.content
FROM pages AS p
JOIN versions AS v
ON v.id = p.current_version_id
JOIN page_tags pt
ON pt.page_date = p.date
JOIN tags t
ON t.id = pt.tag_id
WHERE LOWER(t.name) = LOWER(?)
ORDER BY p.date DESC;
""",
(tag_name,),
).fetchall()
return [(r[0], r[1]) for r in rows]
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()

88
bouquin/flow_layout.py Normal file
View file

@ -0,0 +1,88 @@
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QSizePolicy
class FlowLayout(QLayout):
def __init__(
self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4
):
super().__init__(parent)
self._items = []
self._hspace = hspacing
self._vspace = vspacing
self.setContentsMargins(margin, margin, margin, margin)
def addItem(self, item):
self._items.append(item)
def itemAt(self, index):
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index):
if 0 <= index < len(self._items):
return self._items.pop(index)
return None
def count(self):
return len(self._items)
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width: int) -> int:
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def sizeHint(self) -> QSize:
return self.minimumSize()
def minimumSize(self) -> QSize:
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
left, top, right, bottom = self.getContentsMargins()
size += QSize(left + right, top + bottom)
return size
def _do_layout(self, rect: QRect, test_only: bool) -> int:
x = rect.x()
y = rect.y()
line_height = 0
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(+left, +top, -right, -bottom)
x = effective_rect.x()
y = effective_rect.y()
max_right = effective_rect.right()
for item in self._items:
wid = item.widget()
if wid is None or not wid.isVisible():
continue
space_x = self._hspace
space_y = self._vspace
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > max_right and line_height > 0:
# Wrap
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom

View file

@ -112,11 +112,15 @@
"toolbar_heading": "Heading", "toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes", "toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags", "tags": "Tags",
"manage_tags": "Manage tags…", "manage_tags": "Manage tags on this page",
"add_tag_placeholder": "Add tag and press Enter…", "add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_name": "Tag name", "tag_name": "Tag name",
"tag_color_hex": "Hex colour", "tag_color_hex": "Hex colour",
"pick_color": "Pick colour", "pick_color": "Pick colour",
"invalid_color_title": "Invalid colour", "invalid_color_title": "Invalid colour",
"invalid_color_message": "Please enter a valid hex colour like #RRGGBB." "invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
"add": "Add",
"remove": "Remove",
"ok": "OK"
} }

View file

@ -57,6 +57,7 @@ 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 .tags_widget import PageTagsWidget
from .status_tags_widget import StatusBarTagsWidget
from .toolbar import ToolBar from .toolbar import ToolBar
from .theme import ThemeManager from .theme import ThemeManager
@ -180,6 +181,11 @@ class MainWindow(QMainWindow):
# FindBar will get the current editor dynamically via a callable # FindBar will get the current editor dynamically via a callable
self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self)
self.statusBar().addPermanentWidget(self.findBar) self.statusBar().addPermanentWidget(self.findBar)
# status-bar tags widget
self.statusTags = StatusBarTagsWidget(self.db, self)
self.statusBar().addPermanentWidget(self.statusTags)
# Clicking a tag in the status bar will open tag pages (next section)
self.statusTags.tagActivated.connect(self._on_tag_activated)
# When the findBar closes, put the caret back in the editor # When the findBar closes, put the caret back in the editor
self.findBar.closed.connect(self._focus_editor_now) self.findBar.closed.connect(self._focus_editor_now)
@ -502,11 +508,8 @@ 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 # update per-page tags for the active tab
if hasattr(self, "tags"): self._update_tag_views_for_date(date_iso)
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()
@ -639,8 +642,7 @@ class MainWindow(QMainWindow):
self._reorder_tabs_by_date() self._reorder_tabs_by_date()
# sync tags # sync tags
if hasattr(self, "tags"): self._update_tag_views_for_date(date_iso)
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."""
@ -1005,6 +1007,20 @@ class MainWindow(QMainWindow):
for path_str in paths: for path_str in paths:
self.editor.insert_image_from_path(Path(path_str)) self.editor.insert_image_from_path(Path(path_str))
# ----------- Tags handler ----------------#
def _update_tag_views_for_date(self, date_iso: str):
if hasattr(self, "tags"):
self.tags.set_current_date(date_iso)
if hasattr(self, "statusTags"):
self.statusTags.set_current_page(date_iso)
def _on_tag_activated(self, tag_name: str):
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name)
dlg.openDateRequested.connect(self._load_selected_date)
dlg.exec()
# ----------- Settings handler ------------# # ----------- Settings handler ------------#
def _open_settings(self): def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self) dlg = SettingsDialog(self.cfg, self.db, self)

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QWidget,
QHBoxLayout,
QLabel,
QSizePolicy,
)
from .flow_layout import FlowLayout
from .db import DBManager
from .tags_widget import TagChip # reuse, or make a smaller variant if you prefer
class StatusBarTagsWidget(QWidget):
tagActivated = Signal(str) # tag name
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
self._current_date_iso: str | None = None
outer = QHBoxLayout(self)
outer.setContentsMargins(4, 0, 4, 0)
outer.setSpacing(4)
label = QLabel("Tags:")
outer.addWidget(label)
self.flow = FlowLayout(self, hspacing=2, vspacing=2)
outer.addLayout(self.flow)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def set_current_page(self, date_iso: str):
self._current_date_iso = date_iso
self._reload()
def _clear(self):
while self.flow.count():
item = self.flow.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _reload(self):
self._clear()
if not self._current_date_iso:
return
tags = self._db.get_tags_for_page(self._current_date_iso)
# Keep it small; maybe only first N tags:
MAX_TAGS = 6
for i, (tid, name, color) in enumerate(tags):
if i >= MAX_TAGS:
more = QLabel("")
self.flow.addWidget(more)
break
chip = TagChip(tid, name, color, self)
chip.clicked.connect(self.tagActivated)
# In status bar you might want a smaller style: adjust chip's stylesheet if needed.
self.flow.addWidget(chip)

60
bouquin/tag_browser.py Normal file
View file

@ -0,0 +1,60 @@
# tag_browser.py
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
)
from .db import DBManager
from . import strings
class TagBrowserDialog(QDialog):
openDateRequested = Signal(str)
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("tag_browser_title"))
layout = QVBoxLayout(self)
self.tree = QTreeWidget()
self.tree.setHeaderLabels([strings._("tag"), strings._("date")])
self.tree.itemActivated.connect(self._on_item_activated)
layout.addWidget(self.tree)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
self._populate(focus_tag)
def _populate(self, focus_tag: str | None):
tags = self._db.list_tags()
focus_item = None
for tag_id, name, color in tags:
root = QTreeWidgetItem([name, ""])
# coloured background or icon:
root.setData(0, Qt.ItemDataRole.UserRole, name)
self.tree.addTopLevelItem(root)
pages = self._db.get_pages_for_tag(name)
for date_iso, _content in pages:
child = QTreeWidgetItem(["", date_iso])
child.setData(0, Qt.ItemDataRole.UserRole, date_iso)
root.addChild(child)
if focus_tag and name.lower() == focus_tag.lower():
focus_item = root
if focus_item:
self.tree.expandItem(focus_item)
self.tree.setCurrentItem(focus_item)
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
date_iso = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(date_iso, str) and date_iso:
self.openDateRequested.emit(date_iso)

View file

@ -15,6 +15,7 @@ from PySide6.QtWidgets import (
from . import strings from . import strings
from .db import DBManager from .db import DBManager
class TagManagerDialog(QDialog): class TagManagerDialog(QDialog):
def __init__(self, db: DBManager, parent=None): def __init__(self, db: DBManager, parent=None):
super().__init__(parent) super().__init__(parent)
@ -42,12 +43,12 @@ class TagManagerDialog(QDialog):
action_row = QHBoxLayout() action_row = QHBoxLayout()
ok_btn = QPushButton(strings._("ok")) ok_btn = QPushButton(strings._("ok"))
cancel_btn = QPushButton(strings._("cancel")) close_btn = QPushButton(strings._("close"))
ok_btn.clicked.connect(self.accept) ok_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject) close_btn.clicked.connect(self.reject)
action_row.addStretch(1) action_row.addStretch(1)
action_row.addWidget(ok_btn) action_row.addWidget(ok_btn)
action_row.addWidget(cancel_btn) action_row.addWidget(close_btn)
layout.addLayout(action_row) layout.addLayout(action_row)
self.add_btn.clicked.connect(self._add_row) self.add_btn.clicked.connect(self._add_row)

View file

@ -17,13 +17,20 @@ from PySide6.QtWidgets import (
from . import strings from . import strings
from .db import DBManager from .db import DBManager
from .flow_layout import FlowLayout
class TagChip(QFrame): class TagChip(QFrame):
removeRequested = Signal(int) # tag_id removeRequested = Signal(int) # tag_id
clicked = Signal(str) # tag name
def __init__(self, tag_id: int, name: str, color: str, parent: QWidget | None = None): def __init__(
self, tag_id: int, name: str, color: str, parent: QWidget | None = None
):
super().__init__(parent) super().__init__(parent)
self._id = tag_id self._id = tag_id
self._name = name
self.setObjectName("TagChip") self.setObjectName("TagChip")
self.setFrameShape(QFrame.StyledPanel) self.setFrameShape(QFrame.StyledPanel)
@ -45,12 +52,20 @@ class TagChip(QFrame):
btn.setText("×") btn.setText("×")
btn.setAutoRaise(True) btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
self.setCursor(Qt.PointingHandCursor)
layout.addWidget(btn) layout.addWidget(btn)
@property @property
def tag_id(self) -> int: def tag_id(self) -> int:
return self._id return self._id
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self.clicked.emit(self._name)
super().mouseReleaseEvent(ev)
class PageTagsWidget(QFrame): class PageTagsWidget(QFrame):
""" """
@ -75,7 +90,9 @@ class PageTagsWidget(QFrame):
self.toggle_btn.clicked.connect(self._on_toggle) self.toggle_btn.clicked.connect(self._on_toggle)
self.manage_btn = QToolButton() self.manage_btn = QToolButton()
self.manage_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView)) self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip(strings._("manage_tags")) self.manage_btn.setToolTip(strings._("manage_tags"))
self.manage_btn.setAutoRaise(True) self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._open_manager) self.manage_btn.clicked.connect(self._open_manager)
@ -93,9 +110,7 @@ class PageTagsWidget(QFrame):
self.body_layout.setSpacing(4) self.body_layout.setSpacing(4)
# Simple horizontal layout for now; you can swap for a FlowLayout # Simple horizontal layout for now; you can swap for a FlowLayout
self.chip_row = QHBoxLayout() self.chip_row = FlowLayout(self.body, hspacing=4, vspacing=4)
self.chip_row.setContentsMargins(0, 0, 0, 0)
self.chip_row.setSpacing(4)
self.body_layout.addLayout(self.chip_row) self.body_layout.addLayout(self.chip_row)
self.add_edit = QLineEdit() self.add_edit = QLineEdit()
@ -145,8 +160,8 @@ class PageTagsWidget(QFrame):
for tag_id, name, color in tags: for tag_id, name, color in tags:
chip = TagChip(tag_id, name, color, self) chip = TagChip(tag_id, name, color, self)
chip.removeRequested.connect(self._remove_tag) chip.removeRequested.connect(self._remove_tag)
chip.clicked.connect(self._on_chip_clicked)
self.chip_row.addWidget(chip) self.chip_row.addWidget(chip)
self.chip_row.addStretch(1)
def _on_add_tag(self) -> None: def _on_add_tag(self) -> None:
if not self._current_date: if not self._current_date:
@ -156,7 +171,9 @@ class PageTagsWidget(QFrame):
return return
# Combine current tags + new one, then write back # Combine current tags + new one, then write back
existing = [name for _, name, _ in self._db.get_tags_for_page(self._current_date)] existing = [
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
]
existing.append(new_tag) existing.append(new_tag)
self._db.set_tags_for_page(self._current_date, existing) self._db.set_tags_for_page(self._current_date, existing)
self.add_edit.clear() self.add_edit.clear()
@ -179,3 +196,5 @@ class PageTagsWidget(QFrame):
if self._current_date: if self._current_date:
self._reload_tags() self._reload_tags()
def _on_chip_clicked(self, name: str) -> None:
self.tagActivated.emit(name)