A wayfinder inspired map plugin for obisidian
at main 206 lines 5.2 kB view raw
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}