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:
parent
affed64839
commit
8447943dc0
6 changed files with 523 additions and 35 deletions
173
coulomb_social/apps/spaces/import_tree.py
Normal file
173
coulomb_social/apps/spaces/import_tree.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue