Proof of concept for the other one
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}