Proof of concept for the other one
at main 262 lines 7.3 kB view raw
1export interface CalendarEvent { 2 title: string; 3 date: Date; 4 venue?: string; 5 venueUrl?: string; 6 location?: string; 7 locationUrl?: string; 8 url?: string; 9 soldOut: boolean; 10 rawTime?: string; 11 notes?: string; 12 lat?: number; 13 lng?: number; 14 /** 0-based line number of the first line of this event's block. */ 15 startLine?: number; 16 /** 0-based line number of the last line of this event's block. */ 17 endLine?: number; 18} 19 20const MONTHS: Record<string, number> = { 21 january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, 22 july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, 23}; 24 25const URL_RE = /^https?:\/\/\S+$/; 26 27// "geo: 47.6062,-122.3321" — inline geocode result 28const GEO_RE = /^geo:\s*(-?\d+\.?\d*),\s*(-?\d+\.?\d*)$/; 29 30// "Monday 27 April 2026" — Songkick style 31const DATE_WEEKDAY_DD_MONTH_YYYY = 32 /^(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(\d{1,2})\s+(\w+)\s+(\d{4})/i; 33 34// "Tuesday, March 24, 7 - 11pm PDT" or "Tuesday, March 24" 35const DATE_WEEKDAY_COMMA_MONTH_DD = 36 /^(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday),?\s+(\w+)\s+(\d{1,2})(?:,?\s+(\d{4}))?(?:,?\s+(.+))?/i; 37 38// "April 3, 2026" or "March 24" 39const DATE_MONTH_DD_YYYY = 40 /^(\w+)\s+(\d{1,2})(?:,?\s+(\d{4}))?/i; 41 42function parseMonth(s: string): number | null { 43 const m = MONTHS[s.toLowerCase()]; 44 return m !== undefined ? m : null; 45} 46 47function inferYear(month: number, day: number): number { 48 const now = new Date(); 49 const thisYear = now.getFullYear(); 50 const candidate = new Date(thisYear, month, day); 51 // If the date is more than 30 days in the past, assume next year 52 if (candidate.getTime() < now.getTime() - 30 * 24 * 60 * 60 * 1000) { 53 return thisYear + 1; 54 } 55 return thisYear; 56} 57 58function tryParseDate(line: string): { date: Date; rawTime?: string } | null { 59 let m: RegExpMatchArray | null; 60 61 // "Monday 27 April 2026" 62 m = line.match(DATE_WEEKDAY_DD_MONTH_YYYY); 63 if (m) { 64 const day = parseInt(m[1], 10); 65 const month = parseMonth(m[2]); 66 const year = parseInt(m[3], 10); 67 if (month !== null) { 68 return { date: new Date(year, month, day) }; 69 } 70 } 71 72 // "Tuesday, March 24, 7 - 11pm PDT" 73 m = line.match(DATE_WEEKDAY_COMMA_MONTH_DD); 74 if (m) { 75 const month = parseMonth(m[1]); 76 const day = parseInt(m[2], 10); 77 if (month !== null) { 78 const year = m[3] ? parseInt(m[3], 10) : inferYear(month, day); 79 const rawTime = m[4]?.trim() || undefined; 80 return { date: new Date(year, month, day), rawTime }; 81 } 82 } 83 84 // "April 3, 2026" 85 m = line.match(DATE_MONTH_DD_YYYY); 86 if (m) { 87 const month = parseMonth(m[1]); 88 const day = parseInt(m[2], 10); 89 if (month !== null) { 90 const year = m[3] ? parseInt(m[3], 10) : inferYear(month, day); 91 return { date: new Date(year, month, day) }; 92 } 93 } 94 95 return null; 96} 97 98function extractMarkdownLinks(line: string): Array<{ text: string; url?: string }> { 99 const parts: Array<{ text: string; url?: string }> = []; 100 const re = /\[([^\]]+)\]\(([^)]+)\)/g; 101 let lastIndex = 0; 102 let match: RegExpExecArray | null; 103 104 while ((match = re.exec(line)) !== null) { 105 if (match.index > lastIndex) { 106 const before = line.slice(lastIndex, match.index).trim(); 107 if (before) parts.push({ text: before }); 108 } 109 parts.push({ text: match[1], url: match[2] }); 110 lastIndex = re.lastIndex; 111 } 112 113 if (lastIndex < line.length) { 114 const rest = line.slice(lastIndex).trim(); 115 if (rest) parts.push({ text: rest }); 116 } 117 118 return parts; 119} 120 121function parseSoldOut(titleLine: string): { title: string; soldOut: boolean } { 122 const soldOutRe = /\s*\(sold\s*out\)\s*/i; 123 if (soldOutRe.test(titleLine)) { 124 return { title: titleLine.replace(soldOutRe, "").trim(), soldOut: true }; 125 } 126 return { title: titleLine.trim(), soldOut: false }; 127} 128 129/** 130 * Parse a markdown note into CalendarEvent[]. 131 * 132 * Expected structure: 133 * * <url or event name> 134 * * <artist/title> 135 * * <date> 136 * * <venue/location or notes> 137 * 138 * Top-level bullets start with `* ` (no leading whitespace or one level). 139 * Sub-bullets are indented with a tab or spaces under their parent. 140 */ 141export function parseEvents(markdown: string): CalendarEvent[] { 142 const lines = markdown.split("\n"); 143 const events: CalendarEvent[] = []; 144 145 // Group lines into blocks: each top-level bullet starts a block. 146 // Track the 0-based start and end line numbers of each block. 147 interface Block { 148 lines: string[]; 149 startLine: number; 150 endLine: number; 151 } 152 const blocks: Block[] = []; 153 let currentBlock: Block | null = null; 154 155 for (let i = 0; i < lines.length; i++) { 156 const line = lines[i]; 157 // Top-level bullet: starts with `* ` (possibly after stripping leading whitespace at level 0) 158 if (/^[*\-]\s/.test(line)) { 159 currentBlock = { 160 lines: [line.replace(/^[*\-]\s+/, "").trim()], 161 startLine: i, 162 endLine: i, 163 }; 164 blocks.push(currentBlock); 165 } else if (currentBlock && /^\t[*\-]\s/.test(line)) { 166 // Sub-bullet (tab-indented) 167 currentBlock.lines.push(line.replace(/^\t[*\-]\s+/, "").trim()); 168 currentBlock.endLine = i; 169 } else if (currentBlock && /^\s{2,}[*\-]\s/.test(line)) { 170 // Sub-bullet (space-indented) 171 currentBlock.lines.push(line.replace(/^\s+[*\-]\s+/, "").trim()); 172 currentBlock.endLine = i; 173 } 174 } 175 176 for (const block of blocks) { 177 if (block.lines.length === 0) continue; 178 179 const firstLine = block.lines[0]; 180 if (!firstLine) continue; 181 182 const event: Partial<CalendarEvent> = { 183 soldOut: false, 184 startLine: block.startLine, 185 endLine: block.endLine, 186 }; 187 188 // First line: URL or plain title 189 if (URL_RE.test(firstLine)) { 190 event.url = firstLine; 191 } else { 192 const { title, soldOut } = parseSoldOut(firstLine); 193 event.title = title; 194 event.soldOut = soldOut; 195 } 196 197 // Process sub-bullets 198 const subs = block.lines.slice(1); 199 let dateFound = false; 200 201 for (const sub of subs) { 202 if (!sub) continue; 203 204 // Try to parse as inline geocode result: "geo: lat,lng" 205 const geoMatch = sub.match(GEO_RE); 206 if (geoMatch) { 207 event.lat = parseFloat(geoMatch[1]); 208 event.lng = parseFloat(geoMatch[2]); 209 continue; 210 } 211 212 // Try to parse as date first 213 if (!dateFound) { 214 const parsed = tryParseDate(sub); 215 if (parsed) { 216 event.date = parsed.date; 217 event.rawTime = parsed.rawTime; 218 dateFound = true; 219 continue; 220 } 221 } 222 223 // If we don't have a title yet (URL was first line), first non-date sub is title 224 if (!event.title) { 225 const { title, soldOut } = parseSoldOut(sub); 226 event.title = title; 227 event.soldOut = soldOut; 228 continue; 229 } 230 231 // Try to parse as venue/location (contains markdown links) 232 if (/\[.*\]\(.*\)/.test(sub)) { 233 const allParts = extractMarkdownLinks(sub); 234 // Filter to only actual markdown links (skip plain-text separators like commas) 235 const links = allParts.filter((p) => p.url); 236 if (links.length >= 1) { 237 event.venue = links[0].text; 238 event.venueUrl = links[0].url; 239 } 240 if (links.length >= 2) { 241 event.location = links[1].text; 242 event.locationUrl = links[1].url; 243 } 244 continue; 245 } 246 247 // Everything else is notes 248 if (event.notes) { 249 event.notes += "\n" + sub; 250 } else { 251 event.notes = sub; 252 } 253 } 254 255 // Only include events that have at least a title and a date 256 if (event.title && event.date) { 257 events.push(event as CalendarEvent); 258 } 259 } 260 261 return events; 262}