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

This commit is contained in:
2026-08-13 15:56:02 -07:00
parent 841c213087
commit 6c762c3b33
8 changed files with 742 additions and 78 deletions
+41 -19
View File
@@ -269,9 +269,12 @@ class ArticleRepository:
raise ArticleEditorError(f"malformed controlled tag record: {record_path}")
tags.append(Tag(slug, document.get("title").strip()))
if not tags:
raise ArticleEditorError("content/tags contains no controlled tag records")
raise ArticleEditorError("content/tags contains no tracked tag records")
return tags
def refresh_tags(self) -> None:
self.tags = self._load_tags()
@staticmethod
def _ensure_within(path: Path, parent: Path) -> Path:
resolved = path.resolve()
@@ -357,18 +360,16 @@ class ArticleRepository:
if cleaned["kicker"] != "Article":
raise ArticleEditorError("Kicker must be Article")
approved_tags = {tag.slug for tag in self.tags}
selected_tags = [
tag.strip()
for tag in re.split(r"[,\n]", cleaned.get("tags", ""))
if tag.strip()
]
unknown_tags = sorted(set(selected_tags) - approved_tags)
if unknown_tags:
raise ArticleEditorError(
"unapproved tag slug(s): " + ", ".join(unknown_tags)
)
cleaned["tags"] = ", ".join(dict.fromkeys(selected_tags))
selected_tags: list[str] = []
for value in re.split(r"[,\n]", cleaned.get("tags", "")):
if not value.strip():
continue
slug = suggest_slug(value)
if not slug:
raise ArticleEditorError(f"tag cannot be normalized to a slug: {value!r}")
if slug not in selected_tags:
selected_tags.append(slug)
cleaned["tags"] = ", ".join(selected_tags)
cleaned["external_url"] = self._validate_url(
cleaned.get("external_url", ""), "External URL"
)
@@ -706,7 +707,7 @@ def run_gui(repository: ArticleRepository) -> None:
form.columnconfigure(1, weight=1)
form.columnconfigure(3, weight=1)
form.rowconfigure(10, weight=1)
form.rowconfigure(11, weight=1)
ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w")
ttk.Entry(form, textvariable=self.title_var).grid(
row=0, column=1, columnspan=3, sticky="ew", pady=3
@@ -774,7 +775,7 @@ def run_gui(repository: ArticleRepository) -> None:
)
self.published_text = tk.Text(form, height=3, wrap="none", undo=True)
self.published_text.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3)
ttk.Label(form, text="Controlled tags").grid(
ttk.Label(form, text="Tracked tags").grid(
row=9, column=0, sticky="nw", pady=(5, 0)
)
tag_frame = ttk.Frame(form)
@@ -790,11 +791,17 @@ def run_gui(repository: ArticleRepository) -> None:
for tag in repository.tags:
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
ttk.Label(form, text="Additional tags").grid(row=10, column=0, sticky="w")
self.additional_tags_var = tk.StringVar()
ttk.Entry(form, textvariable=self.additional_tags_var).grid(
row=10, column=1, columnspan=3, sticky="ew", pady=3
)
ttk.Label(form, text="Body (Markdown)").grid(
row=10, column=0, sticky="nw", pady=(5, 0)
row=11, column=0, sticky="nw", pady=(5, 0)
)
body_frame = ttk.Frame(form)
body_frame.grid(row=10, column=1, columnspan=3, sticky="nsew", pady=3)
body_frame.grid(row=11, column=1, columnspan=3, sticky="nsew", pady=3)
body_frame.rowconfigure(0, weight=1)
body_frame.columnconfigure(0, weight=1)
self.body_text = tk.Text(body_frame, wrap="word", undo=True)
@@ -804,7 +811,7 @@ def run_gui(repository: ArticleRepository) -> None:
body_scroll.grid(row=0, column=1, sticky="ns")
footer = ttk.Frame(form)
footer.grid(row=11, column=0, columnspan=4, sticky="ew", pady=(8, 0))
footer.grid(row=12, column=0, columnspan=4, sticky="ew", pady=(8, 0))
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
self.delete_button = ttk.Button(
@@ -822,12 +829,13 @@ def run_gui(repository: ArticleRepository) -> None:
def collect_values(self) -> dict[str, str]:
selected = [repository.tags[index].slug for index in self.tag_list.curselection()]
tags = selected + [self.additional_tags_var.get()]
return {
"title": self.title_var.get(),
"date": self.date_var.get(),
"updated": self.updated_var.get(),
"author": self.author_var.get(),
"tags": ", ".join(selected),
"tags": ", ".join(value for value in tags if value.strip()),
"kicker": self.kicker_var.get(),
"summary": self.summary_text.get("1.0", "end-1c"),
"body": self.body_text.get("1.0", "end-1c"),
@@ -852,11 +860,19 @@ def run_gui(repository: ArticleRepository) -> None:
selected = {
part.strip() for part in values.get("tags", "").split(",") if part.strip()
}
tracked = {tag.slug for tag in repository.tags}
self.additional_tags_var.set(", ".join(tag for tag in selected if tag not in tracked))
self.tag_list.selection_clear(0, tk.END)
for index, tag in enumerate(repository.tags):
if tag.slug in selected:
self.tag_list.selection_set(index)
def refresh_tag_choices(self) -> None:
repository.refresh_tags()
self.tag_list.delete(0, tk.END)
for tag in repository.tags:
self.tag_list.insert(tk.END, f"{tag.title} ({tag.slug})")
def has_unsaved_changes(self) -> bool:
return self.snapshot is not None and self.collect_values() != self.snapshot
@@ -889,6 +905,11 @@ def run_gui(repository: ArticleRepository) -> None:
self.delete_button.configure(state="disabled")
self.slug_entry.configure(state="normal")
self.slug_var.set("")
try:
self.refresh_tag_choices()
except ArticleEditorError as exc:
messagebox.showerror("Cannot load tracked tags", str(exc))
return
self.set_values(
{
"date": current_date.today().isoformat(),
@@ -914,6 +935,7 @@ def run_gui(repository: ArticleRepository) -> None:
if not self.may_discard():
return
try:
self.refresh_tag_choices()
entry = repository.load_entry(selection[0])
except ArticleEditorError as exc:
messagebox.showerror("Cannot load entry", str(exc))