Tags working

This commit is contained in:
Miguel Jacq 2025-11-14 14:54:04 +11:00
parent 3263788415
commit f6e10dccac
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
11 changed files with 1148 additions and 267 deletions

View file

@ -8,7 +8,7 @@ import json
from dataclasses import dataclass
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple, Iterable
from typing import List, Sequence, Tuple
from . import strings
@ -458,8 +458,9 @@ class DBManager:
"""
Replace the tag set for a page with the given names.
Creates new tags as needed (with auto colours).
Tags are case-insensitive - reuses existing tag if found with different case.
"""
# Normalise + dedupe
# Normalise + dedupe (case-insensitive)
clean_names = []
seen = set()
for name in tag_names:
@ -482,8 +483,20 @@ class DBManager:
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
return
# Ensure tag rows exist
# For each tag name, check if it exists with different casing
# If so, reuse that existing tag; otherwise create new
final_tag_names = []
for name in clean_names:
# Look for existing tag (case-insensitive)
existing = cur.execute(
"SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,)
).fetchone()
if existing:
# Use the existing tag's exact name
final_tag_names.append(existing["name"])
else:
# Create new tag with the provided casing
cur.execute(
"""
INSERT OR IGNORE INTO tags(name, color)
@ -491,22 +504,23 @@ class DBManager:
""",
(name, self._default_tag_colour(name)),
)
final_tag_names.append(name)
# Lookup ids
placeholders = ",".join("?" for _ in clean_names)
# Lookup ids for the final tag names
placeholders = ",".join("?" for _ in final_tag_names)
rows = cur.execute(
f"""
SELECT id, name
FROM tags
WHERE name IN ({placeholders});
""",
tuple(clean_names),
tuple(final_tag_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:
for name in final_tag_names:
tag_id = ids_by_name.get(name)
if tag_id is not None:
cur.execute(

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QSizePolicy
from PySide6.QtWidgets import QLayout
class FlowLayout(QLayout):

View file

@ -112,15 +112,23 @@
"toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"manage_tags": "Manage tags on this page",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"tag_name": "Tag name",
"tag_color_hex": "Hex colour",
"color_hex": "Color",
"date": "Date",
"pick_color": "Pick colour",
"invalid_color_title": "Invalid colour",
"invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
"add": "Add",
"remove": "Remove",
"ok": "OK"
"ok": "OK",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages."
}

View file

@ -110,5 +110,25 @@
"toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code",
"toolbar_heading": "Titre",
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases"
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
"tags": "Tags",
"manage_tags": "Gérer les tags",
"add_tag_placeholder": "Ajouter un tag et appuyez sur Entrée",
"tag_browser_title": "Navigateur de tags",
"tag_browser_instructions": "Cliquez sur un tag pour l'étendre et voir toutes les pages avec ce tag. Cliquez sur une date pour l'ouvrir. Sélectionnez un tag pour modifier son nom, changer sa couleur ou le supprimer globalement.",
"tag_name": "Nom du tag",
"tag_color_hex": "Couleur hexadécimale",
"color_hex": "Couleur",
"date": "Date",
"pick_color": "Choisir la couleur",
"invalid_color_title": "Couleur invalide",
"invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.",
"add": "Ajouter",
"remove": "Supprimer",
"ok": "OK",
"edit_tag_name": "Modifier le nom du tag",
"new_tag_name": "Nouveau nom du tag :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer le tag",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer le tag '{name}' ? Cela le supprimera de toutes les pages."
}

View file

@ -110,5 +110,25 @@
"toolbar_numbered_list": "Elenco numerato",
"toolbar_code_block": "Blocco di codice",
"toolbar_heading": "Titolo",
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo"
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo",
"tags": "Tag",
"manage_tags": "Gestisci tag",
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
"tag_name": "Nome del tag",
"tag_color_hex": "Colore esadecimale",
"color_hex": "Colore",
"date": "Data",
"pick_color": "Scegli colore",
"invalid_color_title": "Colore non valido",
"invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.",
"add": "Aggiungi",
"remove": "Rimuovi",
"ok": "OK",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",
"change_color": "Cambia colore",
"delete_tag": "Elimina tag",
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine."
}

View file

