Files
flatrender/scripts/import_template.py
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

87 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Import a template bundle (from export_template.py) into ANY FlatRender DB.
Emits idempotent SQL to STDOUT — pipe it to the target's psql. It replaces any
existing template with the same slug (one cascading delete), recreates the rows
verbatim (original UUIDs, so FKs stay intact), and merges categories & tags BY SLUG
(so they line up with whatever the target DB already calls them).
Usage:
python scripts/import_template.py <bundle_dir> | <target-psql>
# e.g. local:
python scripts/import_template.py dist/template-bundles/fr-instagram-promo \\
| docker exec -i fr2-postgres psql -U postgres -d flatrender
# e.g. live:
python scripts/import_template.py dist/template-bundles/fr-instagram-promo \\
| psql "postgresql://user:pass@live-host:5432/flatrender"
Assets: copy the bundle's assets/ into the target's template-media (the SQL only
moves DB rows). Pass --assets-to <dir> to copy them locally; for a remote box use
`docker cp` / `mc cp` (printed below).
"""
import os, sys, json, shutil
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print("usage: import_template.py <bundle_dir> [--assets-to <dir>] | <target-psql>", file=sys.stderr); sys.exit(1)
BUNDLE = args[0]
ASSETS_TO = None
if "--assets-to" in sys.argv:
ASSETS_TO = sys.argv[sys.argv.index("--assets-to") + 1]
with open(os.path.join(BUNDLE, "template.json"), encoding="utf-8") as f:
b = json.load(f)
SLUG = b["slug"]
def qv(v):
if v is None: return "NULL"
if isinstance(v, bool): return "TRUE" if v else "FALSE"
if isinstance(v, (int, float)): return str(v)
if isinstance(v, (dict, list)): return "'" + json.dumps(v, ensure_ascii=False).replace("'", "''") + "'"
return "'" + str(v).replace("'", "''") + "'"
def ins(table, row, conflict=None):
cols = list(row.keys())
s = f"INSERT INTO content.{table} ({','.join(cols)}) VALUES ({','.join(qv(row[c]) for c in cols)})"
if conflict: s += f" ON CONFLICT ({conflict}) DO NOTHING"
return s + ";"
out = ["BEGIN;"]
# categories & tags first (merge by slug — never clobber the target's own)
for cat in b.get("categories") or []: out.append(ins("categories", cat, "slug"))
for tg in b.get("tags") or []: out.append(ins("tags", tg, "slug"))
# replace any existing template with this slug (cascades to all children)
out.append(f"DELETE FROM content.project_containers WHERE slug={qv(SLUG)};")
# rows verbatim (original UUIDs keep the FK tree intact)
out.append(ins("project_containers", b["container"]))
for p in b.get("projects") or []: out.append(ins("projects", p))
for s in b.get("scenes") or []: out.append(ins("scenes", s))
for e in b.get("content_elements") or []: out.append(ins("scene_content_elements", e))
for sc in b.get("shared_colors") or []: out.append(ins("shared_colors", sc))
for sl in b.get("shared_layers") or []: out.append(ins("shared_layers", sl))
# links resolve the category/tag id BY SLUG in the target DB
cid = b["container"]["id"]
for link in b.get("category_links") or []:
out.append(f"INSERT INTO content.container_categories (container_id,category_id,sort) "
f"SELECT {qv(cid)}, id, {qv(link.get('sort', 0))} FROM content.categories WHERE slug={qv(link['category_slug'])} ON CONFLICT DO NOTHING;")
for link in b.get("tag_links") or []:
out.append(f"INSERT INTO content.container_tags (container_id,tag_id) "
f"SELECT {qv(cid)}, id FROM content.tags WHERE slug={qv(link['tag_slug'])} ON CONFLICT DO NOTHING;")
out.append("COMMIT;")
out.append(f"SELECT slug, name, primary_mode FROM content.project_containers WHERE slug={qv(SLUG)};")
print("\n".join(out))
# Assets: copy locally if asked; otherwise print placement guidance to stderr.
assets = b.get("assets") or []
if ASSETS_TO and assets:
os.makedirs(ASSETS_TO, exist_ok=True)
for a in assets:
src = os.path.join(BUNDLE, "assets", a)
if os.path.isfile(src): shutil.copy2(src, os.path.join(ASSETS_TO, a))
sys.stderr.write(f"-- copied {len(assets)} asset(s) → {ASSETS_TO}\n")
elif assets:
sys.stderr.write(f"\n-- {len(assets)} asset(s) in {BUNDLE}/assets/ — place them in the target's template-media, e.g.:\n")
sys.stderr.write(f"-- local docker: for f in {BUNDLE}/assets/*; do docker cp \"$f\" fr2-frontend:/app/public/template-media/; done\n")
sys.stderr.write(f"-- live (MinIO): mc cp --recursive {BUNDLE}/assets/ myminio/<bucket>/template-media/\n")
sys.stderr.write(f"-- or just copy {BUNDLE}/assets/* into the live app's public/template-media/\n")