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