Template
1798 lines
83 KiB
Python
1798 lines
83 KiB
Python
#!/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
|
|
│ └── <optional image>
|
|
└── devlog/
|
|
├── AGENTS.md
|
|
├── contents.lr
|
|
└── <entry-slug>/
|
|
├── contents.lr
|
|
└── <optional images>
|
|
```
|
|
|
|
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/<stable-slug>/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("<Control-s>", 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("<Double-1>", 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())
|