"""Import an ADR-0004 space tree into the content plane + Space index. Source layout (export or hand-authored): / 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