#!/usr/bin/env python3 """Small, article-specific desktop editor for Labyricorn Lektor records.""" from __future__ import annotations import argparse import configparser from dataclasses import dataclass from datetime import date as current_date import os from pathlib import Path import re import shutil import tempfile import unicodedata from urllib.parse import urlsplit from narration_import import NarrationImportError, import_wav, narration_path, remove_narration SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SEPARATOR_RE = re.compile(r"(?m)(^---[ \t]*(?:\r\n|\n|$))") FIELD_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*):(.*)") FIELD_ORDER = ( "_model", "title", "date", "updated", "author", "tags", "kicker", "summary", "external_url", "published_urls", "body", ) EXPECTED_MODEL_FIELDS = { "title": "string", "date": "date", "updated": "date", "author": "string", "tags": "checkboxes", "kicker": "select", "summary": "text", "body": "markdown", "external_url": "url", "published_urls": "strings", } IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg"} COVER_IMAGE_RE = re.compile( r"\A(?P\s*)!\[(?P[^\]]*)\]\((?Pcover-image\.(?:png|jpe?g))\)(?P\r?\n(?:\r?\n)?)", re.IGNORECASE, ) class ArticleEditorError(Exception): """An error that can be shown directly to an editor user.""" @dataclass(frozen=True) class Tag: slug: str title: str @dataclass class LrDocument: blocks: list[str] delimiters: list[str] newline: str source: str @classmethod def parse(cls, text: str, source: str) -> "LrDocument": parts = SEPARATOR_RE.split(text) blocks = [parts[0]] delimiters: list[str] = [] for index in range(1, len(parts), 2): delimiters.append(parts[index]) blocks.append(parts[index + 1]) if not blocks or any(not block.strip() for block in blocks): raise ArticleEditorError(f"{source}: empty or malformed record block") newline = "\r\n" if "\r\n" in text else "\n" document = cls(blocks, delimiters, newline, source) document._field_indexes() return document def render(self) -> str: pieces = [self.blocks[0]] for delimiter, block in zip(self.delimiters, self.blocks[1:]): pieces.extend((delimiter, block)) return "".join(pieces) def _field_indexes(self) -> dict[str, int]: indexes: dict[str, int] = {} for index, block in enumerate(self.blocks): key, _value = self._parse_block(block) if key in indexes: raise ArticleEditorError(f"{self.source}: duplicate field {key!r}") indexes[key] = index return indexes def _parse_block(self, block: str) -> tuple[str, str]: line_end = block.find("\n") if line_end < 0: first_line = block remainder = "" else: first_line = block[:line_end].rstrip("\r") remainder = block[line_end + 1 :] match = FIELD_RE.fullmatch(first_line) if match is None: raise ArticleEditorError(f"{self.source}: malformed field header {first_line!r}") key, inline = match.groups() if inline.startswith(" "): inline = inline[1:] if inline: value = inline if remainder.rstrip("\r\n"): value += "\n" + remainder.rstrip("\r\n") else: value = remainder.rstrip("\r\n") return key, value def get(self, key: str, default: str = "") -> str: index = self._field_indexes().get(key) if index is None: return default return self._parse_block(self.blocks[index])[1] def set_field(self, key: str, value: str, style: str = "scalar") -> None: indexes = self._field_indexes() current_index = indexes.get(key) if current_index is not None and self.get(key) == value: return rendered = self._render_field(key, value, style) if current_index is not None: self.blocks[current_index] = rendered return desired_order = FIELD_ORDER.index(key) insertion_index = len(self.blocks) for candidate, index in indexes.items(): if candidate in FIELD_ORDER and FIELD_ORDER.index(candidate) > desired_order: insertion_index = min(insertion_index, index) self.blocks.insert(insertion_index, rendered) delimiter = f"---{self.newline}" if insertion_index == 0: self.delimiters.insert(0, delimiter) else: self.delimiters.insert(insertion_index - 1, delimiter) def remove_field(self, key: str) -> None: index = self._field_indexes().get(key) if index is None: return if index == 0: raise ArticleEditorError(f"{self.source}: cannot remove the first record field") del self.blocks[index] del self.delimiters[index - 1] def _render_field(self, key: str, value: str, style: str) -> str: if style == "published_urls": lines = [line.strip() for line in value.splitlines() if line.strip()] suffix = self.newline.join(lines) if suffix: suffix += self.newline return f"{key}:{self.newline}{self.newline}{suffix}" if style == "multiline" or "\n" in value: suffix = value.rstrip("\r\n") if suffix: suffix += self.newline return f"{key}:{self.newline}{suffix}" return f"{key}: {value}{self.newline}" @dataclass class ArticleEntry: slug: str path: Path document: LrDocument original_bytes: bytes @property def title(self) -> str: return self.document.get("title") @property def publication_date(self) -> str: return self.document.get("date") @dataclass(frozen=True) class CoverImage: name: str alt: str path: Path class ArticleRepository: def __init__(self, site_root: Path): self.site_root = site_root.resolve() self.article_root = (self.site_root / "content" / "articles").resolve() self.tags_root = (self.site_root / "content" / "tags").resolve() self._validate_layout() self.tags = self._load_tags() def _validate_layout(self) -> None: project_files = list(self.site_root.glob("*.lektorproject")) if len(project_files) != 1: raise ArticleEditorError( f"{self.site_root}: expected exactly one .lektorproject file" ) if not self.article_root.is_dir() or not (self.article_root / "contents.lr").is_file(): raise ArticleEditorError(f"missing Lektor article section at {self.article_root}") if not self.tags_root.is_dir(): raise ArticleEditorError(f"missing controlled tag records at {self.tags_root}") model_path = self.site_root / "models" / "entry.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 ArticleEditorError(f"cannot read article entry 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 ArticleEditorError( "models/entry.ini no longer matches the fields supported by this " "article-specific editor" ) if parser.get("fields.tags", "source", fallback="") != "site.query('/tags')": raise ArticleEditorError("the article tag field is not sourced from content/tags") choices = [ choice.strip() for choice in parser.get("fields.kicker", "choices", fallback="").split(",") ] if "Article" not in choices: raise ArticleEditorError("the entry model no longer permits the Article kicker") def _read_document(self, path: Path) -> tuple[LrDocument, bytes]: try: raw = path.read_bytes() text = raw.decode("utf-8") except (OSError, UnicodeError) as exc: raise ArticleEditorError(f"cannot read {path}: {exc}") from exc return LrDocument.parse(text, str(path)), raw def _load_tags(self) -> list[Tag]: tags: list[Tag] = [] for directory in sorted(self.tags_root.iterdir(), key=lambda item: item.name): if not directory.is_dir(): continue slug = directory.name if not SLUG_RE.fullmatch(slug): raise ArticleEditorError(f"invalid controlled tag directory name: {slug}") record_path = directory / "contents.lr" if not record_path.is_file(): raise ArticleEditorError(f"controlled tag is missing contents.lr: {directory}") self._ensure_within(record_path, self.tags_root) document, _raw = self._read_document(record_path) if document.get("_model") != "tag" or not document.get("title").strip(): 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 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() if not resolved.is_relative_to(parent.resolve()): raise ArticleEditorError(f"unsafe path outside {parent}: {path}") return resolved def list_entries(self) -> list[ArticleEntry]: entries: list[ArticleEntry] = [] for directory in self.article_root.iterdir(): if not directory.is_dir(): continue record_path = directory / "contents.lr" if not record_path.is_file(): continue entries.append(self.load_entry(directory.name)) entries.sort(key=lambda entry: (entry.publication_date, entry.title), reverse=True) return entries def load_entry(self, slug: str) -> ArticleEntry: self._validate_slug(slug) path = self._ensure_within(self.article_root / slug / "contents.lr", self.article_root) if not path.is_file(): raise ArticleEditorError(f"article entry does not exist: {slug}") document, raw = self._read_document(path) self._validate_loaded_entry(document, slug) return ArticleEntry(slug, path, document, raw) def _validate_loaded_entry(self, document: LrDocument, slug: str) -> None: if document.get("_model") != "entry": raise ArticleEditorError(f"content/articles/{slug} is not an entry record") if document.get("kicker") != "Article": raise ArticleEditorError(f"content/articles/{slug} does not use the Article kicker") self.validate_values(self.values_for(document)) @staticmethod def _validate_slug(slug: str) -> None: if not SLUG_RE.fullmatch(slug): raise ArticleEditorError( "slug must contain lowercase letters, numbers, and single hyphens only" ) @staticmethod def _validate_iso_date(value: str, label: str, required: bool) -> str: value = value.strip() if not value and not required: return "" if not value: raise ArticleEditorError(f"{label} is required") try: parsed = current_date.fromisoformat(value) except ValueError as exc: raise ArticleEditorError(f"{label} must use YYYY-MM-DD") from exc if parsed.isoformat() != value: raise ArticleEditorError(f"{label} must use YYYY-MM-DD") return value @staticmethod def _validate_url(value: str, label: str) -> str: value = value.strip() if not value: return "" parsed = urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ArticleEditorError(f"{label} must be a complete HTTP or HTTPS URL") return value def validate_values(self, values: dict[str, str]) -> dict[str, str]: cleaned = {key: value for key, value in values.items()} for required in ("title", "summary", "body"): cleaned[required] = cleaned.get(required, "") if not cleaned[required].strip(): raise ArticleEditorError(f"{required.replace('_', ' ').title()} is required") cleaned["title"] = cleaned["title"].strip() cleaned["author"] = cleaned.get("author", "").strip() cleaned["date"] = self._validate_iso_date( cleaned.get("date", ""), "Publication date", True ) cleaned["updated"] = self._validate_iso_date( cleaned.get("updated", ""), "Updated date", False ) cleaned["kicker"] = cleaned.get("kicker", "").strip() if cleaned["kicker"] != "Article": raise ArticleEditorError("Kicker must be Article") 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" ) published = [ self._validate_url(line, "Each published URL") for line in cleaned.get("published_urls", "").splitlines() if line.strip() ] cleaned["published_urls"] = "\n".join(published) cleaned["title"] = cleaned["title"].replace("\r", "").replace("\n", " ") cleaned["author"] = cleaned["author"].replace("\r", "").replace("\n", " ") cleaned["summary"] = cleaned["summary"].replace("\r\n", "\n") cleaned["body"] = cleaned["body"].replace("\r\n", "\n") return cleaned @staticmethod def values_for(document: LrDocument) -> dict[str, str]: tags = ", ".join( part.strip() for part in re.split(r"[,\n]", document.get("tags")) if part.strip() ) published_urls = "\n".join( line.strip() for line in document.get("published_urls").splitlines() if line.strip() ) return { "title": document.get("title"), "date": document.get("date"), "updated": document.get("updated"), "author": document.get("author"), "tags": tags, "kicker": document.get("kicker"), "summary": document.get("summary"), "body": document.get("body"), "external_url": document.get("external_url"), "published_urls": published_urls, } def create_entry(self, slug: str, values: dict[str, str]) -> ArticleEntry: self._validate_slug(slug) values = self.validate_values(values) directory = self.article_root / slug destination = directory / "contents.lr" self._ensure_within(destination, self.article_root) if directory.exists(): raise ArticleEditorError(f"content/articles/{slug} already exists") document = LrDocument.parse( "_model: entry\n---\ntitle: placeholder\n", str(destination) ) self._apply_values(document, values, creating=True) payload = document.render().encode("utf-8") try: directory.mkdir() with destination.open("xb") as output: output.write(payload) output.flush() os.fsync(output.fileno()) except (OSError, FileExistsError) as exc: try: if directory.is_dir() and not any(directory.iterdir()): directory.rmdir() except OSError: pass raise ArticleEditorError(f"cannot create {destination}: {exc}") from exc return self.load_entry(slug) def save_entry(self, entry: ArticleEntry, values: dict[str, str]) -> ArticleEntry: values = self.validate_values(values) destination = self._ensure_within(entry.path, self.article_root) temporary: Path | None = None try: current = destination.read_bytes() except OSError as exc: raise ArticleEditorError(f"cannot re-read {destination}: {exc}") from exc if current != entry.original_bytes: raise ArticleEditorError( "the entry changed on disk after it was loaded; reload it before saving" ) self._apply_values(entry.document, values, creating=False) payload = entry.document.render().encode("utf-8") if payload == current: return entry try: with tempfile.NamedTemporaryFile( mode="wb", prefix=".contents.lr.", dir=destination.parent, delete=False ) as output: temporary = Path(output.name) output.write(payload) output.flush() os.fsync(output.fileno()) os.chmod(temporary, destination.stat().st_mode) os.replace(temporary, destination) except OSError as exc: try: if temporary is not None: temporary.unlink(missing_ok=True) except OSError: pass raise ArticleEditorError(f"cannot save {destination}: {exc}") from exc return self.load_entry(entry.slug) def delete_entry(self, entry: ArticleEntry) -> None: self._validate_slug(entry.slug) directory = self._ensure_within(entry.path.parent, self.article_root) destination = self._ensure_within(entry.path, self.article_root) if directory.parent != self.article_root or directory.name != entry.slug: raise ArticleEditorError(f"unsafe article entry directory: {directory}") if directory.is_symlink() or getattr(directory, "is_junction", lambda: False)(): raise ArticleEditorError(f"refusing to delete linked directory: {directory}") try: current = destination.read_bytes() except OSError as exc: raise ArticleEditorError(f"cannot re-read {destination}: {exc}") from exc if current != entry.original_bytes: raise ArticleEditorError( "the entry changed on disk after it was loaded; reload it before deleting" ) for child in directory.rglob("*"): if child.is_symlink() or getattr(child, "is_junction", lambda: False)(): raise ArticleEditorError( f"refusing to delete an entry containing a linked path: {child}" ) try: shutil.rmtree(directory) except OSError as exc: raise ArticleEditorError(f"cannot delete {directory}: {exc}") from exc def narration_file(self, entry: ArticleEntry) -> Path: return self._ensure_within(narration_path(entry.path.parent), self.article_root) def import_narration(self, entry: ArticleEntry, source: Path) -> Path: directory = self._ensure_within(entry.path.parent, self.article_root) if not entry.path.is_file(): raise ArticleEditorError(f"article entry does not exist: {entry.slug}") try: return import_wav( directory, source, fallback_ffmpeg=self.site_root / "support" / "ffmpeg.exe" ) except NarrationImportError as exc: raise ArticleEditorError(str(exc)) from exc def remove_narration(self, entry: ArticleEntry) -> bool: directory = self._ensure_within(entry.path.parent, self.article_root) try: return remove_narration(directory) except NarrationImportError as exc: raise ArticleEditorError(str(exc)) from exc def cover_image(self, entry: ArticleEntry) -> CoverImage | None: match = COVER_IMAGE_RE.match(entry.document.get("body")) if match is None: return None path = self._ensure_within(entry.path.parent / match["name"], entry.path.parent) if not path.is_file(): raise ArticleEditorError( f"article cover image is referenced but missing: {path.name}" ) return CoverImage(match["name"], match["alt"], path) @staticmethod def cover_body(body: str, image_name: str, alt: str) -> str: match = COVER_IMAGE_RE.match(body) if match is not None: return f"![{alt}]({image_name})\n\n" + body[match.end() :] return f"![{alt}]({image_name})\n\n{body.lstrip()}" @staticmethod def remove_cover_body(body: str) -> str: match = COVER_IMAGE_RE.match(body) if match is None: return body return body[match.end() :] def add_or_replace_cover( self, entry: ArticleEntry, source: Path, alt: str, replace: bool ) -> tuple[ArticleEntry, CoverImage]: source = source.resolve() if not source.is_file(): raise ArticleEditorError(f"selected image does not exist: {source}") suffix = source.suffix.lower() if suffix not in IMAGE_SUFFIXES: raise ArticleEditorError("cover image must be a PNG, JPG, or JPEG file") destination = self._ensure_within( entry.path.parent / f"cover-image{suffix}", entry.path.parent ) current_cover = self.cover_image(entry) if destination.exists() and not replace: raise ArticleEditorError( f"{destination.name} already exists; confirm replacement before overwriting it" ) if current_cover is not None and current_cover.path != destination and not replace: raise ArticleEditorError( "the article already has a cover image; confirm replacement before changing it" ) temporary: Path | None = None try: with source.open("rb") as image_input, tempfile.NamedTemporaryFile( mode="wb", prefix=".cover-image.", dir=entry.path.parent, delete=False ) as output: temporary = Path(output.name) shutil.copyfileobj(image_input, output) output.flush() os.fsync(output.fileno()) os.replace(temporary, destination) except OSError as exc: if temporary is not None: temporary.unlink(missing_ok=True) raise ArticleEditorError(f"cannot copy cover image: {exc}") from exc values = self.values_for(entry.document) values["body"] = self.cover_body(values["body"], destination.name, alt.strip()) try: saved = self.save_entry(entry, values) except ArticleEditorError: raise if current_cover is not None and current_cover.path != destination: try: current_cover.path.unlink() except OSError as exc: raise ArticleEditorError( f"new cover saved but could not remove old cover {current_cover.path.name}: {exc}" ) from exc return saved, CoverImage(destination.name, alt.strip(), destination) def remove_cover(self, entry: ArticleEntry, remove_file: bool) -> ArticleEntry: cover = self.cover_image(entry) if cover is None: raise ArticleEditorError("the loaded article has no managed cover image") values = self.values_for(entry.document) values["body"] = self.remove_cover_body(values["body"]) saved = self.save_entry(entry, values) if remove_file: try: cover.path.unlink() except OSError as exc: raise ArticleEditorError( f"cover reference removed but could not remove {cover.path.name}: {exc}" ) from exc return saved @staticmethod def _apply_values( document: LrDocument, values: dict[str, str], creating: bool ) -> None: existing = ArticleRepository.values_for(document) if not creating else {} styles = { "summary": "multiline" if "\n" in values["summary"] else "scalar", "body": "multiline", "published_urls": "published_urls", } optional = {"updated", "tags", "external_url"} for key in FIELD_ORDER[1:]: value = values.get(key, "") if not creating and existing.get(key, "") == value: continue if key in optional and not value: document.remove_field(key) continue if key == "published_urls" or value or creating: document.set_field(key, value, styles.get(key, "scalar")) def suggest_slug(title: str) -> str: normalized = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode() return re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-") def run_gui(repository: ArticleRepository) -> None: try: import tkinter as tk from tkinter import filedialog, messagebox, ttk except ImportError as exc: raise ArticleEditorError( "Tkinter is not available in this Python installation" ) from exc class ArticleEditorApp: def __init__(self) -> None: self.root = tk.Tk() self.root.title("Labyricorn Article Editor") self.root.geometry("1240x860") self.root.minsize(980, 680) self.current_entry: ArticleEntry | None = None self.mode = "new" self.snapshot: dict[str, str] | None = None pane = ttk.Panedwindow(self.root, orient=tk.HORIZONTAL) pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) browser = ttk.Frame(pane, padding=5) form = ttk.Frame(pane, padding=5) pane.add(browser, weight=1) pane.add(form, weight=3) columns = ("date", "title", "slug") self.tree = ttk.Treeview(browser, columns=columns, show="headings") self.tree.heading("date", text="Date") self.tree.heading("title", text="Title") self.tree.heading("slug", text="Slug") self.tree.column("date", width=90, stretch=False) self.tree.column("title", width=210) self.tree.column("slug", width=180) tree_scroll = ttk.Scrollbar(browser, orient=tk.VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=tree_scroll.set) self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") tree_scroll.grid(row=0, column=3, sticky="ns") browser.rowconfigure(0, weight=1) browser.columnconfigure(0, weight=1) ttk.Button(browser, text="New", command=self.new_entry).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("", lambda _event: self.edit_selected()) self.title_var = tk.StringVar() self.slug_var = tk.StringVar() self.date_var = tk.StringVar() self.updated_var = tk.StringVar() self.author_var = tk.StringVar() self.kicker_var = tk.StringVar(value="Article") self.external_url_var = tk.StringVar() self.status_var = tk.StringVar() self.cover_var = tk.StringVar(value="No cover image") self.pending_cover: Path | None = None self.remove_cover_requested = False form.columnconfigure(1, weight=1) form.columnconfigure(3, 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 ) ttk.Label(form, text="Slug").grid(row=1, column=0, sticky="w") self.slug_entry = ttk.Entry(form, textvariable=self.slug_var) self.slug_entry.grid(row=1, column=1, columnspan=2, sticky="ew", pady=3) ttk.Button(form, text="Suggest", command=self.fill_suggested_slug).grid( row=1, column=3, sticky="e", padx=(5, 0) ) ttk.Label(form, text="Publication date").grid(row=2, column=0, sticky="w") ttk.Entry(form, textvariable=self.date_var, width=16).grid( row=2, column=1, sticky="ew", pady=3 ) ttk.Label(form, text="Updated date").grid( row=2, column=2, sticky="w", padx=(12, 0) ) ttk.Entry(form, textvariable=self.updated_var, width=16).grid( row=2, column=3, sticky="ew", pady=3 ) ttk.Label(form, text="Author").grid(row=3, column=0, sticky="w") ttk.Entry(form, textvariable=self.author_var).grid( row=3, column=1, sticky="ew", pady=3 ) ttk.Label(form, text="Type").grid(row=3, column=2, sticky="w", padx=(12, 0)) ttk.Combobox( form, textvariable=self.kicker_var, values=("Article",), state="readonly" ).grid(row=3, column=3, sticky="ew", pady=3) ttk.Label(form, text="External URL").grid(row=4, column=0, sticky="w") ttk.Entry(form, textvariable=self.external_url_var).grid( row=4, column=1, columnspan=3, sticky="ew", pady=3 ) ttk.Label(form, text="Cover image").grid(row=5, column=0, sticky="w") cover_frame = ttk.Frame(form) cover_frame.grid(row=5, column=1, columnspan=3, sticky="ew", pady=3) cover_frame.columnconfigure(0, weight=1) ttk.Label(cover_frame, textvariable=self.cover_var).grid(row=0, column=0, sticky="w") ttk.Button(cover_frame, text="Choose…", command=self.choose_cover).grid( row=0, column=1, padx=(8, 0) ) self.remove_cover_button = ttk.Button( cover_frame, text="Remove", command=self.request_cover_removal, state="disabled" ) self.remove_cover_button.grid(row=0, column=2, padx=(8, 0)) ttk.Label(form, text="Narration").grid(row=6, column=0, sticky="w") narration_frame = ttk.Frame(form) narration_frame.grid(row=6, column=1, columnspan=3, sticky="ew", pady=3) narration_frame.columnconfigure(0, weight=1) self.narration_var = tk.StringVar(value="Narration: None") ttk.Label(narration_frame, textvariable=self.narration_var).grid(row=0, column=0, sticky="w") self.choose_narration_button = ttk.Button( narration_frame, text="Choose Narration WAV…", command=self.choose_narration, state="disabled" ) self.choose_narration_button.grid(row=0, column=1, padx=(8, 0)) self.remove_narration_button = ttk.Button( narration_frame, text="Remove", command=self.remove_current_narration, state="disabled" ) self.remove_narration_button.grid(row=0, column=2, padx=(8, 0)) ttk.Label(form, text="Summary").grid(row=7, column=0, sticky="nw", pady=(5, 0)) self.summary_text = tk.Text(form, height=3, wrap="word", undo=True) self.summary_text.grid(row=7, column=1, columnspan=3, sticky="nsew", pady=3) ttk.Label(form, text="Published URLs\n(one per line)").grid( row=8, column=0, sticky="nw", pady=(5, 0) ) 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="Tracked tags").grid( row=9, column=0, sticky="nw", pady=(5, 0) ) tag_frame = ttk.Frame(form) tag_frame.grid(row=9, column=1, columnspan=3, sticky="nsew", pady=3) tag_frame.columnconfigure(0, weight=1) self.tag_list = tk.Listbox( tag_frame, height=6, selectmode=tk.EXTENDED, exportselection=False ) tag_scroll = ttk.Scrollbar(tag_frame, orient=tk.VERTICAL, command=self.tag_list.yview) self.tag_list.configure(yscrollcommand=tag_scroll.set) self.tag_list.grid(row=0, column=0, sticky="nsew") tag_scroll.grid(row=0, column=1, sticky="ns") 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=11, column=0, sticky="nw", pady=(5, 0) ) body_frame = ttk.Frame(form) 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) body_scroll = ttk.Scrollbar(body_frame, orient=tk.VERTICAL, command=self.body_text.yview) self.body_text.configure(yscrollcommand=body_scroll.set) self.body_text.grid(row=0, column=0, sticky="nsew") body_scroll.grid(row=0, column=1, sticky="ns") footer = ttk.Frame(form) 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( footer, text="Delete entry", command=self.delete_current, state="disabled" ) self.delete_button.grid(row=0, column=1, sticky="e", padx=(0, 8)) ttk.Button(footer, text="Save entry", command=self.save).grid( row=0, column=2, sticky="e" ) self.root.bind("", lambda _event: self.save()) self.root.protocol("WM_DELETE_WINDOW", self.close) self.refresh(preserve_selection=False) self.new_entry(confirm=False) 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(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"), "external_url": self.external_url_var.get(), "published_urls": self.published_text.get("1.0", "end-1c"), } def set_values(self, values: dict[str, str]) -> None: self.title_var.set(values.get("title", "")) self.date_var.set(values.get("date", "")) self.updated_var.set(values.get("updated", "")) self.author_var.set(values.get("author", "")) self.kicker_var.set("Article") self.external_url_var.set(values.get("external_url", "")) for widget, key in ( (self.summary_text, "summary"), (self.published_text, "published_urls"), (self.body_text, "body"), ): widget.delete("1.0", tk.END) widget.insert("1.0", values.get(key, "")) 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 def may_discard(self) -> bool: return not self.has_unsaved_changes() or messagebox.askyesno( "Discard changes?", "Discard the unsaved changes in the current form?" ) 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: entries = repository.list_entries() except ArticleEditorError as exc: messagebox.showerror("Cannot load article entries", str(exc)) return for entry in entries: self.tree.insert( "", tk.END, iid=entry.slug, values=(entry.publication_date, entry.title, entry.slug) ) if selected and self.tree.exists(selected): self.tree.selection_set(selected) self.status_var.set(f"{len(entries)} article entries") def new_entry(self, confirm: bool = True) -> None: if confirm and not self.may_discard(): return self.mode = "new" self.current_entry = 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(), "author": "Christopher Chambers", "kicker": "Article", } ) self.snapshot = self.collect_values() self.status_var.set("Creating a new article entry") self.pending_cover = None self.remove_cover_requested = False self.cover_var.set("No cover image") self.remove_cover_button.configure(state="disabled") self.narration_var.set("Narration: save this entry before adding narration") self.choose_narration_button.configure(state="disabled") self.remove_narration_button.configure(state="disabled") def edit_selected(self) -> None: selection = self.tree.selection() if not selection: messagebox.showinfo("Select an entry", "Select a article entry to edit.") return 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)) return self.mode = "edit" self.current_entry = entry self.delete_button.configure(state="normal") self.slug_var.set(entry.slug) self.slug_entry.configure(state="readonly") self.set_values(repository.values_for(entry.document)) self.pending_cover = None self.remove_cover_requested = False try: cover = repository.cover_image(entry) except ArticleEditorError as exc: messagebox.showerror("Cannot load cover image", str(exc)) return if cover is None: self.cover_var.set("No cover image") self.remove_cover_button.configure(state="disabled") else: self.cover_var.set(f"{cover.name} — {cover.alt or 'no alt text'}") self.remove_cover_button.configure(state="normal") self.update_narration_status() self.snapshot = self.collect_values() self.status_var.set(f"Editing content/articles/{entry.slug}/contents.lr") def fill_suggested_slug(self) -> None: if self.mode != "new": return self.slug_var.set(suggest_slug(self.title_var.get())) def update_narration_status(self) -> None: if self.current_entry is None: self.narration_var.set("Narration: None") self.choose_narration_button.configure(state="disabled") self.remove_narration_button.configure(state="disabled") return narration = repository.narration_file(self.current_entry) exists = narration.is_file() self.narration_var.set("Narration: narration.mp3" if exists else "Narration: None") self.choose_narration_button.configure(state="normal") self.remove_narration_button.configure(state="normal" if exists else "disabled") def choose_narration(self) -> None: if self.current_entry is None: return selected = filedialog.askopenfilename( title="Choose narration WAV file", filetypes=[("WAV audio", "*.wav"), ("All files", "*.*")], ) if not selected: return destination = repository.narration_file(self.current_entry) if destination.is_file() and not messagebox.askyesno( "Replace narration?", "Replace the existing narration.mp3 for this article entry?", icon="warning", ): return try: repository.import_narration(self.current_entry, Path(selected)) except ArticleEditorError as exc: messagebox.showerror("Cannot import narration", str(exc)) return self.update_narration_status() self.status_var.set(f"Imported narration.mp3 for {self.current_entry.slug}") def remove_current_narration(self) -> None: if self.current_entry is None: return if not messagebox.askyesno( "Remove narration?", "Remove narration.mp3 from this article entry?", icon="warning", ): return try: repository.remove_narration(self.current_entry) except ArticleEditorError as exc: messagebox.showerror("Cannot remove narration", str(exc)) return self.update_narration_status() self.status_var.set(f"Removed narration.mp3 from {self.current_entry.slug}") def choose_cover(self) -> None: selected = filedialog.askopenfilename( title="Choose article cover image", filetypes=[("Images", "*.png *.jpg *.jpeg"), ("All files", "*.*")], ) if not selected: return source = Path(selected) if source.suffix.lower() not in IMAGE_SUFFIXES: messagebox.showerror("Unsupported cover image", "Choose a PNG, JPG, or JPEG file.") return has_cover = self.current_entry is not None and repository.cover_image(self.current_entry) if has_cover or self.pending_cover is not None: if not messagebox.askyesno( "Replace cover image?", "Replacing the cover will overwrite the existing canonical cover file " "when saved. Continue?", icon="warning", ): return self.pending_cover = source self.remove_cover_requested = False self.cover_var.set(f"Will copy {source.name} when saved") self.remove_cover_button.configure(state="normal") def request_cover_removal(self) -> None: if self.pending_cover is not None: self.pending_cover = None self.cover_var.set("No cover image") self.remove_cover_button.configure(state="disabled") return if self.current_entry is None: return try: cover = repository.cover_image(self.current_entry) except ArticleEditorError as exc: messagebox.showerror("Cannot inspect cover image", str(exc)) return if cover is None: return if not messagebox.askyesno( "Remove cover image?", f"Remove the {cover.name} reference and delete that article-local file?", icon="warning", ): return self.remove_cover_requested = True self.cover_var.set(f"Will remove {cover.name} when saved") def save(self) -> None: try: values = self.collect_values() if self.mode == "new": entry = repository.create_entry(self.slug_var.get().strip(), values) else: if self.current_entry is None: raise ArticleEditorError("no article entry is loaded") entry = repository.save_entry(self.current_entry, values) if self.pending_cover is not None: entry, _cover = repository.add_or_replace_cover( entry, self.pending_cover, "Article cover image", replace=True ) elif self.remove_cover_requested: entry = repository.remove_cover(entry, remove_file=True) except ArticleEditorError as exc: messagebox.showerror("Cannot save entry", str(exc)) return self.mode = "edit" self.current_entry = entry self.delete_button.configure(state="normal") self.slug_var.set(entry.slug) self.slug_entry.configure(state="readonly") self.set_values(repository.values_for(entry.document)) self.pending_cover = None self.remove_cover_requested = False cover = repository.cover_image(entry) if cover is None: self.cover_var.set("No cover image") self.remove_cover_button.configure(state="disabled") else: self.cover_var.set(f"{cover.name} — {cover.alt or 'no alt text'}") self.remove_cover_button.configure(state="normal") self.update_narration_status() self.snapshot = self.collect_values() self.refresh(preserve_selection=False) self.tree.selection_set(entry.slug) self.tree.see(entry.slug) self.status_var.set(f"Saved content/articles/{entry.slug}/contents.lr") def delete_current(self) -> None: if self.mode != "edit" or self.current_entry is None: return entry = self.current_entry try: additional_files = sum( 1 for path in entry.path.parent.rglob("*") if path.is_file() and path != entry.path ) except OSError as exc: messagebox.showerror("Cannot inspect entry", str(exc)) return attachment_note = ( f" It also contains {additional_files} additional file(s)." if additional_files else "" ) unsaved_note = ( " Unsaved form changes will be discarded." if self.has_unsaved_changes() else "" ) confirmed = messagebox.askyesno( "Delete article entry?", f"Delete {entry.title!r} ({entry.slug}) from the working tree?" f"{attachment_note}{unsaved_note}\n\n" "This removes the complete entry directory. The deletion can be reviewed " "and recovered with Git until it is committed.", icon="warning", ) if not confirmed: return try: repository.delete_entry(entry) except ArticleEditorError as exc: messagebox.showerror("Cannot delete entry", str(exc)) return deleted_slug = entry.slug self.snapshot = None self.refresh(preserve_selection=False) self.new_entry(confirm=False) self.status_var.set(f"Deleted content/articles/{deleted_slug} from the working tree") def close(self) -> None: if self.may_discard(): self.root.destroy() try: app = ArticleEditorApp() except tk.TclError as exc: raise ArticleEditorError(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: repository = ArticleRepository(args.site_root) run_gui(repository) except ArticleEditorError as exc: parser.exit(1, f"article-editor: ERROR: {exc}\n") return 0 if __name__ == "__main__": raise SystemExit(main())