Files
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

456 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,
} 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 입력/선택 (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-table-list': 'table',
'table-list': 'table',
'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<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';
// ★ 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<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 재적용
* - 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 };
}