A wayfinder inspired map plugin for obisidian
at main 200 lines 5.0 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: 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}