Import Bubble export trees into PageOps and finish CSOC-WP-0001

Add import_content_tree management command and import_tree helper for
ADR-0004 sample trees. Document reichelag rehearsal lossiness; mark T02/T04
and the bubble exit workplan finished.
This commit is contained in:
tegwick 2026-08-13 12:48:55 +02:00
parent affed64839
commit 8447943dc0
6 changed files with 523 additions and 35 deletions

View file

@ -0,0 +1,173 @@
"""Import an ADR-0004 space tree into the content plane + Space index.
Source layout (export or hand-authored):
<tree>/
pages/*.md
assets/* (optional)
Does not fetch Bubble; only copies local files. Bodies may be private —
callers should keep source trees out of git.
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from django.db import transaction
from django.utils.text import slugify
from coulomb_social.apps.members.models import Member
from . import pageops
from .models import Space, SpaceMembership
_SLUG_SAFE = re.compile(r"[^a-z0-9]+")
@dataclass
class ImportReport:
space_slug: str
title: str
pages_copied: int = 0
assets_copied: int = 0
space_created: bool = False
space_updated: bool = False
content_path: str = ""
warnings: list[str] = field(default_factory=list)
page_slugs: list[str] = field(default_factory=list)
def _read_index_meta(pages: Path) -> tuple[str, str, str]:
index = pages / "index.md"
if not index.is_file():
return "", "", ""
text = index.read_text(encoding="utf-8")
meta, _ = pageops.parse_frontmatter(text)
title = str(meta.get("title") or "").strip()
abstractor = str(meta.get("abstractor") or meta.get("description") or "").strip()
visual = str(meta.get("visual") or "").strip()
return title, abstractor, visual
def normalize_import_slug(value: str) -> str:
s = slugify(value or "")[:80]
if not s:
raise pageops.PageOpsError("Space slug cannot be empty")
return s
def import_space_tree(
tree: Path,
*,
tenant_id: str,
slug: str | None = None,
member: Member | None = None,
title: str | None = None,
replace: bool = False,
) -> ImportReport:
"""Copy tree into CONTENT_ROOT and upsert Space row.
If ``replace`` is False and the destination pages dir already has files,
raises PageOpsError unless the tree is empty.
"""
tree = tree.expanduser().resolve()
if not tree.is_dir():
raise pageops.PageOpsError(f"Tree not found: {tree}")
pages_src = tree / "pages"
if not pages_src.is_dir():
raise pageops.PageOpsError(f"Missing pages/ under {tree}")
idx_title, idx_abs, idx_vis = _read_index_meta(pages_src)
space_slug = normalize_import_slug(slug or tree.name)
display_title = (title or idx_title or space_slug).strip()
abstractor = idx_abs
visual = idx_vis
dest = pageops.space_dir(space_slug)
dest_pages = pageops.pages_dir(space_slug)
dest_assets = pageops.assets_dir(space_slug)
if dest_pages.is_dir() and any(dest_pages.glob("*.md")) and not replace:
raise pageops.PageOpsError(
f"Destination already has pages at {dest_pages}; pass replace=True to overwrite"
)
report = ImportReport(space_slug=space_slug, title=display_title)
if replace and dest.is_dir():
shutil.rmtree(dest)
pageops.ensure_space_tree(space_slug)
# Copy pages
for path in sorted(pages_src.glob("*.md")):
target = dest_pages / path.name
shutil.copy2(path, target)
report.pages_copied += 1
report.page_slugs.append(path.stem)
# Copy assets if present
assets_src = tree / "assets"
if assets_src.is_dir():
for path in sorted(assets_src.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(assets_src)
target = dest_assets / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
report.assets_copied += 1
if not (dest_pages / "index.md").is_file():
pageops.init_space_content(
space_slug,
title=display_title,
abstractor=abstractor,
visual=visual,
)
report.warnings.append("index.md missing in source; synthesized")
report.pages_copied += 1
report.page_slugs.append("index")
report.content_path = str(dest)
with transaction.atomic():
space, created = Space.objects.update_or_create(
tenant_id=tenant_id,
slug=space_slug,
defaults={
"title": display_title,
"description": abstractor,
"visual": visual,
"is_active": True,
"content_root": "pages",
"default_branch": "main",
},
)
report.space_created = created
report.space_updated = not created
if member is not None:
SpaceMembership.objects.get_or_create(
space=space,
member=member,
defaults={"role": SpaceMembership.Role.OWNER},
)
if space.created_by_id is None:
space.created_by = member
space.save(update_fields=["created_by", "updated_at"])
# Sanity: count readable pages
try:
listed = pageops.list_pages(space_slug)
if len(listed) != report.pages_copied:
report.warnings.append(
f"list_pages returned {len(listed)} vs copied {report.pages_copied}"
)
except pageops.PageOpsError as exc:
report.warnings.append(f"list_pages failed: {exc.message}")
return report

View file

@ -0,0 +1,122 @@
"""Import a local ADR-0004 space tree into CONTENT_ROOT + Space index."""
from __future__ import annotations
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from coulomb_social.apps.members.models import Member
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.import_tree import import_space_tree
class Command(BaseCommand):
help = (
"Import pages/ + assets/ from a local Bubble export (or hand-made) tree "
"into CONTENT_ROOT and upsert the Space index row. Does not call Bubble."
)
def add_arguments(self, parser):
parser.add_argument(
"tree",
type=str,
help="Path to space tree (contains pages/ and optional assets/)",
)
parser.add_argument(
"--slug",
default="",
help="Destination space slug (default: directory name)",
)
parser.add_argument(
"--title",
default="",
help="Override space title (default: index.md frontmatter)",
)
parser.add_argument(
"--tenant",
default="",
help="Tenant id (default: DEFAULT_TENANT_ID)",
)
parser.add_argument(
"--member-subject",
default="",
help="OIDC subject of Member to grant owner membership",
)
parser.add_argument(
"--replace",
action="store_true",
help="Overwrite existing CONTENT_ROOT tree for this slug",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate tree and print plan without writing",
)
def handle(self, *args, **options):
tree = Path(options["tree"]).expanduser()
if not tree.is_dir():
raise CommandError(f"Tree not found: {tree}")
pages = tree / "pages"
if not pages.is_dir():
raise CommandError(f"Missing pages/ under {tree}")
tenant = options["tenant"] or settings.DEFAULT_TENANT_ID
slug = options["slug"] or tree.name
md_files = sorted(pages.glob("*.md"))
assets_dir = tree / "assets"
asset_count = (
sum(1 for p in assets_dir.rglob("*") if p.is_file())
if assets_dir.is_dir()
else 0
)
self.stdout.write(
f"Plan: import {len(md_files)} page(s), {asset_count} asset(s) "
f"→ slug={slug!r} tenant={tenant!r} CONTENT_ROOT={pageops.content_root()}"
)
if options["dry_run"]:
for p in md_files[:20]:
self.stdout.write(f" page {p.name}")
if len(md_files) > 20:
self.stdout.write(f" … {len(md_files) - 20} more")
self.stdout.write(self.style.WARNING("Dry run — no writes"))
return
member = None
subject = (options["member_subject"] or "").strip()
if subject:
member = Member.objects.filter(subject=subject, tenant_id=tenant).first()
if member is None:
# try any tenant match for convenience in local smoke
member = Member.objects.filter(subject=subject).first()
if member is None:
raise CommandError(
f"No Member with subject={subject!r} "
f"(sign in once or pass a known subject)"
)
try:
report = import_space_tree(
tree,
tenant_id=tenant,
slug=slug or None,
member=member,
title=options["title"] or None,
replace=options["replace"],
)
except pageops.PageOpsError as exc:
raise CommandError(exc.message) from exc
action = "Created" if report.space_created else "Updated"
self.stdout.write(
self.style.SUCCESS(
f"{action} space {report.space_slug!r} title={report.title!r} "
f"pages={report.pages_copied} assets={report.assets_copied} "
f"path={report.content_path}"
)
)
for w in report.warnings:
self.stdout.write(self.style.WARNING(f"warning: {w}"))