coulomb-social/coulomb_social/apps/spaces/pageops.py
tegwick d9da608e65 Add private author-managed attachments with retained revisions
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
2026-10-31 19:38:10 +01:00

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")