#!/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 [out_dir] # source DB override (default = local docker): PSQL="psql 'postgresql://user:pass@host:5432/db'" python scripts/export_template.py 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 [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]}")