1
17
18export interface SnapshotPage {
19 title?: string | null;
20 icon?: string | null;
21 description?: string | null;
22 [key: string]: unknown;
23}
24
25export interface SnapshotBlock {
26 id: string;
27 type: string;
28 content: unknown;
29 properties: unknown;
30 parentId: string | null;
31 position: string;
32}
33
34export interface PageSnapshot {
35 page?: SnapshotPage | null;
36 blocks: SnapshotBlock[];
37}
38
39export interface ModifiedBlock {
40 id: string;
41
42 fields: string[];
43}
44
45export interface VersionDiff {
46 from: number;
47 to: number;
48 page: Record<string, { from: unknown; to: unknown }>;
49 added: SnapshotBlock[];
50 deleted: SnapshotBlock[];
51 modified: ModifiedBlock[];
52
53 moved: Array<{ id: string; from: string; to: string }>;
54 summary: { added: number; deleted: number; modified: number; moved: number; unchanged: number };
55}
56
57function canonical(v: unknown): string {
58
59
60 return JSON.stringify(sortKeys(v));
61}
62
63function sortKeys(v: unknown): unknown {
64 if (Array.isArray(v)) return v.map(sortKeys);
65 if (v && typeof v === "object") {
66 const out: Record<string, unknown> = {};
67 for (const k of Object.keys(v as Record<string, unknown>).sort()) out[k] = sortKeys((v as Record<string, unknown>)[k]);
68 return out;
69 }
70 return v;
71}
72
73function blockById(blocks: SnapshotBlock[]): Map<string, SnapshotBlock> {
74 const m = new Map<string, SnapshotBlock>();
75 for (const b of blocks) m.set(b.id, b);
76 return m;
77}
78
79
83export function diffSnapshots(from: PageSnapshot, to: PageSnapshot, fromVersion: number, toVersion: number): VersionDiff {
84 const fromBlocks = blockById(from.blocks ?? []);
85 const toBlocks = blockById(to.blocks ?? []);
86
87 const added: SnapshotBlock[] = [];
88 const deleted: SnapshotBlock[] = [];
89 const modified: ModifiedBlock[] = [];
90 const moved: VersionDiff["moved"] = [];
91 let unchanged = 0;
92
93 for (const [id, b] of toBlocks) {
94 const prev = fromBlocks.get(id);
95 if (!prev) {
96 added.push(b);
97 continue;
98 }
99 const fields: string[] = [];
100 if (prev.type !== b.type) fields.push("type");
101 if (canonical(prev.content) !== canonical(b.content)) fields.push("content");
102 if (canonical(prev.properties) !== canonical(b.properties)) fields.push("properties");
103 if (prev.parentId !== b.parentId) fields.push("parentId");
104 if (fields.length > 0) modified.push({ id, fields });
105 else if (prev.position !== b.position) moved.push({ id, from: prev.position, to: b.position });
106 else unchanged++;
107 }
108 for (const [id, b] of fromBlocks) {
109 if (!toBlocks.has(id)) deleted.push(b);
110 }
111
112
113
114 const byPos = (a: SnapshotBlock, b: SnapshotBlock) => (a.position < b.position ? -1 : a.position > b.position ? 1 : a.id.localeCompare(b.id));
115 added.sort(byPos);
116 deleted.sort(byPos);
117 moved.sort((a, b) => a.id.localeCompare(b.id));
118 modified.sort((a, b) => a.id.localeCompare(b.id));
119
120 const pageFields = ["title", "icon", "description"] as const;
121 const page: VersionDiff["page"] = {};
122 for (const f of pageFields) {
123 const a = (from.page as Record<string, unknown> | null | undefined)?.[f] ?? null;
124 const b = (to.page as Record<string, unknown> | null | undefined)?.[f] ?? null;
125 if (canonical(a) !== canonical(b)) page[f] = { from: a, to: b };
126 }
127
128 return {
129 from: fromVersion,
130 to: toVersion,
131 page,
132 added,
133 deleted,
134 modified,
135 moved,
136 summary: { added: added.length, deleted: deleted.length, modified: modified.length, moved: moved.length, unchanged }
137 };
138}
139