Markdown -> Semble importer
at canon 224 lines 7.4 kB view raw
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}