This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user