diff --git a/AGENTS.md b/AGENTS.md index 176d0ca..394d0aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,15 +40,17 @@ making changes; it is the authoritative operations and deployment runbook. ## Tags and filtering -- Treat the child records under `content/tags/` as the single controlled tag - vocabulary. Their lowercase, hyphenated directory names are the canonical - slugs stored in entry `tags` fields and used in public tag URLs. -- Keep the entry model's checkbox choices dynamically sourced from - `content/tags/`; do not duplicate the vocabulary in model configuration or - introduce free-form spelling and capitalization variants. -- Add a tag record before assigning its slug to an entry. When renaming or - retiring a tag, update all references deliberately and verify that no orphan - slug or broken tag URL remains. +- Treat the child records under `content/tags/` as the tracked tag registry. + Their lowercase, hyphenated directory names are the canonical slugs used in + public tag URLs. Entry `tags` fields may also contain normalized freeform + slugs without matching registry records. +- Keep the entry model's tracked checkbox choices dynamically sourced from + `content/tags/`; do not duplicate the registry in model configuration. + Freeform entry tags remain visible but nonlinked and are excluded from + tracked-tag discovery. +- Tracking or untracking a slug must not rewrite content references. Slug + renaming is separate: update all references deliberately and verify that no + old slug or broken tag URL remains. - Preserve normal links to dedicated tag pages as the no-JavaScript baseline. Client-side filtering must remain a progressive enhancement with semantic buttons, keyboard support, and an announced result count. @@ -86,10 +88,10 @@ making changes; it is the authoritative operations and deployment runbook. - When template changes rely on updated CSS or other static assets, increment the relevant cache-busting version in the template and verify that generated pages reference the new asset URL. -- For taxonomy changes, verify every entry tag has a matching `content/tags/` - record, tag counts match rendered project/devlog/blog/article usage, - dedicated tag URLs build, and listing filters work with valid, absent, and - unknown query values. +- For taxonomy changes, verify tracked-tag counts match rendered + project/devlog/blog/article usage, freeform tags remain visible and unlinked, + dedicated tracked-tag URLs build, and listing filters work with valid, + absent, and unknown query values. - Run `git diff --check` and review the exact diff before committing. - If a push was requested, verify the Gitea Action result, deployed revision, and site health instead of treating a successful `git push` as completion. diff --git a/README.md b/README.md index fce752a..7d74787 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ modify trusted site models, templates, scripts, or content. ```text Labyricorn.lektorproject Lektor project definition content/ Editable site content (`contents.lr` files) -content/tags/ Controlled tag vocabulary and dedicated tag routes +content/tags/ Tracked tag registry and dedicated tag routes models/ Lektor content models templates/ Jinja templates assets/static/ CSS, filtering JavaScript, favicon, and static assets @@ -114,9 +114,9 @@ source-controlled. ## Tags and filtering workflow -`content/tags/` is the single source of truth for the controlled tag -vocabulary. Each child directory is a normal Lektor record whose directory name -is the canonical lowercase, hyphenated slug used in entry data and URLs: +`content/tags/` is the tracked tag registry. Each child directory is a normal +Lektor record whose directory name is the canonical lowercase, hyphenated slug +used in entry data and URLs: ```text content/tags/deployment/contents.lr -> /tags/deployment/ @@ -130,19 +130,21 @@ The tag record supplies the editor label and public description: --- summary: Build, release, rollout, verification, and rollback workflows. -The entry model reads its checkbox choices directly from these records. Blog -entries and articles store the selected slugs as a comma-separated `tags` -value: +Blog entries and articles store all tag slugs in one comma-separated `tags` +value. The entry model reads its tracked checkbox choices from the registry, +while the focused entry editors also accept arbitrary additional tags: ```text tags: lektor, deployment, operations ``` -To add a tag, create its `content/tags//contents.lr` record, run a build, -and then select it on the relevant entries. Do not add a slug directly to an -entry before its tag record exists. To rename or retire a tag, update every -referencing entry deliberately and verify that no old slug remains before -renaming or removing its record. +A tag with a matching registry record is tracked: it links to a dedicated tag +page and participates in tag discovery. A tag without a matching record remains +visible using the existing nonlinked presentation but is excluded from tracked +discovery. Tracking an already-used freeform slug, or untracking an existing +slug, changes that behavior on the next build without rewriting content +entries. Slug renaming is different: it can require deliberately updating every +reference and is not provided by the tag editor. `/tags/` lists every approved tag and counts its usage across projects, project devlog entries, blog entries, and articles. Dedicated tag URLs combine matching @@ -165,8 +167,8 @@ After taxonomy or filtering changes: 1. Run `python scripts/build_with_projects.py --output-path build`. 2. Verify `/tags/`, at least one dedicated tag URL, the three top-level section listings, and a project devlog index. -3. Confirm tag counts cover projects, devlog entries, blog entries, and - articles; every stored entry slug has a matching tag record; and unapproved +3. Confirm tracked-tag counts cover projects, devlog entries, blog entries, and + articles; freeform entry tags remain visible but unlinked; and unapproved remote labels are not counted. 4. Test filtering with JavaScript enabled and confirm tag links remain usable without JavaScript. @@ -298,9 +300,10 @@ run: python scripts/blog_editor.py ``` -The editor uses Tkinter from the Python standard library. It reads the approved -tag choices from `content/tags/`, writes normal `contents.lr` records to the -working tree, and can delete a loaded entry after explicit confirmation. Entry +The editor uses Tkinter from the Python standard library. It presents tracked +tag choices from `content/tags/`, accepts normalized additional/freeform tags, +writes normal `contents.lr` records to the working tree, and can delete a +loaded entry after explicit confirmation. Entry deletion removes its complete record directory, including attachments, so the confirmation identifies additional files before proceeding. The editor does not commit, push, build, or deploy changes. Review saved or deleted records with @@ -314,6 +317,20 @@ attachment that the existing narration player recognizes. It uses a fixed confirmation. If `ffmpeg` is unavailable or conversion fails, the existing narration attachment is left unchanged. +### Local tracked tag editor + +To create, edit, or untrack records in the tracked tag registry, run: + +```bash +python scripts/tag_editor.py +``` + +The editor manages only `content/tags//contents.lr`. Creating a tracked +record does not rewrite existing content that already uses the slug. Untracking +requires confirmation, removes only that tracked record, and leaves all tag +values in blog, article, project, and devlog content untouched. Display names +and summaries can be edited, but slugs cannot be renamed in this editor. + ### Local article editor For a focused desktop form that creates and edits records under @@ -327,6 +344,8 @@ The article editor follows the existing cover convention: a first Markdown image referencing an article-local `cover-image.png` or `cover-image.jpg`. Choose a PNG, JPG, or JPEG in the editor and it is copied beside `contents.lr` with that canonical name. Replacing or removing a cover requires confirmation. +Tracked tags are selected from `content/tags/`; arbitrary additional tags may +be entered without creating tracked records. The editor does not commit, push, build, or deploy changes; review all working tree changes with the routine content workflow above. diff --git a/scripts/article_editor.py b/scripts/article_editor.py index 62b413e..48777f9 100644 --- a/scripts/article_editor.py +++ b/scripts/article_editor.py @@ -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)) diff --git a/scripts/blog_editor.py b/scripts/blog_editor.py index 721d31a..334705f 100644 --- a/scripts/blog_editor.py +++ b/scripts/blog_editor.py @@ -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)) diff --git a/scripts/tag_editor.py b/scripts/tag_editor.py new file mode 100644 index 0000000..de0b654 --- /dev/null +++ b/scripts/tag_editor.py @@ -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("", 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("", 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()) diff --git a/tests/test_article_editor.py b/tests/test_article_editor.py index 56d114c..04ebe5c 100644 --- a/tests/test_article_editor.py +++ b/tests/test_article_editor.py @@ -54,11 +54,11 @@ class ArticleEditorTests(unittest.TestCase): values.update(overrides) return values - def test_current_site_article_records_and_controlled_tags_load(self) -> None: + def test_current_site_article_records_and_tracked_tags_load(self) -> None: repository = ArticleRepository(Path(__file__).resolve().parents[1]) entries = repository.list_entries() self.assertGreaterEqual(len(entries), 7) - self.assertGreaterEqual(len(repository.tags), 45) + self.assertGreaterEqual(len(repository.tags), 40) self.assertTrue(all(entry.document.get("kicker") == "Article" for entry in entries)) for entry in entries: with self.subTest(slug=entry.slug): @@ -126,9 +126,23 @@ class ArticleEditorTests(unittest.TestCase): self.repository.delete_entry(entry) self.assertTrue(entry.path.is_file()) - def test_unapproved_tag_is_rejected(self) -> None: - with self.assertRaisesRegex(ArticleEditorError, "unapproved tag"): - self.repository.create_entry("test-entry", self.values(tags="invented")) + def test_freeform_tags_are_normalized_deduplicated_and_do_not_create_records(self) -> None: + entry = self.repository.create_entry( + "test-entry", self.values(tags="writing, Weird Experiment, weird-experiment") + ) + + self.assertEqual(entry.document.get("tags"), "writing, weird-experiment") + self.assertFalse((self.site_root / "content" / "tags" / "weird-experiment").exists()) + + def test_loading_and_saving_preserves_tracked_and_freeform_tags(self) -> None: + entry = self.repository.create_entry( + "test-entry", self.values(tags="writing, retrocomputing") + ) + values = self.repository.values_for(entry.document) + values["summary"] = "Edited without changing tags." + saved = self.repository.save_entry(entry, values) + + self.assertEqual(saved.document.get("tags"), "writing, retrocomputing") def test_cover_image_is_copied_renamed_referenced_and_reopened(self) -> None: entry = self.repository.create_entry("test-entry", self.values()) diff --git a/tests/test_blog_editor.py b/tests/test_blog_editor.py index 20d412a..540d1e2 100644 --- a/tests/test_blog_editor.py +++ b/tests/test_blog_editor.py @@ -54,11 +54,11 @@ class BlogEditorTests(unittest.TestCase): values.update(overrides) return values - def test_current_site_blog_records_and_controlled_tags_load(self) -> None: + def test_current_site_blog_records_and_tracked_tags_load(self) -> None: repository = BlogRepository(Path(__file__).resolve().parents[1]) entries = repository.list_entries() self.assertGreaterEqual(len(entries), 7) - self.assertGreaterEqual(len(repository.tags), 45) + self.assertGreaterEqual(len(repository.tags), 40) self.assertTrue(all(entry.document.get("kicker") == "Blog" for entry in entries)) for entry in entries: with self.subTest(slug=entry.slug): @@ -126,9 +126,23 @@ class BlogEditorTests(unittest.TestCase): self.repository.delete_entry(entry) self.assertTrue(entry.path.is_file()) - def test_unapproved_tag_is_rejected(self) -> None: - with self.assertRaisesRegex(BlogEditorError, "unapproved tag"): - self.repository.create_entry("test-entry", self.values(tags="invented")) + def test_freeform_tags_are_normalized_deduplicated_and_do_not_create_records(self) -> None: + entry = self.repository.create_entry( + "test-entry", self.values(tags="writing, Weird Experiment, weird-experiment") + ) + + self.assertEqual(entry.document.get("tags"), "writing, weird-experiment") + self.assertFalse((self.site_root / "content" / "tags" / "weird-experiment").exists()) + + def test_loading_and_saving_preserves_tracked_and_freeform_tags(self) -> None: + entry = self.repository.create_entry( + "test-entry", self.values(tags="writing, retrocomputing") + ) + values = self.repository.values_for(entry.document) + values["summary"] = "Edited without changing tags." + saved = self.repository.save_entry(entry, values) + + self.assertEqual(saved.document.get("tags"), "writing, retrocomputing") def test_slug_suggestion_matches_repository_style(self) -> None: self.assertEqual(suggest_slug("We’re Testing: A GUI!"), "were-testing-a-gui") diff --git a/tests/test_tag_editor.py b/tests/test_tag_editor.py new file mode 100644 index 0000000..b4d20f8 --- /dev/null +++ b/tests/test_tag_editor.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import sys +import unittest +import uuid + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from tag_editor import TagEditorError, TagRepository, suggest_slug # noqa: E402 + + +class TagEditorTests(unittest.TestCase): + def setUp(self) -> None: + cache_root = Path(__file__).resolve().parents[1] / ".cache" + cache_root.mkdir(exist_ok=True) + self.site_root = cache_root / f"tag-editor-test-{uuid.uuid4().hex}" + (self.site_root / "content" / "tags" / "writing").mkdir(parents=True) + (self.site_root / "content" / "blog" / "existing-entry").mkdir(parents=True) + (self.site_root / "models").mkdir() + (self.site_root / "Test.lektorproject").write_text( + "[project]\nname = Test\n", encoding="utf-8" + ) + (self.site_root / "content" / "tags" / "contents.lr").write_text( + "_model: tag-index\n---\ntitle: Tags\n", encoding="utf-8" + ) + (self.site_root / "content" / "tags" / "writing" / "contents.lr").write_text( + "_model: tag\n---\ntitle: Writing\n---\nsummary: Writing and editing.\n", + encoding="utf-8", + ) + self.entry_path = ( + self.site_root / "content" / "blog" / "existing-entry" / "contents.lr" + ) + self.entry_path.write_text( + "_model: entry\n---\ntitle: Existing\n---\ntags: writing, retrocomputing\n", + encoding="utf-8", + ) + shutil.copy2( + Path(__file__).resolve().parents[1] / "models" / "tag.ini", + self.site_root / "models" / "tag.ini", + ) + self.repository = TagRepository(self.site_root) + + def tearDown(self) -> None: + shutil.rmtree(self.site_root) + + def test_current_tracked_tag_records_load(self) -> None: + repository = TagRepository(Path(__file__).resolve().parents[1]) + tags = repository.list_tags() + self.assertGreaterEqual(len(tags), 40) + self.assertTrue(all(tag.title and tag.summary for tag in tags)) + + def test_create_tracks_slug_without_rewriting_existing_content(self) -> None: + before = self.entry_path.read_bytes() + + tag = self.repository.create_tag( + "retrocomputing", "Retrocomputing", "Older computers and their software." + ) + + self.assertEqual(tag.slug, "retrocomputing") + self.assertEqual(self.entry_path.read_bytes(), before) + self.assertIn("_model: tag\n---\ntitle: Retrocomputing\n", tag.original_text) + with self.assertRaisesRegex(TagEditorError, "already exists"): + self.repository.create_tag("retrocomputing", "Duplicate", "Duplicate summary.") + + def test_edit_preserves_slug_and_unknown_fields(self) -> None: + path = self.site_root / "content" / "tags" / "writing" / "contents.lr" + text = path.read_text(encoding="utf-8").replace( + "---\nsummary:", "---\neditor_note: preserve me\n---\nsummary:" + ) + path.write_text(text, encoding="utf-8") + tag = self.repository.load_tag("writing") + + saved = self.repository.save_tag(tag, "Writing & Editing", "An updated summary.") + + self.assertEqual(saved.slug, "writing") + self.assertIn("editor_note: preserve me", saved.original_text) + self.assertIn("title: Writing & Editing", saved.original_text) + + def test_edit_detects_external_change(self) -> None: + tag = self.repository.load_tag("writing") + with tag.path.open("a", encoding="utf-8") as output: + output.write("\n") + with self.assertRaisesRegex(TagEditorError, "changed on disk"): + self.repository.save_tag(tag, "Writing", "Changed summary.") + + def test_untrack_removes_only_registry_record_and_keeps_entry_value(self) -> None: + tag = self.repository.create_tag( + "retrocomputing", "Retrocomputing", "Older computers and their software." + ) + before = self.entry_path.read_bytes() + + self.repository.untrack_tag(tag) + + self.assertFalse(tag.path.parent.exists()) + self.assertEqual(self.entry_path.read_bytes(), before) + self.assertIn("retrocomputing", self.entry_path.read_text(encoding="utf-8")) + + def test_untrack_refuses_a_tag_directory_with_other_files(self) -> None: + tag = self.repository.load_tag("writing") + attachment = tag.path.parent / "unexpected.txt" + attachment.write_text("keep", encoding="utf-8") + + with self.assertRaisesRegex(TagEditorError, "files besides contents.lr"): + self.repository.untrack_tag(tag) + + self.assertTrue(tag.path.is_file()) + self.assertTrue(attachment.is_file()) + + def test_slug_validation_and_suggestion(self) -> None: + self.assertEqual(suggest_slug("Retro Computing!"), "retro-computing") + with self.assertRaisesRegex(TagEditorError, "lowercase"): + self.repository.create_tag("Not Valid", "Invalid", "Invalid slug.") + + +if __name__ == "__main__": + unittest.main()