Files
flatrender/scripts/export_template.py
T
soroush.asadi 21b6a30f08 feat(scripts): portable template import/export (bundles)
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>
2026-06-25 10:09:41 +03:30

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