Markdown -> Semble importer
1import type {SembleCard, SembleCollection} from "../semble/types";
2
3export interface ParsedSource {
4 cards: SembleCard[];
5 collections: SembleCollection[];
6}
7
8export function parseMarkdownSource(sourceId: string, text: string): ParsedSource {
9 const lines = text.split(/\r?\n/);
10 const cards: SembleCard[] = [];
11 const collections: SembleCollection[] = [];
12 let collection: string | undefined;
13
14 for (let index = 0; index < lines.length; index += 1) {
15 const line = lines[index];
16 if (!line) continue;
17
18 const headerMatch = line.match(/^#\s+(.+?)\s*$/);
19 if (headerMatch) {
20 collection = headerMatch[1]?.trim();
21 if (collection) {
22 const {description, nextIndex} = parseCollectionDescription(lines, index + 1);
23 collections.push({
24 name: collection,
25 description,
26 sourceId,
27 line: index + 1
28 });
29 if (nextIndex > index + 1) {
30 index = nextIndex - 1;
31 }
32 }
33 continue;
34 }
35
36 const listMatch = line.match(/^\s*[-*]\s+(.+)$/);
37 const candidate = listMatch ? listMatch[1] ?? "" : line.trim();
38 const parsed = parseCardLine(candidate);
39 if (!parsed) continue;
40
41 const cardId = `${sourceId}#L${index + 1}:${parsed.url}`;
42 cards.push({
43 ...parsed,
44 collection,
45 sourceId,
46 line: index + 1,
47 id: cardId
48 });
49 }
50
51 return {cards, collections};
52}
53
54function parseCollectionDescription(lines: string[], startIndex: number): {
55 description?: string;
56 nextIndex: number;
57} {
58 const descriptionParts: string[] = [];
59 let index = startIndex;
60
61 for (; index < lines.length; index += 1) {
62 const line = lines[index] ?? "";
63 const trimmed = line.trim();
64 if (!trimmed) {
65 if (descriptionParts.length > 0) {
66 index += 1;
67 }
68 break;
69 }
70 if (/^#\s+/.test(trimmed) || /^\s*[-*]\s+/.test(trimmed)) {
71 break;
72 }
73 descriptionParts.push(trimmed);
74 }
75
76 const description = descriptionParts.length > 0 ? descriptionParts.join(" ") : undefined;
77 return {description, nextIndex: index};
78}
79
80function parseCardLine(text: string): Omit<SembleCard, "collection" | "sourceId" | "line" | "id"> | null {
81 const trimmed = text.trim();
82 if (!trimmed) return null;
83
84 const splitIndex = trimmed.indexOf(" : ");
85 const linkPart = splitIndex >= 0 ? trimmed.slice(0, splitIndex).trim() : trimmed;
86 const notePart = splitIndex >= 0 ? trimmed.slice(splitIndex + 3).trim() : undefined;
87
88 const markdownLink = linkPart.match(/^\[(.+?)\]\((.+?)\)$/);
89 if (markdownLink) {
90 const title = markdownLink[1]?.trim();
91 const url = markdownLink[2]?.trim();
92 if (!url) return null;
93 return {
94 url,
95 title: title || undefined,
96 note: notePart || undefined
97 };
98 }
99
100 const angleLink = linkPart.match(/^<([^>]+)>$/);
101 if (angleLink) {
102 const url = angleLink[1]?.trim();
103 if (!url) return null;
104 return {
105 url,
106 note: notePart || undefined
107 };
108 }
109
110 if (!/^(https?:\/\/|at:\/\/)\S+$/i.test(linkPart)) {
111 return null;
112 }
113
114 return {
115 url: linkPart,
116 note: notePart || undefined
117 };
118}