Tags working
This commit is contained in:
parent
3263788415
commit
f6e10dccac
11 changed files with 1148 additions and 267 deletions
|
|
@ -8,7 +8,7 @@ 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, Iterable
|
from typing import List, Sequence, Tuple
|
||||||
|
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
@ -458,8 +458,9 @@ class DBManager:
|
||||||
"""
|
"""
|
||||||
Replace the tag set for a page with the given names.
|
Replace the tag set for a page with the given names.
|
||||||
Creates new tags as needed (with auto colours).
|
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 = []
|
clean_names = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for name in tag_names:
|
for name in tag_names:
|
||||||
|
|
@ -482,8 +483,20 @@ class DBManager:
|
||||||
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
|
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
|
||||||
return
|
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:
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO tags(name, color)
|
INSERT OR IGNORE INTO tags(name, color)
|
||||||
|
|
@ -491,22 +504,23 @@ class DBManager:
|
||||||
""",
|
""",
|
||||||
(name, self._default_tag_colour(name)),
|
(name, self._default_tag_colour(name)),
|
||||||
)
|
)
|
||||||
|
final_tag_names.append(name)
|
||||||
|
|
||||||
# Lookup ids
|
# Lookup ids for the final tag names
|
||||||
placeholders = ",".join("?" for _ in clean_names)
|
placeholders = ",".join("?" for _ in final_tag_names)
|
||||||
rows = cur.execute(
|
rows = cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
FROM tags
|
FROM tags
|
||||||
WHERE name IN ({placeholders});
|
WHERE name IN ({placeholders});
|
||||||
""",
|
""",
|
||||||
tuple(clean_names),
|
tuple(final_tag_names),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
ids_by_name = {r["name"]: r["id"] for r in rows}
|
ids_by_name = {r["name"]: r["id"] for r in rows}
|
||||||
|
|
||||||
# Reset page_tags for this page
|
# Reset page_tags for this page
|
||||||
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
|
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)
|
tag_id = ids_by_name.get(name)
|
||||||
if tag_id is not None:
|
if tag_id is not None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||||
from PySide6.QtWidgets import QLayout, QSizePolicy
|
from PySide6.QtWidgets import QLayout
|
||||||
|
|
||||||
|
|
||||||
class FlowLayout(QLayout):
|
class FlowLayout(QLayout):
|
||||||
|
|
|
||||||
|
|
@ -112,15 +112,23 @@
|
||||||
"toolbar_heading": "Heading",
|
"toolbar_heading": "Heading",
|
||||||
"toolbar_toggle_checkboxes": "Toggle checkboxes",
|
"toolbar_toggle_checkboxes": "Toggle checkboxes",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"manage_tags": "Manage tags on this page",
|
"manage_tags": "Manage tags",
|
||||||
"add_tag_placeholder": "Add a tag and press Enter",
|
"add_tag_placeholder": "Add a tag and press Enter",
|
||||||
"tag_browser_title": "Tag Browser",
|
"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_name": "Tag name",
|
||||||
"tag_color_hex": "Hex colour",
|
"tag_color_hex": "Hex colour",
|
||||||
|
"color_hex": "Color",
|
||||||
|
"date": "Date",
|
||||||
"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",
|
"add": "Add",
|
||||||
"remove": "Remove",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,5 +110,25 @@
|
||||||
"toolbar_numbered_list": "Liste numérotée",
|
"toolbar_numbered_list": "Liste numérotée",
|
||||||
"toolbar_code_block": "Bloc de code",
|
"toolbar_code_block": "Bloc de code",
|
||||||
"toolbar_heading": "Titre",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,5 +110,25 @@
|
||||||
"toolbar_numbered_list": "Elenco numerato",
|
"toolbar_numbered_list": "Elenco numerato",
|
||||||
"toolbar_code_block": "Blocco di codice",
|
"toolbar_code_block": "Blocco di codice",
|
||||||
"toolbar_heading": "Titolo",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -96,6 +95,8 @@ class MainWindow(QMainWindow):
|
||||||
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
||||||
|
|
||||||
self.tags = PageTagsWidget(self.db)
|
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
|
# 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.
|
||||||
|
|
@ -181,11 +182,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -213,6 +209,10 @@ class MainWindow(QMainWindow):
|
||||||
act_backup.setShortcut("Ctrl+Shift+B")
|
act_backup.setShortcut("Ctrl+Shift+B")
|
||||||
act_backup.triggered.connect(self._backup)
|
act_backup.triggered.connect(self._backup)
|
||||||
file_menu.addAction(act_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()
|
file_menu.addSeparator()
|
||||||
act_quit = QAction("&" + strings._("quit"), self)
|
act_quit = QAction("&" + strings._("quit"), self)
|
||||||
act_quit.setShortcut("Ctrl+Q")
|
act_quit.setShortcut("Ctrl+Q")
|
||||||
|
|
@ -509,6 +509,7 @@ class MainWindow(QMainWindow):
|
||||||
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
|
||||||
|
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
||||||
self._update_tag_views_for_date(date_iso)
|
self._update_tag_views_for_date(date_iso)
|
||||||
|
|
||||||
# Reconnect toolbar to new active editor
|
# Reconnect toolbar to new active editor
|
||||||
|
|
@ -814,6 +815,10 @@ class MainWindow(QMainWindow):
|
||||||
if current_index >= 0:
|
if current_index >= 0:
|
||||||
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
|
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
|
# Keep tabs sorted by date
|
||||||
self._reorder_tabs_by_date()
|
self._reorder_tabs_by_date()
|
||||||
|
|
||||||
|
|
@ -1034,13 +1039,43 @@ class MainWindow(QMainWindow):
|
||||||
def _update_tag_views_for_date(self, date_iso: str):
|
def _update_tag_views_for_date(self, date_iso: str):
|
||||||
if hasattr(self, "tags"):
|
if hasattr(self, "tags"):
|
||||||
self.tags.set_current_date(date_iso)
|
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
|
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.openDateRequested.connect(self._load_selected_date)
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
# tag_browser.py
|
# tag_browser.py
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
QTreeWidget,
|
QTreeWidget,
|
||||||
QTreeWidgetItem,
|
QTreeWidgetItem,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QColorDialog,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
|
|
@ -18,33 +23,86 @@ class TagBrowserDialog(QDialog):
|
||||||
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
|
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._db = db
|
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)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
instructions = QLabel(strings._("tag_browser_instructions"))
|
||||||
|
instructions.setWordWrap(True)
|
||||||
|
layout.addWidget(instructions)
|
||||||
|
|
||||||
self.tree = QTreeWidget()
|
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.itemActivated.connect(self._on_item_activated)
|
||||||
|
self.tree.itemClicked.connect(self._on_item_clicked)
|
||||||
layout.addWidget(self.tree)
|
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 = QPushButton(strings._("close"))
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
layout.addWidget(close_btn)
|
close_row.addWidget(close_btn)
|
||||||
|
layout.addLayout(close_row)
|
||||||
|
|
||||||
self._populate(focus_tag)
|
self._populate(focus_tag)
|
||||||
|
|
||||||
def _populate(self, focus_tag: str | None):
|
def _populate(self, focus_tag: str | None):
|
||||||
|
self.tree.clear()
|
||||||
tags = self._db.list_tags()
|
tags = self._db.list_tags()
|
||||||
focus_item = None
|
focus_item = None
|
||||||
|
|
||||||
for tag_id, name, color in tags:
|
for tag_id, name, color in tags:
|
||||||
root = QTreeWidgetItem([name, ""])
|
# Create the tree item
|
||||||
# coloured background or icon:
|
root = QTreeWidgetItem([name, "", ""])
|
||||||
root.setData(0, Qt.ItemDataRole.UserRole, 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)
|
self.tree.addTopLevelItem(root)
|
||||||
|
|
||||||
pages = self._db.get_pages_for_tag(name)
|
pages = self._db.get_pages_for_tag(name)
|
||||||
for date_iso, _content in pages:
|
for date_iso, _content in pages:
|
||||||
child = QTreeWidgetItem(["", date_iso])
|
child = QTreeWidgetItem(["", "", date_iso])
|
||||||
child.setData(0, Qt.ItemDataRole.UserRole, date_iso)
|
child.setData(
|
||||||
|
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
|
||||||
|
)
|
||||||
root.addChild(child)
|
root.addChild(child)
|
||||||
|
|
||||||
if focus_tag and name.lower() == focus_tag.lower():
|
if focus_tag and name.lower() == focus_tag.lower():
|
||||||
|
|
@ -54,7 +112,94 @@ class TagBrowserDialog(QDialog):
|
||||||
self.tree.expandItem(focus_item)
|
self.tree.expandItem(focus_item)
|
||||||
self.tree.setCurrentItem(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):
|
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
|
||||||
date_iso = item.data(0, Qt.ItemDataRole.UserRole)
|
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||||
if isinstance(date_iso, str) and date_iso:
|
if isinstance(data, dict):
|
||||||
|
if data.get("type") == "page":
|
||||||
|
date_iso = data.get("date")
|
||||||
|
if date_iso:
|
||||||
self.openDateRequested.emit(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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QStyle,
|
QStyle,
|
||||||
|
QCompleter,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
@ -25,7 +26,12 @@ class TagChip(QFrame):
|
||||||
clicked = Signal(str) # tag name
|
clicked = Signal(str) # tag name
|
||||||
|
|
||||||
def __init__(
|
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)
|
super().__init__(parent)
|
||||||
self._id = tag_id
|
self._id = tag_id
|
||||||
|
|
@ -37,26 +43,26 @@ class TagChip(QFrame):
|
||||||
self.setFrameShadow(QFrame.Raised)
|
self.setFrameShadow(QFrame.Raised)
|
||||||
|
|
||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
layout.setContentsMargins(4, 0, 4, 0)
|
layout.setContentsMargins(4, 2, 4, 2)
|
||||||
layout.setSpacing(4)
|
layout.setSpacing(4)
|
||||||
|
|
||||||
color_lbl = QLabel()
|
color_lbl = QLabel()
|
||||||
color_lbl.setFixedSize(10, 10)
|
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)
|
layout.addWidget(color_lbl)
|
||||||
|
|
||||||
name_lbl = QLabel(name)
|
name_lbl = QLabel(name)
|
||||||
layout.addWidget(name_lbl)
|
layout.addWidget(name_lbl)
|
||||||
|
|
||||||
|
if show_remove:
|
||||||
btn = QToolButton()
|
btn = QToolButton()
|
||||||
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))
|
||||||
|
layout.addWidget(btn)
|
||||||
|
|
||||||
self.setCursor(Qt.PointingHandCursor)
|
self.setCursor(Qt.PointingHandCursor)
|
||||||
|
|
||||||
layout.addWidget(btn)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tag_id(self) -> int:
|
def tag_id(self) -> int:
|
||||||
return self._id
|
return self._id
|
||||||
|
|
@ -70,8 +76,12 @@ class TagChip(QFrame):
|
||||||
class PageTagsWidget(QFrame):
|
class PageTagsWidget(QFrame):
|
||||||
"""
|
"""
|
||||||
Collapsible per-page tag editor shown in the left sidebar.
|
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):
|
def __init__(self, db: DBManager, parent: QWidget | None = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._db = db
|
self._db = db
|
||||||
|
|
@ -103,21 +113,25 @@ class PageTagsWidget(QFrame):
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
header.addWidget(self.manage_btn)
|
header.addWidget(self.manage_btn)
|
||||||
|
|
||||||
# Body (chips + add line)
|
# Body (chips + add line - only visible when expanded)
|
||||||
self.body = QWidget()
|
self.body = QWidget()
|
||||||
self.body_layout = QVBoxLayout(self.body)
|
self.body_layout = QVBoxLayout(self.body)
|
||||||
self.body_layout.setContentsMargins(0, 4, 0, 0)
|
self.body_layout.setContentsMargins(0, 4, 0, 0)
|
||||||
self.body_layout.setSpacing(4)
|
self.body_layout.setSpacing(4)
|
||||||
|
|
||||||
# Simple horizontal layout for now; you can swap for a FlowLayout
|
# Chips container
|
||||||
self.chip_row = FlowLayout(self.body, hspacing=4, vspacing=4)
|
self.chip_container = QWidget()
|
||||||
self.body_layout.addLayout(self.chip_row)
|
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 = QLineEdit()
|
||||||
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
|
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
|
||||||
self.add_edit.returnPressed.connect(self._on_add_tag)
|
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)
|
self.body.setVisible(False)
|
||||||
|
|
||||||
main = QVBoxLayout(self)
|
main = QVBoxLayout(self)
|
||||||
|
|
@ -129,23 +143,33 @@ class PageTagsWidget(QFrame):
|
||||||
|
|
||||||
def set_current_date(self, date_iso: str) -> None:
|
def set_current_date(self, date_iso: str) -> None:
|
||||||
self._current_date = date_iso
|
self._current_date = date_iso
|
||||||
|
# Only reload tags if expanded
|
||||||
if self.toggle_btn.isChecked():
|
if self.toggle_btn.isChecked():
|
||||||
self._reload_tags()
|
self._reload_tags()
|
||||||
else:
|
else:
|
||||||
# Keep it cheap while collapsed; reload only when expanded
|
self._clear_chips() # Clear chips when collapsed
|
||||||
self._clear_chips()
|
self._setup_autocomplete() # Update autocomplete with all available tags
|
||||||
|
|
||||||
# ----- internals ---------------------------------------------------
|
# ----- 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:
|
def _on_toggle(self, checked: bool) -> None:
|
||||||
self.body.setVisible(checked)
|
self.body.setVisible(checked)
|
||||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
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._reload_tags()
|
||||||
|
self.add_edit.setFocus()
|
||||||
|
|
||||||
def _clear_chips(self) -> None:
|
def _clear_chips(self) -> None:
|
||||||
while self.chip_row.count():
|
while self.chip_layout.count():
|
||||||
item = self.chip_row.takeAt(0)
|
item = self.chip_layout.takeAt(0)
|
||||||
w = item.widget()
|
w = item.widget()
|
||||||
if w is not None:
|
if w is not None:
|
||||||
w.deleteLater()
|
w.deleteLater()
|
||||||
|
|
@ -158,26 +182,56 @@ class PageTagsWidget(QFrame):
|
||||||
self._clear_chips()
|
self._clear_chips()
|
||||||
tags = self._db.get_tags_for_page(self._current_date)
|
tags = self._db.get_tags_for_page(self._current_date)
|
||||||
for tag_id, name, color in tags:
|
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.removeRequested.connect(self._remove_tag)
|
||||||
chip.clicked.connect(self._on_chip_clicked)
|
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:
|
def _on_add_tag(self) -> None:
|
||||||
if not self._current_date:
|
if not self._current_date:
|
||||||
return
|
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()
|
new_tag = self.add_edit.text().strip()
|
||||||
if not new_tag:
|
if not new_tag:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Combine current tags + new one, then write back
|
# Get existing tags for current page
|
||||||
existing = [
|
existing = [
|
||||||
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
|
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)
|
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()
|
||||||
self._reload_tags()
|
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:
|
def _remove_tag(self, tag_id: int) -> None:
|
||||||
if not self._current_date:
|
if not self._current_date:
|
||||||
|
|
@ -188,13 +242,15 @@ class PageTagsWidget(QFrame):
|
||||||
self._reload_tags()
|
self._reload_tags()
|
||||||
|
|
||||||
def _open_manager(self) -> None:
|
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():
|
if dlg.exec():
|
||||||
# Names/colours may have changed
|
# Reload tags after manager closes to pick up any changes
|
||||||
if self._current_date:
|
if self._current_date:
|
||||||
self._reload_tags()
|
self._reload_tags()
|
||||||
|
self._setup_autocomplete()
|
||||||
|
|
||||||
def _on_chip_clicked(self, name: str) -> None:
|
def _on_chip_clicked(self, name: str) -> None:
|
||||||
self.tagActivated.emit(name)
|
self.tagActivated.emit(name)
|
||||||
|
|
|
||||||
781
tests/test_tags.py
Normal file
781
tests/test_tags.py
Normal 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 == ""
|
||||||
Loading…
Add table
Add a link
Reference in a new issue