Markdown -> Semble importer
1import type {SembleCard, SembleCollection, SembleCollectionLink} from "../semble/types";
2import type {SembleUrlMetadata} from "../metadata/citoid";
3import {
4 CARD_COLLECTION,
5 COLLECTION_COLLECTION,
6 COLLECTION_LINK_COLLECTION
7} from "./collections";
8
9const DEFAULT_DID = "did:example:semble";
10
11export interface MockStrongRef {
12 uri: string;
13 cid: string;
14}
15
16export interface AtprotoRecordEnvelope {
17 collection: string;
18 rkey: string;
19 record: Record<string, unknown>;
20 strongRef: MockStrongRef;
21 recordType: "card" | "collection" | "collectionLink" | "note";
22 recordId: string;
23 cardId?: string;
24 collectionName?: string;
25}
26
27export interface AtprotoDraftRecords {
28 cards: AtprotoRecordEnvelope[];
29 collections: AtprotoRecordEnvelope[];
30 collectionLinks: AtprotoRecordEnvelope[];
31 notes: AtprotoRecordEnvelope[];
32}
33
34export function buildMockAtprotoRecords(input: {
35 cards: SembleCard[];
36 collections: SembleCollection[];
37 collectionLinks: SembleCollectionLink[];
38 now?: Date;
39 did?: string;
40}): AtprotoDraftRecords {
41 const now = input.now ?? new Date();
42 const did = input.did ?? DEFAULT_DID;
43
44 const cardRecords = input.cards.map(card => buildCardRecord(card, now, did));
45 const collectionRecords = input.collections.map(collection =>
46 buildCollectionRecord(collection, now, did)
47 );
48
49 const cardRefMap = new Map(
50 cardRecords.map(record => [record.recordId, record.strongRef])
51 );
52 const collectionRefMap = new Map(
53 collectionRecords.map(record => [record.recordId, record.strongRef])
54 );
55
56 const collectionLinkRecords = input.collectionLinks.map(link => {
57 const collectionRef = collectionRefMap.get(link.collectionName);
58 const cardRef = cardRefMap.get(link.cardId);
59
60 if (!collectionRef || !cardRef) {
61 return buildCollectionLinkRecord(link, now, did, undefined, undefined);
62 }
63
64 return buildCollectionLinkRecord(link, now, did, collectionRef, cardRef);
65 });
66
67 const noteRecords = input.cards
68 .filter(card => Boolean(card.note))
69 .map(card => {
70 const parentRef = cardRefMap.get(card.id);
71 return buildNoteRecord(card, now, did, parentRef);
72 });
73
74 return {
75 cards: cardRecords.map(record => record.envelope),
76 collections: collectionRecords.map(record => record.envelope),
77 collectionLinks: collectionLinkRecords.map(record => record.envelope),
78 notes: noteRecords.map(record => record.envelope)
79 };
80}
81
82function buildCardRecord(card: SembleCard, now: Date, did: string): {
83 recordId: string;
84 envelope: AtprotoRecordEnvelope;
85 strongRef: MockStrongRef;
86} {
87 const rkey = stableRkey(`card:${card.id}`);
88 const uri = `at://${did}/${CARD_COLLECTION}/${rkey}`;
89 const strongRef = {uri, cid: `mock:${stableHash(uri)}`};
90
91 const record: Record<string, unknown> = {
92 $type: CARD_COLLECTION,
93 type: "URL",
94 content: buildUrlContent(card),
95 createdAt: now.toISOString()
96 };
97
98 const envelope: AtprotoRecordEnvelope = {
99 collection: CARD_COLLECTION,
100 rkey,
101 record,
102 strongRef,
103 recordType: "card",
104 recordId: card.id
105 };
106
107 return {recordId: card.id, envelope, strongRef};
108}
109
110function buildUrlContent(card: SembleCard): Record<string, unknown> {
111 const content: Record<string, unknown> = {
112 $type: `${CARD_COLLECTION}#urlContent`,
113 url: card.url
114 };
115
116 const metadata = buildUrlMetadata(card.metadata);
117 if (metadata) {
118 content.metadata = metadata;
119 }
120
121 return content;
122}
123
124function buildUrlMetadata(metadata?: SembleUrlMetadata): Record<string, unknown> | undefined {
125 if (!metadata) return undefined;
126
127 const record: Record<string, unknown> = {
128 $type: `${CARD_COLLECTION}#urlMetadata`
129 };
130
131 if (metadata.title) record.title = metadata.title;
132 if (metadata.description) record.description = metadata.description;
133 if (metadata.author) record.author = metadata.author;
134 if (metadata.publishedDate) {
135 record.publishedDate = metadata.publishedDate.toISOString();
136 }
137 if (metadata.siteName) record.siteName = metadata.siteName;
138 if (metadata.type) record.type = metadata.type;
139 if (metadata.doi) record.doi = metadata.doi;
140 if (metadata.isbn) record.isbn = metadata.isbn;
141
142 return record;
143}
144
145function buildCollectionRecord(collection: SembleCollection, now: Date, did: string): {
146 recordId: string;
147 envelope: AtprotoRecordEnvelope;
148 strongRef: MockStrongRef;
149} {
150 const rkey = stableRkey(`collection:${collection.name}`);
151 const uri = `at://${did}/${COLLECTION_COLLECTION}/${rkey}`;
152 const strongRef = {uri, cid: `mock:${stableHash(uri)}`};
153
154 const record: Record<string, unknown> = {
155 $type: COLLECTION_COLLECTION,
156 name: collection.name,
157 accessType: "OPEN",
158 createdAt: now.toISOString()
159 };
160
161 if (collection.description) {
162 record.description = collection.description;
163 }
164
165 const envelope: AtprotoRecordEnvelope = {
166 collection: COLLECTION_COLLECTION,
167 rkey,
168 record,
169 strongRef,
170 recordType: "collection",
171 recordId: collection.name
172 };
173
174 return {recordId: collection.name, envelope, strongRef};
175}
176
177function buildNoteRecord(
178 card: SembleCard,
179 now: Date,
180 did: string,
181 parentRef?: MockStrongRef
182): {
183 recordId: string;
184 envelope: AtprotoRecordEnvelope;
185} {
186 const rkey = stableRkey(`note:${card.id}`);
187 const uri = `at://${did}/${CARD_COLLECTION}/${rkey}`;
188 const strongRef = {uri, cid: `mock:${stableHash(uri)}`};
189
190 const record: Record<string, unknown> = {
191 $type: CARD_COLLECTION,
192 type: "NOTE",
193 content: {
194 $type: `${CARD_COLLECTION}#noteContent`,
195 text: card.note
196 },
197 createdAt: now.toISOString()
198 };
199
200 if (card.url) {
201 record.url = card.url;
202 }
203
204 if (parentRef) {
205 record.parentCard = parentRef;
206 }
207
208 const envelope: AtprotoRecordEnvelope = {
209 collection: CARD_COLLECTION,
210 rkey,
211 record,
212 strongRef,
213 recordType: "note",
214 recordId: `${card.id}:note`,
215 cardId: card.id
216 };
217
218 return {recordId: `${card.id}:note`, envelope};
219}
220
221function buildCollectionLinkRecord(
222 link: SembleCollectionLink,
223 now: Date,
224 did: string,
225 collectionRef?: MockStrongRef,
226 cardRef?: MockStrongRef
227): {
228 recordId: string;
229 envelope: AtprotoRecordEnvelope;
230} {
231 const rkey = stableRkey(`link:${link.collectionName}:${link.cardId}`);
232 const uri = `at://${did}/${COLLECTION_LINK_COLLECTION}/${rkey}`;
233 const strongRef = {uri, cid: `mock:${stableHash(uri)}`};
234
235 const record: Record<string, unknown> = {
236 $type: COLLECTION_LINK_COLLECTION,
237 collection: collectionRef ?? {uri: "at://unknown/collection", cid: "mock:unknown"},
238 card: cardRef ?? {uri: "at://unknown/card", cid: "mock:unknown"},
239 addedBy: did,
240 addedAt: now.toISOString(),
241 createdAt: now.toISOString()
242 };
243
244 const envelope: AtprotoRecordEnvelope = {
245 collection: COLLECTION_LINK_COLLECTION,
246 rkey,
247 record,
248 strongRef,
249 recordType: "collectionLink",
250 recordId: `${link.collectionName}:${link.cardId}`,
251 cardId: link.cardId,
252 collectionName: link.collectionName
253 };
254
255 return {recordId: `${link.collectionName}:${link.cardId}`, envelope};
256}
257
258export function stableRkey(seed: string): string {
259 return `semble-${stableHash(seed)}`;
260}
261
262function stableHash(input: string): string {
263 let hash = 0;
264 for (let i = 0; i < input.length; i += 1) {
265 hash = (hash * 31 + input.charCodeAt(i)) | 0;
266 }
267 return Math.abs(hash).toString(36);
268}