Markdown -> Semble importer
1import microdiff from "microdiff";
2import type {AtprotoDraftRecords} from "./record-builder";
3
4export interface RepoRecordEntry {
5 uri: string;
6 cid?: string;
7 value: unknown;
8}
9
10export interface RepoRecordGroups {
11 cards: RepoRecordEntry[];
12 collections: RepoRecordEntry[];
13 collectionLinks: RepoRecordEntry[];
14 notes: RepoRecordEntry[];
15}
16
17export interface SembleRepoDiff {
18 missingInRepo: string[];
19 extraInRepo: string[];
20 changed: Array<{
21 uri: string;
22 changes: ReturnType<typeof microdiff>;
23 }>;
24}
25
26export interface DiffOptions {
27 ignoredFields?: string[];
28}
29
30type RecordKind = "card" | "collection" | "collectionLink" | "note";
31
32export function diffSembleRecords(
33 existing: RepoRecordGroups,
34 drafts: AtprotoDraftRecords,
35 options: DiffOptions = {ignoredFields: ["createdAt"]}
36): SembleRepoDiff {
37 const ignored = new Set(options.ignoredFields ?? []);
38
39 const repoCanonical = canonicalizeRecords(existing, ignored);
40 const draftCanonical = canonicalizeRecords(normalizeDraftRecords(drafts), ignored);
41
42 const repoMap = new Map(repoCanonical.map(entry => [entry.key, entry]));
43 const draftMap = new Map(draftCanonical.map(entry => [entry.key, entry]));
44
45 const missingInRepo: string[] = [];
46 const extraInRepo: string[] = [];
47 const changed: Array<{uri: string; changes: ReturnType<typeof microdiff>}> = [];
48
49 for (const [key, draft] of draftMap.entries()) {
50 const existingRecord = repoMap.get(key);
51 if (!existingRecord) {
52 missingInRepo.push(draft.uri);
53 continue;
54 }
55
56 const left = existingRecord.value as Record<string, unknown> | Array<unknown>;
57 const right = draft.value as Record<string, unknown> | Array<unknown>;
58 const changes = microdiff(left, right);
59 if (changes.length > 0) {
60 changed.push({uri: existingRecord.uri, changes});
61 }
62 }
63
64 for (const [key, record] of repoMap.entries()) {
65 if (!draftMap.has(key)) {
66 extraInRepo.push(record.uri);
67 }
68 }
69
70 return {missingInRepo, extraInRepo, changed};
71}
72
73interface CanonicalRecord extends RepoRecordEntry {
74 kind: RecordKind;
75 key: string;
76}
77
78function canonicalizeRecords(records: RepoRecordGroups, ignored: Set<string>): CanonicalRecord[] {
79 const result: CanonicalRecord[] = [];
80 const uriToKey = new Map<string, string>();
81
82 for (const entry of records.cards) {
83 const cleaned = scrubIgnoredFields(entry.value, ignored);
84 const url = extractUrl(cleaned);
85 const key = buildKey("card", url ?? entry.uri);
86 result.push({kind: "card", key, uri: entry.uri, value: cleaned});
87 uriToKey.set(entry.uri, key);
88 }
89
90 for (const entry of records.collections) {
91 const cleaned = scrubIgnoredFields(entry.value, ignored);
92 const name = extractCollectionName(cleaned);
93 const key = buildKey("collection", name ?? entry.uri);
94 result.push({kind: "collection", key, uri: entry.uri, value: cleaned});
95 uriToKey.set(entry.uri, key);
96 }
97
98 for (const entry of records.notes) {
99 const cleaned = scrubIgnoredFields(entry.value, ignored);
100 const parentKey = extractParentKey(cleaned, uriToKey);
101 const keySeed = extractUrl(cleaned) ?? parentKey ?? entry.uri;
102 const key = buildKey("note", keySeed);
103 const normalizedValue = normalizeNoteValue(cleaned, parentKey);
104 result.push({kind: "note", key, uri: entry.uri, value: normalizedValue});
105 uriToKey.set(entry.uri, key);
106 }
107
108 for (const entry of records.collectionLinks) {
109 const cleaned = scrubIgnoredFields(entry.value, ignored);
110 const collectionKey = extractLinkedKey(cleaned, "collection", uriToKey);
111 const cardKey = extractLinkedKey(cleaned, "card", uriToKey);
112 const key = buildKey("collectionLink", `${collectionKey ?? "unknown"}->${cardKey ?? "unknown"}`);
113 const normalizedValue = normalizeLinkValue(cleaned, collectionKey, cardKey);
114 result.push({kind: "collectionLink", key, uri: entry.uri, value: normalizedValue});
115 uriToKey.set(entry.uri, key);
116 }
117
118 return result;
119}
120
121function buildKey(kind: RecordKind, seed: string): string {
122 return `${kind}|${seed}`;
123}
124
125function scrubIgnoredFields(value: unknown, ignored: Set<string>): unknown {
126 if (value === null || typeof value !== "object") return value;
127 if (Array.isArray(value)) {
128 return value.map(item => scrubIgnoredFields(item, ignored));
129 }
130 const result: Record<string, unknown> = {};
131 for (const [key, val] of Object.entries(value)) {
132 if (ignored.has(key)) continue;
133 result[key] = scrubIgnoredFields(val, ignored);
134 }
135 return result;
136}
137
138function extractUrl(value: unknown): string | undefined {
139 if (!value || typeof value !== "object") return undefined;
140 const record = value as {url?: unknown; content?: {url?: unknown}};
141 const direct = typeof record.url === "string" ? record.url : undefined;
142 if (direct) return direct;
143 const nested = record.content;
144 if (nested && typeof nested === "object" && typeof nested.url === "string") {
145 return nested.url;
146 }
147 return undefined;
148}
149
150function extractCollectionName(value: unknown): string | undefined {
151 if (!value || typeof value !== "object") return undefined;
152 const record = value as {name?: unknown};
153 return typeof record.name === "string" ? record.name : undefined;
154}
155
156function extractParentKey(value: unknown, uriToKey: Map<string, string>): string | undefined {
157 if (!value || typeof value !== "object") return undefined;
158 const record = value as {parentCard?: {uri?: unknown}};
159 const parentUri = record.parentCard && typeof record.parentCard === "object"
160 ? (record.parentCard as {uri?: unknown}).uri
161 : undefined;
162 if (typeof parentUri === "string") {
163 return uriToKey.get(parentUri) ?? parentUri;
164 }
165 return undefined;
166}
167
168function extractLinkedKey(
169 value: unknown,
170 field: "collection" | "card",
171 uriToKey: Map<string, string>
172): string | undefined {
173 if (!value || typeof value !== "object") return undefined;
174 const record = value as Record<string, unknown>;
175 const ref = record[field];
176 if (!ref || typeof ref !== "object") return undefined;
177 const uri = (ref as {uri?: unknown}).uri;
178 if (typeof uri === "string") {
179 return uriToKey.get(uri) ?? uri;
180 }
181 return undefined;
182}
183
184function normalizeNoteValue(value: unknown, parentKey?: string): unknown {
185 if (!value || typeof value !== "object") return value;
186 const clone = {...(value as Record<string, unknown>)};
187 if (parentKey) {
188 clone.parentCard = {key: parentKey};
189 }
190 return clone;
191}
192
193function normalizeLinkValue(value: unknown, collectionKey?: string, cardKey?: string): unknown {
194 if (!value || typeof value !== "object") return value;
195 const clone = {...(value as Record<string, unknown>)};
196 if (collectionKey) {
197 clone.collection = {key: collectionKey};
198 }
199 if (cardKey) {
200 clone.card = {key: cardKey};
201 }
202 return clone;
203}
204
205export function normalizeDraftRecords(drafts: AtprotoDraftRecords): RepoRecordGroups {
206 return {
207 cards: drafts.cards.map(envelope => ({
208 uri: envelope.strongRef.uri,
209 value: envelope.record
210 })),
211 collections: drafts.collections.map(envelope => ({
212 uri: envelope.strongRef.uri,
213 value: envelope.record
214 })),
215 collectionLinks: drafts.collectionLinks.map(envelope => ({
216 uri: envelope.strongRef.uri,
217 value: envelope.record
218 })),
219 notes: drafts.notes.map(envelope => ({
220 uri: envelope.strongRef.uri,
221 value: envelope.record
222 }))
223 };
224}