Something went wrong. Try again.
your personal website on atproto - mirror blento.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277/** * v1 → v2 migration. Reads v1 records (app.blento.card + app.blento.section + publication) and * produces v2 nodes + a page record. See plan §2 (mapping) and §10 (scar tissue to subsume). * * section -> container node (id preserved; index -> rank) * card -> leaf node (sectionId -> parent; coords + rotation -> layout; color -> style) * no sections -> synthesize a default `grid` container (cf. v1 ensureSections) * * Subsumes v1's own card migration ladder (version<1 doubled coords; version<2 defaulted page). */import * as TID from '@atcute/tid';import { generateNKeysBetween } from 'fractional-indexing';import { CARD_CONTENT, CONTAINER_CONTENT, GRID_CELL_LAYOUT, type Node } from './node.js';import type { PageRecord, StyleTokens } from './records.js';
export interface V1Card { id: string; cardType: string; sectionId?: string; x: number; y: number; w: number; h: number; mobileX: number; mobileY: number; mobileW: number; mobileH: number; color?: string; rotation?: number; cardData: unknown; page?: string; version?: number;}
export interface V1Section { id: string; sectionType: string; page: string; index: number; sectionData: Record<string, unknown>;}
export interface V1Publication { url?: string; name?: string; description?: string; preferences?: { accentColor?: string; baseColor?: string; hideProfileSection?: boolean; profilePosition?: 'side' | 'top'; editedOn?: number; layoutMode?: string; };}
export interface MigrateOptions { /** Id generator for synthesized containers. Defaults to a TID; tests can inject a stable one. */ genId?: () => string; /** * Leaf ordering within a container. * - 'document' (default): sort by (y, x, id) — canonical order for a fresh migration / write. * - 'input': preserve the caller's array order — used on the read path so DOM order matches prod. */ order?: 'document' | 'input';}
/** Apply v1's own card migration ladder so we ingest normalized coordinates. */function normalizeV1Card(input: V1Card): V1Card { const c = { ...input }; if (!c.version) { c.x *= 2; c.y *= 2; c.w *= 2; c.h *= 2; c.mobileX *= 2; c.mobileY *= 2; c.mobileW *= 2; c.mobileH *= 2; c.version = 1; } if (!c.version || c.version < 2) { c.page ??= 'blento.self'; c.version = 2; } return c;}
/** * Structural transform: v1 sections + cards -> node graph. Does NOT run the coordinate ladder, so * coordinates pass through verbatim — callers reading already-normalized records (the live load * path) get the same geometry as prod. `migrateV1` layers the ladder on top for raw ingestion. */export function buildGraph( sections: V1Section[], cards: V1Card[], page: string, opts: MigrateOptions = {}): Node[] { const genId = opts.genId ?? (() => TID.now()); const nodes: Node[] = [];
// 1. Sections -> container nodes, ordered by index. Synthesize a grid container if none exist. let sectionList = [...sections].sort((a, b) => a.index - b.index); if (sectionList.length === 0) { sectionList = [{ id: genId(), sectionType: 'grid', page, index: 0, sectionData: {} }]; } const sectionIds = new Set(sectionList.map((s) => s.id)); const sectionRanks = generateNKeysBetween(null, null, sectionList.length); sectionList.forEach((s, i) => { nodes.push({ id: s.id, kind: 'container', parent: null, rank: sectionRanks[i], page, // Generic container fallback: containerType discriminates; sectionData spread as fields. content: { ...(s.sectionData ?? {}), $type: CONTAINER_CONTENT, containerType: s.sectionType }, version: 1 }); });
// Orphan cards (sectionId missing or dangling) go to the first grid container, else the first. const defaultContainerId = sectionList.find((s) => s.sectionType === 'grid')?.id ?? sectionList[0].id;
// 2. Cards -> leaf nodes, grouped by parent container. const byParent = new Map<string, V1Card[]>(); for (const c of cards) { const parent = c.sectionId && sectionIds.has(c.sectionId) ? c.sectionId : defaultContainerId; let group = byParent.get(parent); if (!group) { group = []; byParent.set(parent, group); } group.push(c); }
for (const [parent, group] of byParent) { if ((opts.order ?? 'document') === 'document') { // Deterministic document order within a grid: by (y, x), then id as a tiebreaker. group.sort((a, b) => a.y - b.y || a.x - b.x || a.id.localeCompare(b.id)); } const ranks = generateNKeysBetween(null, null, group.length); group.forEach((c, i) => { const cardData = c.cardData && typeof c.cardData === 'object' ? (c.cardData as Record<string, unknown>) : {}; const node: Node = { id: c.id, kind: 'leaf', parent, rank: ranks[i], page, // Generic card fallback: cardType discriminates; cardData spread as fields. content: { ...cardData, $type: CARD_CONTENT, cardType: c.cardType }, layout: { $type: GRID_CELL_LAYOUT, x: c.x, y: c.y, w: c.w, h: c.h, mobileX: c.mobileX, mobileY: c.mobileY, mobileW: c.mobileW, mobileH: c.mobileH, ...(c.rotation ? { rotation: c.rotation } : {}) }, version: 1 }; if (c.color) node.style = { tokens: { color: c.color } }; nodes.push(node); }); }
return nodes;}
/** * Full v1 -> node migration for INGESTING raw records (one-time / migrate-on-save): runs v1's * coordinate ladder, then builds the graph in canonical document order. */export function migrateV1( sections: V1Section[], cards: V1Card[], page: string, opts: MigrateOptions = {}): Node[] { return buildGraph(sections, cards.map(normalizeV1Card), page, opts);}
/** A v1-shaped container (section), reconstructed from a node for the existing render/editor. */export interface LegacySection extends V1Section { name?: string; version?: number;}
const byRank = (a: Node, b: Node) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0);
/** * Inverse of `buildGraph`: node graph -> v1-shaped { sections, cards } so v1's existing render and * editor keep working while storage moves to nodes. Containers -> sections (rank order -> index), * leaves -> cards (layout -> coords, style.color -> color, parent -> sectionId). */export function nodesToItems(nodes: Node[]): { sections: LegacySection[]; cards: V1Card[] } { const num = (v: unknown) => (typeof v === 'number' ? v : 0);
const sections: LegacySection[] = nodes .filter((n) => n.kind === 'container') .sort(byRank) .map((c, i) => { const sectionData = { ...c.content } as Record<string, unknown>; const containerType = sectionData.containerType as string | undefined; delete sectionData.$type; delete sectionData.containerType; return { id: c.id, sectionType: containerType ?? 'grid', page: c.page, index: i, sectionData, version: c.version }; });
const cards: V1Card[] = nodes .filter((n) => n.kind === 'leaf') .sort(byRank) .map((n) => { const cardData = { ...n.content } as Record<string, unknown>; const cardType = cardData.cardType as string | undefined; delete cardData.$type; delete cardData.cardType; const l = (n.layout ?? {}) as Record<string, unknown>; const card: V1Card = { id: n.id, cardType: cardType ?? 'text', cardData, x: num(l.x), y: num(l.y), w: num(l.w), h: num(l.h), mobileX: num(l.mobileX), mobileY: num(l.mobileY), mobileW: num(l.mobileW), mobileH: num(l.mobileH), sectionId: n.parent ?? undefined, page: n.page, version: n.version }; if (typeof l.rotation === 'number') card.rotation = l.rotation; const color = n.style?.tokens?.color; if (color) card.color = color; return card; });
return { sections, cards };}
/** * Migrate a v1 publication into a v2 page record. Theme colors map to semantic token roles; * profile/layout prefs (hideProfileSection, profilePosition, editedOn) are handled when the profile * container is built in Phase 5 — preserved on the caller side until then. */export function migratePage(pub: V1Publication | undefined): PageRecord { const prefs = pub?.preferences ?? {}; const colors: Record<string, string> = {}; if (prefs.accentColor) colors.accent = prefs.accentColor; if (prefs.baseColor) colors.base = prefs.baseColor; const style: StyleTokens | undefined = Object.keys(colors).length ? { colors } : undefined;
const record: PageRecord = { $type: 'app.blento.page', version: 1 }; if (pub?.name) record.name = pub.name; if (pub?.description) record.description = pub.description; if (style) record.style = style; return record;}