805 lines
33 KiB
Python
805 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""Manage Labyricorn's allowlisted project sources and check devlog readiness."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import configparser
|
|
from dataclasses import dataclass
|
|
import importlib
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from typing import Any
|
|
|
|
from project_providers import (
|
|
ProjectProviderError,
|
|
derive_repository_urls,
|
|
validate_public_https_url,
|
|
)
|
|
|
|
|
|
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
|
SECTION_RE = re.compile(r"(?m)^\[([^\]\r\n]+)\][^\r\n]*(?:\r?\n|$)")
|
|
KNOWN_OPTIONS = ("repository", "web_url", "api_url", "branch", "featured_order")
|
|
|
|
|
|
class ProjectEditorError(Exception):
|
|
"""An expected project-editor problem suitable for display to a user."""
|
|
|
|
|
|
def _python_has_lektor(executable: Path) -> bool:
|
|
if not executable.is_file():
|
|
return False
|
|
options: dict[str, Any] = {
|
|
"capture_output": True,
|
|
"text": True,
|
|
"timeout": 10,
|
|
"check": False,
|
|
}
|
|
if os.name == "nt":
|
|
options["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
try:
|
|
result = subprocess.run(
|
|
[str(executable), "-c", "import lektor, tkinter"],
|
|
**options,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
return result.returncode == 0
|
|
|
|
|
|
def _find_lektor_python(site_root: Path) -> Path | None:
|
|
"""Locate a Python runtime that can import both Lektor and Tkinter."""
|
|
candidates: list[Path] = []
|
|
configured = os.environ.get("LABYRICORN_LEKTOR_PYTHON", "").strip()
|
|
if configured:
|
|
candidates.append(Path(configured).expanduser())
|
|
if os.name == "nt":
|
|
candidates.extend(
|
|
(
|
|
site_root / ".venv" / "Scripts" / "python.exe",
|
|
Path(os.environ.get("LOCALAPPDATA", ""))
|
|
/ "pipx"
|
|
/ "pipx"
|
|
/ "venvs"
|
|
/ "lektor"
|
|
/ "Scripts"
|
|
/ "python.exe",
|
|
Path.home()
|
|
/ ".local"
|
|
/ "pipx"
|
|
/ "venvs"
|
|
/ "lektor"
|
|
/ "Scripts"
|
|
/ "python.exe",
|
|
)
|
|
)
|
|
else:
|
|
candidates.extend(
|
|
(
|
|
site_root / ".venv" / "bin" / "python",
|
|
Path.home() / ".local" / "pipx" / "venvs" / "lektor" / "bin" / "python",
|
|
)
|
|
)
|
|
lektor_command = shutil.which("lektor")
|
|
if lektor_command:
|
|
candidates.append(
|
|
Path(lektor_command).resolve().parent
|
|
/ ("python.exe" if os.name == "nt" else "python")
|
|
)
|
|
|
|
seen: set[Path] = set()
|
|
for candidate in candidates:
|
|
candidate = candidate.resolve()
|
|
if candidate in seen:
|
|
continue
|
|
seen.add(candidate)
|
|
if _python_has_lektor(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _relaunch_with_lektor(site_root: Path) -> bool:
|
|
try:
|
|
import lektor # noqa: F401
|
|
except ModuleNotFoundError:
|
|
pass
|
|
else:
|
|
return False
|
|
if os.environ.get("LABYRICORN_LEKTOR_RELAUNCHED") == "1":
|
|
return False
|
|
executable = _find_lektor_python(site_root)
|
|
if executable is None:
|
|
return False
|
|
gui_executable = executable
|
|
if os.name == "nt":
|
|
pythonw = executable.with_name("pythonw.exe")
|
|
if pythonw.is_file():
|
|
gui_executable = pythonw
|
|
child_environment = {**os.environ, "LABYRICORN_LEKTOR_RELAUNCHED": "1"}
|
|
subprocess.Popen(
|
|
[str(gui_executable), str(Path(__file__).resolve()), *sys.argv[1:]],
|
|
cwd=site_root,
|
|
env=child_environment,
|
|
)
|
|
return True
|
|
|
|
|
|
def _load_project_importer() -> Any:
|
|
try:
|
|
return importlib.import_module("project_sources")
|
|
except ModuleNotFoundError as exc:
|
|
if exc.name != "lektor":
|
|
raise
|
|
raise ProjectEditorError(
|
|
"The Lektor Python package required by the project importer is unavailable. "
|
|
"Run Project Editor with the same Python environment used for Lektor builds."
|
|
) from exc
|
|
|
|
|
|
def _validate_url(value: str, field: str) -> str:
|
|
"""Mirror the importer's credential-free public HTTPS URL policy."""
|
|
try:
|
|
return validate_public_https_url(value, field)
|
|
except ProjectProviderError as exc:
|
|
raise ProjectEditorError(f"{exc}.") from exc
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProjectSource:
|
|
project_id: str
|
|
repository: str
|
|
web_url: str
|
|
api_url: str
|
|
branch: str
|
|
featured_order: int | None
|
|
unknown_options: tuple[tuple[str, str], ...]
|
|
registry_bytes: bytes
|
|
|
|
def importer_source(self) -> dict[str, str | int]:
|
|
source: dict[str, str | int] = {
|
|
"project_id": self.project_id,
|
|
"repository": self.repository,
|
|
"web_url": self.web_url,
|
|
"api_url": self.api_url,
|
|
"branch": self.branch,
|
|
}
|
|
if self.featured_order is not None:
|
|
source["featured_order"] = self.featured_order
|
|
return source
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DevlogReadiness:
|
|
status: str
|
|
summary: str
|
|
details: str
|
|
ready: bool = False
|
|
|
|
|
|
def _clean_stored_values(
|
|
project_id: str,
|
|
repository: str,
|
|
web_url: str,
|
|
api_url: str,
|
|
branch: str,
|
|
featured_order: str | int | None,
|
|
) -> tuple[str, str, str, str, str, int | None]:
|
|
project_id = project_id.strip()
|
|
if not SLUG_RE.fullmatch(project_id):
|
|
raise ProjectEditorError(
|
|
"Project ID must use lowercase letters, numbers, and single hyphens only."
|
|
)
|
|
repository = _validate_url(repository.strip(), "Repository URL")
|
|
web_url = _validate_url(web_url.strip(), "Repository web URL")
|
|
api_url = _validate_url(api_url.strip(), "Repository API URL")
|
|
branch = branch.strip()
|
|
if not branch or not BRANCH_RE.fullmatch(branch) or ".." in branch:
|
|
raise ProjectEditorError("Branch contains unsupported characters.")
|
|
if featured_order is None or str(featured_order).strip() == "":
|
|
parsed_order = None
|
|
else:
|
|
try:
|
|
parsed_order = int(featured_order)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ProjectEditorError("Featured order must be an integer or left blank.") from exc
|
|
return project_id, repository, web_url, api_url, branch, parsed_order
|
|
|
|
|
|
def _clean_editor_values(
|
|
project_id: str,
|
|
web_url: str,
|
|
branch: str,
|
|
featured_order: str | int | None,
|
|
) -> tuple[str, str, str, str, str, int | None]:
|
|
try:
|
|
urls = derive_repository_urls(web_url)
|
|
except ProjectProviderError as exc:
|
|
raise ProjectEditorError(str(exc)) from exc
|
|
return _clean_stored_values(
|
|
project_id,
|
|
urls.repository,
|
|
urls.web_url,
|
|
urls.api_url,
|
|
branch,
|
|
featured_order,
|
|
)
|
|
|
|
|
|
def _parse_registry(raw: bytes) -> tuple[str, configparser.ConfigParser]:
|
|
try:
|
|
text = raw.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise ProjectEditorError("Project source registry is not valid UTF-8.") from exc
|
|
parser = configparser.ConfigParser(interpolation=None)
|
|
try:
|
|
parser.read_string(text)
|
|
except configparser.Error as exc:
|
|
raise ProjectEditorError(f"Cannot parse project source registry: {exc}") from exc
|
|
return text, parser
|
|
|
|
|
|
def _source_from_section(
|
|
project_id: str, section: configparser.SectionProxy, raw: bytes
|
|
) -> ProjectSource:
|
|
required = {"repository", "web_url", "api_url", "branch"}
|
|
missing = required - set(section)
|
|
if missing:
|
|
raise ProjectEditorError(
|
|
f"{project_id}: missing registry fields {', '.join(sorted(missing))}."
|
|
)
|
|
cleaned = _clean_stored_values(
|
|
project_id,
|
|
section["repository"],
|
|
section["web_url"],
|
|
section["api_url"],
|
|
section["branch"],
|
|
section.get("featured_order"),
|
|
)
|
|
unknown = tuple((key, value) for key, value in section.items() if key not in KNOWN_OPTIONS)
|
|
return ProjectSource(*cleaned, unknown, raw)
|
|
|
|
|
|
def _render_option(name: str, value: str) -> str:
|
|
lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
return f"{name} = {lines[0]}" + "".join(f"\n\t{line}" for line in lines[1:])
|
|
|
|
|
|
def _render_section(source: ProjectSource) -> str:
|
|
rows = [
|
|
f"[{source.project_id}]",
|
|
_render_option("repository", source.repository),
|
|
_render_option("web_url", source.web_url),
|
|
_render_option("api_url", source.api_url),
|
|
_render_option("branch", source.branch),
|
|
]
|
|
if source.featured_order is not None:
|
|
rows.append(_render_option("featured_order", str(source.featured_order)))
|
|
rows.extend(_render_option(key, value) for key, value in source.unknown_options)
|
|
return "\n".join(rows) + "\n"
|
|
|
|
|
|
def _section_bounds(text: str, project_id: str) -> tuple[int, int]:
|
|
matches = list(SECTION_RE.finditer(text))
|
|
for index, match in enumerate(matches):
|
|
if match.group(1).strip() == project_id:
|
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
return match.start(), end
|
|
raise ProjectEditorError(f"Project source no longer exists: {project_id}.")
|
|
|
|
|
|
class ProjectSourceRegistry:
|
|
"""Conservative editor for configs/project-sources.ini."""
|
|
|
|
def __init__(self, site_root: Path):
|
|
self.site_root = site_root.resolve()
|
|
registry_path = self.site_root / "configs" / "project-sources.ini"
|
|
if registry_path.is_symlink():
|
|
raise ProjectEditorError("Refusing to manage a linked project source registry.")
|
|
self.path = registry_path.resolve()
|
|
projects_root = (self.site_root / "content" / "projects").resolve()
|
|
if len(list(self.site_root.glob("*.lektorproject"))) != 1:
|
|
raise ProjectEditorError(
|
|
f"{self.site_root}: expected exactly one .lektorproject file."
|
|
)
|
|
if not self.path.is_file():
|
|
raise ProjectEditorError(f"Project source registry is missing: {self.path}.")
|
|
if not self.path.is_relative_to(self.site_root):
|
|
raise ProjectEditorError("Refusing to manage an out-of-tree registry.")
|
|
if not projects_root.is_dir() or not (projects_root / "contents.lr").is_file():
|
|
raise ProjectEditorError(f"Lektor project section is missing: {projects_root}.")
|
|
self.list_sources()
|
|
|
|
def _read(self) -> bytes:
|
|
try:
|
|
return self.path.read_bytes()
|
|
except OSError as exc:
|
|
raise ProjectEditorError(f"Cannot read {self.path}: {exc}") from exc
|
|
|
|
def list_sources(self) -> list[ProjectSource]:
|
|
raw = self._read()
|
|
_text, parser = _parse_registry(raw)
|
|
if not parser.sections():
|
|
raise ProjectEditorError("Project source registry must contain at least one project.")
|
|
return [_source_from_section(project_id, parser[project_id], raw) for project_id in parser.sections()]
|
|
|
|
def load_source(self, project_id: str) -> ProjectSource:
|
|
for source in self.list_sources():
|
|
if source.project_id == project_id:
|
|
return source
|
|
raise ProjectEditorError(f"Project source does not exist: {project_id}.")
|
|
|
|
@staticmethod
|
|
def _write_atomic(path: Path, payload: bytes) -> None:
|
|
temporary: Path | None = None
|
|
try:
|
|
descriptor, name = tempfile.mkstemp(prefix=".project-sources-", suffix=".ini", dir=path.parent)
|
|
os.close(descriptor)
|
|
temporary = Path(name)
|
|
temporary.write_bytes(payload)
|
|
os.replace(temporary, path)
|
|
except OSError as exc:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
raise ProjectEditorError(f"Cannot write {path}: {exc}") from exc
|
|
|
|
def _validate_proposed(self, payload: bytes) -> None:
|
|
_text, parser = _parse_registry(payload)
|
|
if not parser.sections():
|
|
raise ProjectEditorError("The importer requires at least one configured project source.")
|
|
for project_id in parser.sections():
|
|
_source_from_section(project_id, parser[project_id], payload)
|
|
|
|
def create_source(
|
|
self,
|
|
project_id: str,
|
|
web_url: str,
|
|
branch: str,
|
|
featured_order: str | int | None,
|
|
) -> ProjectSource:
|
|
cleaned = _clean_editor_values(project_id, web_url, branch, featured_order)
|
|
raw = self._read()
|
|
text, parser = _parse_registry(raw)
|
|
if cleaned[0] in parser:
|
|
raise ProjectEditorError(f"Project source already exists: {cleaned[0]}.")
|
|
source = ProjectSource(*cleaned, (), raw)
|
|
proposed = (text.rstrip() + "\n\n" + _render_section(source)).encode("utf-8")
|
|
self._validate_proposed(proposed)
|
|
self._write_atomic(self.path, proposed)
|
|
return self.load_source(source.project_id)
|
|
|
|
def save_source(
|
|
self,
|
|
original: ProjectSource,
|
|
web_url: str,
|
|
branch: str,
|
|
featured_order: str | int | None,
|
|
) -> ProjectSource:
|
|
raw = self._read()
|
|
if raw != original.registry_bytes:
|
|
raise ProjectEditorError("The project registry changed on disk; reload before saving.")
|
|
cleaned = _clean_editor_values(
|
|
original.project_id, web_url, branch, featured_order
|
|
)
|
|
updated = ProjectSource(*cleaned, original.unknown_options, raw)
|
|
text, _parser = _parse_registry(raw)
|
|
start, end = _section_bounds(text, original.project_id)
|
|
proposed = (text[:start] + _render_section(updated) + text[end:].lstrip("\r\n")).encode("utf-8")
|
|
self._validate_proposed(proposed)
|
|
self._write_atomic(self.path, proposed)
|
|
return self.load_source(updated.project_id)
|
|
|
|
def delete_source(self, source: ProjectSource) -> None:
|
|
raw = self._read()
|
|
if raw != source.registry_bytes:
|
|
raise ProjectEditorError("The project registry changed on disk; reload before deleting.")
|
|
text, parser = _parse_registry(raw)
|
|
if len(parser.sections()) == 1:
|
|
raise ProjectEditorError(
|
|
"The importer requires at least one configured source; the last project cannot be removed."
|
|
)
|
|
start, end = _section_bounds(text, source.project_id)
|
|
proposed = (text[:start].rstrip() + "\n\n" + text[end:].lstrip("\r\n")).encode("utf-8")
|
|
self._validate_proposed(proposed)
|
|
self._write_atomic(self.path, proposed)
|
|
|
|
|
|
def corrective_guidance(source: ProjectSource) -> str:
|
|
return (
|
|
"This project is configured to publish a devlog, but its required remote "
|
|
"Labyricorn structure is missing or incomplete.\n\n"
|
|
"To correct this:\n\n"
|
|
f"1. Clone the project repository:\n git clone {source.repository}\n\n"
|
|
"2. Copy scripts/devlog_editor.py into the cloned repository root as devlog_editor.py.\n\n"
|
|
"3. From that repository root, run:\n python devlog_editor.py\n\n"
|
|
"4. Choose “Initialize Labyricorn Project & Devlog” if no publishing structure "
|
|
"exists, or “Initialize Devlog” if the project record already exists.\n\n"
|
|
"5. Create and save one or more devlog entries when appropriate. An empty "
|
|
"validated devlog index is accepted by the current importer.\n\n"
|
|
"6. Prefer the editor's “Publish Labyricorn Changes” action. It safely creates "
|
|
"the publishing commit and pushes it; alternatively, commit and push with Git.\n\n"
|
|
"7. Confirm the .labyricorn changes are visible in the remote repository.\n\n"
|
|
"8. Return to Project Editor and click “Check Devlog Status” again.\n\n"
|
|
"Project Editor only diagnoses the remote repository. It never clones it "
|
|
"persistently, edits it, commits, or pushes."
|
|
)
|
|
|
|
|
|
def _incomplete(source: ProjectSource, diagnostic: str, *, not_initialized: bool = False) -> DevlogReadiness:
|
|
status = "Not initialized" if not_initialized else "Required devlog structure is incomplete"
|
|
return DevlogReadiness(status, diagnostic, diagnostic + "\n\n" + corrective_guidance(source))
|
|
|
|
|
|
def check_devlog_readiness(source: ProjectSource, importer: Any | None = None) -> DevlogReadiness:
|
|
"""Inspect a configured source using the importer's exact read-only validation."""
|
|
if shutil.which("git") is None:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"Git is not installed or is not available on PATH.",
|
|
"Install Git, restart Project Editor, and run the check again. No remote repository was changed.",
|
|
)
|
|
try:
|
|
importer = importer or _load_project_importer()
|
|
except ProjectEditorError as exc:
|
|
return DevlogReadiness(
|
|
"Unable to check",
|
|
"The site's authoritative project validator is unavailable in this Python environment.",
|
|
str(exc),
|
|
)
|
|
importer_source = source.importer_source()
|
|
try:
|
|
repository_metadata = importer.fetch_json(source.api_url, require_public=True)
|
|
except importer.RepositoryNotPublicError:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"The repository API is not anonymously accessible.",
|
|
"The importer accepts only public repositories. Confirm that the configured repository is public and that the API URL is correct.",
|
|
)
|
|
except importer.ProjectSourceError as exc:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"Unable to verify the remote repository because its public API could not be reached.",
|
|
f"Network or provider API error: {exc}\n\nCheck the network and configured API URL, then try again.",
|
|
)
|
|
if not isinstance(repository_metadata, dict) or repository_metadata.get("private") is not False:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"The provider did not confirm that this repository is public.",
|
|
"The site importer requires an anonymous provider API response with private=false.",
|
|
)
|
|
if repository_metadata.get("default_branch") != source.branch:
|
|
return _incomplete(
|
|
source,
|
|
f"The provider reports default branch {repository_metadata.get('default_branch')!r}, but the site is configured for {source.branch!r}.",
|
|
)
|
|
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="labyricorn-project-check-") as temporary:
|
|
mirror, fetched = importer.ensure_mirror(importer_source, Path(temporary))
|
|
if not fetched:
|
|
raise importer.ProjectSourceError("anonymous Git fetch failed")
|
|
commit = importer.resolve_branch(mirror, source.branch)
|
|
_files, records = importer.validate_snapshot(importer_source, mirror, commit)
|
|
except FileNotFoundError:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"Git is not installed or is not available on PATH.",
|
|
"Install Git, restart Project Editor, and run the check again.",
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"Unable to fetch the repository anonymously.",
|
|
f"Network, authentication, or Git error: {exc}\n\nCheck repository access and try again.",
|
|
)
|
|
except (importer.ProjectSourceError, ValueError) as exc:
|
|
diagnostic = str(exc)
|
|
if "initial repository fetch failed" in diagnostic:
|
|
return DevlogReadiness(
|
|
"Repository inaccessible",
|
|
"Unable to fetch the repository anonymously.",
|
|
"The repository may be unavailable, private, or blocked by a network/authentication problem.",
|
|
)
|
|
missing_devlog = (
|
|
"publishing tree is missing" in diagnostic
|
|
and ".labyricorn/devlog/contents.lr" in diagnostic
|
|
)
|
|
return _incomplete(source, diagnostic, not_initialized=missing_devlog)
|
|
|
|
project = records[".labyricorn/project/contents.lr"]
|
|
entry_count = sum(
|
|
1
|
|
for path in records
|
|
if path.startswith(".labyricorn/devlog/")
|
|
and path != ".labyricorn/devlog/contents.lr"
|
|
)
|
|
details = (
|
|
f"Repository: {source.web_url}\n"
|
|
f"Branch: {source.branch}\n"
|
|
f"Validated commit: {commit[:10]}\n"
|
|
f"Remote project: {project['title']} ({project['project_id']})\n"
|
|
f"Valid devlog entries: {entry_count}\n\n"
|
|
"The remote snapshot passed the same schema, path, attachment, and commit checks used by the site importer. The check was read-only and its temporary mirror was removed."
|
|
)
|
|
return DevlogReadiness(
|
|
"Ready",
|
|
f"{project['title']} is ready for devlog import at {commit[:10]}.",
|
|
details,
|
|
True,
|
|
)
|
|
|
|
|
|
def run_gui(registry: ProjectSourceRegistry) -> None:
|
|
import tkinter as tk
|
|
from tkinter import messagebox, ttk
|
|
from tkinter.scrolledtext import ScrolledText
|
|
|
|
class ProjectEditorApp:
|
|
def __init__(self) -> None:
|
|
self.root = tk.Tk()
|
|
self.root.title("Labyricorn Project Editor")
|
|
self.root.minsize(900, 680)
|
|
self.current: ProjectSource | None = None
|
|
self.mode = "new"
|
|
self.snapshot: tuple[str, ...] | None = None
|
|
self.checking = False
|
|
|
|
outer = ttk.Frame(self.root, padding=12)
|
|
outer.grid(row=0, column=0, sticky="nsew")
|
|
self.root.columnconfigure(0, weight=1)
|
|
self.root.rowconfigure(0, weight=1)
|
|
outer.columnconfigure(1, weight=1)
|
|
outer.rowconfigure(0, weight=1)
|
|
|
|
browser = ttk.LabelFrame(outer, text="Configured projects", padding=8)
|
|
browser.grid(row=0, column=0, sticky="ns", padx=(0, 10))
|
|
browser.rowconfigure(0, weight=1)
|
|
self.tree = ttk.Treeview(browser, columns=("branch",), show="tree headings", height=24)
|
|
self.tree.heading("#0", text="Project ID")
|
|
self.tree.heading("branch", text="Branch")
|
|
self.tree.column("#0", width=165)
|
|
self.tree.column("branch", width=95)
|
|
self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew")
|
|
self.tree.bind("<Double-1>", lambda _event: self.edit_selected())
|
|
ttk.Button(browser, text="New", command=self.new_source).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))
|
|
self.delete_button = ttk.Button(browser, text="Delete", command=self.delete_current, state="disabled")
|
|
self.delete_button.grid(row=1, column=2, sticky="ew", pady=(8, 0))
|
|
|
|
form = ttk.LabelFrame(outer, text="Site-side project source", padding=10)
|
|
form.grid(row=0, column=1, sticky="nsew")
|
|
form.columnconfigure(1, weight=1)
|
|
form.rowconfigure(9, weight=1)
|
|
self.vars = {
|
|
name: tk.StringVar()
|
|
for name in ("project_id", "web_url", "branch", "featured_order")
|
|
}
|
|
labels = (
|
|
("Project ID", "project_id"),
|
|
("Public repository URL", "web_url"),
|
|
("Branch", "branch"),
|
|
("Featured order (optional)", "featured_order"),
|
|
)
|
|
for row, (label, name) in enumerate(labels):
|
|
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 10), pady=3)
|
|
entry = ttk.Entry(form, textvariable=self.vars[name])
|
|
entry.grid(row=row, column=1, sticky="ew", pady=3)
|
|
if name == "project_id":
|
|
self.id_entry = entry
|
|
|
|
ttk.Label(
|
|
form,
|
|
text=(
|
|
"Project prose, technologies/tags, and images are owned by the external repository under "
|
|
".labyricorn/project/. This editor does not copy or modify them. Every configured source is "
|
|
"expected by the current importer to provide a .labyricorn/devlog/ index. Git and API URLs "
|
|
"are derived for public GitHub and Gitea repositories."
|
|
),
|
|
wraplength=590,
|
|
foreground="#6b5a32",
|
|
).grid(row=6, column=0, columnspan=2, sticky="ew", pady=(8, 10))
|
|
|
|
actions = ttk.Frame(form)
|
|
actions.grid(row=7, column=0, columnspan=2, sticky="ew")
|
|
self.check_button = ttk.Button(actions, text="Check Devlog Status", command=self.check_status, state="disabled")
|
|
self.check_button.grid(row=0, column=0)
|
|
ttk.Button(actions, text="Save Project", command=self.save).grid(row=0, column=1, padx=(8, 0))
|
|
|
|
self.status_var = tk.StringVar(value="Devlog Status: Configuration does not require devlogs")
|
|
ttk.Label(form, textvariable=self.status_var, font=("TkDefaultFont", 10, "bold")).grid(row=8, column=0, columnspan=2, sticky="w", pady=(12, 5))
|
|
self.details = ScrolledText(form, wrap=tk.WORD, height=14, state="disabled")
|
|
self.details.grid(row=9, column=0, columnspan=2, sticky="nsew")
|
|
|
|
self.footer_var = tk.StringVar()
|
|
ttk.Label(outer, textvariable=self.footer_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
|
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
|
self.refresh()
|
|
self.new_source(False)
|
|
|
|
def values(self) -> tuple[str, ...]:
|
|
return tuple(self.vars[name].get() for name in self.vars)
|
|
|
|
def set_details(self, value: str) -> None:
|
|
self.details.configure(state="normal")
|
|
self.details.delete("1.0", tk.END)
|
|
self.details.insert("1.0", value)
|
|
self.details.configure(state="disabled")
|
|
|
|
def may_discard(self) -> bool:
|
|
return self.snapshot is None or self.values() == self.snapshot or messagebox.askyesno(
|
|
"Discard unsaved changes?", "Discard the unsaved project-source changes?", icon="warning", parent=self.root
|
|
)
|
|
|
|
def refresh(self, preserve: bool = True) -> None:
|
|
selected = self.tree.selection()[0] if preserve and self.tree.selection() else None
|
|
self.tree.delete(*self.tree.get_children())
|
|
try:
|
|
sources = registry.list_sources()
|
|
except ProjectEditorError as exc:
|
|
messagebox.showerror("Cannot load projects", str(exc), parent=self.root)
|
|
return
|
|
for source in sources:
|
|
self.tree.insert("", tk.END, iid=source.project_id, text=source.project_id, values=(source.branch,))
|
|
if selected and self.tree.exists(selected):
|
|
self.tree.selection_set(selected)
|
|
self.footer_var.set(f"{len(sources)} configured project source{'s' if len(sources) != 1 else ''}")
|
|
|
|
def new_source(self, confirm: bool = True) -> None:
|
|
if confirm and not self.may_discard():
|
|
return
|
|
self.mode = "new"
|
|
self.current = None
|
|
self.id_entry.configure(state="normal")
|
|
for variable in self.vars.values():
|
|
variable.set("")
|
|
self.vars["branch"].set("main")
|
|
self.delete_button.configure(state="disabled")
|
|
self.check_button.configure(state="disabled")
|
|
self.status_var.set("Devlog Status: Configuration does not require devlogs")
|
|
self.set_details("Save this source to make it part of the site's project importer configuration.")
|
|
self.snapshot = self.values()
|
|
|
|
def edit_selected(self) -> None:
|
|
selection = self.tree.selection()
|
|
if not selection:
|
|
messagebox.showinfo("Select a project", "Select a configured project to edit.", parent=self.root)
|
|
return
|
|
if not self.may_discard():
|
|
return
|
|
try:
|
|
source = registry.load_source(selection[0])
|
|
except ProjectEditorError as exc:
|
|
messagebox.showerror("Cannot load project", str(exc), parent=self.root)
|
|
return
|
|
self.mode = "edit"
|
|
self.current = source
|
|
self.vars["project_id"].set(source.project_id)
|
|
self.vars["web_url"].set(source.web_url)
|
|
self.vars["branch"].set(source.branch)
|
|
self.vars["featured_order"].set("" if source.featured_order is None else str(source.featured_order))
|
|
self.id_entry.configure(state="readonly")
|
|
self.delete_button.configure(state="normal")
|
|
self.check_button.configure(state="normal")
|
|
self.status_var.set("Devlog Status: Not checked")
|
|
self.set_details(f"Configured repository: {source.web_url}\nClick “Check Devlog Status” for a read-only remote validation.")
|
|
self.snapshot = self.values()
|
|
|
|
def save(self) -> None:
|
|
values = self.values()
|
|
try:
|
|
if self.mode == "new":
|
|
source = registry.create_source(*values)
|
|
else:
|
|
if self.current is None:
|
|
raise ProjectEditorError("No configured project is loaded.")
|
|
source = registry.save_source(self.current, *values[1:])
|
|
except ProjectEditorError as exc:
|
|
messagebox.showerror("Cannot save project", str(exc), parent=self.root)
|
|
return
|
|
self.current = source
|
|
self.mode = "edit"
|
|
self.vars["project_id"].set(source.project_id)
|
|
self.vars["web_url"].set(source.web_url)
|
|
self.vars["branch"].set(source.branch)
|
|
self.vars["featured_order"].set(
|
|
"" if source.featured_order is None else str(source.featured_order)
|
|
)
|
|
self.id_entry.configure(state="readonly")
|
|
self.delete_button.configure(state="normal")
|
|
self.check_button.configure(state="normal")
|
|
self.snapshot = self.values()
|
|
self.refresh(False)
|
|
self.tree.selection_set(source.project_id)
|
|
self.tree.see(source.project_id)
|
|
self.status_var.set("Devlog Status: Not checked")
|
|
self.set_details("Project source saved. Run the manual readiness check to inspect the remote repository.")
|
|
self.footer_var.set(f"Saved configs/project-sources.ini [{source.project_id}]")
|
|
|
|
def delete_current(self) -> None:
|
|
if self.current is None:
|
|
return
|
|
source = self.current
|
|
if not messagebox.askyesno(
|
|
"Delete configured project?",
|
|
f"Remove {source.project_id!r} from the site project-source registry?\n\nRepository: {source.web_url}\n\nThis removes only the site-side configuration. It does not delete or modify the external repository.",
|
|
icon="warning",
|
|
parent=self.root,
|
|
):
|
|
return
|
|
try:
|
|
registry.delete_source(source)
|
|
except ProjectEditorError as exc:
|
|
messagebox.showerror("Cannot delete project", str(exc), parent=self.root)
|
|
return
|
|
project_id = source.project_id
|
|
self.snapshot = None
|
|
self.refresh(False)
|
|
self.new_source(False)
|
|
self.footer_var.set(f"Removed [{project_id}] from configs/project-sources.ini; external repository unchanged")
|
|
|
|
def check_status(self) -> None:
|
|
if self.current is None or self.checking:
|
|
return
|
|
if self.values() != self.snapshot:
|
|
messagebox.showinfo("Save before checking", "Save or discard the project-source changes before checking the configured remote.", parent=self.root)
|
|
return
|
|
source = self.current
|
|
self.checking = True
|
|
self.check_button.configure(state="disabled")
|
|
self.status_var.set("Devlog Status: Checking…")
|
|
self.set_details("Checking public provider metadata and validating a temporary read-only Git mirror…")
|
|
|
|
def worker() -> None:
|
|
try:
|
|
result = check_devlog_readiness(source)
|
|
except Exception as exc: # Keep unexpected operational failures out of Tk's worker thread.
|
|
result = DevlogReadiness(
|
|
"Unable to check",
|
|
"An unexpected error stopped the read-only validation.",
|
|
str(exc),
|
|
)
|
|
self.root.after(0, self.finish_check, result)
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def finish_check(self, result: DevlogReadiness) -> None:
|
|
self.checking = False
|
|
self.check_button.configure(state="normal" if self.current else "disabled")
|
|
self.status_var.set(f"Devlog Status: {result.status}")
|
|
self.set_details(result.summary + "\n\n" + result.details)
|
|
|
|
def close(self) -> None:
|
|
if self.may_discard():
|
|
self.root.destroy()
|
|
|
|
try:
|
|
app = ProjectEditorApp()
|
|
except tk.TclError as exc:
|
|
raise ProjectEditorError(f"Cannot open the Tkinter window: {exc}") from exc
|
|
app.root.mainloop()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parents[1])
|
|
args = parser.parse_args()
|
|
site_root = args.site_root.resolve()
|
|
if _relaunch_with_lektor(site_root):
|
|
return 0
|
|
try:
|
|
run_gui(ProjectSourceRegistry(site_root))
|
|
except ProjectEditorError as exc:
|
|
parser.exit(1, f"project-editor: ERROR: {exc}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|