"""Domain adapters over the site's existing editors and importer.""" from __future__ import annotations import configparser from dataclasses import asdict from datetime import date import hashlib import json import os from pathlib import Path import re import sys import tempfile from typing import Any from .errors import LabyricornMcpError from .safety import RepositoryGuard DESIGN_ROOTS = ("templates", "assets/static") DESIGN_SUFFIXES = {".html", ".css", ".js", ".json", ".svg", ".txt", ".xml"} CONTENT_MODELS = {"article", "blog", "project", "devlog"} def _hash(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() def _load_site_modules(root: Path) -> dict[str, Any]: scripts = str(root / "scripts") if scripts not in sys.path: sys.path.insert(0, scripts) try: import article_editor import blog_editor import project_editor import tag_editor except (ImportError, OSError) as exc: raise LabyricornMcpError( "INVALID_LABYRICORN_REPOSITORY", "The repository's management modules could not be loaded." ) from exc return { "article": article_editor, "blog": blog_editor, "project": project_editor, "tag": tag_editor, } class LabyricornDomain: def __init__(self, guard: RepositoryGuard): self.guard = guard self.modules = _load_site_modules(guard.root) def _article_repository(self) -> Any: repository = self.modules["article"].ArticleRepository(self.guard.root) self._require_contained(repository.article_root, repository.tags_root) return repository def _blog_repository(self) -> Any: repository = self.modules["blog"].BlogRepository(self.guard.root) self._require_contained(repository.blog_root, repository.tags_root) return repository def _tag_repository(self) -> Any: repository = self.modules["tag"].TagRepository(self.guard.root) self._require_contained(repository.tags_root) return repository def _project_registry(self) -> Any: return self.modules["project"].ProjectSourceRegistry(self.guard.root) def _require_contained(self, *paths: Path) -> None: if any(not path.resolve().is_relative_to(self.guard.root) for path in paths): raise LabyricornMcpError( "PATH_OUTSIDE_REPOSITORY", "A required content path resolves outside the repository." ) def _translate(self, exc: Exception, *, not_found: bool = False) -> LabyricornMcpError: message = str(exc).replace(str(self.guard.root), ".") folded = message.casefold() if "changed on disk" in folded or "reload" in folded: code = "FILE_CHANGED" elif not_found or "does not exist" in folded: code = "CONTENT_NOT_FOUND" elif "already exists" in folded: code = "WORKTREE_CONFLICT" elif "unsafe" in folded or "outside" in folded or "linked" in folded: code = "PATH_OUTSIDE_REPOSITORY" else: code = "VALIDATION_FAILED" return LabyricornMcpError(code, message) def site_structure(self) -> dict[str, Any]: return { "content": { "articles": {"path": "content/articles", "model": "entry", "kicker": "Article", "ownership": "site"}, "blog": {"path": "content/blog", "model": "entry", "kicker": "Blog", "ownership": "site"}, "tags": {"path": "content/tags", "model": "tag", "ownership": "site"}, "projects": { "path": "content/projects", "models": ["project", "devlog", "devlog-entry"], "ownership": "validated remote sources", "source_registry": "configs/project-sources.ini", }, }, "design": { "inspectable_roots": list(DESIGN_ROOTS), "hash_guarded_text_mutation_roots": list(DESIGN_ROOTS), "binary_assets": "metadata and hash inspection only", }, "models_path": "models", "build_entry_point": "scripts/build_with_projects.py", "transport": "stdio", } def describe_model(self, model: str) -> dict[str, Any]: if not isinstance(model, str) or not re.fullmatch(r"[a-z0-9-]+", model): raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Model names must use lowercase letters, numbers, and hyphens.") path = self.guard.resolve(f"models/{model}.ini", allowed_roots=("models",), must_exist=True) parser = configparser.ConfigParser(interpolation=None) try: parser.read(path, encoding="utf-8") except configparser.Error as exc: raise LabyricornMcpError("VALIDATION_FAILED", "The model definition is malformed.") from exc fields: list[dict[str, Any]] = [] for section in parser.sections(): if not section.startswith("fields."): continue values = dict(parser.items(section)) fields.append({"name": section.removeprefix("fields."), **values}) return { "model": model, "name": parser.get("model", "name", fallback=model), "label": parser.get("model", "label", fallback=""), "hidden": parser.getboolean("model", "hidden", fallback=False), "protected": parser.getboolean("model", "protected", fallback=False), "children": dict(parser.items("children")) if parser.has_section("children") else None, "fields": fields, "source": self.guard.relative(path), "sha256": _hash(path.read_bytes()), } def _entry_result(self, model: str, entry: Any, repository: Any, *, include_body: bool) -> dict[str, Any]: values = repository.values_for(entry.document) if not include_body: values.pop("body", None) return { "model": model, "slug": entry.slug, "path": self.guard.relative(entry.path), "fields": values, "sha256": _hash(entry.original_bytes), } def list_content(self, filters: dict[str, Any]) -> dict[str, Any]: requested = filters.get("model") models = [requested] if requested else ["article", "blog", "project", "devlog"] if any(model not in CONTENT_MODELS for model in models): raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Supported content models are article, blog, project, and devlog.") items: list[dict[str, Any]] = [] if "article" in models: repository = self._article_repository() items.extend(self._entry_result("article", item, repository, include_body=False) for item in repository.list_entries()) if "blog" in models: repository = self._blog_repository() items.extend(self._entry_result("blog", item, repository, include_body=False) for item in repository.list_entries()) if "project" in models or "devlog" in models: for project in self.list_projects()["projects"]: if "project" in models: items.append({"model": "project", **project}) if "devlog" in models: try: items.extend(self.list_project_devlogs(project["project_id"])["entries"]) except LabyricornMcpError as exc: if exc.code not in {"CONTENT_NOT_FOUND", "RUNTIME_MISSING"}: raise tag = str(filters.get("tag") or "").strip() project_id = str(filters.get("project") or "").strip() slug = str(filters.get("slug") or "").strip() date_value = str(filters.get("date") or "").strip() search = str(filters.get("search") or "").strip().casefold() def matches(item: dict[str, Any]) -> bool: fields = item.get("fields", {}) tags = fields.get("tags", []) if isinstance(tags, str): tags = [part.strip() for part in re.split(r"[,\n]", tags) if part.strip()] if tag and tag not in tags and tag not in fields.get("topic_tag_slugs", []): return False if project_id and item.get("project_id") != project_id: return False if slug and item.get("slug") != slug: return False if date_value and fields.get("date") != date_value: return False if search and search not in json.dumps(item, ensure_ascii=False).casefold(): return False return True filtered = [item for item in items if matches(item)] return {"count": len(filtered), "items": filtered} def get_content(self, model: str, slug: str, project: str | None = None) -> dict[str, Any]: try: if model == "article": repository = self._article_repository() return self._entry_result(model, repository.load_entry(slug), repository, include_body=True) if model == "blog": repository = self._blog_repository() return self._entry_result(model, repository.load_entry(slug), repository, include_body=True) if model == "project": return self.get_project(slug) if model == "devlog": if not project: raise LabyricornMcpError("VALIDATION_FAILED", "The project argument is required for devlog content.") return self.get_devlog(project, slug) except LabyricornMcpError: raise except Exception as exc: raise self._translate(exc, not_found=True) from exc raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Supported content models are article, blog, project, and devlog.") def _create_entry(self, model: str, slug: str, fields: dict[str, Any]) -> dict[str, Any]: repository = self._article_repository() if model == "article" else self._blog_repository() kicker = "Article" if model == "article" else "Blog" values = { "title": str(fields.get("title", "")), "date": str(fields.get("date", date.today().isoformat())), "updated": str(fields.get("updated", "")), "author": str(fields.get("author", "")), "tags": self._tag_input(fields.get("tags", "")), "kicker": kicker, "summary": str(fields.get("summary", "")), "body": str(fields.get("body", "")), "external_url": str(fields.get("external_url", "")), } if model == "article": values["published_urls"] = self._lines_input(fields.get("published_urls", "")) try: entry = repository.create_entry(slug, values) return self._entry_result(model, entry, repository, include_body=True) except Exception as exc: raise self._translate(exc) from exc def _update_entry(self, model: str, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]: repository = self._article_repository() if model == "article" else self._blog_repository() try: entry = repository.load_entry(slug) actual_hash = _hash(entry.original_bytes) if actual_hash != expected_hash: raise LabyricornMcpError( "FILE_CHANGED", "The content changed after it was read; reload it before updating.", {"expected_sha256": expected_hash, "actual_sha256": actual_hash}, ) values = repository.values_for(entry.document) for key, value in fields.items(): if key not in values: raise LabyricornMcpError("VALIDATION_FAILED", f"Unsupported {model} field: {key}") if key == "tags": values[key] = self._tag_input(value) elif key == "published_urls": values[key] = self._lines_input(value) else: values[key] = str(value) saved = repository.save_entry(entry, values) return self._entry_result(model, saved, repository, include_body=True) except LabyricornMcpError: raise except Exception as exc: raise self._translate(exc, not_found=True) from exc @staticmethod def _tag_input(value: Any) -> str: if isinstance(value, list): return ", ".join(str(item) for item in value) return str(value) @staticmethod def _lines_input(value: Any) -> str: if isinstance(value, list): return "\n".join(str(item) for item in value) return str(value) def create_article(self, slug: str, fields: dict[str, Any]) -> dict[str, Any]: return self._create_entry("article", slug, fields) def update_article(self, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]: return self._update_entry("article", slug, fields, expected_hash) def create_blog_post(self, slug: str, fields: dict[str, Any]) -> dict[str, Any]: return self._create_entry("blog", slug, fields) def update_blog_post(self, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]: return self._update_entry("blog", slug, fields, expected_hash) def list_tags(self) -> dict[str, Any]: try: tags = self._tag_repository().list_tags() except Exception as exc: raise self._translate(exc) from exc return { "count": len(tags), "tags": [ {"slug": tag.slug, "title": tag.title, "summary": tag.summary, "sha256": _hash(tag.original_bytes)} for tag in tags ], } def get_tag_usage(self, slug: str) -> dict[str, Any]: usages: list[dict[str, str]] = [] for model in ("article", "blog"): repository = self._article_repository() if model == "article" else self._blog_repository() for entry in repository.list_entries(): tags = [part.strip() for part in re.split(r"[,\n]", entry.document.get("tags")) if part.strip()] if slug in tags: usages.append({"model": model, "slug": entry.slug}) for project in self.list_projects()["projects"]: try: snapshot = self._cached_project_snapshot(project["project_id"]) except LabyricornMcpError: continue project_record = snapshot["records"][".labyricorn/project/contents.lr"] if slug in self._split(project_record.get("tags", "")): usages.append({"model": "project", "slug": project["project_id"]}) for path, record in snapshot["records"].items(): if path.startswith(".labyricorn/devlog/") and path.count("/") == 3 and slug in self._split(record.get("tags", "")): usages.append({"model": "devlog", "slug": Path(path).parent.name, "project": project["project_id"]}) tracked = any(tag["slug"] == slug for tag in self.list_tags()["tags"]) return {"slug": slug, "tracked": tracked, "count": len(usages), "usages": usages} def create_tag(self, slug: str, title: str, summary: str) -> dict[str, Any]: try: tag = self._tag_repository().create_tag(slug, title, summary) except Exception as exc: raise self._translate(exc) from exc return {"slug": tag.slug, "title": tag.title, "summary": tag.summary, "sha256": _hash(tag.original_bytes)} def update_tag(self, slug: str, title: str, summary: str, expected_hash: str) -> dict[str, Any]: repository = self._tag_repository() try: tag = repository.load_tag(slug) actual = _hash(tag.original_bytes) if actual != expected_hash: raise LabyricornMcpError("FILE_CHANGED", "The tracked tag changed after it was read.", {"actual_sha256": actual}) saved = repository.save_tag(tag, title, summary) except LabyricornMcpError: raise except Exception as exc: raise self._translate(exc, not_found=True) from exc return {"slug": saved.slug, "title": saved.title, "summary": saved.summary, "sha256": _hash(saved.original_bytes)} def untrack_tag(self, slug: str, expected_hash: str) -> dict[str, Any]: repository = self._tag_repository() try: tag = repository.load_tag(slug) actual = _hash(tag.original_bytes) if actual != expected_hash: raise LabyricornMcpError("FILE_CHANGED", "The tracked tag changed after it was read.", {"actual_sha256": actual}) usage = self.get_tag_usage(slug) repository.untrack_tag(tag) except LabyricornMcpError: raise except Exception as exc: raise self._translate(exc, not_found=True) from exc return {"slug": slug, "untracked": True, "preserved_content_references": usage["count"]} def list_projects(self) -> dict[str, Any]: try: sources = self._project_registry().list_sources() except Exception as exc: raise self._translate(exc) from exc state = self._project_state() projects = [] for source in sources: cached = state.get("projects", {}).get(source.project_id, {}) projects.append( { "project_id": source.project_id, "repository": source.web_url, "branch": source.branch, "featured_order": source.featured_order, "cached_commit": cached.get("commit"), "synchronized_at": cached.get("synchronized_at"), "metadata": cached.get("metadata", {}), } ) return {"count": len(projects), "projects": projects} def get_project(self, project_id: str) -> dict[str, Any]: snapshot = self._cached_project_snapshot(project_id) record = snapshot["records"][".labyricorn/project/contents.lr"] return { "model": "project", "project_id": project_id, "slug": project_id, "fields": record, "commit": snapshot["commit"], "source": snapshot["source"], "metadata": snapshot["state"].get("metadata", {}), } def list_project_devlogs(self, project_id: str) -> dict[str, Any]: snapshot = self._cached_project_snapshot(project_id) entries = [] for path, record in snapshot["records"].items(): pure = Path(path.replace("/", "\\")) if sys.platform == "win32" else Path(path) if not path.startswith(".labyricorn/devlog/") or path.count("/") != 3: continue slug = pure.parent.name entries.append( {"model": "devlog", "project_id": project_id, "slug": slug, "fields": record, "commit": snapshot["commit"]} ) entries.sort(key=lambda item: (item["fields"].get("date", ""), item["slug"]), reverse=True) return {"project_id": project_id, "count": len(entries), "entries": entries} def get_devlog(self, project_id: str, slug: str) -> dict[str, Any]: for entry in self.list_project_devlogs(project_id)["entries"]: if entry["slug"] == slug: return entry raise LabyricornMcpError("CONTENT_NOT_FOUND", "The requested project devlog entry does not exist.") def list_project_sources(self) -> dict[str, Any]: projects = self.list_projects()["projects"] return { "count": len(projects), "sources": [ {key: value for key, value in project.items() if key != "metadata"} for project in projects ], } def inspect_project_source(self, project_id: str) -> dict[str, Any]: snapshot = self._cached_project_snapshot(project_id) return { "project_id": project_id, "source": snapshot["source"], "cached_commit": snapshot["commit"], "validated_files": sorted(snapshot["files"]), "records": snapshot["records"], "network_accessed": False, } def validate_project_source(self, project_id: str) -> dict[str, Any]: try: source = self._project_registry().load_source(project_id) readiness = self.modules["project"].check_devlog_readiness(source) except Exception as exc: raise self._translate(exc, not_found=True) from exc return {"project_id": project_id, **asdict(readiness), "network_accessed": True, "repository_modified": False} def _project_state(self) -> dict[str, Any]: path = self.guard.resolve(".cache/project-sources/state.json") if not path.is_file(): return {"version": 1, "projects": {}} try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise LabyricornMcpError("VALIDATION_FAILED", "The local project cache state is malformed.") from exc def _cached_project_snapshot(self, project_id: str) -> dict[str, Any]: try: source = self._project_registry().load_source(project_id) except Exception as exc: raise self._translate(exc, not_found=True) from exc state = self._project_state().get("projects", {}).get(project_id) if not state or not state.get("commit"): raise LabyricornMcpError("CONTENT_NOT_FOUND", "No validated local snapshot is cached for this project.") try: import project_sources except ModuleNotFoundError as exc: raise LabyricornMcpError( "RUNTIME_MISSING", "Inspecting cached project content requires the existing Lektor Python environment." ) from exc mirror = self.guard.resolve(f".cache/project-sources/repos/{project_id}.git", must_exist=True) try: files, records = project_sources.validate_snapshot(source.importer_source(), mirror, state["commit"]) except Exception as exc: raise LabyricornMcpError("VALIDATION_FAILED", f"The cached project snapshot failed validation: {exc}") from exc return { "source": {key: value for key, value in source.importer_source().items() if key != "api_url"}, "commit": state["commit"], "state": state, "files": files, "records": records, } @staticmethod def _split(value: str) -> list[str]: return [part.strip() for part in re.split(r"[,\n]", value) if part.strip()] def list_design_files(self, area: str | None = None) -> dict[str, Any]: roots = DESIGN_ROOTS if area: if area not in DESIGN_ROOTS: raise LabyricornMcpError("PATH_NOT_ALLOWED", "Area must be templates or assets/static.") roots = (area,) files = [] for root in roots: base = self.guard.resolve(root, allowed_roots=DESIGN_ROOTS, must_exist=True) for path in sorted(base.rglob("*")): if not path.resolve().is_relative_to(self.guard.root): raise LabyricornMcpError( "PATH_OUTSIDE_REPOSITORY", "A design path resolves outside the repository." ) if path.is_file() and not path.is_symlink(): files.append( { "path": self.guard.relative(path), "size": path.stat().st_size, "sha256": _hash(path.read_bytes()), "text_readable": path.suffix.casefold() in DESIGN_SUFFIXES, } ) return {"count": len(files), "files": files} def get_design_file(self, relative: str) -> dict[str, Any]: path = self.guard.resolve(relative, allowed_roots=DESIGN_ROOTS, must_exist=True) if path.suffix.casefold() not in DESIGN_SUFFIXES or not path.is_file(): raise LabyricornMcpError("PATH_NOT_ALLOWED", "Only allowlisted text design files can be read.") try: payload = path.read_bytes() content = payload.decode("utf-8") except (OSError, UnicodeError) as exc: raise LabyricornMcpError("VALIDATION_FAILED", "The design file is not readable UTF-8 text.") from exc return {"path": self.guard.relative(path), "content": content, "sha256": _hash(payload)} def update_design_file(self, relative: str, content: str, expected_hash: str) -> dict[str, Any]: path = self.guard.resolve(relative, allowed_roots=DESIGN_ROOTS, must_exist=True, reject_links=True) if path.suffix.casefold() not in DESIGN_SUFFIXES or not path.is_file(): raise LabyricornMcpError("PATH_NOT_ALLOWED", "Only existing allowlisted text design files can be updated.") current = path.read_bytes() actual = _hash(current) if actual != expected_hash: raise LabyricornMcpError("FILE_CHANGED", "The design file changed after it was read.", {"actual_sha256": actual}) payload = content.encode("utf-8") temporary: Path | None = None try: with tempfile.NamedTemporaryFile( mode="wb", prefix=f".{path.name}.labyricorn-mcp-", dir=path.parent, delete=False ) as output: temporary = Path(output.name) output.write(payload) output.flush() os.fsync(output.fileno()) if path.read_bytes() != current: temporary.unlink(missing_ok=True) raise LabyricornMcpError( "FILE_CHANGED", "The design file changed while the update was being prepared." ) os.chmod(temporary, path.stat().st_mode) os.replace(temporary, path) except LabyricornMcpError: raise except OSError as exc: if temporary is not None: temporary.unlink(missing_ok=True) raise LabyricornMcpError("VALIDATION_FAILED", "The design file could not be updated.") from exc return {"path": self.guard.relative(path), "sha256": _hash(payload), "changed": payload != current}