A wayfinder inspired map plugin for obisidian
1/**
2 * parser.ts — Note Parser (Pure)
3 *
4 * Parse markdown content into an array of Place objects.
5 * This is a pure function with no side effects.
6 */
7
8export interface Place {
9 name: string;
10 url?: string;
11 fields: Record<string, string>;
12 notes: string[];
13 lat?: number;
14 lng?: number;
15 startLine: number;
16 endLine: number;
17}
18
19/** Regex for top-level bullet: `* ` or `- ` at column 0 */
20const TOP_BULLET_RE = /^[*-] /;
21
22/**
23 * Regex for sub-bullet: any leading whitespace (tab/spaces, 1+ chars for tab,
24 * 2+ chars for spaces) followed by `* ` or `- `. Uses a flat character class
25 * instead of nested quantifiers to avoid catastrophic backtracking (ReDoS).
26 */
27const SUB_BULLET_RE = /^[\t ]{2,}[*-] |^\t[*-] /;
28
29/** Regex for structured field: single word key, colon, space, then value */
30const FIELD_RE = /^(\w+): (.*)$/;
31
32/** Regex for markdown link: [text](url) or [text](url "title") */
33const MD_LINK_RE = /^\[([^\]]*)\]\(([^)"]*?)(?:\s+"[^"]*")?\)$/;
34
35/** Regex for wiki-link: [[Page]] or [[Target|Display]] */
36const WIKI_LINK_RE = /^\[\[([^\]]*)\]\]$/;
37
38/**
39 * Regex for valid geo coordinates.
40 * Requires digits (not just a dot), optional decimal part with digits after dot.
41 * Format: lat,lng with optional space after comma.
42 */
43const GEO_RE = /^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/;
44
45/**
46 * Parse the name portion of a top-level bullet, handling markdown links,
47 * wiki-links, and plain text.
48 */
49function parseName(raw: string): { name: string; url?: string } {
50 // Try markdown link
51 const mdMatch = raw.match(MD_LINK_RE);
52 if (mdMatch) {
53 const text = mdMatch[1];
54 const href = mdMatch[2];
55 return {
56 name: text,
57 url: href || undefined,
58 };
59 }
60
61 // Try wiki-link
62 const wikiMatch = raw.match(WIKI_LINK_RE);
63 if (wikiMatch) {
64 const inner = wikiMatch[1];
65 const pipeIdx = inner.indexOf("|");
66 if (pipeIdx !== -1) {
67 const display = inner.substring(pipeIdx + 1);
68 return { name: display };
69 }
70 return { name: inner };
71 }
72
73 // Plain text
74 return { name: raw };
75}
76
77/**
78 * Parse geo field value into lat/lng if valid.
79 */
80function parseGeo(value: string): { lat?: number; lng?: number } {
81 const match = value.match(GEO_RE);
82 if (!match) return {};
83
84 const lat = parseFloat(match[1]);
85 const lng = parseFloat(match[2]);
86
87 if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return {};
88
89 return { lat, lng };
90}
91
92/**
93 * Extract the text content from a sub-bullet line, stripping indentation
94 * and bullet prefix.
95 */
96function extractSubBulletText(line: string): string {
97 return line.replace(SUB_BULLET_RE, "").trim();
98}
99
100export function parsePlaces(content: string): Place[] {
101 if (!content) return [];
102
103 // Normalize Windows line endings
104 const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
105 const lines = normalized.split("\n");
106
107 const places: Place[] = [];
108 let current: {
109 name: string;
110 url?: string;
111 fields: Record<string, string>;
112 notes: string[];
113 startLine: number;
114 endLine: number;
115 } | null = null;
116
117 for (let i = 0; i < lines.length; i++) {
118 const line = lines[i];
119
120 if (TOP_BULLET_RE.test(line)) {
121 // Finalize previous place
122 if (current) {
123 finalizePlace(current, places);
124 }
125
126 // Extract raw name after bullet prefix
127 const raw = line.replace(/^[*-] /, "").trim();
128 const { name, url } = parseName(raw);
129
130 current = {
131 name,
132 url,
133 fields: Object.create(null) as Record<string, string>,
134 notes: [],
135 startLine: i,
136 endLine: i,
137 };
138 } else if (SUB_BULLET_RE.test(line) && current) {
139 // Sub-bullet belongs to current place
140 current.endLine = i;
141 const text = extractSubBulletText(line);
142
143 // Try to parse as field
144 const fieldMatch = text.match(FIELD_RE);
145 if (fieldMatch) {
146 const key = fieldMatch[1].toLowerCase();
147 const value = fieldMatch[2].trim();
148 current.fields[key] = value;
149 } else if (text) {
150 current.notes.push(text);
151 }
152 }
153 // Non-bullet lines are ignored (dead zones)
154 }
155
156 // Finalize last place
157 if (current) {
158 finalizePlace(current, places);
159 }
160
161 return places;
162}
163
164/**
165 * Finalize a place block: parse geo, exclude empty names, push to results.
166 */
167function finalizePlace(
168 block: {
169 name: string;
170 url?: string;
171 fields: Record<string, string>;
172 notes: string[];
173 startLine: number;
174 endLine: number;
175 },
176 places: Place[]
177): void {
178 // Exclude empty/whitespace-only names
179 if (!block.name.trim()) return;
180
181 const place: Place = {
182 name: block.name,
183 url: block.url,
184 fields: block.fields,
185 notes: block.notes,
186 startLine: block.startLine,
187 endLine: block.endLine,
188 };
189
190 // Parse geo if present
191 if (block.fields.geo) {
192 const { lat, lng } = parseGeo(block.fields.geo);
193 if (lat !== undefined && lng !== undefined) {
194 place.lat = lat;
195 place.lng = lng;
196 }
197 }
198
199 places.push(place);
200}