diff --git a/.labyricorn/AGENTS.md b/.labyricorn/AGENTS.md new file mode 100644 index 0000000..06d4668 --- /dev/null +++ b/.labyricorn/AGENTS.md @@ -0,0 +1,42 @@ +# Instructions for Labyricorn publishing content + +These instructions apply to the entire `.labyricorn/` directory. More specific +instructions in `project/AGENTS.md` and `devlog/AGENTS.md` also apply within +those directories. + +## Purpose and ownership + +- This directory is the repository-owned source for the project's exhibition + page and development log on Labyricorn. +- This project repository is authoritative for the content. The Labyricorn site + imports it as read-only content. +- Read `.labyricorn/README.md` and the nearest scoped `AGENTS.md` before editing + a publishing record. +- Keep publishing changes focused. Do not alter application code merely to + support an exhibition or devlog edit unless the user separately requests it. + +## Authorization and content contract + +- Preserve unrelated and uncommitted work. Never discard changes to obtain a + clean working tree. +- Do not commit or push unless the user explicitly requests publication. +- Use native Lektor records named `contents.lr` and preserve `schema_version: 1` + until a coordinated schema migration is approved. +- Keep attachments inside the record subtree that owns them. +- Use UTF-8 text, `YYYY-MM-DD` dates, and stable lowercase hyphenated slugs. +- Do not add templates, models, plugins, workflows, executable files, builds, + application state, credentials, raw HTML, scripts, or active third-party + content below `.labyricorn/`. +- Treat published URLs as durable. Ask before renaming or removing a published + record; redirects or archival behavior may be required first. + +## Security, synchronization, and review + +- Never store or print passwords, API tokens, refresh credentials, private keys, + `.netrc` contents, or other secrets in this directory. +- Do not claim content is public merely because a push succeeded. Report push + status and Labyricorn synchronization status separately. +- Parse changed records, confirm referenced source commits exist, run + `git diff --check`, and review the exact publishing diff before committing. +- At handoff, report changed publishing records, validation performed, commit + and push status, and synchronization status if known. \ No newline at end of file diff --git a/.labyricorn/README.md b/.labyricorn/README.md new file mode 100644 index 0000000..4c58b74 --- /dev/null +++ b/.labyricorn/README.md @@ -0,0 +1,38 @@ +# Labyricorn publishing content + +This directory is the repository-owned source for this project's exhibition +and development log on Labyricorn. The project repository remains the source +of truth; the Labyricorn site imports this content as read-only. + +The content uses native Lektor records: + +```text +.labyricorn/ +├── AGENTS.md +├── README.md +├── project/ +│ ├── AGENTS.md +│ ├── contents.lr +│ └── +└── devlog/ + ├── AGENTS.md + ├── contents.lr + └── / + ├── contents.lr + └── +``` + +Rules: + +- `project/contents.lr` uses the `project` model. +- `devlog/contents.lr` uses the `devlog` model. +- Each entry is a directory below `devlog/` containing a `contents.lr` record + using the `devlog-entry` model. +- Entry directory names are stable public slugs. Do not rename a published entry + without arranging a redirect on the Labyricorn site. +- Dates use `YYYY-MM-DD`; `source_commit` uses the full relevant commit ID. +- Publishing attachments stay inside the record subtree that owns them. +- Do not put credentials, builds, application state, or executable code here. + +Editing these records does not itself prove that the public site synchronized. +Report repository publication and Labyricorn synchronization separately. \ No newline at end of file diff --git a/.labyricorn/devlog/AGENTS.md b/.labyricorn/devlog/AGENTS.md new file mode 100644 index 0000000..8e4b976 --- /dev/null +++ b/.labyricorn/devlog/AGENTS.md @@ -0,0 +1,57 @@ +# Instructions for the Labyricorn development log + +These instructions apply to `.labyricorn/devlog/` and help coding assistants +maintain the project's public development narrative safely. + +## Format and ownership + +- Keep the devlog index at `devlog/contents.lr` using `_model: devlog` and + `schema_version: 1`. +- Store each entry at `devlog//contents.lr` using + `_model: devlog-entry` and `schema_version: 1`. +- Use lowercase hyphenated entry slugs. Published slugs are durable public URLs; + do not rename or delete them without explicit approval and a redirect or + archival decision. +- Every entry must contain `title`, `date`, `author`, `summary`, `tags`, + `source_commit`, and Markdown `body` fields. +- Keep entry attachments in that entry's directory. Only PNG, JPEG, and WebP + images are accepted by the Labyricorn importer. +- Do not add templates, models, plugins, executable code, builds, application + state, credentials, or active third-party content below this directory. + +## Content guidance + +- Base entries on verifiable repository history, project documentation, and + implemented behavior. Do not invent motivations, results, release status, + user feedback, or completion claims. +- Explain the milestone, why it mattered, and what changed. Keep one main + milestone per entry and write a summary that works in a chronological list. + Do not merely expand a commit message or enumerate every changed file. +- Distinguish design and schema contracts from completed runtime behavior. +- Link to the relevant canonical commit using the repository's HTTPS web URL. +- Use the referenced commit's actual `YYYY-MM-DD` calendar date for retrospective + entries, not the date on which the prose was drafted. +- Set `source_commit` to the full lowercase 40-character commit ID most directly + associated with the milestone. It must be an ancestor of the published branch. +- Topics are comma-separated display labels. Unknown topics are allowed: the + site keeps them visible but unlinked. Do not create or edit the site's tracked + tag registry from this project repository. +- Use Markdown without raw HTML, scripts, embedded credentials, or active + external content. +- Multiple entries may share a date. The site resolves ties from source history; + do not invent timestamps or manual next/previous links. +- Do not rewrite older entries merely because the implementation later changed. + Add a new entry or a clearly labelled correction when historical context is + needed. + +## Safety and review + +- Preserve unrelated project files and uncommitted work. The devlog owns only + `.labyricorn/devlog/`. +- Do not commit or push unless the user explicitly requests publication. +- Before publication, verify claims against the referenced commit, validate all + records, check that the source-commit link uses the same full commit ID, review + the exact devlog diff, and confirm unrelated files are not staged in the + publishing commit. +- Prefer the repository-root `devlog_editor.py` for routine validation and safe + publication when it is available. \ No newline at end of file diff --git a/.labyricorn/devlog/contents.lr b/.labyricorn/devlog/contents.lr new file mode 100644 index 0000000..164045c --- /dev/null +++ b/.labyricorn/devlog/contents.lr @@ -0,0 +1,7 @@ +_model: devlog +--- +schema_version: 1 +--- +title: Labyricorn Project Template development log +--- +summary: Milestones and development log entries for the Labyricorn project template. \ No newline at end of file diff --git a/.labyricorn/project/AGENTS.md b/.labyricorn/project/AGENTS.md new file mode 100644 index 0000000..b6dc3d9 --- /dev/null +++ b/.labyricorn/project/AGENTS.md @@ -0,0 +1,33 @@ +# Instructions for the Labyricorn project record + +These instructions supplement `.labyricorn/AGENTS.md` and apply to the +`project/` directory. + +## Record structure + +- Maintain exactly one project record at `project/contents.lr` using + `_model: project` and `schema_version: 1`. +- Keep `project_id` stable. It is the durable identity used by the importer and + public URL. +- Ask before changing the repository URL, default branch, project status, + author, start date, or project identity. +- Store project-owned images in this directory and reference them by filename + from `contents.lr`. Only PNG, JPEG, and WebP images are accepted. + +## Editorial guidance + +- Write the exhibition page as a concise project narrative, not as a copy of + the repository README or as release documentation. +- Verify technical and status claims against the repository before editing. +- Distinguish completed work, formal design work, experiments, and future work. +- Do not manually add repository-derived commit, license, language, release, + issue, star, or fork metadata. The Labyricorn importer owns those values. +- Keep the summary suitable for listings and social previews. + +## Review requirements + +- Confirm all required project fields are present and any image reference stays + inside `project/`. +- Check Markdown links are intentional HTTPS links and prose makes no unsupported + claims. +- Validate the record and run `git diff --check` before committing. \ No newline at end of file diff --git a/.labyricorn/project/contents.lr b/.labyricorn/project/contents.lr new file mode 100644 index 0000000..d0b5ff6 --- /dev/null +++ b/.labyricorn/project/contents.lr @@ -0,0 +1,27 @@ +_model: project +--- +schema_version: 1 +--- +project_id: labyricorn-project-template +--- +title: Labyricorn Project Template +--- +summary: + +Initial template and publishing structure for Labyricorn-compatible projects and development logs. +--- +status: active +--- +started: 2026-08-23 +--- +author: Labyricorn +--- +repository_url: https://git.labyricorn.com/Labyricorn/labyricorn-project-template +--- +default_branch: main +--- +tags: template, lektor, python +--- +body: + +Starter template providing the standard .labyricorn/ publishing records, assistant instructions, devlog structure, and repository tooling for publication on Labyricorn. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1740954 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Instructions for coding assistants + +These instructions apply to this repository. Read `README.md` and the relevant +scoped `AGENTS.md` files before making changes. + +## Communication and authorization + +- Do not assume the user's desired behavior, architecture, or content structure + when the answer would materially affect the result. +- When the user requests discussion or review, do not edit files, run mutating + commands, commit, or push. +- An implementation request authorizes work only within its clearly stated + scope. Routine steps within that scope may proceed, but confirm before + expanding scope or making irreversible choices. +- Before implementation, state the intended files and systems to be changed. +- Do not commit or push unless the user explicitly requests it. +- Preserve unrelated edits and uncommitted work. Never discard changes merely + to obtain a clean working tree. + +## Project and publishing boundaries + +- Application and project source code lives at the repository root and normal + subdirectories. +- The `.labyricorn/` directory is the repository-owned source for project + exhibition records and development logs published on Labyricorn. +- Keep publishing records in `.labyricorn/` aligned with actual repository + implementation and history. +- Read `.labyricorn/AGENTS.md`, `.labyricorn/project/AGENTS.md`, and + `.labyricorn/devlog/AGENTS.md` when modifying publishing records. + +## Content contract and devlog practices + +- Use native Lektor records named `contents.lr` with `schema_version: 1`. +- Keep attachments inside the record subtree that owns them (PNG, JPEG, WebP). +- Do not introduce raw HTML, scripts, executable files, builds, or credentials + below `.labyricorn/`. +- For devlog entries, base records on verifiable repository commits and history. + Set `source_commit` to the full 40-character commit ID on the branch. +- Devlog dates should reflect the commit's calendar date (`YYYY-MM-DD`). + +## Validation and review + +- Run `python devlog_editor.py --validate` to verify that `.labyricorn/` records, + project identity, devlog index, and devlog entries adhere to the schema. +- Alternatively, launch the graphical editor with `python devlog_editor.py` for + interactive management of devlog entries and publishing status. +- Run `git diff --check` and review diffs carefully before staging or committing. +- At handoff, summarize files changed, validation performed, and any next steps. \ No newline at end of file diff --git a/devlog_editor.py b/devlog_editor.py new file mode 100644 index 0000000..dd4166a --- /dev/null +++ b/devlog_editor.py @@ -0,0 +1,1797 @@ +#!/usr/bin/env python3 +"""Standalone editor for repository-owned Labyricorn project and devlog content.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import date +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unicodedata +from urllib.parse import urlsplit, urlunsplit + + +SCHEMA_VERSION = "1" +DEVLOG_RELATIVE = Path(".labyricorn") / "devlog" +PROJECT_RELATIVE = Path(".labyricorn") / "project" / "contents.lr" +PUBLISHING_PATHS = ( + ".labyricorn/AGENTS.md", + ".labyricorn/README.md", + ".labyricorn/project", + ".labyricorn/devlog", +) +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SEPARATOR_RE = re.compile(r"(?m)(^---[ \t]*(?:\r\n|\n|$))") +FIELD_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*):(.*)") +RAW_HTML_RE = re.compile(r"<\s*(?:!|/?[A-Za-z])[\s\S]*?>") +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +IGNORED_DOCUMENTATION = {"AGENTS.md", "README.md"} +MAX_FILES = 100 +MAX_FILE_SIZE = 5 * 1024 * 1024 +MAX_TOTAL_SIZE = 20 * 1024 * 1024 + +ROOT_AGENTS_TEXT = """# Instructions for Labyricorn publishing content + +These instructions apply to the entire `.labyricorn/` directory. More specific +instructions in `project/AGENTS.md` and `devlog/AGENTS.md` also apply within +those directories. + +## Purpose and ownership + +- This directory is the repository-owned source for the project's exhibition + page and development log on Labyricorn. +- This project repository is authoritative for the content. The Labyricorn site + imports it as read-only content. +- Read `.labyricorn/README.md` and the nearest scoped `AGENTS.md` before editing + a publishing record. +- Keep publishing changes focused. Do not alter application code merely to + support an exhibition or devlog edit unless the user separately requests it. + +## Authorization and content contract + +- Preserve unrelated and uncommitted work. Never discard changes to obtain a + clean working tree. +- Do not commit or push unless the user explicitly requests publication. +- Use native Lektor records named `contents.lr` and preserve `schema_version: 1` + until a coordinated schema migration is approved. +- Keep attachments inside the record subtree that owns them. +- Use UTF-8 text, `YYYY-MM-DD` dates, and stable lowercase hyphenated slugs. +- Do not add templates, models, plugins, workflows, executable files, builds, + application state, credentials, raw HTML, scripts, or active third-party + content below `.labyricorn/`. +- Treat published URLs as durable. Ask before renaming or removing a published + record; redirects or archival behavior may be required first. + +## Security, synchronization, and review + +- Never store or print passwords, API tokens, refresh credentials, private keys, + `.netrc` contents, or other secrets in this directory. +- Do not claim content is public merely because a push succeeded. Report push + status and Labyricorn synchronization status separately. +- Parse changed records, confirm referenced source commits exist, run + `git diff --check`, and review the exact publishing diff before committing. +- At handoff, report changed publishing records, validation performed, commit + and push status, and synchronization status if known. +""" + +ROOT_README_TEMPLATE = """# Labyricorn publishing content + +This directory is the repository-owned source for {project_title}'s project +exhibition and development log on Labyricorn. The project repository remains +the source of truth; the Labyricorn site imports this content as read-only. + +The content uses native Lektor records: + +```text +.labyricorn/ +├── AGENTS.md +├── README.md +├── project/ +│ ├── AGENTS.md +│ ├── contents.lr +│ └── +└── devlog/ + ├── AGENTS.md + ├── contents.lr + └── / + ├── contents.lr + └── +``` + +Rules: + +- `project/contents.lr` uses the `project` model. +- `devlog/contents.lr` uses the `devlog` model. +- Each entry is a directory below `devlog/` containing a `contents.lr` record + using the `devlog-entry` model. +- Entry directory names are stable public slugs. Do not rename a published entry + without arranging a redirect on the Labyricorn site. +- Dates use `YYYY-MM-DD`; `source_commit` uses the full relevant commit ID. +- Publishing attachments stay inside the record subtree that owns them. +- Do not put credentials, builds, application state, or executable code here. + +Editing these records does not itself prove that the public site synchronized. +Report repository publication and Labyricorn synchronization separately. +""" + +PROJECT_AGENTS_TEXT = """# Instructions for the Labyricorn project record + +These instructions supplement `.labyricorn/AGENTS.md` and apply to the +`project/` directory. + +## Record structure + +- Maintain exactly one project record at `project/contents.lr` using + `_model: project` and `schema_version: 1`. +- Keep `project_id` stable. It is the durable identity used by the importer and + public URL. +- Ask before changing the repository URL, default branch, project status, + author, start date, or project identity. +- Store project-owned images in this directory and reference them by filename + from `contents.lr`. Only PNG, JPEG, and WebP images are accepted. + +## Editorial guidance + +- Write the exhibition page as a concise project narrative, not as a copy of + the repository README or as release documentation. +- Verify technical and status claims against the repository before editing. +- Distinguish completed work, formal design work, experiments, and future work. +- Do not manually add repository-derived commit, license, language, release, + issue, star, or fork metadata. The Labyricorn importer owns those values. +- Keep the summary suitable for listings and social previews. + +## Review requirements + +- Confirm all required project fields are present and any image reference stays + inside `project/`. +- Check Markdown links are intentional HTTPS links and prose makes no unsupported + claims. +- Validate the record and run `git diff --check` before committing. +""" + +DEVLOG_AGENTS_TEXT = """# Instructions for the Labyricorn development log + +These instructions apply to `.labyricorn/devlog/` and help coding assistants +maintain the project's public development narrative safely. + +## Format and ownership + +- Keep the devlog index at `devlog/contents.lr` using `_model: devlog` and + `schema_version: 1`. +- Store each entry at `devlog//contents.lr` using + `_model: devlog-entry` and `schema_version: 1`. +- Use lowercase hyphenated entry slugs. Published slugs are durable public URLs; + do not rename or delete them without explicit approval and a redirect or + archival decision. +- Every entry must contain `title`, `date`, `author`, `summary`, `tags`, + `source_commit`, and Markdown `body` fields. +- Keep entry attachments in that entry's directory. Only PNG, JPEG, and WebP + images are accepted by the Labyricorn importer. +- Do not add templates, models, plugins, executable code, builds, application + state, credentials, or active third-party content below this directory. + +## Content guidance + +- Base entries on verifiable repository history, project documentation, and + implemented behavior. Do not invent motivations, results, release status, + user feedback, or completion claims. +- Explain the milestone, why it mattered, and what changed. Keep one main + milestone per entry and write a summary that works in a chronological list. + Do not merely expand a commit message or enumerate every changed file. +- Distinguish design and schema contracts from completed runtime behavior. +- Link to the relevant canonical commit using the repository's HTTPS web URL. +- Use the referenced commit's actual `YYYY-MM-DD` calendar date for retrospective + entries, not the date on which the prose was drafted. +- Set `source_commit` to the full lowercase 40-character commit ID most directly + associated with the milestone. It must be an ancestor of the published branch. +- Topics are comma-separated display labels. Unknown topics are allowed: the + site keeps them visible but unlinked. Do not create or edit the site's tracked + tag registry from this project repository. +- Use Markdown without raw HTML, scripts, embedded credentials, or active + external content. +- Multiple entries may share a date. The site resolves ties from source history; + do not invent timestamps or manual next/previous links. +- Do not rewrite older entries merely because the implementation later changed. + Add a new entry or a clearly labelled correction when historical context is + needed. + +## Safety and review + +- Preserve unrelated project files and uncommitted work. The devlog owns only + `.labyricorn/devlog/`. +- Do not commit or push unless the user explicitly requests publication. +- Before publication, verify claims against the referenced commit, validate all + records, check that the source-commit link uses the same full commit ID, review + the exact devlog diff, and confirm unrelated files are not staged in the + publishing commit. +- Prefer the repository-root `devlog_editor.py` for routine validation and safe + publication when it is available. +""" + +PROJECT_REQUIRED_FIELDS = { + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "tags", "body", +} +DEVLOG_REQUIRED_FIELDS = {"_model", "schema_version", "title", "summary"} +ENTRY_REQUIRED_FIELDS = { + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +} +ENTRY_FIELD_ORDER = ( + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +) +PROJECT_FIELD_ORDER = ( + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "logo", "tags", "body", +) + + +class DevlogError(Exception): + """An expected error suitable for display to a nontechnical user.""" + + +class GitError(DevlogError): + """A safe summary of a failed Git operation.""" + + +def is_link(path: Path) -> bool: + return path.is_symlink() or bool(getattr(path, "is_junction", lambda: False)()) + + +def suggest_slug(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + return re.sub(r"[^a-z0-9]+", "-", normalized.casefold()).strip("-") + + +def is_publishing_path(value: str) -> bool: + value = value.replace("\\", "/") + return any(value == root or value.startswith(root + "/") for root in PUBLISHING_PATHS) + + +def validate_iso_date(value: str, label: str) -> str: + value = value.strip() + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise DevlogError(f"{label} must use YYYY-MM-DD.") from exc + if parsed.isoformat() != value: + raise DevlogError(f"{label} must use YYYY-MM-DD.") + return value + + +def normalized_topics(value: str) -> str: + values: list[str] = [] + for item in re.split(r"[,\n]", value): + item = item.strip().replace("\t", " ") + if item and item not in values: + values.append(item) + return ", ".join(values) + + +@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 DevlogError(f"{source}: empty or malformed record block.") + document = cls(blocks, delimiters, "\r\n" if "\r\n" in text else "\n", 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 parse_block(self, block: str) -> tuple[str, str]: + line_end = block.find("\n") + if line_end < 0: + first_line, remainder = block, "" + else: + first_line = block[:line_end].rstrip("\r") + remainder = block[line_end + 1 :] + match = FIELD_RE.fullmatch(first_line) + if match is None: + raise DevlogError(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") + if value.startswith("\r\n"): + value = value[2:] + elif value.startswith("\n"): + value = value[1:] + return key, value + + 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 DevlogError(f"{self.source}: duplicate field {key!r}.") + indexes[key] = index + return indexes + + def values(self) -> dict[str, str]: + return {key: self.parse_block(self.blocks[index])[1] for key, index in self.field_indexes().items()} + + def get(self, key: str, default: str = "") -> str: + index = self.field_indexes().get(key) + return default if index is None else self.parse_block(self.blocks[index])[1] + + def set_field( + self, + key: str, + value: str, + *, + multiline: bool = False, + field_order: tuple[str, ...] = ENTRY_FIELD_ORDER, + ) -> None: + indexes = self.field_indexes() + current = indexes.get(key) + if current is not None and self.get(key) == value: + return + newline = self.newline + if multiline or "\n" in value: + clean = value.rstrip("\r\n") + rendered = f"{key}:{newline}{newline}{clean}{newline}" if clean else f"{key}:{newline}" + else: + rendered = f"{key}: {value}{newline}" + if current is not None: + self.blocks[current] = rendered + return + desired = field_order.index(key) if key in field_order else len(field_order) + insertion = len(self.blocks) + for candidate, index in indexes.items(): + candidate_order = field_order.index(candidate) if candidate in field_order else len(field_order) + if candidate_order > desired: + insertion = min(insertion, index) + self.blocks.insert(insertion, rendered) + delimiter = f"---{newline}" + self.delimiters.insert(0 if insertion == 0 else insertion - 1, delimiter) + + +@dataclass +class DevlogEntry: + 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 +class RepositoryStatus: + name: str + branch: str + remote_name: str + remote_url: str + upstream: str + ahead: int + behind: int + changed_count: int + publishing_changed_count: int + conflicts: list[str] + operation: str + short_status: str + + @property + def state_text(self) -> str: + if self.operation: + return f"{self.operation} is in progress — manual Git assistance required" + if self.conflicts: + return "Repository has unresolved conflicts — manual Git assistance required" + if not self.branch: + return "Detached Git checkout — manual Git assistance required" + if not self.remote_name: + return "No remote is configured" + if not self.upstream: + return "No upstream branch is configured" + if self.ahead and self.behind: + return "Local and remote histories have diverged — manual Git assistance required" + if self.behind: + return f"Remote has {self.behind} newer commit(s)" + if self.ahead: + return f"Local branch has {self.ahead} unpublished commit(s)" + return "Up to date (as of the last fetch)" + + +class GitRepository: + def __init__(self, start: Path): + self.start = start.resolve() + self.git_executable, self.uses_bundled_git = self._find_git(self.start) + try: + result = subprocess.run( + self._git_command("-C", str(self.start), "rev-parse", "--show-toplevel"), + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not respond while locating the repository.") from exc + if result.returncode != 0: + raise GitError("devlog_editor.py is not inside a Git repository.") + self.root = Path(result.stdout.strip()).resolve() + try: + self.start.relative_to(self.root) + except ValueError as exc: + raise GitError( + "devlog_editor.py is not inside the detected repository root.\n\n" + f"Detected repository root: {self.root}" + ) from exc + self.git_dir = Path(self.run("rev-parse", "--absolute-git-dir").strip()).resolve() + + @staticmethod + def _find_git(start: Path) -> tuple[Path | str, bool]: + system_git = shutil.which("git") + if system_git: + return system_git, False + bundled_candidates = ( + start / "support" / "PortableGit" / "cmd" / "git.exe", + start / "PortableGit" / "cmd" / "git.exe", + start.parent / "support" / "PortableGit" / "cmd" / "git.exe", + ) + for bundled in bundled_candidates: + if bundled.is_file(): + return bundled, True + raise GitError( + "Git is unavailable. Place PortableGit under support\\PortableGit or install Git." + ) + + def _git_command(self, *args: str) -> list[str]: + command = [str(self.git_executable)] + if self.uses_bundled_git: + command.extend(("-c", "credential.helper=manager")) + command.extend(args) + return command + + @staticmethod + def _safe_url(value: str) -> str: + value = value.strip() + try: + parsed = urlsplit(value) + except ValueError: + return "configured" + if parsed.scheme and parsed.hostname: + host = parsed.hostname + if parsed.port: + host += f":{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return re.sub(r"//[^/@\s]+@", "//***@", value) + + def _sanitize(self, message: str) -> str: + message = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", message) + return message.strip() + + def run(self, *args: str, timeout: int = 30, check: bool = True) -> str: + try: + result = subprocess.run( + self._git_command("-C", str(self.root), *args), capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not finish in time. Check the network and Git authentication.") from exc + if check and result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) or "Git returned an error." + raise GitError(detail) + return result.stdout + + def result(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + self._git_command("-C", str(self.root), *args), capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise GitError("Git could not complete the requested operation.") from exc + + @staticmethod + def count_porcelain(raw: str) -> int: + chunks = raw.split("\0") + count = 0 + index = 0 + while index < len(chunks): + record = chunks[index] + if not record: + index += 1 + continue + count += 1 + code = record[:2] + index += 2 if "R" in code or "C" in code else 1 + return count + + def branch(self) -> str: + return self.run("branch", "--show-current").strip() + + def upstream_configuration(self, branch: str) -> tuple[str, str, str, str]: + if not branch: + return "", "", "", "" + remote = self.run("config", "--get", f"branch.{branch}.remote", check=False).strip() + if not remote: + remotes = self.run("remote", check=False).splitlines() + if "origin" in remotes: + remote = "origin" + merge_ref = self.run("config", "--get", f"branch.{branch}.merge", check=False).strip() + upstream = self.run( + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", check=False + ).strip() + url = self.run("remote", "get-url", remote, check=False).strip() if remote and remote != "." else "" + return remote, merge_ref, upstream, url + + def operation_in_progress(self) -> str: + states = ( + ("MERGE_HEAD", "A merge"), ("rebase-merge", "A rebase"), + ("rebase-apply", "A rebase"), ("CHERRY_PICK_HEAD", "A cherry-pick"), + ("REVERT_HEAD", "A revert"), ("BISECT_LOG", "A bisect"), + ) + for marker, label in states: + path_text = self.run("rev-parse", "--git-path", marker).strip() + marker_path = Path(path_text) + if path_text and not marker_path.is_absolute(): + marker_path = self.root / marker_path + if path_text and marker_path.exists(): + return label + return "" + + def status(self) -> RepositoryStatus: + branch = self.branch() + remote, _merge_ref, upstream, remote_url = self.upstream_configuration(branch) + ahead = behind = 0 + if upstream: + counts = self.run("rev-list", "--left-right", "--count", f"HEAD...{upstream}").split() + if len(counts) == 2: + ahead, behind = map(int, counts) + raw = self.run("status", "--porcelain=v1", "-z", "--untracked-files=all") + publishing_raw = self.run( + "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", + *PUBLISHING_PATHS, + ) + conflicts = [ + item for item in self.run("diff", "--name-only", "--diff-filter=U", "-z").split("\0") if item + ] + display_url = self._safe_url(remote_url) if remote_url else "" + name_source = display_url.rstrip("/").rsplit("/", 1)[-1] if display_url else self.root.name + name = name_source.removesuffix(".git") or self.root.name + short_status = self.run("status", "--short", "--untracked-files=all").strip() + return RepositoryStatus( + name, branch, remote, display_url, upstream, ahead, behind, + self.count_porcelain(raw), self.count_porcelain(publishing_raw), conflicts, + self.operation_in_progress(), short_status, + ) + + def fetch(self) -> RepositoryStatus: + status = self.status() + if not status.remote_name or status.remote_name == ".": + raise GitError("This branch has no network remote to fetch.") + self.run("fetch", "--prune", status.remote_name, timeout=120) + return self.status() + + def get_latest(self) -> RepositoryStatus: + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "Git could not fast-forward without risking local work. No files were discarded.\n\n" + + (detail or "Commit or move the overlapping changes, then try again.") + ) + return self.status() + + @staticmethod + def ensure_safe_base(status: RepositoryStatus) -> None: + if status.operation: + raise GitError(f"{status.operation} is already in progress. Manual Git assistance is required.") + if status.conflicts: + raise GitError("The repository has unresolved conflicts. Manual Git assistance is required.") + if not status.branch: + raise GitError("The repository is in a detached checkout. Manual Git assistance is required.") + if not status.remote_name or not status.upstream: + raise GitError("The current branch needs a configured remote and upstream before it can synchronize.") + + def validate_commit(self, commit: str, imported_head: str = "HEAD") -> None: + if not COMMIT_RE.fullmatch(commit): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + result = self.result("merge-base", "--is-ancestor", commit, imported_head) + if result.returncode != 0: + raise DevlogError( + f"Source commit {commit[:10]} is not an ancestor of the current branch." + ) + + def head_defaults(self) -> tuple[str, str, str]: + raw = self.run("show", "-s", "--format=%H%x00%cs%x00%an", "HEAD").rstrip("\n") + parts = raw.split("\0", 2) + if len(parts) != 3: + raise GitError("Could not read the current commit metadata.") + return parts[0], parts[1], parts[2] + + def project_defaults(self) -> dict[str, str]: + status = self.status() + _commit, current_date, author = self.head_defaults() + dates = [line.strip() for line in self.run("log", "--reverse", "--format=%cs").splitlines() if line.strip()] + started = dates[0] if dates else current_date + repository_url = status.remote_url.rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if parsed.scheme != "https" or not parsed.netloc: + repository_url = "" + title = re.sub(r"[-_]+", " ", status.name).strip().title() or self.root.name + return { + "project_id": suggest_slug(status.name), + "title": title, + "status": "active", + "started": started, + "author": author, + "repository_url": repository_url, + "default_branch": status.branch, + "tags": "", + "summary": "", + "body": "", + "devlog_title": f"{title} development log", + "devlog_summary": f"Milestones and design decisions from the development of {title}.", + } + + def owned_outgoing_paths(self, upstream: str) -> tuple[bool, list[str]]: + paths = [ + item for item in self.run("diff", "--name-only", "-z", f"{upstream}..HEAD").split("\0") if item + ] + unrelated = [path for path in paths if not is_publishing_path(path)] + return not unrelated, unrelated + + def publish(self, content: "DevlogRepository") -> str: + content.validate_all(validate_commits=True) + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "The remote is newer, but Git could not fast-forward while preserving local files. " + "Publishing stopped and no files were discarded.\n\n" + detail + ) + content.validate_all(validate_commits=True) + status = self.status() + + self.run("add", "-A", "--", *PUBLISHING_PATHS) + staged = self.result("diff", "--cached", "--quiet", "--", *PUBLISHING_PATHS) + created_commit = staged.returncode == 1 + if staged.returncode not in {0, 1}: + raise GitError("Git could not inspect the staged Labyricorn changes.") + if created_commit: + result = self.result( + "commit", "-m", "Update Labyricorn project and devlog", "--", + *PUBLISHING_PATHS, timeout=60 + ) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + if "identity unknown" in detail.casefold() or "user.email" in detail.casefold(): + detail += "\n\nConfigure your Git name and email, then try publishing again." + raise GitError("Git could not create the Labyricorn publishing commit.\n\n" + detail) + + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError( + "The remote changed while publishing. Your publishing commit is safe locally, but automatic " + "publishing stopped.\n\n" + divergence_message() + ) + if not status.ahead: + return "No unpublished Labyricorn changes were found." + owned, unrelated = self.owned_outgoing_paths(status.upstream) + if not owned: + examples = "\n".join(f" • {path}" for path in unrelated[:8]) + raise GitError( + "Pushing would also publish existing commits that change files outside the approved " + "Labyricorn project/devlog paths. " + "The editor stopped at its ownership boundary. Manual Git assistance is required.\n\n" + examples + ) + branch = status.branch + _remote, merge_ref, _upstream, _url = self.upstream_configuration(branch) + if not merge_ref.startswith("refs/heads/"): + raise GitError("The configured upstream branch is not a normal remote branch.") + result = self.result("push", status.remote_name, f"HEAD:{merge_ref}", timeout=120) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + hint = ( + "\n\nGit authentication is not configured or was rejected. Configure access for this " + "repository and use Publish again; the publishing commit remains safe locally." + if any(word in detail.casefold() for word in ("authentication", "credential", "permission denied", "could not read username")) + else "\n\nThe publishing commit remains safe locally. Check the network or remote, then try Publish again." + ) + raise GitError("Git could not push the Labyricorn changes.\n\n" + detail + hint) + return "Labyricorn project and devlog changes were committed and pushed successfully." + + +def divergence_message() -> str: + return ( + "The local and remote repositories both contain changes that the other does not have.\n\n" + "Automatic publishing has stopped to protect the repository. Someone familiar with Git " + "needs to resolve the repository history before publishing can continue." + ) + + +class DevlogRepository: + def __init__(self, git: GitRepository): + self.git = git + self.root = git.root + self.labyricorn_root = (self.root / ".labyricorn").resolve() + self.devlog_root = (self.root / DEVLOG_RELATIVE).resolve() + self.project_path = (self.root / PROJECT_RELATIVE).resolve() + + def ensure_within(self, path: Path, parent: Path) -> Path: + resolved = path.resolve() + try: + resolved.relative_to(parent.resolve()) + except ValueError as exc: + raise DevlogError(f"Unsafe path outside {parent}: {path}") from exc + return resolved + + def read_document(self, path: Path) -> tuple[LrDocument, bytes]: + try: + raw = path.read_bytes() + if len(raw) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing file exceeds {MAX_FILE_SIZE} bytes: {path}") + text = raw.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise DevlogError(f"Cannot read {path}: {exc}") from exc + document = LrDocument.parse(text, str(path)) + self.reject_raw_html(document) + return document, raw + + @staticmethod + def reject_raw_html(document: LrDocument) -> None: + for key, value in document.values().items(): + if key != "repository_url" and RAW_HTML_RE.search(value): + raise DevlogError(f"{document.source}: raw HTML is not allowed in {key}.") + + def validate_project(self) -> LrDocument: + if not self.project_path.is_file(): + raise DevlogError( + "This repository has no .labyricorn/project/contents.lr project identity record." + ) + self.ensure_within(self.project_path, self.labyricorn_root) + document, _raw = self.read_document(self.project_path) + values = document.values() + missing = PROJECT_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Project identity record is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "project" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Project identity record uses an unsupported model or schema version.") + self.clean_project_values(values) + logo = values.get("logo", "").strip() + if logo: + logo_path = self.project_path.parent / logo + if Path(logo).name != logo or logo_path.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project logo must be a local PNG, JPEG, or WebP filename.") + if not logo_path.is_file(): + raise DevlogError(f"Project logo is missing: .labyricorn/project/{logo}") + return document + + def clean_project_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = { + key: str(value).replace("\r\n", "\n").replace("\r", "\n") + for key, value in values.items() + } + for required in ( + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "body", + ): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"Project {required.replace('_', ' ')} is required.") + cleaned["project_id"] = cleaned["project_id"].strip() + if not SLUG_RE.fullmatch(cleaned["project_id"]): + raise DevlogError("Project ID must be a stable lowercase hyphenated slug.") + for scalar in ("title", "author", "status", "default_branch"): + cleaned[scalar] = " ".join(cleaned[scalar].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["started"] = validate_iso_date(cleaned["started"], "Project start date") + if cleaned["status"] not in {"active", "released", "maintained", "archived"}: + raise DevlogError("Project status must be active, released, maintained, or archived.") + branch = cleaned["default_branch"] + if not re.fullmatch(r"[A-Za-z0-9._/-]+", branch) or ".." in branch: + raise DevlogError("Project default branch is invalid.") + repository_url = cleaned["repository_url"].strip().rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if ( + parsed.scheme != "https" or not parsed.hostname or parsed.username + or parsed.password or parsed.query or parsed.fragment + ): + raise DevlogError("Project repository URL must be a credential-free HTTPS web URL.") + cleaned["repository_url"] = repository_url + for field in ("title", "summary", "author", "tags", "body"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in project {field}.") + return cleaned + + def initialization_state(self) -> tuple[str, str]: + if not (self.root / ".labyricorn").exists(): + return ( + "ready_project", + "This repository does not have Labyricorn project publishing content yet.", + ) + if is_link(self.root / ".labyricorn"): + return "malformed", ".labyricorn must not be a symbolic link or junction." + try: + project = self.validate_project() + except DevlogError as exc: + return "malformed", str(exc) + devlog_path = self.root / DEVLOG_RELATIVE + if not devlog_path.exists(): + return "ready_devlog", f"{project.get('title')} does not have a Labyricorn devlog yet." + if is_link(devlog_path) or not devlog_path.is_dir(): + return "malformed", ".labyricorn/devlog is not a normal directory." + index = devlog_path / "contents.lr" + if not index.is_file(): + return "malformed", ".labyricorn/devlog exists but has no contents.lr index. It was not overwritten." + try: + self.validate_all(validate_commits=False) + except DevlogError as exc: + return "malformed", str(exc) + return "initialized", "" + + def validate_image(self, path: Path) -> None: + try: + data = path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read image {path}: {exc}") from exc + suffix = path.suffix.lower() + signatures = { + ".png": (b"\x89PNG\r\n\x1a\n",), ".jpg": (b"\xff\xd8\xff",), + ".jpeg": (b"\xff\xd8\xff",), ".webp": (b"RIFF",), + } + if len(data) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing image exceeds {MAX_FILE_SIZE} bytes: {path}") + if not any(data.startswith(prefix) for prefix in signatures[suffix]): + raise DevlogError(f"Image signature does not match its extension: {path}") + if suffix == ".webp" and data[8:12] != b"WEBP": + raise DevlogError(f"Image signature does not match its extension: {path}") + + def validate_tree(self) -> None: + laby = self.root / ".labyricorn" + if not laby.is_dir() or is_link(laby): + raise DevlogError(".labyricorn must be a normal directory.") + files = 0 + total = 0 + for path in laby.rglob("*"): + if is_link(path): + raise DevlogError(f"Linked publishing paths are not allowed: {path}") + relative = path.relative_to(laby) + parts = relative.parts + if path.is_dir(): + allowed = ( + parts in {("project",), ("devlog",)} + or (len(parts) == 2 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1])) + ) + if not allowed: + raise DevlogError(f"Unsupported publishing directory: .labyricorn/{relative.as_posix()}") + continue + if path.name in IGNORED_DOCUMENTATION: + continue + files += 1 + try: + size = path.stat().st_size + except OSError as exc: + raise DevlogError(f"Cannot inspect {path}: {exc}") from exc + total += size + is_project_record = parts == ("project", "contents.lr") + is_devlog_index = parts == ("devlog", "contents.lr") + is_entry_record = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and parts[2] == "contents.lr" + ) + is_project_image = len(parts) == 2 and parts[0] == "project" and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + is_entry_image = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + ) + if not (is_project_record or is_devlog_index or is_entry_record or is_project_image or is_entry_image): + raise DevlogError(f"Unsupported publishing file: .labyricorn/{relative.as_posix()}") + if is_project_image or is_entry_image: + self.validate_image(path) + if files > MAX_FILES or total > MAX_TOTAL_SIZE: + raise DevlogError("The publishing tree exceeds the importer file or size limit.") + required_guidance = ( + laby / "AGENTS.md", + laby / "README.md", + laby / "project" / "AGENTS.md", + laby / "devlog" / "AGENTS.md", + ) + missing_guidance = [path.relative_to(self.root).as_posix() for path in required_guidance if not path.is_file()] + if missing_guidance: + raise DevlogError( + "Labyricorn assistant guidance is incomplete; missing: " + + ", ".join(missing_guidance) + ) + + def validate_index(self) -> LrDocument: + path = self.devlog_root / "contents.lr" + if not path.is_file(): + raise DevlogError("Devlog index is missing: .labyricorn/devlog/contents.lr") + document, _raw = self.read_document(path) + values = document.values() + missing = DEVLOG_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Devlog index is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "devlog" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Devlog index uses an unsupported model or schema version.") + if not values["title"].strip() or not values["summary"].strip(): + raise DevlogError("Devlog index title and summary must not be empty.") + return document + + def clean_entry_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = {key: value.replace("\r\n", "\n").replace("\r", "\n") for key, value in values.items()} + for required in ("title", "author", "summary", "body"): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"{required.replace('_', ' ').title()} is required.") + cleaned["title"] = " ".join(cleaned["title"].splitlines()).strip() + cleaned["author"] = " ".join(cleaned["author"].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["date"] = validate_iso_date(cleaned.get("date", ""), "Publication date") + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["source_commit"] = cleaned.get("source_commit", "").strip() + if not COMMIT_RE.fullmatch(cleaned["source_commit"]): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + for field in ("title", "author", "summary", "body", "tags"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in {field}.") + self.git.validate_commit(cleaned["source_commit"]) + return cleaned + + def validate_loaded_entry(self, document: LrDocument, slug: str, *, validate_commit: bool) -> None: + values = document.values() + missing = ENTRY_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"devlog/{slug}/contents.lr is missing: {', '.join(sorted(missing))}.") + if document.get("_model") != "devlog-entry" or document.get("schema_version") != SCHEMA_VERSION: + raise DevlogError(f"devlog/{slug}/contents.lr uses an unsupported model or schema version.") + validate_iso_date(values["date"], "Publication date") + if not COMMIT_RE.fullmatch(values["source_commit"]): + raise DevlogError(f"devlog/{slug}: source_commit must be a full lowercase commit ID.") + for required in ("title", "author", "summary", "body"): + if not values[required].strip(): + raise DevlogError(f"devlog/{slug}: {required} must not be empty.") + if validate_commit: + self.git.validate_commit(values["source_commit"]) + + def list_entries(self, *, validate_commits: bool = False) -> list[DevlogEntry]: + if not self.devlog_root.is_dir(): + return [] + entries: list[DevlogEntry] = [] + for directory in self.devlog_root.iterdir(): + if not directory.is_dir(): + continue + if not SLUG_RE.fullmatch(directory.name): + raise DevlogError(f"Invalid devlog entry slug: {directory.name}") + entries.append(self.load_entry(directory.name, validate_commit=validate_commits)) + entries.sort(key=lambda entry: (entry.publication_date, entry.title, entry.slug), reverse=True) + return entries + + def load_entry(self, slug: str, *, validate_commit: bool = False) -> DevlogEntry: + self.validate_slug(slug) + path = self.ensure_within(self.devlog_root / slug / "contents.lr", self.devlog_root) + if not path.is_file(): + raise DevlogError(f"Devlog entry does not exist: {slug}") + document, raw = self.read_document(path) + self.validate_loaded_entry(document, slug, validate_commit=validate_commit) + return DevlogEntry(slug, path, document, raw) + + def validate_all(self, *, validate_commits: bool) -> None: + self.validate_tree() + self.validate_project() + self.validate_index() + self.list_entries(validate_commits=validate_commits) + + @staticmethod + def validate_slug(slug: str) -> None: + if not SLUG_RE.fullmatch(slug): + raise DevlogError("Slug must use lowercase letters, numbers, and single hyphens only.") + + def initialize( + self, + title: str, + summary: str, + project_values: dict[str, str] | None = None, + ) -> None: + state, message = self.initialization_state() + if state not in {"ready_project", "ready_devlog"}: + raise DevlogError(message or "Labyricorn publishing cannot be initialized in its current state.") + title = " ".join(title.splitlines()).strip() + summary = summary.replace("\r", " ").replace("\n", " ").strip() + if not title or not summary: + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(title) or RAW_HTML_RE.search(summary): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + devlog_index = self.root / DEVLOG_RELATIVE / "contents.lr" + devlog_payload = ( + f"_model: devlog\n---\nschema_version: {SCHEMA_VERSION}\n---\n" + f"title: {title}\n---\nsummary: {summary}\n" + ).encode("utf-8") + + files: dict[Path, bytes] = { + devlog_index: devlog_payload, + devlog_index.parent / "AGENTS.md": DEVLOG_AGENTS_TEXT.encode("utf-8"), + } + directories: list[Path] = [] + labyricorn = self.root / ".labyricorn" + project_directory = labyricorn / "project" + devlog_directory = labyricorn / "devlog" + + if state == "ready_project": + if project_values is None: + raise DevlogError("Project information is required for first-time initialization.") + cleaned = self.clean_project_values(project_values) + project_record = project_directory / "contents.lr" + logo_source_text = project_values.get("logo_source", "").strip() + logo_name = "" + logo_data = b"" + if logo_source_text: + logo_source = Path(logo_source_text).expanduser().resolve() + if is_link(logo_source) or not logo_source.is_file(): + raise DevlogError("The selected project image is not a normal file.") + if logo_source.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project image must be PNG, JPEG, or WebP.") + self.validate_image(logo_source) + try: + logo_data = logo_source.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read the selected project image: {exc}") from exc + logo_name = logo_source.name + document = LrDocument.parse( + f"_model: project\n---\nschema_version: {SCHEMA_VERSION}\n", + str(project_record), + ) + project_keys = [ + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "tags", "body", + ] + if logo_name: + cleaned["logo"] = logo_name + project_keys.insert(project_keys.index("tags"), "logo") + for key in project_keys: + document.set_field( + key, + cleaned[key], + multiline=key in {"summary", "body"}, + field_order=PROJECT_FIELD_ORDER, + ) + files.update( + { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=cleaned["title"] + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + project_record: document.render().encode("utf-8"), + } + ) + if logo_name: + files[project_directory / logo_name] = logo_data + directories.extend((labyricorn, project_directory, devlog_directory)) + else: + directories.append(devlog_directory) + optional_guidance = { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=self.validate_project().get("title") + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + } + files.update({path: payload for path, payload in optional_guidance.items() if not path.exists()}) + + created: list[Path] = [] + created_directories: list[Path] = [] + try: + for directory in directories: + directory.mkdir() + created_directories.append(directory) + for destination, payload in files.items(): + with destination.open("xb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + created.append(destination) + self.validate_all(validate_commits=False) + except (OSError, DevlogError) as exc: + try: + for path in reversed(created): + path.unlink(missing_ok=True) + for directory in reversed(created_directories): + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + if isinstance(exc, DevlogError): + raise + raise DevlogError(f"Cannot initialize Labyricorn publishing: {exc}") from exc + + def apply_values(self, document: LrDocument, values: dict[str, str]) -> None: + for key in ("title", "date", "author", "summary", "tags", "source_commit", "body"): + document.set_field(key, values[key], multiline=key in {"summary", "body"}) + + def create_entry(self, slug: str, values: dict[str, str]) -> DevlogEntry: + self.validate_slug(slug) + cleaned = self.clean_entry_values(values) + directory = self.ensure_within(self.devlog_root / slug, self.devlog_root) + destination = directory / "contents.lr" + if directory.exists(): + raise DevlogError(f"Devlog entry already exists: {slug}") + document = LrDocument.parse( + f"_model: devlog-entry\n---\nschema_version: {SCHEMA_VERSION}\n---\ntitle: placeholder\n", + str(destination), + ) + self.apply_values(document, cleaned) + try: + directory.mkdir() + with destination.open("xb") as output: + output.write(document.render().encode("utf-8")) + output.flush() + os.fsync(output.fileno()) + except OSError as exc: + try: + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + raise DevlogError(f"Cannot create {destination}: {exc}") from exc + return self.load_entry(slug, validate_commit=True) + + def save_entry(self, entry: DevlogEntry, values: dict[str, str]) -> DevlogEntry: + cleaned = self.clean_entry_values(values) + destination = self.ensure_within(entry.path, self.devlog_root) + try: + current = destination.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {destination}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before saving.") + self.apply_values(entry.document, cleaned) + payload = entry.document.render().encode("utf-8") + if payload != current: + self.atomic_replace(destination, payload) + return self.load_entry(entry.slug, validate_commit=True) + + @staticmethod + def atomic_replace(destination: Path, payload: bytes) -> None: + temporary: Path | None = None + 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: + if temporary is not None: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise DevlogError(f"Cannot save {destination}: {exc}") from exc + + def delete_entry(self, entry: DevlogEntry) -> None: + self.validate_slug(entry.slug) + directory = self.ensure_within(entry.path.parent, self.devlog_root) + if directory.parent != self.devlog_root or directory.name != entry.slug or is_link(directory): + raise DevlogError(f"Unsafe devlog entry directory: {directory}") + try: + current = entry.path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {entry.path}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before deleting.") + children = list(directory.iterdir()) + for child in children: + if is_link(child) or not child.is_file(): + raise DevlogError(f"Refusing to delete an entry containing an unsafe path: {child}") + if child.name != "contents.lr" and child.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError(f"Refusing to delete an unsupported entry file: {child}") + try: + for child in children: + child.unlink() + directory.rmdir() + except OSError as exc: + raise DevlogError(f"Cannot delete {directory}: {exc}") from exc + + +def run_gui(content: DevlogRepository) -> None: + import tkinter as tk + from tkinter import filedialog, messagebox, simpledialog, ttk + + class DevlogEditorApp: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Labyricorn Devlog") + self.root.minsize(900, 640) + self.current_entry: DevlogEntry | None = None + self.snapshot: dict[str, str] | None = None + self.mode = "new" + + outer = ttk.Frame(self.root, padding=12) + outer.pack(fill=tk.BOTH, expand=True) + outer.columnconfigure(0, weight=1) + outer.rowconfigure(1, weight=1) + + project = ttk.LabelFrame(outer, text="Project", padding=10) + project.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + project.columnconfigure(0, weight=1) + self.repo_var = tk.StringVar() + self.state_var = tk.StringVar() + self.changes_var = tk.StringVar() + ttk.Label(project, textvariable=self.repo_var).grid(row=0, column=0, sticky="w") + ttk.Label(project, textvariable=self.state_var).grid(row=1, column=0, sticky="w", pady=(3, 0)) + ttk.Label(project, textvariable=self.changes_var).grid(row=2, column=0, sticky="w", pady=(3, 0)) + controls = ttk.Frame(project) + controls.grid(row=0, column=1, rowspan=3, sticky="e") + ttk.Button(controls, text="Refresh", command=self.refresh_remote).grid(row=0, column=0, padx=3) + ttk.Button(controls, text="Get Latest Version", command=self.get_latest).grid(row=0, column=1, padx=3) + ttk.Button(controls, text="Details", command=self.show_details).grid(row=0, column=2, padx=3) + + self.devlog = ttk.LabelFrame(outer, text="Devlog", padding=10) + self.devlog.grid(row=1, column=0, sticky="nsew") + self.devlog.columnconfigure(0, weight=1) + self.devlog.rowconfigure(0, weight=1) + + self.status_var = tk.StringVar() + footer = ttk.Frame(outer) + footer.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + footer.columnconfigure(0, weight=1) + ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w") + ttk.Button(footer, text="Publish Labyricorn Changes", command=self.publish).grid(row=0, column=1, sticky="e") + + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.bind("", lambda _event: self.save()) + self.build_content_area() + self.refresh_status() + + def clear_devlog(self) -> None: + for child in self.devlog.winfo_children(): + child.destroy() + + def build_content_area(self) -> None: + self.clear_devlog() + state, message = content.initialization_state() + if state != "initialized": + panel = ttk.Frame(self.devlog, padding=30) + panel.grid(row=0, column=0, sticky="nsew") + panel.columnconfigure(0, weight=1) + ttk.Label(panel, text=message, wraplength=700, justify="center").grid(row=0, column=0, pady=(80, 15)) + button_text = ( + "Initialize Labyricorn Project & Devlog" + if state == "ready_project" + else "Initialize Devlog" + ) + button = ttk.Button(panel, text=button_text, command=self.initialize) + button.grid(row=1, column=0) + if state not in {"ready_project", "ready_devlog"}: + button.configure(state="disabled") + self.status_var.set( + "Labyricorn publishing is not initialized" + if state in {"ready_project", "ready_devlog"} + else "Labyricorn publishing needs attention" + ) + return + + pane = ttk.Panedwindow(self.devlog, orient=tk.HORIZONTAL) + pane.grid(row=0, column=0, sticky="nsew") + 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") + for column, label, width in (("date", "Date", 90), ("title", "Title", 220), ("slug", "Slug", 170)): + self.tree.heading(column, text=label) + self.tree.column(column, width=width, stretch=column != "date") + scroll = ttk.Scrollbar(browser, orient=tk.VERTICAL, command=self.tree.yview) + self.tree.configure(yscrollcommand=scroll.set) + self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + 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", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0)) + ttk.Button(browser, text="Delete", command=self.delete_selected).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.author_var = tk.StringVar() + self.commit_var = tk.StringVar() + self.topics_var = tk.StringVar() + form.columnconfigure(1, weight=1) + form.columnconfigure(3, weight=1) + form.rowconfigure(7, 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_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).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(form, text="Author").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(form, textvariable=self.author_var).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(form, text="Source commit").grid(row=3, column=0, sticky="w") + ttk.Entry(form, textvariable=self.commit_var).grid(row=3, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Topics (comma-separated)").grid(row=4, column=0, sticky="w") + ttk.Entry(form, textvariable=self.topics_var).grid(row=4, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Summary").grid(row=5, column=0, sticky="nw", pady=(5, 0)) + self.summary_text = tk.Text(form, height=4, wrap="word", undo=True) + self.summary_text.grid(row=5, column=1, columnspan=3, sticky="nsew", pady=3) + ttk.Label(form, text="Body (Markdown)").grid(row=7, column=0, sticky="nw", pady=(5, 0)) + body_frame = ttk.Frame(form) + body_frame.grid(row=7, 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") + ttk.Button(form, text="Save Entry", command=self.save).grid(row=8, column=3, sticky="e", pady=(8, 0)) + self.reload_entries() + self.new_entry(confirm=False) + + def initialize(self) -> None: + state, _message = content.initialization_state() + project_values: dict[str, str] | None = None + if state == "ready_project": + try: + result = self.ask_project_initialization(content.git.project_defaults()) + except DevlogError as exc: + messagebox.showerror("Cannot prepare initialization", str(exc)) + return + if result is None: + return + title = result.pop("devlog_title") + summary = result.pop("devlog_summary") + project_values = result + elif state == "ready_devlog": + try: + project = content.validate_project() + except DevlogError as exc: + messagebox.showerror("Cannot initialize devlog", str(exc)) + return + title = simpledialog.askstring( + "Initialize Devlog", "Devlog title:", + initialvalue=f"{project.get('title')} development log", parent=self.root + ) + if title is None: + return + summary = simpledialog.askstring( + "Initialize Devlog", "Short description of this development log:", parent=self.root + ) + if summary is None: + return + else: + messagebox.showerror("Cannot initialize", "The publishing structure is incomplete or malformed.") + return + try: + content.initialize(title, summary, project_values) + except DevlogError as exc: + messagebox.showerror("Cannot initialize Labyricorn publishing", str(exc)) + return + self.build_content_area() + self.refresh_status() + self.status_var.set("Initialized the Labyricorn project record and devlog") + + def ask_project_initialization( + self, defaults: dict[str, str] + ) -> dict[str, str] | None: + dialog = tk.Toplevel(self.root) + dialog.title("Initialize Labyricorn Project & Devlog") + dialog.transient(self.root) + dialog.grab_set() + dialog.minsize(760, 680) + dialog.geometry("820x760") + frame = ttk.Frame(dialog, padding=14) + frame.pack(fill=tk.BOTH, expand=True) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(3, weight=1) + frame.rowconfigure(8, weight=1) + + variables = { + key: tk.StringVar(value=defaults.get(key, "")) + for key in ( + "project_id", "title", "status", "started", "author", + "repository_url", "default_branch", "tags", "logo_source", + "devlog_title", + ) + } + ttk.Label(frame, text="Project exhibition record").grid( + row=0, column=0, columnspan=4, sticky="w", pady=(0, 8) + ) + ttk.Label(frame, text="Project title").grid(row=1, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["title"]).grid(row=1, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Stable project ID").grid(row=1, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["project_id"]).grid(row=1, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Status").grid(row=2, column=0, sticky="w") + ttk.Combobox( + frame, textvariable=variables["status"], state="readonly", + values=("active", "released", "maintained", "archived"), + ).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Started (YYYY-MM-DD)").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["started"]).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Author").grid(row=3, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["author"]).grid(row=3, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Default branch").grid(row=3, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["default_branch"]).grid(row=3, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Repository web URL").grid(row=4, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["repository_url"]).grid( + row=4, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Technologies/topics").grid(row=5, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["tags"]).grid(row=5, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Project image (optional)").grid(row=5, column=2, sticky="w", padx=(12, 0)) + image_picker = ttk.Frame(frame) + image_picker.grid(row=5, column=3, sticky="ew", pady=3) + image_picker.columnconfigure(0, weight=1) + ttk.Entry(image_picker, textvariable=variables["logo_source"]).grid(row=0, column=0, sticky="ew") + + def choose_logo() -> None: + selected = filedialog.askopenfilename( + title="Choose project image", + filetypes=( + ("Supported images", "*.png *.jpg *.jpeg *.webp"), + ("All files", "*.*"), + ), + parent=dialog, + ) + if selected: + variables["logo_source"].set(selected) + + ttk.Button(image_picker, text="Browse…", command=choose_logo).grid( + row=0, column=1, padx=(5, 0) + ) + ttk.Label(frame, text="Project summary").grid(row=6, column=0, sticky="nw", pady=(5, 0)) + project_summary = tk.Text(frame, height=3, wrap="word", undo=True) + project_summary.grid(row=6, column=1, columnspan=3, sticky="nsew", pady=3) + project_summary.insert("1.0", defaults.get("summary", "")) + ttk.Label(frame, text="Project narrative\n(Markdown)").grid(row=8, column=0, sticky="nw", pady=(5, 0)) + project_body = tk.Text(frame, height=8, wrap="word", undo=True) + project_body.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3) + project_body.insert("1.0", defaults.get("body", "")) + + ttk.Separator(frame).grid(row=9, column=0, columnspan=4, sticky="ew", pady=10) + ttk.Label(frame, text="Development log").grid(row=10, column=0, columnspan=4, sticky="w") + ttk.Label(frame, text="Devlog title").grid(row=11, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["devlog_title"]).grid( + row=11, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Devlog summary").grid(row=12, column=0, sticky="nw", pady=(5, 0)) + devlog_summary = tk.Text(frame, height=3, wrap="word", undo=True) + devlog_summary.grid(row=12, column=1, columnspan=3, sticky="nsew", pady=3) + devlog_summary.insert("1.0", defaults.get("devlog_summary", "")) + + result: dict[str, str] | None = None + + def accept() -> None: + nonlocal result + candidate = {key: variable.get() for key, variable in variables.items()} + candidate["summary"] = project_summary.get("1.0", "end-1c") + candidate["body"] = project_body.get("1.0", "end-1c") + candidate["devlog_summary"] = devlog_summary.get("1.0", "end-1c") + try: + content.clean_project_values(candidate) + if not candidate["devlog_title"].strip() or not candidate["devlog_summary"].strip(): + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(candidate["devlog_title"]) or RAW_HTML_RE.search(candidate["devlog_summary"]): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + except DevlogError as exc: + messagebox.showerror("Check project information", str(exc), parent=dialog) + return + result = candidate + dialog.destroy() + + buttons = ttk.Frame(frame) + buttons.grid(row=13, column=0, columnspan=4, sticky="e", pady=(12, 0)) + ttk.Button(buttons, text="Cancel", command=dialog.destroy).grid(row=0, column=0, padx=(0, 8)) + ttk.Button(buttons, text="Initialize", command=accept).grid(row=0, column=1) + dialog.protocol("WM_DELETE_WINDOW", dialog.destroy) + dialog.wait_window() + return result + + def collect_values(self) -> dict[str, str]: + return { + "title": self.title_var.get(), "date": self.date_var.get(), + "author": self.author_var.get(), "source_commit": self.commit_var.get(), + "tags": self.topics_var.get(), + "summary": self.summary_text.get("1.0", "end-1c"), + "body": self.body_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.author_var.set(values.get("author", "")) + self.commit_var.set(values.get("source_commit", "")) + self.topics_var.set(normalized_topics(values.get("tags", ""))) + for widget, key in ((self.summary_text, "summary"), (self.body_text, "body")): + widget.delete("1.0", tk.END) + widget.insert("1.0", values.get(key, "")) + + def has_unsaved(self) -> bool: + return self.snapshot is not None and self.collect_values() != self.snapshot + + def may_discard(self) -> bool: + return not self.has_unsaved() or messagebox.askyesno("Discard changes?", "Discard unsaved changes in the current form?") + + def reload_entries(self, selected: str | None = None) -> None: + self.tree.delete(*self.tree.get_children()) + try: + entries = content.list_entries() + except DevlogError as exc: + messagebox.showerror("Cannot load devlog", 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.tree.see(selected) + + def new_entry(self, confirm: bool = True) -> None: + if confirm and not self.may_discard(): + return + try: + commit, commit_date, author = content.git.head_defaults() + except DevlogError as exc: + messagebox.showerror("Cannot read Git history", str(exc)) + return + self.mode = "new" + self.current_entry = None + self.slug_entry.configure(state="normal") + self.slug_var.set("") + self.set_values({"date": commit_date, "author": author, "source_commit": commit}) + self.snapshot = self.collect_values() + self.status_var.set("Creating a new devlog entry") + + def edit_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to edit.") + return + if not self.may_discard(): + return + try: + entry = content.load_entry(selection[0], validate_commit=True) + except DevlogError as exc: + messagebox.showerror("Cannot load entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.status_var.set(f"Editing .labyricorn/devlog/{entry.slug}/contents.lr") + + def fill_slug(self) -> None: + if self.mode == "new": + self.slug_var.set(suggest_slug(self.title_var.get())) + + def save(self) -> None: + if not hasattr(self, "title_var"): + return + try: + if self.mode == "new": + entry = content.create_entry(self.slug_var.get().strip(), self.collect_values()) + else: + if self.current_entry is None: + raise DevlogError("No entry is loaded.") + entry = content.save_entry(self.current_entry, self.collect_values()) + except DevlogError as exc: + messagebox.showerror("Cannot save entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.reload_entries(entry.slug) + self.refresh_status() + self.status_var.set(f"Saved .labyricorn/devlog/{entry.slug}/contents.lr") + + def delete_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to delete.") + return + try: + entry = content.load_entry(selection[0]) + attachments = sum(1 for path in entry.path.parent.iterdir() if path.is_file() and path != entry.path) + except (DevlogError, OSError) as exc: + messagebox.showerror("Cannot inspect entry", str(exc)) + return + note = f"\n\nThis also removes {attachments} entry attachment(s)." if attachments else "" + if not messagebox.askyesno( + "Delete devlog entry?", + f"Delete {entry.title!r} ({entry.slug}) from the working tree?{note}\n\n" + "Git can recover the deletion until it is published.", icon="warning", + ): + return + try: + content.delete_entry(entry) + except DevlogError as exc: + messagebox.showerror("Cannot delete entry", str(exc)) + return + self.snapshot = None + self.reload_entries() + self.new_entry(confirm=False) + self.refresh_status() + self.status_var.set(f"Deleted .labyricorn/devlog/{entry.slug}") + + def refresh_status(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + self.state_var.set(f"Status: {exc}") + return + remote = status.remote_name or "None" + self.repo_var.set(f"Repository: {status.name} Branch: {status.branch or 'detached'} Remote: {remote}") + self.state_var.set(f"Status: {status.state_text}") + word = "file" if status.publishing_changed_count == 1 else "files" + self.changes_var.set( + f"Labyricorn changes: {status.publishing_changed_count} unpublished {word}" + ) + + def refresh_remote(self) -> None: + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + content.git.fetch() + if content.initialization_state()[0] == "initialized": + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot refresh repository", str(exc)) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def get_latest(self) -> None: + if self.has_unsaved() and not messagebox.askyesno( + "Unsaved form changes", "The form has unsaved text. Continue only if it is also saved on disk?" + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + before = content.git.status() + after = content.git.get_latest() + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot get latest version", str(exc)) + else: + self.build_content_area() + messagebox.showinfo( + "Repository updated", + "The repository is already current." if not before.behind else f"Fast-forwarded by {before.behind} commit(s). Local files were preserved.", + ) + self.state_var.set(f"Status: {after.state_text}") + finally: + self.root.configure(cursor="") + self.refresh_status() + + def publish(self) -> None: + if self.has_unsaved(): + messagebox.showinfo("Save the entry first", "Save or discard the current form changes before publishing.") + return + if not messagebox.askyesno( + "Publish Labyricorn changes?", + "Validate, commit, and push only the project record, publishing guidance, " + "approved project images, and devlog inside .labyricorn/?\n\n" + "All files outside that boundary will be left out of the publishing commit.", + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + result = content.git.publish(content) + except DevlogError as exc: + messagebox.showerror("Publishing stopped safely", str(exc)) + else: + messagebox.showinfo("Publish Labyricorn Changes", result) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def show_details(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + messagebox.showerror("Cannot read details", str(exc)) + return + details = [ + f"Repository root: {content.git.root}", f"Branch: {status.branch or 'detached'}", + f"Remote: {status.remote_name or 'None'}", f"Remote URL: {status.remote_url or 'None'}", + f"Upstream: {status.upstream or 'None'}", f"Ahead / behind: {status.ahead} / {status.behind}", + f"Changed files: {status.changed_count}", + f"Labyricorn changed files: {status.publishing_changed_count}", + ] + if status.short_status: + details.extend(("", "Working-tree details:", status.short_status)) + messagebox.showinfo("Repository details", "\n".join(details)) + + def close(self) -> None: + if not hasattr(self, "title_var") or self.may_discard(): + self.root.destroy() + + try: + app = DevlogEditorApp() + except tk.TclError as exc: + raise DevlogError(f"Cannot open the Tkinter window: {exc}") from exc + app.root.mainloop() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--validate", action="store_true", + help="validate the local Labyricorn devlog without opening the GUI", + ) + args = parser.parse_args() + try: + git = GitRepository(Path(__file__).resolve().parent) + content = DevlogRepository(git) + if args.validate: + content.validate_all(validate_commits=True) + print("devlog-editor: devlog is valid") + else: + run_gui(content) + except DevlogError as exc: + parser.exit(1, f"devlog-editor: ERROR: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())