/** * v1 → v2 템플릿 저장 포맷 마이그레이션 * ============================================================================ * * DB 의 TEMPLATES.VIEWS jsonb 를 v2 스키마 (% 좌표 + role + responsivePolicy) * 로 변환한다. 런타임에 로드 시 version 필드 확인 후 v1 이면 즉석 변환. * * role / responsivePolicy 추론은 마이그레이션 1회 규칙이다. 일단 현재 렌더 * 결과와 비슷하게 나오도록 좌표 기반 추론을 사용하되, v2 포맷으로 저장된 * 이후엔 디자이너가 명시 값으로 덮어쓰는 것을 전제로 한다. * * 설계 문서: notes/gbpark/2026-04-20-template-storage-model-v2.md * ============================================================================ */ import type { BlockV2, CanvasV2, ResponsivePolicy, TemplateViewsV2, ViewV2, } from '@/types/invyone-component'; // ───────────────────────────────────────────────────────────────────────────── // 1. v1 파싱 헬퍼 // ───────────────────────────────────────────────────────────────────────────── /** v1 저장본의 다양한 legacy componentId 를 통합 ID 로 정규화. */ const LEGACY_TO_UNIFIED: Record = { 'v2-divider-line': 'divider', 'divider-line': 'divider', 'v2-split-line': 'divider', 'v2-text-display': 'title', 'text-display': 'title', 'v2-button-primary': 'button', 'button-primary': 'button', 'v2-table-search-widget': 'search', 'table-search-widget': 'search', // V2 입력/선택 (Phase D.2) · 기본 입력 6종 (Phase E) · radio-basic/toggle-switch // (Phase F.1) 폐기 — runtime alias 모두 제거. 'v2-aggregation-widget': 'stats', 'aggregation-widget': 'stats', 'v2-status-count': 'stats', 'v2-tabs-widget': 'container', 'tabs-widget': 'container', 'tabs': 'container', 'v2-tabs': 'container', 'v2-section-card': 'container', 'v2-section-paper': 'container', 'v2-repeat-container': 'container', 'v2-repeater': 'container', 'section-card': 'container', 'section-paper': 'container', 'accordion-basic': 'container', }; const FULL_WIDTH_IDS = new Set([ 'table', 'container', 'search', 'accordion', 'tabs', 'split-panel', // ★ 2026-05-18 canonical data-view 추가 'grouped-table', ]); const MAIN_CONTENT_IDS = new Set([ ...FULL_WIDTH_IDS, 'stats', 'form', 'title', // ★ 2026-05-18 canonical data-view 추가 'chart', 'card-list', ]); function extractIdFromUrl(url?: unknown): string { if (typeof url !== 'string' || !url) return ''; const parts = url.split('/').filter(Boolean); return parts[parts.length - 1] ?? ''; } function extractComponents(viewData: unknown): any[] { if (!viewData) return []; if (Array.isArray(viewData)) return viewData; if (typeof viewData === 'object' && viewData !== null) { const v = viewData as any; if (Array.isArray(v.components)) return v.components; if (Array.isArray(v.blocks)) return v.blocks; } return []; } function unifiedComponentId(c: any): string { const raw = String( c?.componentId ?? c?.componentType ?? c?.type ?? extractIdFromUrl(c?.url) ?? c?.overrides?.type ?? '', ); return LEGACY_TO_UNIFIED[raw] ?? raw; } function readPos(c: any): { left: number; top: number; width: number; height: number; } { const p = c?.position ?? {}; const s = c?.size ?? {}; return { left: Number(p.left ?? p.x ?? 0) || 0, top: Number(p.top ?? p.y ?? 0) || 0, width: Number(p.width ?? s.width ?? 200) || 200, height: Number(p.height ?? s.height ?? 100) || 100, }; } function readConfig(c: any): Record { const a = c?.config; if (a && typeof a === 'object' && !Array.isArray(a)) return a; const b = c?.componentConfig; if (b && typeof b === 'object' && !Array.isArray(b)) return b; const d = c?.overrides; if (d && typeof d === 'object' && !Array.isArray(d)) return d; return {}; } // ───────────────────────────────────────────────────────────────────────────── // 2. role / policy 추론 (마이그레이션 1회용) // ───────────────────────────────────────────────────────────────────────────── interface MainBbox { top: number; bottom: number; left: number; right: number; } /** * 레거시 컴포넌트의 role 을 추론. * - button 은 항상 action: 원본 좌표가 main band 내부에 걸쳐있어도 * "action bar" 의미로 취급한다. 이렇게 해야 TemplateRenderer 의 * content-size 정책이 버튼에 일관되게 적용된다. * - input 은 좌표 기반 추정: main band 내부면 companion (필터 인풋 등), * 바깥이면 action. * - divider 는 companion. */ function inferRole(cid: string, pos: { top: number; height: number; }, mains: MainBbox[]): BlockRole { if (MAIN_CONTENT_IDS.has(cid)) return 'main'; if (cid === 'divider') return 'companion'; if (cid === 'button') return 'action'; if (cid === 'input') { const centerY = pos.top + pos.height / 2; for (const m of mains) { if (centerY >= m.top && centerY <= m.bottom) return 'companion'; } return 'action'; } return 'main'; } function inferPolicy(cid: string): ResponsivePolicy { if (FULL_WIDTH_IDS.has(cid)) return 'scroll'; // ★ 2026-05-18 chart / card-list 는 reflow (작은 카드/그리드 모드도 자연스러움) if (cid === 'stats' || cid === 'form' || cid === 'chart' || cid === 'card-list') return 'reflow'; if ( cid === 'button' || cid === 'input' || cid === 'title' || cid === 'divider' ) { return 'fixed'; } return 'fixed'; } // ───────────────────────────────────────────────────────────────────────────── // 3. 뷰 변환 // ───────────────────────────────────────────────────────────────────────────── interface BandBbox { id: string; top: number; bottom: number; } function migrateView( raw: unknown, canvas: { width: number; height: number }, ): ViewV2 { const rawBlocks = (extractComponents(raw) as any[]).filter( (c) => c && typeof c === 'object', ); // 1차: 정규화 정보 수집 interface Prelim { raw: any; i: number; cid: string; pos: ReturnType; config: Record; role: BlockRole; } const mains: MainBbox[] = []; const prelim: Prelim[] = rawBlocks.map((c, i) => { const cid = unifiedComponentId(c); const pos = readPos(c); const config = readConfig(c); // role 은 main 수집 후에 확정해야 companion 판정 가능 return { raw: c, i, cid, pos, config, role: 'main' as BlockRole }; }); // main bbox 수집 (role 추론 재료) for (const p of prelim) { if (MAIN_CONTENT_IDS.has(p.cid)) { mains.push({ top: p.pos.top, bottom: p.pos.top + p.pos.height, left: p.pos.left, right: p.pos.left + p.pos.width, }); } } // role 확정 for (const p of prelim) { p.role = inferRole(p.cid, p.pos, mains); } // 2차: main 블록 y-overlap clustering → bandId 생성 // (normalize 단계의 1회 추론. 런타임에서는 이 bandId 를 그대로 사용) const mainPrelims = prelim .filter((p) => p.role === 'main') .sort((a, b) => a.pos.top - b.pos.top); const bandIdOf = new Map(); const bandBboxes: BandBbox[] = []; let bandIdx = 0; for (const m of mainPrelims) { const myTop = m.pos.top; const myBot = m.pos.top + m.pos.height; // 기존 band 중 세로 overlap 이 충분한 것 찾기 (작은 쪽 h 의 30% 이상) let matched: BandBbox | null = null; for (const bb of bandBboxes) { const overlap = Math.min(bb.bottom, myBot) - Math.max(bb.top, myTop); const smallerH = Math.min(bb.bottom - bb.top, myBot - myTop); if (smallerH > 0 && overlap / smallerH > 0.3) { matched = bb; break; } } if (!matched) { matched = { id: `band-${bandIdx++}`, top: myTop, bottom: myBot }; bandBboxes.push(matched); } else { matched.top = Math.min(matched.top, myTop); matched.bottom = Math.max(matched.bottom, myBot); } bandIdOf.set(m, matched.id); } // 3차: companion/action/overlay 는 가장 가까운 main band 에 합류 // - centerY 가 band 내부면 그 band // - 아니면 가장 가까운 (top/bottom 거리 최소) band // - main band 가 전혀 없으면 bandId 비워둠 (런타임 fallback 에 위임) for (const p of prelim) { if (bandIdOf.has(p)) continue; if (bandBboxes.length === 0) continue; const centerY = p.pos.top + p.pos.height / 2; let target: BandBbox | null = null; let minDist = Infinity; for (const bb of bandBboxes) { if (centerY >= bb.top && centerY <= bb.bottom) { target = bb; break; } const dist = Math.min( Math.abs(centerY - bb.top), Math.abs(centerY - bb.bottom), ); if (dist < minDist) { minDist = dist; target = bb; } } if (target) bandIdOf.set(p, target.id); } // 4차: BlockV2 생성 const blocks: BlockV2[] = prelim.map((p) => { const bandId = bandIdOf.get(p); return { id: String(p.raw.id ?? `blk_${p.i}`), componentId: p.cid, xPct: canvas.width > 0 ? p.pos.left / canvas.width : 0, yPct: canvas.height > 0 ? p.pos.top / canvas.height : 0, wPct: canvas.width > 0 ? p.pos.width / canvas.width : 0, hPct: canvas.height > 0 ? p.pos.height / canvas.height : 0, role: p.role, responsivePolicy: inferPolicy(p.cid), ...(bandId ? { bandId } : {}), config: p.config, }; }); return { blocks }; } // ───────────────────────────────────────────────────────────────────────────── // 4. Public API // ───────────────────────────────────────────────────────────────────────────── export function isV2Views(views: unknown): views is TemplateViewsV2 { return ( !!views && typeof views === 'object' && (views as any).version === 2 ); } /** * 런타임 정규화. * 이미 v2 포맷으로 저장된 stale 데이터가 최신 규칙을 반영하지 못해 화면이 * 틀어지는 문제 방지. 현재는 한 단계로 동작: * * [Stage 1] componentId / responsivePolicy 보정 * - LEGACY_TO_UNIFIED 로 componentId 를 통합 ID 로 교체 * - unified ID 기준으로 inferPolicy 재적용 * - role / bandId 는 사용자가 저장한 값을 그대로 보존 */ function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 { const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => { if (!view) return view; // Stage 1: componentId / policy 보정. role / bandId 는 그대로 보존. const stage1: BlockV2[] = view.blocks.map((b) => { const unified = LEGACY_TO_UNIFIED[b.componentId] ?? b.componentId; const idChanged = unified !== b.componentId; const nextPolicy = inferPolicy(unified); const policyChanged = b.responsivePolicy !== nextPolicy; if (!idChanged && !policyChanged) return b; const { bandId: _drop, ...rest } = b; return { ...rest, componentId: unified, responsivePolicy: nextPolicy, }; }); return { ...view, blocks: stage1 }; }; return { ...v, list: patchView(v.list)!, ...(v.create ? { create: patchView(v.create)! } : {}), ...(v.edit ? { edit: patchView(v.edit)! } : {}), }; } export function migrateV1toV2(v1Views: unknown): TemplateViewsV2 { const root = (v1Views ?? {}) as any; const sr = root.screenResolution ?? root.designSize ?? {}; const defaultCanvas: CanvasV2 = { baseWidth: Number(sr.width) || 1920, baseHeight: Number(sr.height) || 1080, aspectPolicy: 'preserve', }; // 뷰별 해상도 파싱 — 빌더(ScreenDesigner)가 저장한 viewScreenResolutions // (JSON 키는 screenResolutions). 없는 뷰는 defaultCanvas 로 폴백. const srs = root.screenResolutions ?? root.screen_resolutions; const makeCanvas = (v: any): CanvasV2 | undefined => { if (!v) return undefined; const w = Number(v.width); const h = Number(v.height); if (!isFinite(w) || !isFinite(h) || w <= 0 || h <= 0) return undefined; return { baseWidth: w, baseHeight: h, aspectPolicy: 'preserve' }; }; const listCanvas = makeCanvas(srs?.list) ?? defaultCanvas; const createCanvas = makeCanvas(srs?.create) ?? defaultCanvas; const editCanvas = makeCanvas(srs?.edit) ?? defaultCanvas; // 각 뷰의 컴포넌트 좌표는 자기 canvas 기준으로 정규화. // (빌더가 activeView 별 screenResolution 으로 px 를 저장하므로, 그 기준으로 나눠야 정확.) const list = migrateView(root.list, { width: listCanvas.baseWidth, height: listCanvas.baseHeight, }); const create = root.create ? migrateView(root.create, { width: createCanvas.baseWidth, height: createCanvas.baseHeight, }) : undefined; const edit = root.edit ? migrateView(root.edit, { width: editCanvas.baseWidth, height: editCanvas.baseHeight, }) : undefined; const viewCanvases: TemplateViewsV2['viewCanvases'] = { ...(makeCanvas(srs?.list) ? { list: listCanvas } : {}), ...(makeCanvas(srs?.create) ? { create: createCanvas } : {}), ...(makeCanvas(srs?.edit) ? { edit: editCanvas } : {}), }; return { version: 2, canvas: defaultCanvas, list, ...(create ? { create } : {}), ...(edit ? { edit } : {}), ...(viewCanvases && Object.keys(viewCanvases).length > 0 ? { viewCanvases } : {}), }; } /** * 로드 시점 정규화 헬퍼 — v1 이든 v2 이든 받아서 항상 v2 로 반환. * 기존 v2 저장본이라도 현재 role 규칙(button→action 등)에 맞춰 재정규화한다. */ export function ensureV2Views(views: unknown): TemplateViewsV2 { const v2 = isV2Views(views) ? views : migrateV1toV2(views); return normalizeRoles(v2); } /** * template 객체의 views 필드를 v2 로 정규화한 사본을 반환. * template 자체는 변형하지 않는다. */ export function ensureTemplateV2(template: any): any { if (!template) return template; const views = template.views ?? template.VIEWS; if (!views) return template; const v2 = ensureV2Views(views); return { ...template, views: v2 }; }