Add tracked and freeform tag editing
Deploy production / deploy (push) Successful in 4s

This commit is contained in:
2026-08-13 15:56:02 -07:00
parent 841c213087
commit 6c762c3b33
8 changed files with 742 additions and 78 deletions
+41 -19
View File
@@ -269,9 +269,12 @@ class ArticleRepository:
raise ArticleEditorError(f"malformed controlled tag record: {record_path}")
tags.append(Tag(slug, document.get("title").strip()))
if not tags:
raise ArticleEditorError("content/tags contains no controlled tag records")
raise ArticleEditorError("content/tags contains no tracked tag records")
return tags
def refresh_tags(self) -> None:
self.tags = self._load_tags()
@staticmethod
def _ensure_within(path: Path, parent: Path) -> Path:
resolved = path.resolve()
@@ -357,18 +360,16 @@ class ArticleRepository:
if cleaned["kicker"] != "Article":
raise ArticleEditorError("Kicker must be Article")
approved_tags = {tag.slug for tag in self.tags}
selected_tags = [
tag.strip()
for tag in re.split(r"[,\n]", cleaned.get("tags", ""))
if tag.strip()
]
unknown_tags = sorted(set(selected_tags) - approved_tags)
if unknown_tags:
raise ArticleEditorError(
"unapproved tag slug(s): " + ", ".join(unknown_tags)
)
cleaned["tags"] = ", ".join(dict.fromkeys(selected_tags))
selected_tags: list[str] = []
for value in re.split(r"[,\n]", cleaned.get("tags", "")):
if not value.strip():
continue
slug = suggest_slug(value)
if not slug:
raise ArticleEditorError(f"tag cannot be normalized to a slug: {value!r}")
if slug not in selected_tags:
selected_tags.append(slug)
cleaned["tags"] = ", ".join(selected_tags)
cleaned["external_url"] = self._validate_url(
cleaned.get("external_url", ""), "External URL"
)
@@ -706,7 +707,7 @@ def run_gui(repository: ArticleRepository) -> None:
form.columnconfigure(1, weight=1)
form.columnconfigure(3, weight=1)
form.rowconfigure(10, weight=1)
form.rowconfigure(11, weight=1)
ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w")
ttk.Entry(form, textvariable=self.title_var).grid(
row=0, column=1, columnspan=3, sticky="ew", pady=3
@@ -774,7 +775,7 @@ def run_gui(repository: ArticleRepository) -> None:
)
self.published_text = tk.Text(form, height=3, wrap="none", undo=True)
self.published_text.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3)
ttk.Label(form, text="Controlled tags").grid(
ttk.Label(form, text="Tracked tags").grid(
row=9, column=0, sticky="nw", pady=(5, 0)
)
tag_frame = ttk.Frame(form)
@@ -790,11 +791,17 @@ def run_gui(repository: ArticleRepository) -> None:
for tag in repository.tags:
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
ttk.Label(form, text="Additional tags").grid(row=10, column=0, sticky="w")
self.additional_tags_var = tk.StringVar()
ttk.Entry(form, textvariable=self.additional_tags_var).grid(
row=10, column=1, columnspan=3, sticky="ew", pady=3
)
ttk.Label(form, text="Body (Markdown)").grid(
row=10, column=0, sticky="nw", pady=(5, 0)
row=11, column=0, sticky="nw", pady=(5, 0)
)
body_frame = ttk.Frame(form)
body_frame.grid(row=10, column=1, columnspan=3, sticky="nsew", pady=3)
body_frame.grid(row=11, column=1, columnspan=3, sticky="nsew", pady=3)
body_frame.rowconfigure(0, weight=1)
body_frame.columnconfigure(0, weight=1)
self.body_text = tk.Text(body_frame, wrap="word", undo=True)
@@ -804,7 +811,7 @@ def run_gui(repository: ArticleRepository) -> None:
body_scroll.grid(row=0, column=1, sticky="ns")
footer = ttk.Frame(form)
footer.grid(row=11, column=0, columnspan=4, sticky="ew", pady=(8, 0))
footer.grid(row=12, column=0, columnspan=4, sticky="ew", pady=(8, 0))
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
self.delete_button = ttk.Button(
@@ -822,12 +829,13 @@ def run_gui(repository: ArticleRepository) -> None:
def collect_values(self) -> dict[str, str]:
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
tags = selected + [self.additional_tags_var.get()]
return {
"title": self.title_var.get(),
"date": self.date_var.get(),
"updated": self.updated_var.get(),
"author": self.author_var.get(),
"tags": ", ".join(selected),
"tags": ", ".join(value for value in tags if value.strip()),
"kicker": self.kicker_var.get(),
"summary": self.summary_text.get("1.0", "end-1c"),
"body": self.body_text.get("1.0", "end-1c"),
@@ -852,11 +860,19 @@ def run_gui(repository: ArticleRepository) -> None:
selected = {
part.strip() for part in values.get("tags", "").split(",") if part.strip()
}
tracked = {tag.slug for tag in repository.tags}
self.additional_tags_var.set(", ".join(tag for tag in selected if tag not in tracked))
self.tag_list.selection_clear(0, tk.END)
for index, tag in enumerate(repository.tags):
if tag.slug in selected:
self.tag_list.selection_set(index)
def refresh_tag_choices(self) -> None:
repository.refresh_tags()
self.tag_list.delete(0, tk.END)
for tag in repository.tags:
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
def has_unsaved_changes(self) -> bool:
return self.snapshot is not None and self.collect_values() != self.snapshot
@@ -889,6 +905,11 @@ def run_gui(repository: ArticleRepository) -> None:
self.delete_button.configure(state="disabled")
self.slug_entry.configure(state="normal")
self.slug_var.set("")
try:
self.refresh_tag_choices()
except ArticleEditorError as exc:
messagebox.showerror("Cannot load tracked tags", str(exc))
return
self.set_values(
{
"date": current_date.today().isoformat(),
@@ -914,6 +935,7 @@ def run_gui(repository: ArticleRepository) -> None:
if not self.may_discard():
return
try:
self.refresh_tag_choices()
entry = repository.load_entry(selection[0])
except ArticleEditorError as exc:
messagebox.showerror("Cannot load entry", str(exc))
+41 -19
View File
@@ -257,9 +257,12 @@ class BlogRepository:
raise BlogEditorError(f"malformed controlled tag record: {record_path}")
tags.append(Tag(slug, document.get("title").strip()))
if not tags:
raise BlogEditorError("content/tags contains no controlled tag records")
raise BlogEditorError("content/tags contains no tracked tag records")
return tags
def refresh_tags(self) -> None:
self.tags = self._load_tags()
@staticmethod
def _ensure_within(path: Path, parent: Path) -> Path:
resolved = path.resolve()
@@ -345,18 +348,16 @@ class BlogRepository:
if cleaned["kicker"] != "Blog":
raise BlogEditorError("Kicker must be Blog")
approved_tags = {tag.slug for tag in self.tags}
selected_tags = [
tag.strip()
for tag in re.split(r"[,\n]", cleaned.get("tags", ""))
if tag.strip()
]
unknown_tags = sorted(set(selected_tags) - approved_tags)
if unknown_tags:
raise BlogEditorError(
"unapproved tag slug(s): " + ", ".join(unknown_tags)
)
cleaned["tags"] = ", ".join(dict.fromkeys(selected_tags))
selected_tags: list[str] = []
for value in re.split(r"[,\n]", cleaned.get("tags", "")):
if not value.strip():
continue
slug = suggest_slug(value)
if not slug:
raise BlogEditorError(f"tag cannot be normalized to a slug: {value!r}")
if slug not in selected_tags:
selected_tags.append(slug)
cleaned["tags"] = ", ".join(selected_tags)
cleaned["external_url"] = self._validate_url(
cleaned.get("external_url", ""), "External URL"
)
@@ -598,7 +599,7 @@ def run_gui(repository: BlogRepository) -> None:
form.columnconfigure(1, weight=1)
form.columnconfigure(3, weight=1)
form.rowconfigure(9, weight=1)
form.rowconfigure(10, weight=1)
ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w")
ttk.Entry(form, textvariable=self.title_var).grid(
row=0, column=1, columnspan=3, sticky="ew", pady=3
@@ -655,7 +656,7 @@ def run_gui(repository: BlogRepository) -> None:
)
self.published_text = tk.Text(form, height=3, wrap="none", undo=True)
self.published_text.grid(row=7, column=1, columnspan=3, sticky="nsew", pady=3)
ttk.Label(form, text="Controlled tags").grid(
ttk.Label(form, text="Tracked tags").grid(
row=8, column=0, sticky="nw", pady=(5, 0)
)
tag_frame = ttk.Frame(form)
@@ -671,11 +672,17 @@ def run_gui(repository: BlogRepository) -> None:
for tag in repository.tags:
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
ttk.Label(form, text="Additional tags").grid(row=9, column=0, sticky="w")
self.additional_tags_var = tk.StringVar()
ttk.Entry(form, textvariable=self.additional_tags_var).grid(
row=9, column=1, columnspan=3, sticky="ew", pady=3
)
ttk.Label(form, text="Body (Markdown)").grid(
row=9, column=0, sticky="nw", pady=(5, 0)
row=10, column=0, sticky="nw", pady=(5, 0)
)
body_frame = ttk.Frame(form)
body_frame.grid(row=9, column=1, columnspan=3, sticky="nsew", pady=3)
body_frame.grid(row=10, column=1, columnspan=3, sticky="nsew", pady=3)
body_frame.rowconfigure(0, weight=1)
body_frame.columnconfigure(0, weight=1)
self.body_text = tk.Text(body_frame, wrap="word", undo=True)
@@ -685,7 +692,7 @@ def run_gui(repository: BlogRepository) -> None:
body_scroll.grid(row=0, column=1, sticky="ns")
footer = ttk.Frame(form)
footer.grid(row=10, column=0, columnspan=4, sticky="ew", pady=(8, 0))
footer.grid(row=11, column=0, columnspan=4, sticky="ew", pady=(8, 0))
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
self.delete_button = ttk.Button(
@@ -703,12 +710,13 @@ def run_gui(repository: BlogRepository) -> None:
def collect_values(self) -> dict[str, str]:
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
tags = selected + [self.additional_tags_var.get()]
return {
"title": self.title_var.get(),
"date": self.date_var.get(),
"updated": self.updated_var.get(),
"author": self.author_var.get(),
"tags": ", ".join(selected),
"tags": ", ".join(value for value in tags if value.strip()),
"kicker": self.kicker_var.get(),
"summary": self.summary_text.get("1.0", "end-1c"),
"body": self.body_text.get("1.0", "end-1c"),
@@ -733,11 +741,19 @@ def run_gui(repository: BlogRepository) -> None:
selected = {
part.strip() for part in values.get("tags", "").split(",") if part.strip()
}
tracked = {tag.slug for tag in repository.tags}
self.additional_tags_var.set(", ".join(tag for tag in selected if tag not in tracked))
self.tag_list.selection_clear(0, tk.END)
for index, tag in enumerate(repository.tags):
if tag.slug in selected:
self.tag_list.selection_set(index)
def refresh_tag_choices(self) -> None:
repository.refresh_tags()
self.tag_list.delete(0, tk.END)
for tag in repository.tags:
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
def has_unsaved_changes(self) -> bool:
return self.snapshot is not None and self.collect_values() != self.snapshot
@@ -770,6 +786,11 @@ def run_gui(repository: BlogRepository) -> None:
self.delete_button.configure(state="disabled")
self.slug_entry.configure(state="normal")
self.slug_var.set("")
try:
self.refresh_tag_choices()
except BlogEditorError as exc:
messagebox.showerror("Cannot load tracked tags", str(exc))
return
self.set_values(
{
"date": current_date.today().isoformat(),
@@ -791,6 +812,7 @@ def run_gui(repository: BlogRepository) -> None:
if not self.may_discard():
return
try:
self.refresh_tag_choices()
entry = repository.load_entry(selection[0])
except BlogEditorError as exc:
messagebox.showerror("Cannot load entry", str(exc))
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
"""Small desktop editor for Labyricorn's tracked tag records."""
from __future__ import annotations
import argparse
import configparser
from dataclasses import dataclass
import os
from pathlib import Path
import re
import tempfile
import unicodedata
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
FIELD_SEPARATOR = "\n---\n"
EXPECTED_MODEL_FIELDS = {"title": "string", "summary": "text"}
class TagEditorError(Exception):
"""Raised when a tracked tag record cannot be safely managed."""
@dataclass(frozen=True)
class TagRecord:
slug: str
title: str
summary: str
path: Path
original_bytes: bytes
original_text: str
def suggest_slug(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
normalized = normalized.replace("'", "").replace("", "")
return re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")
def _field_name(block: str) -> str | None:
first_line = block.split("\n", 1)[0]
if ":" not in first_line:
return None
return first_line.split(":", 1)[0].strip()
def _field_value(block: str) -> str:
first_line, separator, remainder = block.partition("\n")
scalar = first_line.split(":", 1)[1].lstrip()
if scalar:
return scalar
return remainder if separator else ""
def _render_field(name: str, value: str, previous: str | None = None) -> str:
multiline = "\n" in value or (
previous is not None and previous.split("\n", 1)[0].rstrip() == f"{name}:"
)
return f"{name}:\n{value}" if multiline else f"{name}: {value}"
class TagRepository:
def __init__(self, site_root: Path):
self.site_root = site_root.resolve()
self.tags_root = (self.site_root / "content" / "tags").resolve()
self._validate_layout()
def _validate_layout(self) -> None:
if len(list(self.site_root.glob("*.lektorproject"))) != 1:
raise TagEditorError(f"{self.site_root}: expected exactly one .lektorproject file")
if not self.tags_root.is_dir() or not (self.tags_root / "contents.lr").is_file():
raise TagEditorError(f"missing Lektor tag section at {self.tags_root}")
model_path = self.site_root / "models" / "tag.ini"
parser = configparser.ConfigParser(interpolation=None)
try:
with model_path.open("r", encoding="utf-8") as model_file:
parser.read_file(model_file)
except (OSError, UnicodeError, configparser.Error) as exc:
raise TagEditorError(f"cannot read tracked tag model {model_path}: {exc}") from exc
actual_fields = {
section.removeprefix("fields."): parser.get(section, "type", fallback="")
for section in parser.sections()
if section.startswith("fields.")
}
if actual_fields != EXPECTED_MODEL_FIELDS:
raise TagEditorError(
"models/tag.ini no longer matches the fields supported by this tag-specific editor"
)
@staticmethod
def _ensure_within(path: Path, parent: Path) -> Path:
resolved = path.resolve()
if not resolved.is_relative_to(parent.resolve()):
raise TagEditorError(f"unsafe path outside {parent}: {path}")
return resolved
@staticmethod
def _validate_slug(slug: str) -> str:
slug = slug.strip()
if not SLUG_RE.fullmatch(slug):
raise TagEditorError(
"slug must contain lowercase letters, numbers, and single hyphens only"
)
return slug
@staticmethod
def _validate_values(title: str, summary: str) -> tuple[str, str]:
title = title.strip().replace("\r", "").replace("\n", " ")
summary = summary.strip().replace("\r\n", "\n").replace("\r", "\n")
if not title:
raise TagEditorError("Display name is required")
if not summary:
raise TagEditorError("Summary is required")
return title, summary
def _read_record(self, path: Path, slug: str) -> TagRecord:
try:
raw = path.read_bytes()
text = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
except (OSError, UnicodeError) as exc:
raise TagEditorError(f"cannot read {path}: {exc}") from exc
blocks = text.rstrip("\n").split(FIELD_SEPARATOR)
fields: dict[str, str] = {}
for block in blocks:
name = _field_name(block)
if name is not None:
fields[name] = _field_value(block)
if fields.get("_model", "").strip() != "tag":
raise TagEditorError(f"tracked tag does not use the tag model: {path}")
title, summary = self._validate_values(fields.get("title", ""), fields.get("summary", ""))
return TagRecord(slug, title, summary, path, raw, text)
def list_tags(self) -> list[TagRecord]:
tags: list[TagRecord] = []
for directory in self.tags_root.iterdir():
if not directory.is_dir():
continue
slug = self._validate_slug(directory.name)
path = self._ensure_within(directory / "contents.lr", self.tags_root)
if not path.is_file():
raise TagEditorError(f"tracked tag is missing contents.lr: {directory}")
tags.append(self._read_record(path, slug))
return sorted(tags, key=lambda tag: (tag.title.casefold(), tag.slug))
def load_tag(self, slug: str) -> TagRecord:
slug = self._validate_slug(slug)
path = self._ensure_within(self.tags_root / slug / "contents.lr", self.tags_root)
if not path.is_file():
raise TagEditorError(f"tracked tag does not exist: {slug}")
return self._read_record(path, slug)
@staticmethod
def _write_atomic(destination: Path, text: str) -> None:
temporary: Path | None = None
try:
descriptor, temporary_name = tempfile.mkstemp(
prefix=".contents-", suffix=".lr", dir=destination.parent
)
os.close(descriptor)
temporary = Path(temporary_name)
temporary.write_text(text, encoding="utf-8", newline="\n")
os.replace(temporary, destination)
except OSError as exc:
if temporary is not None:
temporary.unlink(missing_ok=True)
raise TagEditorError(f"cannot write {destination}: {exc}") from exc
def create_tag(self, slug: str, title: str, summary: str) -> TagRecord:
slug = self._validate_slug(slug)
title, summary = self._validate_values(title, summary)
directory = self._ensure_within(self.tags_root / slug, self.tags_root)
destination = self._ensure_within(directory / "contents.lr", self.tags_root)
if directory.exists():
raise TagEditorError(f"tracked tag path already exists: {directory}")
try:
directory.mkdir()
self._write_atomic(
destination,
FIELD_SEPARATOR.join(
("_model: tag", _render_field("title", title), _render_field("summary", summary))
)
+ "\n",
)
except Exception:
try:
directory.rmdir()
except OSError:
pass
raise
return self.load_tag(slug)
def save_tag(self, tag: TagRecord, title: str, summary: str) -> TagRecord:
title, summary = self._validate_values(title, summary)
destination = self._ensure_within(tag.path, self.tags_root)
try:
current = destination.read_bytes()
except OSError as exc:
raise TagEditorError(f"cannot re-read {destination}: {exc}") from exc
if current != tag.original_bytes:
raise TagEditorError("the tracked tag changed on disk; reload it before saving")
blocks = tag.original_text.rstrip("\n").split(FIELD_SEPARATOR)
values = {"title": title, "summary": summary}
seen: set[str] = set()
for index, block in enumerate(blocks):
name = _field_name(block)
if name in values:
seen.add(name)
if _field_value(block) != values[name]:
blocks[index] = _render_field(name, values[name], block)
for name in ("title", "summary"):
if name not in seen:
blocks.append(_render_field(name, values[name]))
self._write_atomic(destination, FIELD_SEPARATOR.join(blocks) + "\n")
return self.load_tag(tag.slug)
def untrack_tag(self, tag: TagRecord) -> None:
directory = self._ensure_within(tag.path.parent, self.tags_root)
destination = self._ensure_within(tag.path, self.tags_root)
if directory.parent != self.tags_root or directory.name != tag.slug:
raise TagEditorError(f"unsafe tracked tag directory: {directory}")
if directory.is_symlink() or getattr(directory, "is_junction", lambda: False)():
raise TagEditorError(f"refusing to untrack a linked directory: {directory}")
try:
current = destination.read_bytes()
except OSError as exc:
raise TagEditorError(f"cannot re-read {destination}: {exc}") from exc
if current != tag.original_bytes:
raise TagEditorError("the tracked tag changed on disk; reload it before untracking")
children = list(directory.iterdir())
if children != [destination]:
raise TagEditorError(
f"refusing to untrack {tag.slug}: its directory contains files besides contents.lr"
)
try:
destination.unlink()
directory.rmdir()
except OSError as exc:
raise TagEditorError(f"cannot untrack {tag.slug}: {exc}") from exc
def run_gui(repository: TagRepository) -> None:
try:
import tkinter as tk
from tkinter import messagebox, ttk
except ImportError as exc:
raise TagEditorError("Tkinter is required to run the tracked tag editor") from exc
class TagEditorApp:
def __init__(self) -> None:
self.root = tk.Tk()
self.root.title("Labyricorn Tracked Tag Editor")
self.root.geometry("840x560")
self.mode = "new"
self.current_tag: TagRecord | None = None
self.snapshot: tuple[str, str] | None = None
outer = ttk.Frame(self.root, padding=12)
outer.grid(sticky="nsew")
self.root.rowconfigure(0, weight=1)
self.root.columnconfigure(0, weight=1)
outer.rowconfigure(0, weight=1)
outer.columnconfigure(0, weight=1)
outer.columnconfigure(1, weight=2)
browser = ttk.Frame(outer)
browser.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
browser.rowconfigure(0, weight=1)
browser.columnconfigure(0, weight=1)
self.tree = ttk.Treeview(browser, columns=("title", "slug"), show="headings")
self.tree.heading("title", text="Display name")
self.tree.heading("slug", text="Slug")
self.tree.column("title", width=190)
self.tree.column("slug", width=170)
self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew")
scroll = ttk.Scrollbar(browser, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=scroll.set)
scroll.grid(row=0, column=3, sticky="ns")
ttk.Button(browser, text="New", command=self.new_tag).grid(row=1, column=0, sticky="ew", pady=(8, 0))
ttk.Button(browser, text="Edit selected", command=self.edit_selected).grid(
row=1, column=1, sticky="ew", padx=5, pady=(8, 0)
)
ttk.Button(browser, text="Refresh", command=self.refresh).grid(row=1, column=2, sticky="ew", pady=(8, 0))
self.tree.bind("<Double-1>", lambda _event: self.edit_selected())
form = ttk.Frame(outer)
form.grid(row=0, column=1, sticky="nsew")
form.columnconfigure(1, weight=1)
form.rowconfigure(2, weight=1)
self.slug_var = tk.StringVar()
self.title_var = tk.StringVar()
self.status_var = tk.StringVar()
ttk.Label(form, text="Slug").grid(row=0, column=0, sticky="w")
self.slug_entry = ttk.Entry(form, textvariable=self.slug_var)
self.slug_entry.grid(row=0, column=1, sticky="ew", pady=3)
ttk.Button(form, text="Suggest", command=self.fill_suggested_slug).grid(row=0, column=2, padx=(5, 0))
ttk.Label(form, text="Display name").grid(row=1, column=0, sticky="w")
ttk.Entry(form, textvariable=self.title_var).grid(row=1, column=1, columnspan=2, sticky="ew", pady=3)
ttk.Label(form, text="Summary").grid(row=2, column=0, sticky="nw")
self.summary_text = tk.Text(form, height=12, wrap="word", undo=True)
self.summary_text.grid(row=2, column=1, columnspan=2, sticky="nsew", pady=3)
footer = ttk.Frame(form)
footer.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(8, 0))
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
self.untrack_button = ttk.Button(footer, text="Untrack Tag", command=self.untrack_current, state="disabled")
self.untrack_button.grid(row=0, column=1, padx=(0, 8))
ttk.Button(footer, text="Save Tag", command=self.save).grid(row=0, column=2)
self.root.bind("<Control-s>", lambda _event: self.save())
self.root.protocol("WM_DELETE_WINDOW", self.close)
self.refresh(False)
self.new_tag(False)
def values(self) -> tuple[str, str]:
return self.title_var.get(), self.summary_text.get("1.0", "end-1c")
def has_unsaved_changes(self) -> bool:
return self.snapshot is not None and self.values() != self.snapshot
def may_discard(self) -> bool:
return not self.has_unsaved_changes() or messagebox.askyesno(
"Discard changes?", "Discard the unsaved tracked-tag changes?"
)
def refresh(self, preserve_selection: bool = True) -> None:
selected = self.tree.selection()[0] if preserve_selection and self.tree.selection() else None
self.tree.delete(*self.tree.get_children())
try:
tags = repository.list_tags()
except TagEditorError as exc:
messagebox.showerror("Cannot load tracked tags", str(exc))
return
for tag in tags:
self.tree.insert("", tk.END, iid=tag.slug, values=(tag.title, tag.slug))
if selected and self.tree.exists(selected):
self.tree.selection_set(selected)
self.status_var.set(f"{len(tags)} tracked tags")
def new_tag(self, confirm: bool = True) -> None:
if confirm and not self.may_discard():
return
self.mode = "new"
self.current_tag = None
self.slug_entry.configure(state="normal")
self.slug_var.set("")
self.title_var.set("")
self.summary_text.delete("1.0", tk.END)
self.untrack_button.configure(state="disabled")
self.snapshot = self.values()
self.status_var.set("Creating a new tracked tag")
def edit_selected(self) -> None:
selection = self.tree.selection()
if not selection:
messagebox.showinfo("Select a tag", "Select a tracked tag to edit.")
return
if not self.may_discard():
return
try:
tag = repository.load_tag(selection[0])
except TagEditorError as exc:
messagebox.showerror("Cannot load tracked tag", str(exc))
return
self.mode = "edit"
self.current_tag = tag
self.slug_var.set(tag.slug)
self.slug_entry.configure(state="readonly")
self.title_var.set(tag.title)
self.summary_text.delete("1.0", tk.END)
self.summary_text.insert("1.0", tag.summary)
self.untrack_button.configure(state="normal")
self.snapshot = self.values()
self.status_var.set(f"Editing content/tags/{tag.slug}/contents.lr")
def fill_suggested_slug(self) -> None:
if self.mode == "new":
self.slug_var.set(suggest_slug(self.title_var.get()))
def save(self) -> None:
title, summary = self.values()
try:
if self.mode == "new":
tag = repository.create_tag(self.slug_var.get(), title, summary)
else:
if self.current_tag is None:
raise TagEditorError("no tracked tag is loaded")
tag = repository.save_tag(self.current_tag, title, summary)
except TagEditorError as exc:
messagebox.showerror("Cannot save tracked tag", str(exc))
return
self.current_tag = tag
self.mode = "edit"
self.slug_entry.configure(state="readonly")
self.slug_var.set(tag.slug)
self.title_var.set(tag.title)
self.summary_text.delete("1.0", tk.END)
self.summary_text.insert("1.0", tag.summary)
self.untrack_button.configure(state="normal")
self.snapshot = self.values()
self.refresh(False)
self.tree.selection_set(tag.slug)
self.tree.see(tag.slug)
self.status_var.set(f"Saved content/tags/{tag.slug}/contents.lr")
def untrack_current(self) -> None:
if self.current_tag is None:
return
tag = self.current_tag
if not messagebox.askyesno(
"Untrack tag?",
f"Untrack {tag.title!r} ({tag.slug})?\n\n"
"Only its tracked tag record will be removed. Existing content keeps the tag value and will render it as untracked.",
icon="warning",
):
return
try:
repository.untrack_tag(tag)
except TagEditorError as exc:
messagebox.showerror("Cannot untrack tag", str(exc))
return
slug = tag.slug
self.snapshot = None
self.refresh(False)
self.new_tag(False)
self.status_var.set(f"Untracked {slug}; existing content values were not changed")
def close(self) -> None:
if self.may_discard():
self.root.destroy()
try:
app = TagEditorApp()
except tk.TclError as exc:
raise TagEditorError(f"cannot open the Tkinter window: {exc}") from exc
app.root.mainloop()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
try:
run_gui(TagRepository(args.site_root))
except TagEditorError as exc:
parser.exit(1, f"tag-editor: ERROR: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())