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: key (word chars + hyphens), 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 matching a `geo:` sub-bullet line in raw note content.
40 * Shared between parser and mapView write-back logic.
41 */
42export const GEO_LINE_RE = /^[\t ]+[*-] geo: .*/;
43
44/**
45 * Regex for valid geo coordinates.
46 * Requires digits (not just a dot), optional decimal part with digits after dot.
47 * Format: lat,lng with optional space after comma.
48 */
49const GEO_RE = /^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/;
50
51/**
52 * Parse the name portion of a top-level bullet, handling markdown links,
53 * wiki-links, and plain text.
54 */
55function parseName(raw: string): { name: string; url?: string } {
56 // Try markdown link
57 const mdMatch = raw.match(MD_LINK_RE);
58 if (mdMatch) {
59 const text = mdMatch[1];
60 const href = mdMatch[2];
61 return {
62 name: text,
63 url: href || undefined,
64 };
65 }
66
67 // Try wiki-link
68 const wikiMatch = raw.match(WIKI_LINK_RE);
69 if (wikiMatch) {
70 const inner = wikiMatch[1];
71 const pipeIdx = inner.indexOf("|");
72 if (pipeIdx !== -1) {
73 const display = inner.substring(pipeIdx + 1);
74 return { name: display };
75 }
76 return { name: inner };
77 }
78
79 // Plain text
80 return { name: raw };
81}
82
83/**
84 * Parse geo field value into lat/lng if valid.
85 */
86function parseGeo(value: string): { lat?: number; lng?: number } {
87 const match = value.match(GEO_RE);
88 if (!match) return {};
89
90 const lat = parseFloat(match[1]);
91 const lng = parseFloat(match[2]);
92
93 if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return {};
94
95 return { lat, lng };
96}
97
98/**
99 * Extract the text content from a sub-bullet line, stripping indentation
100 * and bullet prefix.
101 */
102function extractSubBulletText(line: string): string {
103 return line.replace(SUB_BULLET_RE, "").trim();
104}
105
106export function parsePlaces(content: string): Place[] {
107 if (!content) return [];
108
109 // Normalize Windows line endings
110 const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
111 const lines = normalized.split("\n");
112
113 const places: Place[] = [];
114 let current: {
115 name: string;
116 url?: string;
117 fields: Record<string, string>;
118 notes: string[];
119 startLine: number;
120 endLine: number;
121 } | null = null;
122
123 for (let i = 0; i < lines.length; i++) {
124 const line = lines[i];
125
126 if (TOP_BULLET_RE.test(line)) {
127 // Finalize previous place
128 if (current) {
129 finalizePlace(current, places);
130 }
131
132 // Extract raw name after bullet prefix
133 const raw = line.replace(/^[*-] /, "").trim();
134 const { name, url } = parseName(raw);
135
136 current = {
137 name,
138 url,
139 fields: Object.create(null) as Record<string, string>,
140 notes: [],
141 startLine: i,
142 endLine: i,
143 };
144 } else if (SUB_BULLET_RE.test(line) && current) {
145 // Sub-bullet belongs to current place
146 current.endLine = i;
147 const text = extractSubBulletText(line);
148
149 // Try to parse as field
150 const fieldMatch = text.match(FIELD_RE);
151 if (fieldMatch) {
152 const key = fieldMatch[1].toLowerCase();
153 const value = fieldMatch[2].trim();
154 current.fields[key] = value;
155 } else if (text) {
156 current.notes.push(text);
157 }
158 }
159 // Non-bullet lines are ignored (dead zones)
160 }
161
162 // Finalize last place
163 if (current) {
164 finalizePlace(current, places);
165 }
166
167 return places;
168}
169
170/**
171 * Finalize a place block: parse geo, exclude empty names, push to results.
172 */
173function finalizePlace(
174 block: {
175 name: string;
176 url?: string;
177 fields: Record<string, string>;
178 notes: string[];
179 startLine: number;
180 endLine: number;
181 },
182 places: Place[]
183): void {
184 // Exclude empty/whitespace-only names
185 if (!block.name.trim()) return;
186
187 const place: Place = {
188 name: block.name,
189 url: block.url,
190 fields: block.fields,
191 notes: block.notes,
192 startLine: block.startLine,
193 endLine: block.endLine,
194 };
195
196 // Parse geo if present
197 if (block.fields.geo) {
198 const { lat, lng } = parseGeo(block.fields.geo);
199 if (lat !== undefined && lng !== undefined) {
200 place.lat = lat;
201 place.lng = lng;
202 }
203 }
204
205 places.push(place);
206}