1182 lines
50 KiB
Python
1182 lines
50 KiB
Python
#!/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<prefix>\s*)!\[(?P<alt>[^\]]*)\]\((?P<name>cover-image\.(?:png|jpe?g))\)(?P<spacing>\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"\n\n" + body[match.end() :]
|
|
return f"\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("<Double-1>", 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("<Control-s>", 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())
|