454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
/**
|
|
* 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,
|
|
BlockRole,
|
|
} from '@/types/invyone-component';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 1. v1 파싱 헬퍼
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** v1 저장본의 다양한 legacy componentId 를 통합 ID 로 정규화. */
|
|
const LEGACY_TO_UNIFIED: Record<string, string> = {
|
|
'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-input': 'input',
|
|
'v2-select': 'input',
|
|
'v2-date': 'input',
|
|
'text-input': 'input',
|
|
'number-input': 'input',
|
|
'date-input': 'input',
|
|
'select-basic': 'input',
|
|
'checkbox-basic': 'input',
|
|
'textarea-basic': 'input',
|
|
'slider-basic': 'input',
|
|
'radio-basic': 'input',
|
|
'toggle-switch': 'input',
|
|
'v2-aggregation-widget': 'stats',
|
|
'aggregation-widget': 'stats',
|
|
'v2-status-count': 'stats',
|
|
'v2-card-display': 'stats',
|
|
'card-display': 'stats',
|
|
'v2-table-list': 'table',
|
|
'table-list': 'table',
|
|
'v2-table-grouped': 'table',
|
|
'v2-pivot-grid': 'table',
|
|
'v2-tabs-widget': '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',
|
|
]);
|
|
|
|
const MAIN_CONTENT_IDS = new Set([
|
|
...FULL_WIDTH_IDS,
|
|
'stats',
|
|
'form',
|
|
'title',
|
|
]);
|
|
|
|
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<string, any> {
|
|
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';
|
|
if (cid === 'stats' || cid === 'form') 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<typeof readPos>;
|
|
config: Record<string, any>;
|
|
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<Prelim, string>();
|
|
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 재적용
|
|
*
|
|
* [Stage 2] button/input role 좌표 기반 재분류 (view 단위)
|
|
* - main 블록의 yPct 범위를 모은 뒤
|
|
* - button/input 의 centerY 가 어떤 main 범위 안이면 companion
|
|
* (= 같은 가로 라인에 배치), 밖이면 action (= 별도 액션 라인)
|
|
* - 디자이너가 명시 main/overlay 로 둔 경우는 그대로 보존
|
|
*
|
|
* 분류가 바뀌면 bandId 는 리셋 → 런타임 buildBands fallback 에 위임.
|
|
*/
|
|
const COORD_DEPENDENT_ROLES = new Set(['button', 'input']);
|
|
|
|
function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
|
|
const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => {
|
|
if (!view) return view;
|
|
|
|
// Stage 1: componentId / policy 보정. role 은 일단 보존.
|
|
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,
|
|
};
|
|
});
|
|
|
|
// Stage 2: 좌표 기반 role 재분류 (button/input 만).
|
|
// main 블록 y 범위 수집 — 디자이너가 명시한 role='main' 을 신뢰.
|
|
const mainRanges = stage1
|
|
.filter((b) => b.role === 'main')
|
|
.map((b) => ({ top: b.yPct, bottom: b.yPct + b.hPct }));
|
|
|
|
const stage2: BlockV2[] = stage1.map((b) => {
|
|
if (!COORD_DEPENDENT_ROLES.has(b.componentId)) return b;
|
|
if (b.role === 'main' || b.role === 'overlay') return b;
|
|
const centerY = b.yPct + b.hPct / 2;
|
|
const inMain = mainRanges.some(
|
|
(r) => centerY >= r.top && centerY <= r.bottom,
|
|
);
|
|
const nextRole: BlockRole = inMain ? 'companion' : 'action';
|
|
if (b.role === nextRole) return b;
|
|
const { bandId: _drop, ...rest } = b;
|
|
return { ...rest, role: nextRole };
|
|
});
|
|
|
|
return { ...view, blocks: stage2 };
|
|
};
|
|
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 canvas: CanvasV2 = {
|
|
baseWidth: Number(sr.width) || 1920,
|
|
baseHeight: Number(sr.height) || 1080,
|
|
aspectPolicy: 'preserve',
|
|
};
|
|
const intCanvas = { width: canvas.baseWidth, height: canvas.baseHeight };
|
|
|
|
const list = migrateView(root.list, intCanvas);
|
|
const create = root.create ? migrateView(root.create, intCanvas) : undefined;
|
|
const edit = root.edit ? migrateView(root.edit, intCanvas) : undefined;
|
|
|
|
return {
|
|
version: 2,
|
|
canvas,
|
|
list,
|
|
...(create ? { create } : {}),
|
|
...(edit ? { edit } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 로드 시점 정규화 헬퍼 — 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 };
|
|
}
|