Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
906 lines
31 KiB
Python
906 lines
31 KiB
Python
"""PageOps — thin filesystem content plane (ADR-0003 / ADR-0004).
|
|
|
|
Layout under CONTENT_ROOT:
|
|
|
|
spaces/<space-slug>/
|
|
pages/<page-slug>.md
|
|
assets/…
|
|
|
|
Markdown files use YAML-like frontmatter (title, abstractor, visual, …).
|
|
The app DB holds Space rows as an index; bodies live on disk.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import threading
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
from urllib.parse import quote, unquote, urlsplit
|
|
|
|
from django.conf import settings
|
|
from django.utils.text import slugify as django_slugify
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
_WRITER_LOCK = threading.RLock()
|
|
_WRITER_STATE = threading.local()
|
|
|
|
|
|
@contextmanager
|
|
def content_write_lock():
|
|
"""Serialize cooperating app/import workers on one mounted content instance."""
|
|
with _WRITER_LOCK:
|
|
if getattr(_WRITER_STATE, "active", False):
|
|
yield False
|
|
return
|
|
root = content_root()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
fd = os.open(root / ".writer.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
_WRITER_STATE.active = True
|
|
try:
|
|
yield True
|
|
finally:
|
|
_WRITER_STATE.active = False
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def content_writer(function):
|
|
@wraps(function)
|
|
def locked(*args, **kwargs):
|
|
with content_write_lock() as outermost:
|
|
if outermost and function.__name__ != "recover_interrupted_content":
|
|
_recover_interrupted_content()
|
|
return function(*args, **kwargs)
|
|
return locked
|
|
|
|
|
|
def _fsync_directory(path):
|
|
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _remember(space_slug, page_slug, data):
|
|
"""Immutable content-addressed versions are part of the paired content backup."""
|
|
directory = content_root() / ".revisions" / space_slug / page_slug
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
path = directory / f"{digest}.md"
|
|
if path.exists():
|
|
if path.read_bytes() != data:
|
|
raise PageOpsError("Stored revision integrity check failed")
|
|
return
|
|
temporary = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream:
|
|
temporary = Path(stream.name)
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
_fsync_directory(directory)
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
class PageOpsError(Exception):
|
|
def __init__(self, message: str):
|
|
self.message = message
|
|
super().__init__(message)
|
|
|
|
|
|
@dataclass
|
|
class PageDoc:
|
|
slug: str
|
|
title: str = ""
|
|
abstractor: str = ""
|
|
visual: str = ""
|
|
body: str = ""
|
|
meta: dict = field(default_factory=dict)
|
|
path: str = ""
|
|
revision: str = ""
|
|
|
|
@property
|
|
def exists(self) -> bool:
|
|
return bool(self.path)
|
|
|
|
|
|
def content_root() -> Path:
|
|
raw = (getattr(settings, "CONTENT_ROOT", None) or "").strip()
|
|
if raw:
|
|
return Path(raw).expanduser().resolve()
|
|
# Default under project var/ (gitignored)
|
|
base = Path(settings.BASE_DIR)
|
|
return (base / "var" / "content").resolve()
|
|
|
|
|
|
def space_dir(space_slug: str) -> Path:
|
|
if not _SLUG_RE.fullmatch(space_slug):
|
|
raise PageOpsError("Invalid space slug")
|
|
return content_root() / "spaces" / space_slug
|
|
|
|
|
|
def pages_dir(space_slug: str) -> Path:
|
|
return space_dir(space_slug) / "pages"
|
|
|
|
|
|
def assets_dir(space_slug: str) -> Path:
|
|
return space_dir(space_slug) / "assets"
|
|
|
|
|
|
def page_file(space_slug: str, page_slug: str) -> Path:
|
|
if not _SLUG_RE.fullmatch(page_slug):
|
|
raise PageOpsError("Invalid page slug")
|
|
name = "index.md" if page_slug == "index" else f"{page_slug}.md"
|
|
path = pages_dir(space_slug) / name
|
|
for component in (content_root() / "spaces", space_dir(space_slug),
|
|
pages_dir(space_slug), path):
|
|
if component.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
return path
|
|
|
|
|
|
def normalize_slug(value: str, *, allow_index: bool = True) -> str:
|
|
s = django_slugify(value or "")[:80]
|
|
if not s:
|
|
raise PageOpsError("Slug cannot be empty")
|
|
if s == "index" and not allow_index:
|
|
raise PageOpsError("Slug 'index' is reserved")
|
|
if not _SLUG_RE.match(s):
|
|
raise PageOpsError("Invalid slug")
|
|
return s
|
|
|
|
|
|
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
if not text.startswith("---\n"):
|
|
return {}, text
|
|
end = text.find("\n---\n", 4)
|
|
if end < 0:
|
|
# allow trailing --- at EOF
|
|
end = text.find("\n---", 4)
|
|
if end < 0 or end + 4 != len(text.rstrip()):
|
|
return {}, text
|
|
body = ""
|
|
fm_raw = text[4:end]
|
|
else:
|
|
fm_raw = text[4:end]
|
|
body = text[end + 5 :]
|
|
data: dict = {}
|
|
for line in fm_raw.splitlines():
|
|
if not line.strip() or line.strip().startswith("#"):
|
|
continue
|
|
if ":" not in line:
|
|
continue
|
|
key, raw = line.split(":", 1)
|
|
key = key.strip()
|
|
raw = raw.strip()
|
|
if not key:
|
|
continue
|
|
if raw in ("true", "false"):
|
|
data[key] = raw == "true"
|
|
elif raw.startswith('"') or raw.startswith("'"):
|
|
try:
|
|
data[key] = json.loads(raw.replace("'", '"') if raw.startswith("'") else raw)
|
|
except json.JSONDecodeError:
|
|
data[key] = raw.strip("\"'")
|
|
else:
|
|
data[key] = raw
|
|
return data, body.lstrip("\n")
|
|
|
|
|
|
def dump_frontmatter(meta: dict, body: str) -> str:
|
|
body = body.replace("\r\n", "\n").replace("\r", "\n")
|
|
lines = ["---"]
|
|
for key, value in meta.items():
|
|
if value is None or value == "":
|
|
lines.append(f'{key}: ""')
|
|
elif isinstance(value, bool):
|
|
lines.append(f"{key}: {'true' if value else 'false'}")
|
|
elif isinstance(value, (int, float)):
|
|
lines.append(f"{key}: {value}")
|
|
else:
|
|
lines.append(f"{key}: {json.dumps(str(value))}")
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append(body.rstrip() + ("\n" if body.strip() else ""))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def ensure_space_tree(space_slug: str) -> Path:
|
|
root = space_dir(space_slug)
|
|
page_file(space_slug, "index")
|
|
if assets_dir(space_slug).is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
pages_dir(space_slug).mkdir(parents=True, exist_ok=True)
|
|
assets_dir(space_slug).mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def page_aliases(space_slug):
|
|
page_file(space_slug, "index") # Validate the trusted tree boundaries.
|
|
path = space_dir(space_slug) / ".page-aliases.json"
|
|
if path.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
aliases = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(aliases, dict):
|
|
raise ValueError
|
|
for source, target in aliases.items():
|
|
if (not _SLUG_RE.fullmatch(source) or not isinstance(target, list)
|
|
or len(target) != 2 or not all(
|
|
isinstance(part, str) and _SLUG_RE.fullmatch(part) for part in target
|
|
)):
|
|
raise ValueError
|
|
return aliases
|
|
except (ValueError, TypeError) as exc:
|
|
raise PageOpsError("Page-address history needs operator review") from exc
|
|
|
|
|
|
def _store_aliases(space_slug, aliases):
|
|
directory = space_dir(space_slug)
|
|
temporary = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream:
|
|
temporary = Path(stream.name)
|
|
stream.write((json.dumps(aliases, sort_keys=True) + "\n").encode("utf-8"))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, directory / ".page-aliases.json")
|
|
_fsync_directory(directory)
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
@content_writer
|
|
def remember_page_address(source_space, source_page, dest_space, dest_page):
|
|
source_page = normalize_slug(source_page)
|
|
dest_page = normalize_slug(dest_page)
|
|
space_dir(dest_space)
|
|
aliases = page_aliases(source_space)
|
|
aliases[source_page] = [dest_space, dest_page]
|
|
_store_aliases(source_space, aliases)
|
|
|
|
|
|
def _recovery_dir():
|
|
path = content_root() / ".recovery"
|
|
if path.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
_fsync_directory(path.parent)
|
|
return path
|
|
|
|
|
|
def _write_record(record):
|
|
directory = _recovery_dir()
|
|
destination = directory / f"{record['id']}.json"
|
|
if destination.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
temporary = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream:
|
|
temporary = Path(stream.name)
|
|
payload = (json.dumps(record, sort_keys=True) + "\n").encode("utf-8")
|
|
stream.write(payload)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, destination)
|
|
_fsync_directory(directory)
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
return record
|
|
|
|
|
|
def _read_record(record_id):
|
|
path = _recovery_dir() / f"{record_id}.json"
|
|
if path.is_symlink() or not path.is_file():
|
|
return None
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
return "held"
|
|
if not isinstance(record, dict) or record.get("id") != record_id:
|
|
return "held"
|
|
return record
|
|
|
|
|
|
def _clear_record(record):
|
|
path = _recovery_dir() / f"{record['id']}.json"
|
|
if path.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
path.unlink(missing_ok=True)
|
|
_fsync_directory(path.parent)
|
|
|
|
|
|
def _start_record(op, source_space, source_page, dest_space, dest_page, source_revision):
|
|
return _write_record({
|
|
"id": uuid.uuid4().hex,
|
|
"version": 1,
|
|
"op": op,
|
|
"source_space": source_space,
|
|
"source_page": source_page,
|
|
"dest_space": dest_space,
|
|
"dest_page": dest_page,
|
|
"source_revision": source_revision,
|
|
"dest_revision": "",
|
|
"assets": [],
|
|
"phase": "started",
|
|
})
|
|
|
|
|
|
def _note_asset(record, name):
|
|
if not isinstance(name, str) or not name or "/" in name or name in (".", ".."):
|
|
raise PageOpsError("Recovery asset name needs operator review")
|
|
if name not in record["assets"]:
|
|
record["assets"].append(name)
|
|
_write_record(record)
|
|
|
|
|
|
def _file_revision(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def _discard_recorded_assets(space_slug, names):
|
|
if not names:
|
|
return
|
|
root = assets_dir(space_slug)
|
|
for name in names:
|
|
if not isinstance(name, str) or not name or "/" in name or name in (".", ".."):
|
|
raise PageOpsError("Recovery asset name needs operator review")
|
|
path = root / name
|
|
if path.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
if path.is_file():
|
|
path.unlink()
|
|
_fsync_directory(root)
|
|
|
|
|
|
def _discard_unconfirmed_page(space_slug, page_slug):
|
|
path = page_file(space_slug, page_slug)
|
|
if path.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
if not path.is_file():
|
|
return
|
|
_remember(space_slug, page_slug, path.read_bytes())
|
|
path.unlink()
|
|
_fsync_directory(path.parent)
|
|
|
|
|
|
def _drop_matching_alias(record):
|
|
aliases = page_aliases(record["source_space"])
|
|
expected = [record["dest_space"], record["dest_page"]]
|
|
if aliases.get(record["source_page"]) != expected:
|
|
return
|
|
aliases.pop(record["source_page"])
|
|
_store_aliases(record["source_space"], aliases)
|
|
|
|
|
|
def _record_is_actionable(record):
|
|
if not isinstance(record, dict) or record.get("version") != 1:
|
|
return False
|
|
if record.get("op") not in {"copy", "transfer"}:
|
|
return False
|
|
if record.get("phase") not in {"started", "published", "aliased"}:
|
|
return False
|
|
try:
|
|
for key in ("source_space", "dest_space"):
|
|
if not _SLUG_RE.fullmatch(record[key]):
|
|
return False
|
|
for key in ("source_page", "dest_page"):
|
|
normalize_slug(record[key])
|
|
except (KeyError, TypeError, PageOpsError):
|
|
return False
|
|
if record["dest_page"] == "index":
|
|
return False
|
|
if record["op"] == "transfer" and record["source_page"] == "index":
|
|
return False
|
|
if not isinstance(record.get("assets"), list):
|
|
return False
|
|
return isinstance(record.get("source_revision"), str)
|
|
|
|
|
|
def _finish_transfer_record(record, source_path, dest_confirmed):
|
|
"""Complete a published move, or keep a source that changed after it started."""
|
|
source_same = source_path.is_file() and _file_revision(source_path) == record["source_revision"]
|
|
if source_same and dest_confirmed:
|
|
if record["phase"] != "aliased":
|
|
remember_page_address(
|
|
record["source_space"], record["source_page"],
|
|
record["dest_space"], record["dest_page"],
|
|
)
|
|
if _file_revision(source_path) != record["source_revision"]:
|
|
return "held"
|
|
delete_page(record["source_space"], record["source_page"])
|
|
_clear_record(record)
|
|
return "cleared"
|
|
if source_path.is_file() and not source_same:
|
|
_drop_matching_alias(record)
|
|
_clear_record(record)
|
|
return "cleared"
|
|
if dest_confirmed or page_file(record["dest_space"], record["dest_page"]).is_file():
|
|
_clear_record(record)
|
|
return "cleared"
|
|
_discard_recorded_assets(record["dest_space"], record["assets"])
|
|
_clear_record(record)
|
|
return "cleared"
|
|
|
|
|
|
def _recover_record(record):
|
|
if isinstance(record, dict) and record.get("op") == "attachment_upload":
|
|
from .attachments import recover
|
|
return recover(record)
|
|
if record == "held" or not _record_is_actionable(record):
|
|
logger.error("content recovery left a journal for operator review")
|
|
return "held"
|
|
source_path = page_file(record["source_space"], record["source_page"])
|
|
dest_path = page_file(record["dest_space"], record["dest_page"])
|
|
dest_confirmed = bool(
|
|
record.get("dest_revision")
|
|
and dest_path.is_file()
|
|
and _file_revision(dest_path) == record["dest_revision"]
|
|
)
|
|
source_exists = source_path.is_file()
|
|
if record["op"] == "copy":
|
|
if dest_confirmed or (not source_exists and dest_path.is_file()):
|
|
_clear_record(record)
|
|
return "cleared"
|
|
if source_exists:
|
|
_discard_unconfirmed_page(record["dest_space"], record["dest_page"])
|
|
_discard_recorded_assets(record["dest_space"], record["assets"])
|
|
_clear_record(record)
|
|
return "cleared"
|
|
if dest_confirmed:
|
|
return _finish_transfer_record(record, source_path, dest_confirmed)
|
|
if record["phase"] == "aliased" and source_exists:
|
|
_drop_matching_alias(record)
|
|
if source_exists:
|
|
_discard_unconfirmed_page(record["dest_space"], record["dest_page"])
|
|
_discard_recorded_assets(record["dest_space"], record["assets"])
|
|
_clear_record(record)
|
|
return "cleared"
|
|
if dest_path.is_file():
|
|
_clear_record(record)
|
|
return "cleared"
|
|
_discard_recorded_assets(record["dest_space"], record["assets"])
|
|
_clear_record(record)
|
|
return "cleared"
|
|
|
|
|
|
def _recover_interrupted_content():
|
|
directory = content_root() / ".recovery"
|
|
if not directory.exists():
|
|
return {"cleared": 0, "held": 0}
|
|
if directory.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
cleared = held = 0
|
|
for path in sorted(directory.glob("*.json")):
|
|
if path.is_symlink():
|
|
held += 1
|
|
logger.error("content recovery left a journal for operator review")
|
|
continue
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
held += 1
|
|
logger.error("content recovery left a journal for operator review")
|
|
continue
|
|
if _recover_record(record) == "held":
|
|
held += 1
|
|
else:
|
|
cleared += 1
|
|
return {"cleared": cleared, "held": held}
|
|
|
|
|
|
@content_writer
|
|
def recover_interrupted_content():
|
|
"""Finish or roll back a copy/move whose process stopped between files.
|
|
|
|
A confirmed destination is kept. The source is removed only when its bytes
|
|
still match the journal. Address aliases are written or removed only for
|
|
that journal's source page, and they do not grant destination access.
|
|
"""
|
|
return _recover_interrupted_content()
|
|
|
|
|
|
@content_writer
|
|
def purge_revisions(*, older_than):
|
|
"""Delete expired inactive revisions. Never runs as part of a page save.
|
|
|
|
The live page bytes are kept. Every stored revision of a page that no
|
|
longer has a file is kept, because that directory is its recovery copy.
|
|
A revision whose bytes do not match its content-address name stops the
|
|
purge before anything is deleted.
|
|
"""
|
|
if older_than.tzinfo is None:
|
|
raise PageOpsError("Revision purge cutoff must include a timezone")
|
|
root = content_root() / ".revisions"
|
|
if not root.exists():
|
|
return 0
|
|
if root.is_symlink():
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
victims = []
|
|
for path in sorted(root.rglob("*.md")):
|
|
if path.is_symlink() or any(parent.is_symlink() for parent in path.parents):
|
|
raise PageOpsError("Content paths must not be symlinks")
|
|
relative = path.relative_to(root)
|
|
if len(relative.parts) != 3:
|
|
raise PageOpsError("Stored revision integrity check failed")
|
|
data = path.read_bytes()
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
if path.stem != digest:
|
|
raise PageOpsError("Stored revision integrity check failed")
|
|
space_slug, page_slug, _name = relative.parts
|
|
live = page_file(space_slug, page_slug)
|
|
if live.is_file() and _file_revision(live) == digest:
|
|
continue
|
|
if not live.is_file():
|
|
continue
|
|
modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
|
|
if modified < older_than:
|
|
victims.append(path)
|
|
for path in victims:
|
|
path.unlink()
|
|
_fsync_directory(path.parent)
|
|
return len(victims)
|
|
|
|
|
|
@content_writer
|
|
def write_page(
|
|
space_slug: str,
|
|
page_slug: str,
|
|
*,
|
|
title: str = "",
|
|
abstractor: str = "",
|
|
visual: str = "",
|
|
body: str = "",
|
|
extra_meta: dict | None = None,
|
|
expected_revision: str | None = None,
|
|
) -> PageDoc:
|
|
page_slug = normalize_slug(page_slug) if page_slug != "index" else "index"
|
|
ensure_space_tree(space_slug)
|
|
meta = {
|
|
"title": title or page_slug,
|
|
"abstractor": abstractor or "",
|
|
"visual": visual or "",
|
|
"space": space_slug,
|
|
}
|
|
if extra_meta:
|
|
meta.update(extra_meta)
|
|
path = page_file(space_slug, page_slug)
|
|
exists = path.is_file()
|
|
current = path.read_bytes() if exists else b""
|
|
if not exists and page_slug in page_aliases(space_slug):
|
|
raise PageOpsError("This page address is reserved by a renamed or moved page")
|
|
revision = hashlib.sha256(current).hexdigest() if exists else ""
|
|
if expected_revision is not None and expected_revision != revision:
|
|
raise PageOpsError("This page changed. Reopen it and merge your edits before saving")
|
|
data = dump_frontmatter(meta, body or "").encode("utf-8")
|
|
if exists:
|
|
_remember(space_slug, page_slug, current)
|
|
_remember(space_slug, page_slug, data)
|
|
temporary = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
|
|
temporary = Path(stream.name)
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
_fsync_directory(path.parent)
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
return read_page(space_slug, page_slug)
|
|
|
|
|
|
def read_page(space_slug: str, page_slug: str) -> PageDoc:
|
|
page_slug = "index" if page_slug in ("", "index") else normalize_slug(page_slug)
|
|
path = page_file(space_slug, page_slug)
|
|
if not path.is_file():
|
|
return PageDoc(slug=page_slug, path="")
|
|
data = path.read_bytes()
|
|
text = data.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
|
meta, body = parse_frontmatter(text)
|
|
return PageDoc(
|
|
slug=page_slug,
|
|
title=str(meta.get("title") or ""),
|
|
abstractor=str(meta.get("abstractor") or meta.get("description") or ""),
|
|
visual=str(meta.get("visual") or ""),
|
|
body=body,
|
|
meta=meta,
|
|
path=str(path),
|
|
revision=hashlib.sha256(data).hexdigest(),
|
|
)
|
|
|
|
|
|
def list_pages(space_slug: str) -> list[PageDoc]:
|
|
pdir = pages_dir(space_slug)
|
|
if not pdir.is_dir():
|
|
return []
|
|
docs: list[PageDoc] = []
|
|
for path in sorted(pdir.glob("*.md")):
|
|
slug = path.stem
|
|
docs.append(read_page(space_slug, slug))
|
|
# index first
|
|
docs.sort(key=lambda d: (0 if d.slug == "index" else 1, d.title.lower() or d.slug))
|
|
return docs
|
|
|
|
|
|
@content_writer
|
|
def delete_page(space_slug: str, page_slug: str) -> None:
|
|
if page_slug == "index":
|
|
raise PageOpsError("Cannot delete the space index page")
|
|
path = page_file(space_slug, normalize_slug(page_slug))
|
|
if path.is_file():
|
|
_remember(space_slug, page_slug, path.read_bytes())
|
|
path.unlink()
|
|
_fsync_directory(path.parent)
|
|
|
|
|
|
_BODY_ASSET = re.compile(
|
|
r"(?P<prefix>\]\(\s*<?|\]:\s*<?)(?P<url>(?:\.\./|\./)?assets/[^\s)>]+)"
|
|
)
|
|
|
|
|
|
def _copy_assets(source_space, dest_space, dest_page, visual, body, before_create=None,
|
|
attached=()):
|
|
"""Copy local Visual and Markdown link/image destinations as one staged set."""
|
|
from .assets import asset_parts, open_local_asset
|
|
|
|
copied = {}
|
|
created = []
|
|
|
|
def copy_reference(reference, *, optional=False):
|
|
parsed = urlsplit(reference)
|
|
path = unquote(parsed.path)
|
|
for prefix in ("../assets/", "./assets/", "assets/"):
|
|
if path.startswith(prefix):
|
|
path = path[len(prefix):]
|
|
break
|
|
else:
|
|
raise PageOpsError("Local attachments must be inside the source space assets")
|
|
try:
|
|
parts = asset_parts(path)
|
|
if path not in copied:
|
|
try:
|
|
source_file = open_local_asset(source_space, path)
|
|
except FileNotFoundError:
|
|
if optional:
|
|
return reference
|
|
raise
|
|
# Include the relative-path hash to distinguish nested equal filenames.
|
|
suffix = (
|
|
"" if len(parts) == 1 else f"-{hashlib.sha256(path.encode()).hexdigest()[:12]}"
|
|
)
|
|
dest_name = f"{dest_page}{suffix}-{parts[-1]}"
|
|
with source_file:
|
|
destination = assets_dir(dest_space) / dest_name
|
|
# The enclosing content writer lock serializes asset creation.
|
|
# Refuse existing entries before recording cleanup authority,
|
|
# then persist intent before any partial destination can exist.
|
|
try:
|
|
destination.lstat()
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
raise FileExistsError("Destination attachment already exists")
|
|
if before_create is not None:
|
|
before_create(dest_name)
|
|
with open_local_asset(dest_space, dest_name, create=True) as stream:
|
|
created.append(destination)
|
|
shutil.copyfileobj(source_file, stream)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
_fsync_directory(destination.parent)
|
|
copied[path] = f"assets/{quote(dest_name)}"
|
|
suffix = (f"?{parsed.query}" if parsed.query else "")
|
|
suffix += f"#{parsed.fragment}" if parsed.fragment else ""
|
|
return copied[path] + suffix
|
|
except (OSError, ValueError) as exc:
|
|
raise PageOpsError(
|
|
"Cannot copy the destination Visual or body attachment safely"
|
|
) from exc
|
|
|
|
try:
|
|
if visual and not visual.startswith(("http", "/")):
|
|
visual = copy_reference(visual, optional=True)
|
|
body = _BODY_ASSET.sub(
|
|
lambda match: match["prefix"] + copy_reference(match["url"]), body,
|
|
)
|
|
attached = [{**entry, "path": copy_reference(entry["path"])} for entry in attached]
|
|
return visual, body, created, attached
|
|
except BaseException:
|
|
for path in created:
|
|
path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
@content_writer
|
|
def copy_page(
|
|
source_space: str,
|
|
source_page: str,
|
|
dest_space: str,
|
|
dest_page: str,
|
|
*,
|
|
title: str | None = None,
|
|
journal: bool = True,
|
|
on_asset=None,
|
|
) -> PageDoc:
|
|
src = read_page(source_space, source_page)
|
|
if not src.exists:
|
|
raise PageOpsError("Source page not found")
|
|
dest_page = normalize_slug(dest_page)
|
|
if (page_file(dest_space, dest_page).is_file()
|
|
or dest_page in page_aliases(dest_space)):
|
|
raise PageOpsError("Destination page already exists")
|
|
ensure_space_tree(dest_space)
|
|
source_revision = _file_revision(Path(src.path))
|
|
record = _start_record(
|
|
"copy", source_space, src.slug, dest_space, dest_page, source_revision,
|
|
) if journal else None
|
|
created = []
|
|
|
|
def note_asset(name):
|
|
if record is not None:
|
|
_note_asset(record, name)
|
|
if on_asset is not None:
|
|
on_asset(name)
|
|
|
|
try:
|
|
from .attachments import KEY, entries
|
|
|
|
visual, body, created, attached = _copy_assets(
|
|
source_space, dest_space, dest_page, src.visual, src.body, note_asset,
|
|
attached=entries(src.meta),
|
|
)
|
|
new_title = title if title is not None else (src.title or dest_page)
|
|
if title is None and new_title == src.title and source_space == dest_space:
|
|
new_title = f"{src.title} (copy)" if src.title else f"{dest_page}"
|
|
doc = write_page(
|
|
dest_space, dest_page, title=new_title, abstractor=src.abstractor,
|
|
visual=visual, body=body, expected_revision="",
|
|
extra_meta={k: v for k, v in src.meta.items()
|
|
if k not in {"title", "abstractor", "visual", "space", KEY}}
|
|
| ({KEY: json.dumps(attached)} if KEY in src.meta else {}),
|
|
)
|
|
if record is not None:
|
|
record["dest_revision"] = doc.revision
|
|
record["phase"] = "published"
|
|
_write_record(record)
|
|
_clear_record(record)
|
|
return doc
|
|
except BaseException:
|
|
if record is not None:
|
|
stored = _read_record(record["id"])
|
|
if stored is not None:
|
|
_recover_record(stored)
|
|
if not page_file(dest_space, dest_page).exists():
|
|
for path in created:
|
|
path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
@content_writer
|
|
def transfer_page(
|
|
source_space: str,
|
|
source_page: str,
|
|
dest_space: str,
|
|
dest_page: str | None = None,
|
|
) -> PageDoc:
|
|
source_page = normalize_slug(source_page)
|
|
dest_page = normalize_slug(dest_page or source_page)
|
|
if source_page == "index":
|
|
raise PageOpsError("Cannot transfer the space index page")
|
|
src = read_page(source_space, source_page)
|
|
if not src.exists:
|
|
raise PageOpsError("Source page not found")
|
|
# The journal is authority to remove an unconfirmed destination. Refuse an
|
|
# existing page before writing that journal.
|
|
if (page_file(dest_space, dest_page).is_file()
|
|
or dest_page in page_aliases(dest_space)):
|
|
raise PageOpsError("Destination page already exists")
|
|
record = _start_record(
|
|
"transfer", source_space, source_page, dest_space, dest_page,
|
|
_file_revision(Path(src.path)),
|
|
)
|
|
try:
|
|
doc = copy_page(
|
|
source_space, source_page, dest_space, dest_page, title=src.title,
|
|
journal=False, on_asset=lambda name: _note_asset(record, name),
|
|
)
|
|
record["dest_revision"] = doc.revision
|
|
record["phase"] = "published"
|
|
_write_record(record)
|
|
remember_page_address(source_space, source_page, dest_space, dest_page)
|
|
record["phase"] = "aliased"
|
|
_write_record(record)
|
|
source_path = page_file(source_space, source_page)
|
|
if _file_revision(source_path) != record["source_revision"]:
|
|
raise PageOpsError("Source changed before the move finished")
|
|
delete_page(source_space, source_page)
|
|
_clear_record(record)
|
|
return read_page(dest_space, dest_page)
|
|
except BaseException:
|
|
stored = _read_record(record["id"])
|
|
if stored is not None:
|
|
_recover_record(stored)
|
|
raise
|
|
|
|
|
|
@content_writer
|
|
def init_space_content(
|
|
space_slug: str,
|
|
*,
|
|
title: str,
|
|
abstractor: str = "",
|
|
visual: str = "",
|
|
) -> PageDoc:
|
|
"""Create tree + index.md for a new space."""
|
|
ensure_space_tree(space_slug)
|
|
body = f"# {title}\n\n{abstractor}\n" if abstractor else f"# {title}\n"
|
|
return write_page(
|
|
space_slug,
|
|
"index",
|
|
title=title,
|
|
abstractor=abstractor,
|
|
visual=visual,
|
|
body=body,
|
|
extra_meta={"bubble_type": "space"},
|
|
)
|
|
|
|
|
|
@content_writer
|
|
def rename_space_tree(old_slug: str, new_slug: str) -> None:
|
|
old = space_dir(old_slug)
|
|
new = space_dir(new_slug)
|
|
if not old.is_dir():
|
|
return
|
|
if new.exists():
|
|
raise PageOpsError("Target space directory already exists")
|
|
new.parent.mkdir(parents=True, exist_ok=True)
|
|
old.rename(new)
|
|
|
|
|
|
@content_writer
|
|
def archive_space_tree(space_slug: str) -> None:
|
|
"""Soft-archive: rename tree to .archived-<slug> if present."""
|
|
root = space_dir(space_slug)
|
|
if not root.is_dir():
|
|
return
|
|
dest = content_root() / "spaces" / f".archived-{space_slug}"
|
|
if dest.exists():
|
|
raise PageOpsError("A retained archive already exists; resolve it before archiving again")
|
|
root.rename(dest)
|
|
|
|
|
|
def local_page_text(space_slug: str, page_slug: str) -> str | None:
|
|
"""Raw markdown if present on the content plane."""
|
|
try:
|
|
doc = read_page(space_slug, page_slug)
|
|
except PageOpsError:
|
|
return None
|
|
if not doc.exists:
|
|
return None
|
|
return Path(doc.path).read_text(encoding="utf-8")
|