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
+15 -13
View File
@@ -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.
+36 -17
View File
@@ -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/<slug>/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/<slug>/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.
+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())
+19 -5
View File
@@ -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())
+19 -5
View File
@@ -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("Were Testing: A GUI!"), "were-testing-a-gui")
+119
View File
@@ -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()