This commit is contained in:
@@ -40,15 +40,17 @@ making changes; it is the authoritative operations and deployment runbook.
|
|||||||
|
|
||||||
## Tags and filtering
|
## Tags and filtering
|
||||||
|
|
||||||
- Treat the child records under `content/tags/` as the single controlled tag
|
- Treat the child records under `content/tags/` as the tracked tag registry.
|
||||||
vocabulary. Their lowercase, hyphenated directory names are the canonical
|
Their lowercase, hyphenated directory names are the canonical slugs used in
|
||||||
slugs stored in entry `tags` fields and used in public tag URLs.
|
public tag URLs. Entry `tags` fields may also contain normalized freeform
|
||||||
- Keep the entry model's checkbox choices dynamically sourced from
|
slugs without matching registry records.
|
||||||
`content/tags/`; do not duplicate the vocabulary in model configuration or
|
- Keep the entry model's tracked checkbox choices dynamically sourced from
|
||||||
introduce free-form spelling and capitalization variants.
|
`content/tags/`; do not duplicate the registry in model configuration.
|
||||||
- Add a tag record before assigning its slug to an entry. When renaming or
|
Freeform entry tags remain visible but nonlinked and are excluded from
|
||||||
retiring a tag, update all references deliberately and verify that no orphan
|
tracked-tag discovery.
|
||||||
slug or broken tag URL remains.
|
- 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.
|
- Preserve normal links to dedicated tag pages as the no-JavaScript baseline.
|
||||||
Client-side filtering must remain a progressive enhancement with semantic
|
Client-side filtering must remain a progressive enhancement with semantic
|
||||||
buttons, keyboard support, and an announced result count.
|
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
|
- When template changes rely on updated CSS or other static assets, increment
|
||||||
the relevant cache-busting version in the template and verify that generated
|
the relevant cache-busting version in the template and verify that generated
|
||||||
pages reference the new asset URL.
|
pages reference the new asset URL.
|
||||||
- For taxonomy changes, verify every entry tag has a matching `content/tags/`
|
- For taxonomy changes, verify tracked-tag counts match rendered
|
||||||
record, tag counts match rendered project/devlog/blog/article usage,
|
project/devlog/blog/article usage, freeform tags remain visible and unlinked,
|
||||||
dedicated tag URLs build, and listing filters work with valid, absent, and
|
dedicated tracked-tag URLs build, and listing filters work with valid,
|
||||||
unknown query values.
|
absent, and unknown query values.
|
||||||
- Run `git diff --check` and review the exact diff before committing.
|
- Run `git diff --check` and review the exact diff before committing.
|
||||||
- If a push was requested, verify the Gitea Action result, deployed revision,
|
- If a push was requested, verify the Gitea Action result, deployed revision,
|
||||||
and site health instead of treating a successful `git push` as completion.
|
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
|
```text
|
||||||
Labyricorn.lektorproject Lektor project definition
|
Labyricorn.lektorproject Lektor project definition
|
||||||
content/ Editable site content (`contents.lr` files)
|
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
|
models/ Lektor content models
|
||||||
templates/ Jinja templates
|
templates/ Jinja templates
|
||||||
assets/static/ CSS, filtering JavaScript, favicon, and static assets
|
assets/static/ CSS, filtering JavaScript, favicon, and static assets
|
||||||
@@ -114,9 +114,9 @@ source-controlled.
|
|||||||
|
|
||||||
## Tags and filtering workflow
|
## Tags and filtering workflow
|
||||||
|
|
||||||
`content/tags/` is the single source of truth for the controlled tag
|
`content/tags/` is the tracked tag registry. Each child directory is a normal
|
||||||
vocabulary. Each child directory is a normal Lektor record whose directory name
|
Lektor record whose directory name is the canonical lowercase, hyphenated slug
|
||||||
is the canonical lowercase, hyphenated slug used in entry data and URLs:
|
used in entry data and URLs:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
content/tags/deployment/contents.lr -> /tags/deployment/
|
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.
|
summary: Build, release, rollout, verification, and rollback workflows.
|
||||||
|
|
||||||
The entry model reads its checkbox choices directly from these records. Blog
|
Blog entries and articles store all tag slugs in one comma-separated `tags`
|
||||||
entries and articles store the selected slugs as a comma-separated `tags`
|
value. The entry model reads its tracked checkbox choices from the registry,
|
||||||
value:
|
while the focused entry editors also accept arbitrary additional tags:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
tags: lektor, deployment, operations
|
tags: lektor, deployment, operations
|
||||||
```
|
```
|
||||||
|
|
||||||
To add a tag, create its `content/tags/<slug>/contents.lr` record, run a build,
|
A tag with a matching registry record is tracked: it links to a dedicated tag
|
||||||
and then select it on the relevant entries. Do not add a slug directly to an
|
page and participates in tag discovery. A tag without a matching record remains
|
||||||
entry before its tag record exists. To rename or retire a tag, update every
|
visible using the existing nonlinked presentation but is excluded from tracked
|
||||||
referencing entry deliberately and verify that no old slug remains before
|
discovery. Tracking an already-used freeform slug, or untracking an existing
|
||||||
renaming or removing its record.
|
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
|
`/tags/` lists every approved tag and counts its usage across projects, project
|
||||||
devlog entries, blog entries, and articles. Dedicated tag URLs combine matching
|
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`.
|
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
|
2. Verify `/tags/`, at least one dedicated tag URL, the three top-level section
|
||||||
listings, and a project devlog index.
|
listings, and a project devlog index.
|
||||||
3. Confirm tag counts cover projects, devlog entries, blog entries, and
|
3. Confirm tracked-tag counts cover projects, devlog entries, blog entries, and
|
||||||
articles; every stored entry slug has a matching tag record; and unapproved
|
articles; freeform entry tags remain visible but unlinked; and unapproved
|
||||||
remote labels are not counted.
|
remote labels are not counted.
|
||||||
4. Test filtering with JavaScript enabled and confirm tag links remain usable
|
4. Test filtering with JavaScript enabled and confirm tag links remain usable
|
||||||
without JavaScript.
|
without JavaScript.
|
||||||
@@ -298,9 +300,10 @@ run:
|
|||||||
python scripts/blog_editor.py
|
python scripts/blog_editor.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The editor uses Tkinter from the Python standard library. It reads the approved
|
The editor uses Tkinter from the Python standard library. It presents tracked
|
||||||
tag choices from `content/tags/`, writes normal `contents.lr` records to the
|
tag choices from `content/tags/`, accepts normalized additional/freeform tags,
|
||||||
working tree, and can delete a loaded entry after explicit confirmation. Entry
|
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
|
deletion removes its complete record directory, including attachments, so the
|
||||||
confirmation identifies additional files before proceeding. The editor does
|
confirmation identifies additional files before proceeding. The editor does
|
||||||
not commit, push, build, or deploy changes. Review saved or deleted records with
|
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
|
confirmation. If `ffmpeg` is unavailable or conversion fails, the existing
|
||||||
narration attachment is left unchanged.
|
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
|
### Local article editor
|
||||||
|
|
||||||
For a focused desktop form that creates and edits records under
|
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`.
|
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`
|
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.
|
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
|
The editor does not commit, push, build, or deploy changes; review all working
|
||||||
tree changes with the routine content workflow above.
|
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}")
|
raise ArticleEditorError(f"malformed controlled tag record: {record_path}")
|
||||||
tags.append(Tag(slug, document.get("title").strip()))
|
tags.append(Tag(slug, document.get("title").strip()))
|
||||||
if not tags:
|
if not tags:
|
||||||
raise ArticleEditorError("content/tags contains no controlled tag records")
|
raise ArticleEditorError("content/tags contains no tracked tag records")
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
def refresh_tags(self) -> None:
|
||||||
|
self.tags = self._load_tags()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_within(path: Path, parent: Path) -> Path:
|
def _ensure_within(path: Path, parent: Path) -> Path:
|
||||||
resolved = path.resolve()
|
resolved = path.resolve()
|
||||||
@@ -357,18 +360,16 @@ class ArticleRepository:
|
|||||||
if cleaned["kicker"] != "Article":
|
if cleaned["kicker"] != "Article":
|
||||||
raise ArticleEditorError("Kicker must be Article")
|
raise ArticleEditorError("Kicker must be Article")
|
||||||
|
|
||||||
approved_tags = {tag.slug for tag in self.tags}
|
selected_tags: list[str] = []
|
||||||
selected_tags = [
|
for value in re.split(r"[,\n]", cleaned.get("tags", "")):
|
||||||
tag.strip()
|
if not value.strip():
|
||||||
for tag in re.split(r"[,\n]", cleaned.get("tags", ""))
|
continue
|
||||||
if tag.strip()
|
slug = suggest_slug(value)
|
||||||
]
|
if not slug:
|
||||||
unknown_tags = sorted(set(selected_tags) - approved_tags)
|
raise ArticleEditorError(f"tag cannot be normalized to a slug: {value!r}")
|
||||||
if unknown_tags:
|
if slug not in selected_tags:
|
||||||
raise ArticleEditorError(
|
selected_tags.append(slug)
|
||||||
"unapproved tag slug(s): " + ", ".join(unknown_tags)
|
cleaned["tags"] = ", ".join(selected_tags)
|
||||||
)
|
|
||||||
cleaned["tags"] = ", ".join(dict.fromkeys(selected_tags))
|
|
||||||
cleaned["external_url"] = self._validate_url(
|
cleaned["external_url"] = self._validate_url(
|
||||||
cleaned.get("external_url", ""), "External URL"
|
cleaned.get("external_url", ""), "External URL"
|
||||||
)
|
)
|
||||||
@@ -706,7 +707,7 @@ def run_gui(repository: ArticleRepository) -> None:
|
|||||||
|
|
||||||
form.columnconfigure(1, weight=1)
|
form.columnconfigure(1, weight=1)
|
||||||
form.columnconfigure(3, 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.Label(form, text="Title").grid(row=0, column=0, sticky="w")
|
||||||
ttk.Entry(form, textvariable=self.title_var).grid(
|
ttk.Entry(form, textvariable=self.title_var).grid(
|
||||||
row=0, column=1, columnspan=3, sticky="ew", pady=3
|
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 = tk.Text(form, height=3, wrap="none", undo=True)
|
||||||
self.published_text.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3)
|
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)
|
row=9, column=0, sticky="nw", pady=(5, 0)
|
||||||
)
|
)
|
||||||
tag_frame = ttk.Frame(form)
|
tag_frame = ttk.Frame(form)
|
||||||
@@ -790,11 +791,17 @@ def run_gui(repository: ArticleRepository) -> None:
|
|||||||
for tag in repository.tags:
|
for tag in repository.tags:
|
||||||
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
|
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(
|
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 = 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.rowconfigure(0, weight=1)
|
||||||
body_frame.columnconfigure(0, weight=1)
|
body_frame.columnconfigure(0, weight=1)
|
||||||
self.body_text = tk.Text(body_frame, wrap="word", undo=True)
|
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")
|
body_scroll.grid(row=0, column=1, sticky="ns")
|
||||||
|
|
||||||
footer = ttk.Frame(form)
|
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)
|
footer.columnconfigure(0, weight=1)
|
||||||
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
|
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
|
||||||
self.delete_button = ttk.Button(
|
self.delete_button = ttk.Button(
|
||||||
@@ -822,12 +829,13 @@ def run_gui(repository: ArticleRepository) -> None:
|
|||||||
|
|
||||||
def collect_values(self) -> dict[str, str]:
|
def collect_values(self) -> dict[str, str]:
|
||||||
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
|
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
|
||||||
|
tags = selected + [self.additional_tags_var.get()]
|
||||||
return {
|
return {
|
||||||
"title": self.title_var.get(),
|
"title": self.title_var.get(),
|
||||||
"date": self.date_var.get(),
|
"date": self.date_var.get(),
|
||||||
"updated": self.updated_var.get(),
|
"updated": self.updated_var.get(),
|
||||||
"author": self.author_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(),
|
"kicker": self.kicker_var.get(),
|
||||||
"summary": self.summary_text.get("1.0", "end-1c"),
|
"summary": self.summary_text.get("1.0", "end-1c"),
|
||||||
"body": self.body_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 = {
|
selected = {
|
||||||
part.strip() for part in values.get("tags", "").split(",") if part.strip()
|
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)
|
self.tag_list.selection_clear(0, tk.END)
|
||||||
for index, tag in enumerate(repository.tags):
|
for index, tag in enumerate(repository.tags):
|
||||||
if tag.slug in selected:
|
if tag.slug in selected:
|
||||||
self.tag_list.selection_set(index)
|
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:
|
def has_unsaved_changes(self) -> bool:
|
||||||
return self.snapshot is not None and self.collect_values() != self.snapshot
|
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.delete_button.configure(state="disabled")
|
||||||
self.slug_entry.configure(state="normal")
|
self.slug_entry.configure(state="normal")
|
||||||
self.slug_var.set("")
|
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(
|
self.set_values(
|
||||||
{
|
{
|
||||||
"date": current_date.today().isoformat(),
|
"date": current_date.today().isoformat(),
|
||||||
@@ -914,6 +935,7 @@ def run_gui(repository: ArticleRepository) -> None:
|
|||||||
if not self.may_discard():
|
if not self.may_discard():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
self.refresh_tag_choices()
|
||||||
entry = repository.load_entry(selection[0])
|
entry = repository.load_entry(selection[0])
|
||||||
except ArticleEditorError as exc:
|
except ArticleEditorError as exc:
|
||||||
messagebox.showerror("Cannot load entry", str(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}")
|
raise BlogEditorError(f"malformed controlled tag record: {record_path}")
|
||||||
tags.append(Tag(slug, document.get("title").strip()))
|
tags.append(Tag(slug, document.get("title").strip()))
|
||||||
if not tags:
|
if not tags:
|
||||||
raise BlogEditorError("content/tags contains no controlled tag records")
|
raise BlogEditorError("content/tags contains no tracked tag records")
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
def refresh_tags(self) -> None:
|
||||||
|
self.tags = self._load_tags()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_within(path: Path, parent: Path) -> Path:
|
def _ensure_within(path: Path, parent: Path) -> Path:
|
||||||
resolved = path.resolve()
|
resolved = path.resolve()
|
||||||
@@ -345,18 +348,16 @@ class BlogRepository:
|
|||||||
if cleaned["kicker"] != "Blog":
|
if cleaned["kicker"] != "Blog":
|
||||||
raise BlogEditorError("Kicker must be Blog")
|
raise BlogEditorError("Kicker must be Blog")
|
||||||
|
|
||||||
approved_tags = {tag.slug for tag in self.tags}
|
selected_tags: list[str] = []
|
||||||
selected_tags = [
|
for value in re.split(r"[,\n]", cleaned.get("tags", "")):
|
||||||
tag.strip()
|
if not value.strip():
|
||||||
for tag in re.split(r"[,\n]", cleaned.get("tags", ""))
|
continue
|
||||||
if tag.strip()
|
slug = suggest_slug(value)
|
||||||
]
|
if not slug:
|
||||||
unknown_tags = sorted(set(selected_tags) - approved_tags)
|
raise BlogEditorError(f"tag cannot be normalized to a slug: {value!r}")
|
||||||
if unknown_tags:
|
if slug not in selected_tags:
|
||||||
raise BlogEditorError(
|
selected_tags.append(slug)
|
||||||
"unapproved tag slug(s): " + ", ".join(unknown_tags)
|
cleaned["tags"] = ", ".join(selected_tags)
|
||||||
)
|
|
||||||
cleaned["tags"] = ", ".join(dict.fromkeys(selected_tags))
|
|
||||||
cleaned["external_url"] = self._validate_url(
|
cleaned["external_url"] = self._validate_url(
|
||||||
cleaned.get("external_url", ""), "External URL"
|
cleaned.get("external_url", ""), "External URL"
|
||||||
)
|
)
|
||||||
@@ -598,7 +599,7 @@ def run_gui(repository: BlogRepository) -> None:
|
|||||||
|
|
||||||
form.columnconfigure(1, weight=1)
|
form.columnconfigure(1, weight=1)
|
||||||
form.columnconfigure(3, 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.Label(form, text="Title").grid(row=0, column=0, sticky="w")
|
||||||
ttk.Entry(form, textvariable=self.title_var).grid(
|
ttk.Entry(form, textvariable=self.title_var).grid(
|
||||||
row=0, column=1, columnspan=3, sticky="ew", pady=3
|
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 = tk.Text(form, height=3, wrap="none", undo=True)
|
||||||
self.published_text.grid(row=7, column=1, columnspan=3, sticky="nsew", pady=3)
|
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)
|
row=8, column=0, sticky="nw", pady=(5, 0)
|
||||||
)
|
)
|
||||||
tag_frame = ttk.Frame(form)
|
tag_frame = ttk.Frame(form)
|
||||||
@@ -671,11 +672,17 @@ def run_gui(repository: BlogRepository) -> None:
|
|||||||
for tag in repository.tags:
|
for tag in repository.tags:
|
||||||
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
|
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(
|
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 = 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.rowconfigure(0, weight=1)
|
||||||
body_frame.columnconfigure(0, weight=1)
|
body_frame.columnconfigure(0, weight=1)
|
||||||
self.body_text = tk.Text(body_frame, wrap="word", undo=True)
|
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")
|
body_scroll.grid(row=0, column=1, sticky="ns")
|
||||||
|
|
||||||
footer = ttk.Frame(form)
|
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)
|
footer.columnconfigure(0, weight=1)
|
||||||
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
|
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
|
||||||
self.delete_button = ttk.Button(
|
self.delete_button = ttk.Button(
|
||||||
@@ -703,12 +710,13 @@ def run_gui(repository: BlogRepository) -> None:
|
|||||||
|
|
||||||
def collect_values(self) -> dict[str, str]:
|
def collect_values(self) -> dict[str, str]:
|
||||||
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
|
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
|
||||||
|
tags = selected + [self.additional_tags_var.get()]
|
||||||
return {
|
return {
|
||||||
"title": self.title_var.get(),
|
"title": self.title_var.get(),
|
||||||
"date": self.date_var.get(),
|
"date": self.date_var.get(),
|
||||||
"updated": self.updated_var.get(),
|
"updated": self.updated_var.get(),
|
||||||
"author": self.author_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(),
|
"kicker": self.kicker_var.get(),
|
||||||
"summary": self.summary_text.get("1.0", "end-1c"),
|
"summary": self.summary_text.get("1.0", "end-1c"),
|
||||||
"body": self.body_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 = {
|
selected = {
|
||||||
part.strip() for part in values.get("tags", "").split(",") if part.strip()
|
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)
|
self.tag_list.selection_clear(0, tk.END)
|
||||||
for index, tag in enumerate(repository.tags):
|
for index, tag in enumerate(repository.tags):
|
||||||
if tag.slug in selected:
|
if tag.slug in selected:
|
||||||
self.tag_list.selection_set(index)
|
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:
|
def has_unsaved_changes(self) -> bool:
|
||||||
return self.snapshot is not None and self.collect_values() != self.snapshot
|
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.delete_button.configure(state="disabled")
|
||||||
self.slug_entry.configure(state="normal")
|
self.slug_entry.configure(state="normal")
|
||||||
self.slug_var.set("")
|
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(
|
self.set_values(
|
||||||
{
|
{
|
||||||
"date": current_date.today().isoformat(),
|
"date": current_date.today().isoformat(),
|
||||||
@@ -791,6 +812,7 @@ def run_gui(repository: BlogRepository) -> None:
|
|||||||
if not self.may_discard():
|
if not self.may_discard():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
self.refresh_tag_choices()
|
||||||
entry = repository.load_entry(selection[0])
|
entry = repository.load_entry(selection[0])
|
||||||
except BlogEditorError as exc:
|
except BlogEditorError as exc:
|
||||||
messagebox.showerror("Cannot load entry", str(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)
|
values.update(overrides)
|
||||||
return values
|
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])
|
repository = ArticleRepository(Path(__file__).resolve().parents[1])
|
||||||
entries = repository.list_entries()
|
entries = repository.list_entries()
|
||||||
self.assertGreaterEqual(len(entries), 7)
|
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))
|
self.assertTrue(all(entry.document.get("kicker") == "Article" for entry in entries))
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
with self.subTest(slug=entry.slug):
|
with self.subTest(slug=entry.slug):
|
||||||
@@ -126,9 +126,23 @@ class ArticleEditorTests(unittest.TestCase):
|
|||||||
self.repository.delete_entry(entry)
|
self.repository.delete_entry(entry)
|
||||||
self.assertTrue(entry.path.is_file())
|
self.assertTrue(entry.path.is_file())
|
||||||
|
|
||||||
def test_unapproved_tag_is_rejected(self) -> None:
|
def test_freeform_tags_are_normalized_deduplicated_and_do_not_create_records(self) -> None:
|
||||||
with self.assertRaisesRegex(ArticleEditorError, "unapproved tag"):
|
entry = self.repository.create_entry(
|
||||||
self.repository.create_entry("test-entry", self.values(tags="invented"))
|
"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:
|
def test_cover_image_is_copied_renamed_referenced_and_reopened(self) -> None:
|
||||||
entry = self.repository.create_entry("test-entry", self.values())
|
entry = self.repository.create_entry("test-entry", self.values())
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ class BlogEditorTests(unittest.TestCase):
|
|||||||
values.update(overrides)
|
values.update(overrides)
|
||||||
return values
|
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])
|
repository = BlogRepository(Path(__file__).resolve().parents[1])
|
||||||
entries = repository.list_entries()
|
entries = repository.list_entries()
|
||||||
self.assertGreaterEqual(len(entries), 7)
|
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))
|
self.assertTrue(all(entry.document.get("kicker") == "Blog" for entry in entries))
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
with self.subTest(slug=entry.slug):
|
with self.subTest(slug=entry.slug):
|
||||||
@@ -126,9 +126,23 @@ class BlogEditorTests(unittest.TestCase):
|
|||||||
self.repository.delete_entry(entry)
|
self.repository.delete_entry(entry)
|
||||||
self.assertTrue(entry.path.is_file())
|
self.assertTrue(entry.path.is_file())
|
||||||
|
|
||||||
def test_unapproved_tag_is_rejected(self) -> None:
|
def test_freeform_tags_are_normalized_deduplicated_and_do_not_create_records(self) -> None:
|
||||||
with self.assertRaisesRegex(BlogEditorError, "unapproved tag"):
|
entry = self.repository.create_entry(
|
||||||
self.repository.create_entry("test-entry", self.values(tags="invented"))
|
"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:
|
def test_slug_suggestion_matches_repository_style(self) -> None:
|
||||||
self.assertEqual(suggest_slug("We’re Testing: A GUI!"), "were-testing-a-gui")
|
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