@ -57,7 +57,6 @@ 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 .status_tags_widget import StatusBarTagsWidget
from .toolbar import ToolBar
from .theme import ThemeManager
@ -96,6 +95,8 @@ class MainWindow(QMainWindow):
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
# Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized.
@ -181,11 +182,6 @@ class MainWindow(QMainWindow):
# FindBar will get the current editor dynamically via a callable
self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self)
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
self.findBar.closed.connect(self._focus_editor_now)
@ -213,6 +209,10 @@ class MainWindow(QMainWindow):
act_backup.setShortcut("Ctrl+Shift+B")
act_backup.triggered.connect(self._backup)
file_menu.addAction(act_backup)
act_tags = QAction("&" + strings._("manage_tags"), self)
act_tags.setShortcut("Ctrl+T")
act_tags.triggered.connect(self.tags._open_manager)
file_menu.addAction(act_tags)
file_menu.addSeparator()
act_quit = QAction("&" + strings._("quit"), self)
act_quit.setShortcut("Ctrl+Q")
@ -509,6 +509,7 @@ class MainWindow(QMainWindow):
self.calendar.setSelectedDate(editor.current_date)
# update per-page tags for the active tab
date_iso = editor.current_date.toString("yyyy-MM-dd")
self._update_tag_views_for_date(date_iso)
# Reconnect toolbar to new active editor
@ -814,6 +815,10 @@ class MainWindow(QMainWindow):
if current_index >= 0:
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
# Update tags for the newly loaded page
date_iso = new_date.toString("yyyy-MM-dd")
self._update_tag_views_for_date(date_iso)
# Keep tabs sorted by date
self._reorder_tabs_by_date()
@ -1034,13 +1039,43 @@ class MainWindow(QMainWindow):
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):
def _on_tag_added(self):
"""Called when a tag is added - trigger autosave for current page"""
# Use QTimer to defer the save slightly, avoiding re-entrancy issues
from PySide6.QtCore import QTimer
QTimer.singleShot(0, self._do_tag_save)
def _do_tag_save(self):
"""Actually perform the save after tag is added"""
if hasattr(self, "editor") and hasattr(self.editor, "current_date"):
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Get current editor content
text = self.editor.to_markdown()
# Save the content (or blank if page is empty)
# This ensures the page shows up in tag browser
self.db.save_new_version(date_iso, text, note="Tag added")
self._dirty = False
self._refresh_calendar_marks()
from datetime import datetime as _dt
self.statusBar().showMessage(
strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}",
2000,
)
def _on_tag_activated(self, tag_name_or_date: str):
# If it's a date (YYYY-MM-DD format), load it
if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2:
self._load_selected_date(tag_name_or_date)
else:
# It's a tag name, open the tag browser
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name)
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date)
dlg.openDateRequested.connect(self._load_selected_date)
dlg.exec()

View file

@ -1,63 +0,0 @@
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)

View file

