21b6a30f08
Move a template fully between environments (local → live): container, projects, all scenes + editable fields, shared colours/layers, its categories & tags, and the asset files. - export_template.py <slug> → a self-contained bundle (template.json + assets/). One SQL query captures the whole tree as JSON; assets are resolved from template-media references and copied in. Source DB via PSQL env (default = local docker). - import_template.py <bundle> → idempotent SQL (pipe to target psql). Replaces by slug via one cascading delete (all content.* FKs are ON DELETE CASCADE), recreates rows verbatim (UUIDs preserved → FKs intact), merges categories/tags BY SLUG so they line up across DBs. --assets-to copies media; docker cp / mc cp hints for remote. - TEMPLATE_BUNDLES.md documents it. Round-trip tested on fr-instagram-promo: DB → bundle → DB restores identical 3 projects / 15 scenes / 138 fields and field values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
5.4 KiB
Python
94 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Export a FlatRender template to a portable, self-contained bundle.
|
|
|
|
A bundle = a directory holding `template.json` (every DB row related to the template
|
|
— container, projects, scenes, content-elements, shared colours/layers, plus the
|
|
categories & tags it links to, merged by slug on import) and `assets/` (the actual
|
|
media files it references). Move the directory to any environment and run
|
|
`import_template.py` to load it into that DB.
|
|
|
|
Usage:
|
|
python scripts/export_template.py <slug> [out_dir]
|
|
# source DB override (default = local docker):
|
|
PSQL="psql 'postgresql://user:pass@host:5432/db'" python scripts/export_template.py <slug>
|
|
|
|
What it does NOT export: user-generated data (comments, favourites, view counts are
|
|
reset on import) — only the template definition + assets travel.
|
|
"""
|
|
import os, sys, json, subprocess, shutil, re, shlex
|
|
|
|
if len(sys.argv) < 2:
|
|
print("usage: export_template.py <slug> [out_dir]", file=sys.stderr); sys.exit(1)
|
|
SLUG = sys.argv[1]
|
|
OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join("dist", "template-bundles", SLUG)
|
|
PSQL = shlex.split(os.environ.get("PSQL", "docker exec -i fr2-postgres psql -U postgres -d flatrender"))
|
|
PUBLIC = os.environ.get("TEMPLATE_MEDIA", "public/template-media")
|
|
S = SLUG.replace("'", "''")
|
|
|
|
QUERY = f"""
|
|
WITH c AS (SELECT id FROM content.project_containers WHERE slug = '{S}' AND deleted_at IS NULL)
|
|
SELECT json_build_object(
|
|
'version', 1,
|
|
'slug', '{S}',
|
|
'container', (SELECT row_to_json(x) FROM content.project_containers x WHERE x.id=(SELECT id FROM c)),
|
|
'projects', (SELECT coalesce(json_agg(row_to_json(x) ORDER BY x.sort),'[]') FROM content.projects x WHERE x.container_id=(SELECT id FROM c)),
|
|
'scenes', (SELECT coalesce(json_agg(row_to_json(x) ORDER BY x.sort),'[]') FROM content.scenes x WHERE x.project_id IN (SELECT id FROM content.projects WHERE container_id=(SELECT id FROM c))),
|
|
'content_elements', (SELECT coalesce(json_agg(row_to_json(x) ORDER BY x.position_in_container),'[]') FROM content.scene_content_elements x WHERE x.scene_id IN (SELECT s.id FROM content.scenes s JOIN content.projects p ON p.id=s.project_id WHERE p.container_id=(SELECT id FROM c))),
|
|
'shared_colors', (SELECT coalesce(json_agg(row_to_json(x) ORDER BY x.sort),'[]') FROM content.shared_colors x WHERE x.project_id IN (SELECT id FROM content.projects WHERE container_id=(SELECT id FROM c))),
|
|
'shared_layers', (SELECT coalesce(json_agg(row_to_json(x)),'[]') FROM content.shared_layers x WHERE x.project_id IN (SELECT id FROM content.projects WHERE container_id=(SELECT id FROM c))),
|
|
'categories', (SELECT coalesce(json_agg(row_to_json(x)),'[]') FROM content.categories x WHERE x.id IN (SELECT category_id FROM content.container_categories WHERE container_id=(SELECT id FROM c))),
|
|
'category_links', (SELECT coalesce(json_agg(json_build_object('category_slug',cat.slug,'sort',cc.sort)),'[]') FROM content.container_categories cc JOIN content.categories cat ON cat.id=cc.category_id WHERE cc.container_id=(SELECT id FROM c)),
|
|
'tags', (SELECT coalesce(json_agg(row_to_json(x)),'[]') FROM content.tags x WHERE x.id IN (SELECT tag_id FROM content.container_tags WHERE container_id=(SELECT id FROM c))),
|
|
'tag_links', (SELECT coalesce(json_agg(json_build_object('tag_slug',tg.slug)),'[]') FROM content.container_tags ct JOIN content.tags tg ON tg.id=ct.tag_id WHERE ct.container_id=(SELECT id FROM c))
|
|
);
|
|
"""
|
|
|
|
res = subprocess.run(PSQL + ["-t", "-A"], input=QUERY, capture_output=True, text=True, encoding="utf-8")
|
|
if res.returncode != 0:
|
|
print("psql error:\n" + res.stderr, file=sys.stderr); sys.exit(1)
|
|
raw = res.stdout.strip()
|
|
if not raw or raw == "":
|
|
print(f"no output from DB for slug '{SLUG}'", file=sys.stderr); sys.exit(1)
|
|
bundle = json.loads(raw)
|
|
if not bundle.get("container"):
|
|
print(f"template '{SLUG}' not found in source DB", file=sys.stderr); sys.exit(1)
|
|
|
|
# Collect asset filenames referenced anywhere in the bundle (template-media paths).
|
|
assets, externals = set(), set()
|
|
def scan(v):
|
|
if isinstance(v, str):
|
|
for m in re.findall(r"/?template-media/([^\"'\s]+)", v):
|
|
assets.add(m)
|
|
if re.match(r"^https?://", v) and "template-media" not in v:
|
|
externals.add(v)
|
|
elif isinstance(v, dict):
|
|
for x in v.values(): scan(x)
|
|
elif isinstance(v, list):
|
|
for x in v: scan(x)
|
|
scan(bundle)
|
|
|
|
os.makedirs(os.path.join(OUT, "assets"), exist_ok=True)
|
|
copied, missing = [], []
|
|
for a in sorted(assets):
|
|
src = os.path.join(PUBLIC, a)
|
|
if os.path.isfile(src):
|
|
shutil.copy2(src, os.path.join(OUT, "assets", os.path.basename(a)))
|
|
copied.append(a)
|
|
else:
|
|
missing.append(a)
|
|
bundle["assets"] = sorted(os.path.basename(a) for a in copied)
|
|
|
|
with open(os.path.join(OUT, "template.json"), "w", encoding="utf-8") as f:
|
|
json.dump(bundle, f, ensure_ascii=False, indent=2)
|
|
|
|
n_proj = len(bundle.get("projects") or [])
|
|
n_scene = len(bundle.get("scenes") or [])
|
|
n_el = len(bundle.get("content_elements") or [])
|
|
print(f"✓ exported '{SLUG}' → {OUT}")
|
|
print(f" {n_proj} projects · {n_scene} scenes · {n_el} fields · {len(copied)} assets · "
|
|
f"{len(bundle.get('categories') or [])} categories · {len(bundle.get('tags') or [])} tags")
|
|
if missing:
|
|
print(f" ⚠ {len(missing)} asset(s) referenced but not found in {PUBLIC}: {', '.join(missing[:5])}")
|
|
if externals:
|
|
print(f" ⚠ {len(externals)} external URL asset(s) NOT bundled (host them on the target): {list(externals)[:3]}")
|