@ -1,11 +1,16 @@
# tag_browser.py
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
QLabel,
QColorDialog,
QMessageBox,
)
from .db import DBManager
@ -18,33 +23,86 @@ class TagBrowserDialog(QDialog):
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("tag_browser_title"))
self.setWindowTitle(
strings._("tag_browser_title") + " / " + strings._("manage_tags")
)
self.resize(600, 500)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(strings._("tag_browser_instructions"))
instructions.setWordWrap(True)
layout.addWidget(instructions)
self.tree = QTreeWidget()
self.tree.setHeaderLabels([strings._("tag"), strings._("date")])
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 100)
self.tree.itemActivated.connect(self._on_item_activated)
self.tree.itemClicked.connect(self._on_item_clicked)
layout.addWidget(self.tree)
# Tag management buttons
btn_row = QHBoxLayout()
self.edit_name_btn = QPushButton(strings._("edit_tag_name"))
self.edit_name_btn.clicked.connect(self._edit_tag_name)
self.edit_name_btn.setEnabled(False)
btn_row.addWidget(self.edit_name_btn)
self.change_color_btn = QPushButton(strings._("change_color"))
self.change_color_btn.clicked.connect(self._change_tag_color)
self.change_color_btn.setEnabled(False)
btn_row.addWidget(self.change_color_btn)
self.delete_btn = QPushButton(strings._("delete_tag"))
self.delete_btn.clicked.connect(self._delete_tag)
self.delete_btn.setEnabled(False)
btn_row.addWidget(self.delete_btn)
btn_row.addStretch(1)
layout.addLayout(btn_row)
# Close button
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self._populate(focus_tag)
def _populate(self, focus_tag: str | None):
self.tree.clear()
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)
# Create the tree item
root = QTreeWidgetItem([name, "", ""])
root.setData(
0,
Qt.ItemDataRole.UserRole,
{"type": "tag", "id": tag_id, "name": name, "color": color},
)
# Set background color for the second column to show the tag color
root.setBackground(1, QColor(color))
root.setText(1, color) # Also show the hex code
root.setTextAlignment(1, Qt.AlignCenter)
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)
child = QTreeWidgetItem(["", "", date_iso])
child.setData(
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
)
root.addChild(child)
if focus_tag and name.lower() == focus_tag.lower():
@ -54,7 +112,94 @@ class TagBrowserDialog(QDialog):
self.tree.expandItem(focus_item)
self.tree.setCurrentItem(focus_item)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Enable/disable buttons based on selection"""
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "tag":
self.edit_name_btn.setEnabled(True)
self.change_color_btn.setEnabled(True)
self.delete_btn.setEnabled(True)
else:
self.edit_name_btn.setEnabled(False)
self.change_color_btn.setEnabled(False)
self.delete_btn.setEnabled(False)
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:
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
self.accept()
def _edit_tag_name(self):
"""Edit the name of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
old_name = data["name"]
color = data["color"]
# Simple input dialog
from PySide6.QtWidgets import QInputDialog
new_name, ok = QInputDialog.getText(
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
)
if ok and new_name and new_name != old_name:
self._db.update_tag(tag_id, new_name, color)
self._populate(None)
def _change_tag_color(self):
"""Change the color of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
current_color = data["color"]
color = QColorDialog.getColor(QColor(current_color), self)
if color.isValid():
self._db.update_tag(tag_id, name, color.name())
self._populate(None)
def _delete_tag(self):
"""Delete the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
# Confirm deletion
reply = QMessageBox.question(
self,
strings._("delete_tag"),
strings._("delete_tag_confirm").format(name=name),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_tag(tag_id)
self._populate(None)

View file

@ -1,135 +0,0 @@
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"))
close_btn = QPushButton(strings._("close"))
ok_btn.clicked.connect(self.accept)
close_btn.clicked.connect(self.reject)
action_row.addStretch(1)
action_row.addWidget(ok_btn)
action_row.addWidget(close_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()

View file

@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
QLineEdit,
QSizePolicy,
QStyle,
QCompleter,
)
from . import strings
@ -25,7 +26,12 @@ class TagChip(QFrame):
clicked = Signal(str) # tag name
def __init__(
self, tag_id: int, name: str, color: str, parent: QWidget | None = None
self,
tag_id: int,
name: str,
color: str,
parent: QWidget | None = None,
show_remove: bool = True,
):
super().__init__(parent)
self._id = tag_id
@ -37,26 +43,26 @@ class TagChip(QFrame):
self.setFrameShadow(QFrame.Raised)
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 0, 4, 0)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(4)
color_lbl = QLabel()
color_lbl.setFixedSize(10, 10)
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 3px;")
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;")
layout.addWidget(color_lbl)
name_lbl = QLabel(name)
layout.addWidget(name_lbl)
if show_remove:
btn = QToolButton()
btn.setText("×")
btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
layout.addWidget(btn)
self.setCursor(Qt.PointingHandCursor)
layout.addWidget(btn)
@property
def tag_id(self) -> int:
return self._id
@ -70,8 +76,12 @@ class TagChip(QFrame):
class PageTagsWidget(QFrame):
"""
Collapsible per-page tag editor shown in the left sidebar.
Now displays tag chips even when collapsed.
"""
tagActivated = Signal(str) # tag name
tagAdded = Signal() # emitted when a tag is added to trigger autosave
def __init__(self, db: DBManager, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
@ -103,21 +113,25 @@ class PageTagsWidget(QFrame):
header.addStretch(1)
header.addWidget(self.manage_btn)
# Body (chips + add line)
# Body (chips + add line - only visible when expanded)
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 = FlowLayout(self.body, hspacing=4, vspacing=4)
self.body_layout.addLayout(self.chip_row)
# Chips container
self.chip_container = QWidget()
self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4)
self.body_layout.addWidget(self.chip_container)
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)
# Setup autocomplete
self._setup_autocomplete()
self.body_layout.addWidget(self.add_edit)
self.body.setVisible(False)
main = QVBoxLayout(self)
@ -129,23 +143,33 @@ class PageTagsWidget(QFrame):
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
# Only reload tags if expanded
if self.toggle_btn.isChecked():
self._reload_tags()
else:
# Keep it cheap while collapsed; reload only when expanded
self._clear_chips()
self._clear_chips() # Clear chips when collapsed
self._setup_autocomplete() # Update autocomplete with all available tags
# ----- internals ---------------------------------------------------
def _setup_autocomplete(self) -> None:
"""Setup autocomplete for the tag input with all existing tags"""
all_tags = [name for _, name, _ in self._db.list_tags()]
completer = QCompleter(all_tags, self.add_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.add_edit.setCompleter(completer)
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:
if checked:
if self._current_date:
self._reload_tags()
self.add_edit.setFocus()
def _clear_chips(self) -> None:
while self.chip_row.count():
item = self.chip_row.takeAt(0)
while self.chip_layout.count():
item = self.chip_layout.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
@ -158,26 +182,56 @@ class PageTagsWidget(QFrame):
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)
# Always show remove button since chips only visible when expanded
chip = TagChip(tag_id, name, color, self, show_remove=True)
chip.removeRequested.connect(self._remove_tag)
chip.clicked.connect(self._on_chip_clicked)
self.chip_row.addWidget(chip)
self.chip_layout.addWidget(chip)
chip.show()
chip.adjustSize()
# Force complete layout recalculation
self.chip_layout.invalidate()
self.chip_layout.activate()
self.chip_container.updateGeometry()
self.updateGeometry()
# Process pending events to ensure layout is applied
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
def _on_add_tag(self) -> None:
if not self._current_date:
return
# If the completer popup is visible and user pressed Enter,
# the completer will handle it - don't process it again
if self.add_edit.completer() and self.add_edit.completer().popup().isVisible():
return
new_tag = self.add_edit.text().strip()
if not new_tag:
return
# Combine current tags + new one, then write back
# Get existing tags for current page
existing = [
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
]
# Check for duplicates (case-insensitive)
if any(tag.lower() == new_tag.lower() for tag in existing):
self.add_edit.clear()
return
existing.append(new_tag)
self._db.set_tags_for_page(self._current_date, existing)
self.add_edit.clear()
self._reload_tags()
self._setup_autocomplete() # Update autocomplete list
# Signal that a tag was added so main window can trigger autosave
self.tagAdded.emit()
def _remove_tag(self, tag_id: int) -> None:
if not self._current_date:
@ -188,13 +242,15 @@ class PageTagsWidget(QFrame):
self._reload_tags()
def _open_manager(self) -> None:
from .tags_dialog import TagManagerDialog
from .tag_browser import TagBrowserDialog
dlg = TagManagerDialog(self._db, self)
dlg = TagBrowserDialog(self._db, self)
dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso))
if dlg.exec():
# Names/colours may have changed
# Reload tags after manager closes to pick up any changes
if self._current_date:
self._reload_tags()
self._setup_autocomplete()
def _on_chip_clicked(self, name: str) -> None:
self.tagActivated.emit(name)

781
tests/test_tags.py Normal file
View file

@ -0,0 +1,781 @@
from bouquin.db import DBManager
from bouquin.tags_widget import PageTagsWidget, TagChip
from bouquin.tag_browser import TagBrowserDialog
# ============================================================================
# DB Layer Tag Tests
# ============================================================================
def test_set_tags_for_page_creates_tags(fresh_db):
"""Test that setting tags for a page creates the tags in the database"""
date_iso = "2024-01-15"
tags = ["work", "important", "meeting"]
fresh_db.set_tags_for_page(date_iso, tags)
# Verify tags were created
all_tags = fresh_db.list_tags()
tag_names = [name for _, name, _ in all_tags]
assert "work" in tag_names
assert "important" in tag_names
assert "meeting" in tag_names
def test_get_tags_for_page(fresh_db):
"""Test retrieving tags for a specific page"""
date_iso = "2024-01-15"
tags = ["work", "important"]
fresh_db.set_tags_for_page(date_iso, tags)
retrieved_tags = fresh_db.get_tags_for_page(date_iso)
assert len(retrieved_tags) == 2
tag_names = [name for _, name, _ in retrieved_tags]
assert "work" in tag_names
assert "important" in tag_names
def test_tags_have_colors(fresh_db):
"""Test that created tags have default colors assigned"""
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["test"])
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 1
tag_id, name, color = tags[0]
assert color.startswith("#")
assert len(color) in (4, 7) # #RGB or #RRGGBB
def test_set_tags_replaces_existing(fresh_db):
"""Test that setting tags replaces the existing tag set"""
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["old1", "old2"])
fresh_db.set_tags_for_page(date_iso, ["new1", "new2"])
tags = fresh_db.get_tags_for_page(date_iso)
tag_names = [name for _, name, _ in tags]
assert "new1" in tag_names
assert "new2" in tag_names
assert "old1" not in tag_names
assert "old2" not in tag_names
def test_set_tags_empty_clears_tags(fresh_db):
"""Test that setting empty tag list clears all tags for page"""
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"])
fresh_db.set_tags_for_page(date_iso, [])
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 0
def test_tags_case_insensitive_deduplication(fresh_db):
"""Test that tags are deduplicated case-insensitively"""
date_iso = "2024-01-15"
# Try to set tags with different cases
fresh_db.set_tags_for_page(date_iso, ["Work", "work", "WORK"])
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 1
def test_tags_case_insensitive_reuse(fresh_db):
"""Test that existing tags are reused regardless of case"""
date1 = "2024-01-15"
date2 = "2024-01-16"
# Create tag with lowercase
fresh_db.set_tags_for_page(date1, ["work"])
# Try to add same tag with different case on different page
fresh_db.set_tags_for_page(date2, ["Work"])
# Should reuse the existing tag
all_tags = fresh_db.list_tags()
work_tags = [t for t in all_tags if t[1].lower() == "work"]
assert len(work_tags) == 1 # Only one "work" tag should exist
def test_tags_whitespace_normalization(fresh_db):
"""Test that tags are trimmed and empty strings ignored"""
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, [" work ", "", " ", "meeting"])
tags = fresh_db.get_tags_for_page(date_iso)
tag_names = [name for _, name, _ in tags]
assert "work" in tag_names
assert "meeting" in tag_names
assert len(tags) == 2 # Empty strings should be filtered
def test_list_all_tags(fresh_db):
"""Test listing all tags in the database"""
fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"])
fresh_db.set_tags_for_page("2024-01-16", ["tag2", "tag3"])
all_tags = fresh_db.list_tags()
tag_names = [name for _, name, _ in all_tags]
assert len(all_tags) == 3
assert "tag1" in tag_names
assert "tag2" in tag_names
assert "tag3" in tag_names
def test_update_tag_name_and_color(fresh_db):
"""Test updating a tag's name and color"""
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["oldname"])
tags = fresh_db.list_tags()
tag_id = tags[0][0]
fresh_db.update_tag(tag_id, "newname", "#FF0000")
updated_tags = fresh_db.list_tags()
assert len(updated_tags) == 1
assert updated_tags[0][1] == "newname"
assert updated_tags[0][2] == "#FF0000"
def test_delete_tag(fresh_db):
"""Test deleting a tag removes it globally"""
fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"])
fresh_db.set_tags_for_page("2024-01-16", ["tag1"])
tags = fresh_db.list_tags()
tag1_id = [tid for tid, name, _ in tags if name == "tag1"][0]
fresh_db.delete_tag(tag1_id)
# Tag should be removed from all pages
tags_page1 = fresh_db.get_tags_for_page("2024-01-15")
tags_page2 = fresh_db.get_tags_for_page("2024-01-16")
assert len(tags_page1) == 1
assert tags_page1[0][1] == "tag2"
assert len(tags_page2) == 0
def test_get_pages_for_tag(fresh_db):
"""Test retrieving all pages that have a specific tag"""
fresh_db.save_new_version("2024-01-15", "Content 1", "note")
fresh_db.save_new_version("2024-01-16", "Content 2", "note")
fresh_db.save_new_version("2024-01-17", "Content 3", "note")
fresh_db.set_tags_for_page("2024-01-15", ["work"])
fresh_db.set_tags_for_page("2024-01-16", ["work", "meeting"])
fresh_db.set_tags_for_page("2024-01-17", ["personal"])
pages = fresh_db.get_pages_for_tag("work")
dates = [date_iso for date_iso, _ in pages]
assert "2024-01-15" in dates
assert "2024-01-16" in dates
assert "2024-01-17" not in dates
def test_tags_persist_across_reconnect(fresh_db, tmp_db_cfg):
"""Test that tags persist when database is closed and reopened"""
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["persistent", "tag"])
fresh_db.close()
# Reopen database
db2 = DBManager(tmp_db_cfg)
assert db2.connect()
tags = db2.get_tags_for_page(date_iso)
tag_names = [name for _, name, _ in tags]
assert "persistent" in tag_names
assert "tag" in tag_names
db2.close()
# ============================================================================
# PageTagsWidget Tests
# ============================================================================
def test_page_tags_widget_creation(app, fresh_db):
"""Test that PageTagsWidget can be created"""
widget = PageTagsWidget(fresh_db)
assert widget is not None
assert widget._db == fresh_db
def test_page_tags_widget_set_current_date(app, fresh_db):
"""Test setting the current date on the widget"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["test"])
widget.set_current_date(date_iso)
assert widget._current_date == date_iso
def test_page_tags_widget_hidden_when_collapsed(app, fresh_db):
"""Test that tag chips are hidden when widget is collapsed"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"])
widget.set_current_date(date_iso)
# Body should be hidden when collapsed
assert not widget.body.isVisible()
assert not widget.toggle_btn.isChecked()
def test_page_tags_widget_shows_tags_when_expanded(app, fresh_db):
"""Test that tags are shown when widget is expanded"""
widget = PageTagsWidget(fresh_db)
widget.show() # Widget needs to be shown for visibility to work
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"])
widget.set_current_date(date_iso)
# Expand the widget
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
assert widget.body.isVisible()
assert widget.chip_layout.count() == 2
def test_page_tags_widget_add_tag(app, fresh_db):
"""Test adding a tag through the widget"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Simulate adding a tag
widget.add_edit.setText("newtag")
widget._on_add_tag()
# Verify tag was added
tags = fresh_db.get_tags_for_page(date_iso)
tag_names = [name for _, name, _ in tags]
assert "newtag" in tag_names
def test_page_tags_widget_prevents_duplicates(app, fresh_db):
"""Test that duplicate tags are prevented"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["existing"])
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Try to add the same tag
widget.add_edit.setText("existing")
widget._on_add_tag()
# Should still only have one tag
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 1
def test_page_tags_widget_case_insensitive_duplicates(app, fresh_db):
"""Test that duplicate checking is case-insensitive"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["Test"])
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Try to add with different case
widget.add_edit.setText("test")
widget._on_add_tag()
# Should still only have one tag
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 1
def test_page_tags_widget_remove_tag(app, fresh_db):
"""Test removing a tag through the widget"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"])
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Get the first tag's ID
tags = fresh_db.get_tags_for_page(date_iso)
tag_id = tags[0][0]
# Remove it
widget._remove_tag(tag_id)
# Verify tag was removed
remaining_tags = fresh_db.get_tags_for_page(date_iso)
assert len(remaining_tags) == 1
def test_page_tags_widget_empty_input_ignored(app, fresh_db):
"""Test that empty tag input is ignored"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Try to add empty tag
widget.add_edit.setText("")
widget._on_add_tag()
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 0
def test_page_tags_widget_whitespace_trimmed(app, fresh_db):
"""Test that whitespace is trimmed from tags"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Add tag with whitespace
widget.add_edit.setText(" spaced ")
widget._on_add_tag()
tags = fresh_db.get_tags_for_page(date_iso)
assert tags[0][1] == "spaced"
def test_page_tags_widget_autocomplete_setup(app, fresh_db):
"""Test that autocomplete is set up with existing tags"""
widget = PageTagsWidget(fresh_db)
# Create some tags
fresh_db.set_tags_for_page("2024-01-15", ["alpha", "beta", "gamma"])
# Setup autocomplete
widget._setup_autocomplete()
completer = widget.add_edit.completer()
assert completer is not None
# Check that model contains the tags
model = completer.model()
items = [model.index(i, 0).data() for i in range(model.rowCount())]
assert "alpha" in items
assert "beta" in items
assert "gamma" in items
def test_page_tags_widget_signal_tag_added(app, fresh_db):
"""Test that tagAdded signal is emitted when tag is added"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
widget.set_current_date(date_iso)
signal_emitted = {"emitted": False}
def on_tag_added():
signal_emitted["emitted"] = True
widget.tagAdded.connect(on_tag_added)
widget.add_edit.setText("testtag")
widget._on_add_tag()
assert signal_emitted["emitted"]
def test_page_tags_widget_signal_tag_activated(app, fresh_db):
"""Test that tagActivated signal is emitted when tag is clicked"""
widget = PageTagsWidget(fresh_db)
date_iso = "2024-01-15"
fresh_db.set_tags_for_page(date_iso, ["clickable"])
widget.set_current_date(date_iso)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
signal_data = {"tag_name": None}
def on_tag_activated(name):
signal_data["tag_name"] = name
widget.tagActivated.connect(on_tag_activated)
widget._on_chip_clicked("clickable")
assert signal_data["tag_name"] == "clickable"
# ============================================================================
# TagChip Tests
# ============================================================================
def test_tag_chip_creation(app):
"""Test that TagChip can be created"""
chip = TagChip(1, "test", "#FF0000")
assert chip is not None
assert chip.tag_id == 1
def test_tag_chip_with_remove_button(app):
"""Test TagChip with remove button"""
chip = TagChip(1, "test", "#FF0000", show_remove=True)
# Find the remove button (should be a QToolButton with text "×")
buttons = chip.findChildren(object)
assert any(hasattr(b, "text") and b.text() == "×" for b in buttons)
def test_tag_chip_without_remove_button(app):
"""Test TagChip without remove button"""
chip = TagChip(1, "test", "#FF0000", show_remove=False)
# Should not have remove button
buttons = chip.findChildren(object)
assert not any(hasattr(b, "text") and b.text() == "×" for b in buttons)
def test_tag_chip_color_display(app):
"""Test that TagChip displays the correct color"""
chip = TagChip(1, "test", "#FF0000")
# Find the color label
labels = chip.findChildren(object)
color_labels = [
l
for l in labels
if hasattr(l, "styleSheet") and "background-color" in str(l.styleSheet())
]
assert len(color_labels) > 0
assert (
"#FF0000" in color_labels[0].styleSheet()
or "rgb(255, 0, 0)" in color_labels[0].styleSheet()
)
def test_tag_chip_remove_signal(app):
"""Test that TagChip emits removeRequested signal"""
chip = TagChip(1, "test", "#FF0000", show_remove=True)
signal_data = {"tag_id": None}
def on_remove(tag_id):
signal_data["tag_id"] = tag_id
chip.removeRequested.connect(on_remove)
chip.removeRequested.emit(1)
assert signal_data["tag_id"] == 1
def test_tag_chip_clicked_signal(app):
"""Test that TagChip emits clicked signal"""
chip = TagChip(1, "test", "#FF0000")
signal_data = {"tag_name": None}
def on_clicked(name):
signal_data["tag_name"] = name
chip.clicked.connect(on_clicked)
chip.clicked.emit("test")
assert signal_data["tag_name"] == "test"
# ============================================================================
# TagBrowserDialog Tests
# ============================================================================
def test_tag_browser_creation(app, fresh_db):
"""Test that TagBrowserDialog can be created"""
dialog = TagBrowserDialog(fresh_db)
assert dialog is not None
def test_tag_browser_displays_tags(app, fresh_db):
"""Test that TagBrowserDialog displays tags in tree"""
fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"])
fresh_db.save_new_version("2024-01-15", "Content", "note")
dialog = TagBrowserDialog(fresh_db)
# Tree should have top-level items for each tag
assert dialog.tree.topLevelItemCount() == 2
def test_tag_browser_tag_with_pages(app, fresh_db):
"""Test that TagBrowserDialog shows pages under tags"""
fresh_db.save_new_version("2024-01-15", "Content 1", "note")
fresh_db.save_new_version("2024-01-16", "Content 2", "note")
fresh_db.set_tags_for_page("2024-01-15", ["work"])
fresh_db.set_tags_for_page("2024-01-16", ["work"])
dialog = TagBrowserDialog(fresh_db)
# Find the "work" tag item
root = dialog.tree.topLevelItem(0)
# Should have 2 child items (the dates)
assert root.childCount() == 2
def test_tag_browser_focus_tag(app, fresh_db):
"""Test that TagBrowserDialog can focus on a specific tag"""
fresh_db.set_tags_for_page("2024-01-15", ["alpha", "beta"])
dialog = TagBrowserDialog(fresh_db, focus_tag="beta")
# The focused tag should be expanded and selected
current_item = dialog.tree.currentItem()
assert current_item is not None
def test_tag_browser_color_display(app, fresh_db):
"""Test that tags display their colors in the browser"""
fresh_db.set_tags_for_page("2024-01-15", ["colorful"])
dialog = TagBrowserDialog(fresh_db)
root = dialog.tree.topLevelItem(0)
# Color should be shown in column 1
color_text = root.text(1)
assert color_text.startswith("#")
def test_tag_browser_edit_name_button_disabled(app, fresh_db):
"""Test that edit button is disabled when no tag selected"""
dialog = TagBrowserDialog(fresh_db)
assert not dialog.edit_name_btn.isEnabled()
def test_tag_browser_edit_name_button_enabled(app, fresh_db):
"""Test that edit button is enabled when tag selected"""
fresh_db.set_tags_for_page("2024-01-15", ["test"])
dialog = TagBrowserDialog(fresh_db)
# Select the first item
item = dialog.tree.topLevelItem(0)
dialog.tree.setCurrentItem(item)
dialog._on_item_clicked(item, 0)
assert dialog.edit_name_btn.isEnabled()
def test_tag_browser_delete_button_state(app, fresh_db):
"""Test that delete button state changes with selection"""
fresh_db.set_tags_for_page("2024-01-15", ["test"])
dialog = TagBrowserDialog(fresh_db)
# Initially disabled
assert not dialog.delete_btn.isEnabled()
# Select a tag
item = dialog.tree.topLevelItem(0)
dialog.tree.setCurrentItem(item)
dialog._on_item_clicked(item, 0)
# Should be enabled now
assert dialog.delete_btn.isEnabled()
def test_tag_browser_change_color_button_state(app, fresh_db):
"""Test that change color button state changes with selection"""
fresh_db.set_tags_for_page("2024-01-15", ["test"])
dialog = TagBrowserDialog(fresh_db)
# Initially disabled
assert not dialog.change_color_btn.isEnabled()
# Select a tag
item = dialog.tree.topLevelItem(0)
dialog.tree.setCurrentItem(item)
dialog._on_item_clicked(item, 0)
# Should be enabled now
assert dialog.change_color_btn.isEnabled()
def test_tag_browser_open_date_signal(app, fresh_db):
"""Test that clicking a date emits openDateRequested signal"""
fresh_db.save_new_version("2024-01-15", "Content", "note")
fresh_db.set_tags_for_page("2024-01-15", ["test"])
dialog = TagBrowserDialog(fresh_db)
signal_data = {"date": None}
def on_date_requested(date_iso):
signal_data["date"] = date_iso
dialog.openDateRequested.connect(on_date_requested)
# Get the tag item and expand it
root = dialog.tree.topLevelItem(0)
dialog.tree.expandItem(root)
# Get the date child item
date_item = root.child(0)
# Simulate activation
dialog._on_item_activated(date_item, 0)
assert signal_data["date"] == "2024-01-15"
# ============================================================================
# Integration Tests
# ============================================================================
def test_tag_workflow_end_to_end(app, fresh_db, tmp_path):
"""Test complete tag workflow: create, list, update, delete"""
# Create some entries with tags
dates = ["2024-01-15", "2024-01-16", "2024-01-17"]
for date in dates:
fresh_db.save_new_version(date, f"Entry {date}", "note")
fresh_db.set_tags_for_page(dates[0], ["work", "urgent"])
fresh_db.set_tags_for_page(dates[1], ["work", "meeting"])
fresh_db.set_tags_for_page(dates[2], ["personal"])
# List all tags - should be 4 unique tags: work, urgent, meeting, personal
all_tags = fresh_db.list_tags()
assert len(all_tags) == 4
# Get pages for "work" tag
work_pages = fresh_db.get_pages_for_tag("work")
work_dates = [d for d, _ in work_pages]
assert dates[0] in work_dates
assert dates[1] in work_dates
assert dates[2] not in work_dates
# Update a tag
work_tag = [t for t in all_tags if t[1] == "work"][0]
fresh_db.update_tag(work_tag[0], "office", "#0000FF")
# Verify update
updated_tags = fresh_db.list_tags()
office_tag = [t for t in updated_tags if t[1] == "office"][0]
assert office_tag[2] == "#0000FF"
# Delete a tag
urgent_tag = [t for t in all_tags if t[1] == "urgent"][0]
fresh_db.delete_tag(urgent_tag[0])
# Verify deletion - should have 3 tags now (office, meeting, personal)
final_tags = fresh_db.list_tags()
tag_names = [t[1] for t in final_tags]
assert "urgent" not in tag_names
assert len(final_tags) == 3
def test_tags_with_export(fresh_db, tmp_path):
"""Test that tags are preserved during export operations"""
date_iso = "2024-01-15"
fresh_db.save_new_version(date_iso, "Content", "note")
fresh_db.set_tags_for_page(date_iso, ["exported", "preserved"])
# Export to JSON
entries = fresh_db.get_all_entries()
json_path = tmp_path / "export.json"
fresh_db.export_json(entries, str(json_path))
assert json_path.exists()
# Tags should still be in database
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 2
def test_tags_survive_rekey(fresh_db, tmp_db_cfg):
"""Test that tags persist after database rekey"""
date_iso = "2024-01-15"
fresh_db.save_new_version(date_iso, "Content", "note")
fresh_db.set_tags_for_page(date_iso, ["persistent"])
# Rekey the database
fresh_db.rekey("new-key-456")
fresh_db.close()
# Reopen with new key
tmp_db_cfg.key = "new-key-456"
db2 = DBManager(tmp_db_cfg)
assert db2.connect()
# Tags should still be there
tags = db2.get_tags_for_page(date_iso)
assert len(tags) == 1
assert tags[0][1] == "persistent"
db2.close()
def test_multiple_pages_same_tags(fresh_db):
"""Test multiple pages can share the same tags"""
dates = ["2024-01-15", "2024-01-16", "2024-01-17"]
for date in dates:
fresh_db.save_new_version(date, f"Content {date}", "note")
fresh_db.set_tags_for_page(date, ["shared", "tag"])
# All pages should have the tags
for date in dates:
tags = fresh_db.get_tags_for_page(date)
tag_names = [name for _, name, _ in tags]
assert "shared" in tag_names
assert "tag" in tag_names
# But there should only be 2 unique tags in the database
all_tags = fresh_db.list_tags()
assert len(all_tags) == 2
def test_tag_page_without_content(fresh_db):
"""Test that pages can have tags even without content"""
date_iso = "2024-01-15"
# Set tags without saving any content
fresh_db.set_tags_for_page(date_iso, ["tagonly"])
# Tags should be retrievable
tags = fresh_db.get_tags_for_page(date_iso)
assert len(tags) == 1
assert tags[0][1] == "tagonly"
# Page should be created but with no content
content = fresh_db.get_entry(date_iso)
assert content is None or content == ""