Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(calendar): add iCal (.ics) file import' (#311) from feat/calendar-ics-import into main

scott 8b2e3e84 f2c77d1f

+1002
+459
src/calendar/ics-parser.ts
··· 1 + /** 2 + * Lightweight iCal (.ics) parser. 3 + * 4 + * Parses VCALENDAR files and extracts VEVENT components, converting 5 + * them to our CalendarEvent format. Handles: 6 + * 7 + * - DATE and DATE-TIME values (with and without timezone) 8 + * - All-day events (DTSTART;VALUE=DATE) 9 + * - SUMMARY, DESCRIPTION, DTSTART, DTEND, DURATION 10 + * - Basic RRULE expansion (DAILY, WEEKLY, MONTHLY, YEARLY) up to a horizon 11 + * - Folded lines (RFC 5545 §3.1) 12 + * - Multiple VEVENT blocks in a single file 13 + * 14 + * Does NOT handle: VTIMEZONE definitions (uses local time), VALARM, 15 + * VTODO, VJOURNAL, or complex RRULE (BYDAY with positions, BYSETPOS). 16 + */ 17 + 18 + import { type CalendarEvent, EVENT_COLORS } from './helpers.js'; 19 + 20 + // --------------------------------------------------------------------------- 21 + // Types 22 + // --------------------------------------------------------------------------- 23 + 24 + interface ParsedVEvent { 25 + summary: string; 26 + description: string; 27 + dtstart: string; // raw iCal value 28 + dtend: string; // raw iCal value (may be empty) 29 + duration: string; // raw DURATION value (may be empty) 30 + allDay: boolean; 31 + rrule: string; // raw RRULE value (may be empty) 32 + } 33 + 34 + // --------------------------------------------------------------------------- 35 + // Line unfolding (RFC 5545 §3.1) 36 + // --------------------------------------------------------------------------- 37 + 38 + function unfoldLines(raw: string): string[] { 39 + // Lines can be folded by inserting CRLF followed by a space or tab 40 + const unfolded = raw.replace(/\r\n[\t ]/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 41 + return unfolded.split('\n'); 42 + } 43 + 44 + // --------------------------------------------------------------------------- 45 + // Date/time parsing 46 + // --------------------------------------------------------------------------- 47 + 48 + /** Parse an iCal DATE (YYYYMMDD) to { year, month, day } */ 49 + function parseIcsDate(val: string): { year: number; month: number; day: number } { 50 + const y = parseInt(val.slice(0, 4), 10); 51 + const m = parseInt(val.slice(4, 6), 10); 52 + const d = parseInt(val.slice(6, 8), 10); 53 + return { year: y, month: m, day: d }; 54 + } 55 + 56 + /** Parse an iCal DATETIME (YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ) */ 57 + function parseIcsDateTime(val: string): Date { 58 + const datePart = val.slice(0, 8); 59 + const timePart = val.slice(9, 15); 60 + const isUtc = val.endsWith('Z'); 61 + 62 + const y = parseInt(datePart.slice(0, 4), 10); 63 + const mo = parseInt(datePart.slice(4, 6), 10) - 1; 64 + const d = parseInt(datePart.slice(6, 8), 10); 65 + const h = parseInt(timePart.slice(0, 2), 10); 66 + const mi = parseInt(timePart.slice(2, 4), 10); 67 + const s = parseInt(timePart.slice(4, 6), 10) || 0; 68 + 69 + if (isUtc) { 70 + return new Date(Date.UTC(y, mo, d, h, mi, s)); 71 + } 72 + return new Date(y, mo, d, h, mi, s); 73 + } 74 + 75 + /** Check if a DTSTART value is a DATE (not DATE-TIME) */ 76 + function isDateOnly(val: string): boolean { 77 + return val.length === 8 && !val.includes('T'); 78 + } 79 + 80 + /** Format a Date to our YYYY-MM-DD format */ 81 + function toDateStr(d: Date): string { 82 + const y = d.getFullYear(); 83 + const m = String(d.getMonth() + 1).padStart(2, '0'); 84 + const day = String(d.getDate()).padStart(2, '0'); 85 + return `${y}-${m}-${day}`; 86 + } 87 + 88 + /** Format a Date to HH:MM */ 89 + function toTimeStr(d: Date): string { 90 + const h = String(d.getHours()).padStart(2, '0'); 91 + const m = String(d.getMinutes()).padStart(2, '0'); 92 + return `${h}:${m}`; 93 + } 94 + 95 + // --------------------------------------------------------------------------- 96 + // Duration parsing (RFC 5545 §3.3.6) 97 + // --------------------------------------------------------------------------- 98 + 99 + /** Parse an iCal DURATION like P1DT2H30M into minutes */ 100 + function parseDuration(dur: string): number { 101 + let minutes = 0; 102 + const weekMatch = dur.match(/(\d+)W/); 103 + const dayMatch = dur.match(/(\d+)D/); 104 + const hourMatch = dur.match(/(\d+)H/); 105 + const minMatch = dur.match(/(\d+)M/); 106 + 107 + if (weekMatch) minutes += parseInt(weekMatch[1]!, 10) * 7 * 24 * 60; 108 + if (dayMatch) minutes += parseInt(dayMatch[1]!, 10) * 24 * 60; 109 + if (hourMatch) minutes += parseInt(hourMatch[1]!, 10) * 60; 110 + if (minMatch) minutes += parseInt(minMatch[1]!, 10); 111 + 112 + return minutes; 113 + } 114 + 115 + // --------------------------------------------------------------------------- 116 + // RRULE expansion 117 + // --------------------------------------------------------------------------- 118 + 119 + const EXPANSION_HORIZON_DAYS = 365; // expand recurring events up to 1 year 120 + 121 + interface RRuleParts { 122 + freq: string; 123 + count?: number; 124 + until?: Date; 125 + interval: number; 126 + byday?: string[]; 127 + } 128 + 129 + function parseRRule(rrule: string): RRuleParts { 130 + const parts: Record<string, string> = {}; 131 + for (const segment of rrule.split(';')) { 132 + const [key, val] = segment.split('='); 133 + if (key && val) parts[key.toUpperCase()] = val; 134 + } 135 + 136 + const result: RRuleParts = { 137 + freq: parts['FREQ'] || 'DAILY', 138 + interval: parseInt(parts['INTERVAL'] || '1', 10), 139 + }; 140 + 141 + if (parts['COUNT']) result.count = parseInt(parts['COUNT'], 10); 142 + if (parts['UNTIL']) { 143 + result.until = isDateOnly(parts['UNTIL']) 144 + ? new Date(parseInt(parts['UNTIL'].slice(0, 4), 10), parseInt(parts['UNTIL'].slice(4, 6), 10) - 1, parseInt(parts['UNTIL'].slice(6, 8), 10)) 145 + : parseIcsDateTime(parts['UNTIL']); 146 + } 147 + if (parts['BYDAY']) result.byday = parts['BYDAY'].split(','); 148 + 149 + return result; 150 + } 151 + 152 + const DAY_MAP: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; 153 + 154 + function expandRRule(startDate: Date, rrule: string): Date[] { 155 + const rule = parseRRule(rrule); 156 + const dates: Date[] = []; 157 + const horizon = new Date(); 158 + horizon.setDate(horizon.getDate() + EXPANSION_HORIZON_DAYS); 159 + 160 + const limit = rule.count ?? 500; 161 + const until = rule.until ?? horizon; 162 + let count = 0; 163 + const cur = new Date(startDate); 164 + 165 + // Advance from start, generating occurrences 166 + while (count < limit && cur <= until) { 167 + // For WEEKLY with BYDAY, generate for each matching day in the week 168 + if (rule.freq === 'WEEKLY' && rule.byday && rule.byday.length > 0) { 169 + const weekStart = new Date(cur); 170 + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Sunday 171 + for (const dayCode of rule.byday) { 172 + const dayNum = DAY_MAP[dayCode.replace(/^[+-]?\d*/, '')] ?? 0; 173 + const candidate = new Date(weekStart); 174 + candidate.setDate(candidate.getDate() + dayNum); 175 + candidate.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds()); 176 + if (candidate >= startDate && candidate <= until && count < limit) { 177 + // Skip the original start date (it's already the base event) 178 + if (candidate.getTime() !== startDate.getTime()) { 179 + dates.push(new Date(candidate)); 180 + } 181 + count++; 182 + } 183 + } 184 + cur.setDate(cur.getDate() + 7 * rule.interval); 185 + } else { 186 + if (cur.getTime() !== startDate.getTime()) { 187 + dates.push(new Date(cur)); 188 + count++; 189 + } else { 190 + count++; // count the original 191 + } 192 + 193 + switch (rule.freq) { 194 + case 'DAILY': 195 + cur.setDate(cur.getDate() + rule.interval); 196 + break; 197 + case 'WEEKLY': 198 + cur.setDate(cur.getDate() + 7 * rule.interval); 199 + break; 200 + case 'MONTHLY': 201 + cur.setMonth(cur.getMonth() + rule.interval); 202 + break; 203 + case 'YEARLY': 204 + cur.setFullYear(cur.getFullYear() + rule.interval); 205 + break; 206 + default: 207 + cur.setDate(cur.getDate() + rule.interval); 208 + } 209 + } 210 + } 211 + 212 + return dates; 213 + } 214 + 215 + // --------------------------------------------------------------------------- 216 + // Extract property value, handling parameters like VALUE=DATE or TZID=... 217 + // --------------------------------------------------------------------------- 218 + 219 + function extractValue(line: string): { value: string; params: Record<string, string> } { 220 + // Format: PROPNAME;PARAM=VALUE;...:actual-value 221 + const colonIdx = line.indexOf(':'); 222 + if (colonIdx === -1) return { value: '', params: {} }; 223 + 224 + const before = line.slice(0, colonIdx); 225 + const value = line.slice(colonIdx + 1).trim(); 226 + const params: Record<string, string> = {}; 227 + 228 + const semiIdx = before.indexOf(';'); 229 + if (semiIdx !== -1) { 230 + const paramStr = before.slice(semiIdx + 1); 231 + for (const p of paramStr.split(';')) { 232 + const [k, v] = p.split('='); 233 + if (k && v) params[k.toUpperCase()] = v; 234 + } 235 + } 236 + 237 + return { value, params }; 238 + } 239 + 240 + // --------------------------------------------------------------------------- 241 + // Unescape iCal text values 242 + // --------------------------------------------------------------------------- 243 + 244 + function unescapeIcsText(text: string): string { 245 + return text 246 + .replace(/\\n/gi, '\n') 247 + .replace(/\\,/g, ',') 248 + .replace(/\\;/g, ';') 249 + .replace(/\\\\/g, '\\'); 250 + } 251 + 252 + // --------------------------------------------------------------------------- 253 + // Main parser 254 + // --------------------------------------------------------------------------- 255 + 256 + function extractVEvents(lines: string[]): ParsedVEvent[] { 257 + const events: ParsedVEvent[] = []; 258 + let inEvent = false; 259 + let current: Partial<ParsedVEvent> = {}; 260 + 261 + for (const line of lines) { 262 + if (line === 'BEGIN:VEVENT') { 263 + inEvent = true; 264 + current = { summary: '', description: '', dtstart: '', dtend: '', duration: '', rrule: '', allDay: false }; 265 + continue; 266 + } 267 + 268 + if (line === 'END:VEVENT') { 269 + inEvent = false; 270 + if (current.dtstart) { 271 + events.push(current as ParsedVEvent); 272 + } 273 + continue; 274 + } 275 + 276 + if (!inEvent) continue; 277 + 278 + const propName = line.split(/[;:]/)[0]?.toUpperCase() ?? ''; 279 + 280 + switch (propName) { 281 + case 'SUMMARY': { 282 + const { value } = extractValue(line); 283 + current.summary = unescapeIcsText(value); 284 + break; 285 + } 286 + case 'DESCRIPTION': { 287 + const { value } = extractValue(line); 288 + current.description = unescapeIcsText(value); 289 + break; 290 + } 291 + case 'DTSTART': { 292 + const { value, params } = extractValue(line); 293 + current.dtstart = value; 294 + current.allDay = params['VALUE'] === 'DATE' || isDateOnly(value); 295 + break; 296 + } 297 + case 'DTEND': { 298 + const { value } = extractValue(line); 299 + current.dtend = value; 300 + break; 301 + } 302 + case 'DURATION': { 303 + const { value } = extractValue(line); 304 + current.duration = value; 305 + break; 306 + } 307 + case 'RRULE': { 308 + const colonIdx = line.indexOf(':'); 309 + current.rrule = colonIdx !== -1 ? line.slice(colonIdx + 1).trim() : ''; 310 + break; 311 + } 312 + } 313 + } 314 + 315 + return events; 316 + } 317 + 318 + // --------------------------------------------------------------------------- 319 + // Convert parsed events to CalendarEvent format 320 + // --------------------------------------------------------------------------- 321 + 322 + function veventToCalendarEvents(vevt: ParsedVEvent, colorIdx: number): CalendarEvent[] { 323 + const now = Date.now(); 324 + const color = EVENT_COLORS[colorIdx % EVENT_COLORS.length] ?? EVENT_COLORS[0] ?? '#3a8a7a'; 325 + 326 + if (vevt.allDay) { 327 + const { year, month, day } = parseIcsDate(vevt.dtstart); 328 + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; 329 + 330 + const baseEvent: CalendarEvent = { 331 + id: crypto.randomUUID(), 332 + title: vevt.summary || 'Imported Event', 333 + date: dateStr, 334 + startTime: '', 335 + endTime: '', 336 + allDay: true, 337 + color, 338 + description: vevt.description, 339 + createdAt: now, 340 + updatedAt: now, 341 + }; 342 + 343 + const events = [baseEvent]; 344 + 345 + // Expand recurrences 346 + if (vevt.rrule) { 347 + const startDate = new Date(year, month - 1, day); 348 + const recurDates = expandRRule(startDate, vevt.rrule); 349 + for (const d of recurDates) { 350 + events.push({ 351 + ...baseEvent, 352 + id: crypto.randomUUID(), 353 + date: toDateStr(d), 354 + }); 355 + } 356 + } 357 + 358 + return events; 359 + } 360 + 361 + // Timed event 362 + const startDt = parseIcsDateTime(vevt.dtstart); 363 + let endDt: Date; 364 + 365 + if (vevt.dtend) { 366 + endDt = parseIcsDateTime(vevt.dtend); 367 + } else if (vevt.duration) { 368 + const durationMin = parseDuration(vevt.duration); 369 + endDt = new Date(startDt.getTime() + durationMin * 60_000); 370 + } else { 371 + // Default 1 hour 372 + endDt = new Date(startDt.getTime() + 60 * 60_000); 373 + } 374 + 375 + const baseEvent: CalendarEvent = { 376 + id: crypto.randomUUID(), 377 + title: vevt.summary || 'Imported Event', 378 + date: toDateStr(startDt), 379 + startTime: toTimeStr(startDt), 380 + endTime: toTimeStr(endDt), 381 + allDay: false, 382 + color, 383 + description: vevt.description, 384 + createdAt: now, 385 + updatedAt: now, 386 + }; 387 + 388 + const events = [baseEvent]; 389 + 390 + // Expand recurrences 391 + if (vevt.rrule) { 392 + const recurDates = expandRRule(startDt, vevt.rrule); 393 + for (const d of recurDates) { 394 + const endOffset = endDt.getTime() - startDt.getTime(); 395 + const newEnd = new Date(d.getTime() + endOffset); 396 + events.push({ 397 + ...baseEvent, 398 + id: crypto.randomUUID(), 399 + date: toDateStr(d), 400 + startTime: toTimeStr(d), 401 + endTime: toTimeStr(newEnd), 402 + }); 403 + } 404 + } 405 + 406 + return events; 407 + } 408 + 409 + // --------------------------------------------------------------------------- 410 + // Public API 411 + // --------------------------------------------------------------------------- 412 + 413 + export interface IcsImportResult { 414 + events: CalendarEvent[]; 415 + sourceEventCount: number; 416 + expandedCount: number; 417 + errors: string[]; 418 + } 419 + 420 + /** 421 + * Parse an .ics file string and return CalendarEvent objects. 422 + * Recurring events are expanded up to 1 year into the future. 423 + */ 424 + export function parseIcsFile(icsContent: string): IcsImportResult { 425 + const errors: string[] = []; 426 + 427 + const lines = unfoldLines(icsContent); 428 + const hasCalendar = lines.some(l => l.startsWith('BEGIN:VCALENDAR')); 429 + if (!hasCalendar) { 430 + errors.push('File does not contain a valid iCalendar (VCALENDAR) block'); 431 + return { events: [], sourceEventCount: 0, expandedCount: 0, errors }; 432 + } 433 + 434 + const vevents = extractVEvents(lines); 435 + if (vevents.length === 0) { 436 + errors.push('No events (VEVENT) found in the calendar file'); 437 + return { events: [], sourceEventCount: 0, expandedCount: 0, errors }; 438 + } 439 + 440 + const allEvents: CalendarEvent[] = []; 441 + for (let i = 0; i < vevents.length; i++) { 442 + try { 443 + const converted = veventToCalendarEvents(vevents[i]!, i); 444 + allEvents.push(...converted); 445 + } catch (err) { 446 + errors.push(`Failed to parse event "${vevents[i]!.summary}": ${(err as Error).message}`); 447 + } 448 + } 449 + 450 + return { 451 + events: allEvents, 452 + sourceEventCount: vevents.length, 453 + expandedCount: allEvents.length - vevents.length, 454 + errors, 455 + }; 456 + } 457 + 458 + // Re-export for testing 459 + export { unfoldLines, parseIcsDate, parseIcsDateTime, parseDuration, expandRRule, extractVEvents, veventToCalendarEvents };
+2
src/calendar/index.html
··· 55 55 <button class="cal-nav-btn" id="btn-next" title="Next period (&rarr;)">&#9654;</button> 56 56 <span class="cal-date-label" id="current-label"></span> 57 57 <span class="topbar-spacer"></span> 58 + <button class="cal-nav-btn" id="btn-import" title="Import .ics calendar file">&#8679; Import</button> 59 + <input type="file" id="ics-import-input" accept=".ics,.ical,.ifb,.icalendar" style="display:none"> 58 60 <div class="cal-view-group"> 59 61 <button class="cal-view-btn active" data-view="month" title="Month view (M)">Month</button> 60 62 <button class="cal-view-btn" data-view="week" title="Week view (W)">Week</button>
+48
src/calendar/main.ts
··· 32 32 getDayOfWeekFull, 33 33 eventsOnDate as eventsOnDateHelper, 34 34 } from './helpers.js'; 35 + import { parseIcsFile } from './ics-parser.js'; 36 + import { showToast } from '../landing-toast.js'; 35 37 36 38 // --------------------------------------------------------------------------- 37 39 // Types & constants ··· 891 893 const view = (btn as HTMLElement).dataset.view as CalendarView; 892 894 if (view) setView(view); 893 895 }); 896 + }); 897 + 898 + // --------------------------------------------------------------------------- 899 + // iCal import 900 + // --------------------------------------------------------------------------- 901 + 902 + const importBtn = document.getElementById('btn-import'); 903 + const importInput = document.getElementById('ics-import-input') as HTMLInputElement | null; 904 + 905 + importBtn?.addEventListener('click', () => importInput?.click()); 906 + 907 + importInput?.addEventListener('change', async () => { 908 + const file = importInput.files?.[0]; 909 + if (!file) return; 910 + 911 + try { 912 + const text = await file.text(); 913 + const result = parseIcsFile(text); 914 + 915 + if (result.errors.length > 0 && result.events.length === 0) { 916 + showToast(result.errors[0] ?? 'Failed to parse calendar file', 5000); 917 + return; 918 + } 919 + 920 + if (result.events.length === 0) { 921 + showToast('No events found in file', 4000); 922 + return; 923 + } 924 + 925 + // Add all imported events to Yjs in a single transaction 926 + ydoc.transact(() => { 927 + for (const evt of result.events) { 928 + yEvents.push([JSON.stringify(evt)]); 929 + } 930 + }); 931 + 932 + const msg = result.expandedCount > 0 933 + ? `Imported ${result.sourceEventCount} events (${result.expandedCount} recurring instances)` 934 + : `Imported ${result.events.length} events`; 935 + showToast(msg, 4000); 936 + } catch { 937 + showToast('Failed to read calendar file', 5000); 938 + } 939 + 940 + // Reset input so the same file can be re-imported 941 + importInput.value = ''; 894 942 }); 895 943 896 944 // ---------------------------------------------------------------------------
+493
tests/ics-parser.test.ts
··· 1 + /** 2 + * Tests for the iCal (.ics) parser. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + parseIcsFile, 7 + unfoldLines, 8 + parseIcsDate, 9 + parseIcsDateTime, 10 + parseDuration, 11 + extractVEvents, 12 + } from '../src/calendar/ics-parser.js'; 13 + 14 + // --------------------------------------------------------------------------- 15 + // unfoldLines 16 + // --------------------------------------------------------------------------- 17 + 18 + describe('unfoldLines', () => { 19 + it('passes through simple lines', () => { 20 + const lines = unfoldLines('LINE1\r\nLINE2\r\nLINE3'); 21 + expect(lines).toEqual(['LINE1', 'LINE2', 'LINE3']); 22 + }); 23 + 24 + it('unfolds continuation lines (space)', () => { 25 + const lines = unfoldLines('DESCRIPTION:This is a long\r\n description that continues'); 26 + expect(lines).toEqual(['DESCRIPTION:This is a long description that continues']); 27 + }); 28 + 29 + it('unfolds continuation lines (tab)', () => { 30 + const lines = unfoldLines('SUMMARY:Hello\r\n\tWorld'); 31 + expect(lines).toEqual(['SUMMARY:HelloWorld']); 32 + }); 33 + 34 + it('handles LF-only line endings', () => { 35 + const lines = unfoldLines('A\nB\nC'); 36 + expect(lines).toEqual(['A', 'B', 'C']); 37 + }); 38 + 39 + it('handles bare CR line endings', () => { 40 + const lines = unfoldLines('A\rB\rC'); 41 + expect(lines).toEqual(['A', 'B', 'C']); 42 + }); 43 + }); 44 + 45 + // --------------------------------------------------------------------------- 46 + // parseIcsDate 47 + // --------------------------------------------------------------------------- 48 + 49 + describe('parseIcsDate', () => { 50 + it('parses a basic DATE value', () => { 51 + expect(parseIcsDate('20260415')).toEqual({ year: 2026, month: 4, day: 15 }); 52 + }); 53 + 54 + it('parses January 1st', () => { 55 + expect(parseIcsDate('20250101')).toEqual({ year: 2025, month: 1, day: 1 }); 56 + }); 57 + 58 + it('parses December 31st', () => { 59 + expect(parseIcsDate('20251231')).toEqual({ year: 2025, month: 12, day: 31 }); 60 + }); 61 + }); 62 + 63 + // --------------------------------------------------------------------------- 64 + // parseIcsDateTime 65 + // --------------------------------------------------------------------------- 66 + 67 + describe('parseIcsDateTime', () => { 68 + it('parses local datetime', () => { 69 + const d = parseIcsDateTime('20260415T143000'); 70 + expect(d.getFullYear()).toBe(2026); 71 + expect(d.getMonth()).toBe(3); // April = 3 72 + expect(d.getDate()).toBe(15); 73 + expect(d.getHours()).toBe(14); 74 + expect(d.getMinutes()).toBe(30); 75 + }); 76 + 77 + it('parses UTC datetime', () => { 78 + const d = parseIcsDateTime('20260101T120000Z'); 79 + // UTC noon — local hours will vary by TZ, but the Date should be valid 80 + expect(d.getUTCHours()).toBe(12); 81 + expect(d.getUTCMinutes()).toBe(0); 82 + }); 83 + 84 + it('handles midnight', () => { 85 + const d = parseIcsDateTime('20260415T000000'); 86 + expect(d.getHours()).toBe(0); 87 + expect(d.getMinutes()).toBe(0); 88 + }); 89 + }); 90 + 91 + // --------------------------------------------------------------------------- 92 + // parseDuration 93 + // --------------------------------------------------------------------------- 94 + 95 + describe('parseDuration', () => { 96 + it('parses hours', () => { 97 + expect(parseDuration('PT2H')).toBe(120); 98 + }); 99 + 100 + it('parses hours and minutes', () => { 101 + expect(parseDuration('PT1H30M')).toBe(90); 102 + }); 103 + 104 + it('parses days', () => { 105 + expect(parseDuration('P1D')).toBe(1440); 106 + }); 107 + 108 + it('parses weeks', () => { 109 + expect(parseDuration('P2W')).toBe(2 * 7 * 24 * 60); 110 + }); 111 + 112 + it('parses complex duration', () => { 113 + expect(parseDuration('P1DT2H30M')).toBe(1440 + 120 + 30); 114 + }); 115 + 116 + it('handles minutes only', () => { 117 + expect(parseDuration('PT45M')).toBe(45); 118 + }); 119 + }); 120 + 121 + // --------------------------------------------------------------------------- 122 + // extractVEvents 123 + // --------------------------------------------------------------------------- 124 + 125 + describe('extractVEvents', () => { 126 + it('extracts a simple event', () => { 127 + const lines = [ 128 + 'BEGIN:VCALENDAR', 129 + 'BEGIN:VEVENT', 130 + 'SUMMARY:Meeting', 131 + 'DTSTART:20260415T140000', 132 + 'DTEND:20260415T150000', 133 + 'END:VEVENT', 134 + 'END:VCALENDAR', 135 + ]; 136 + const events = extractVEvents(lines); 137 + expect(events).toHaveLength(1); 138 + expect(events[0]!.summary).toBe('Meeting'); 139 + expect(events[0]!.dtstart).toBe('20260415T140000'); 140 + expect(events[0]!.dtend).toBe('20260415T150000'); 141 + expect(events[0]!.allDay).toBe(false); 142 + }); 143 + 144 + it('extracts all-day event with VALUE=DATE', () => { 145 + const lines = [ 146 + 'BEGIN:VEVENT', 147 + 'SUMMARY:Holiday', 148 + 'DTSTART;VALUE=DATE:20260101', 149 + 'DTEND;VALUE=DATE:20260102', 150 + 'END:VEVENT', 151 + ]; 152 + const events = extractVEvents(lines); 153 + expect(events).toHaveLength(1); 154 + expect(events[0]!.allDay).toBe(true); 155 + expect(events[0]!.dtstart).toBe('20260101'); 156 + }); 157 + 158 + it('detects all-day from short date format', () => { 159 + const lines = [ 160 + 'BEGIN:VEVENT', 161 + 'SUMMARY:Birthday', 162 + 'DTSTART:20260315', 163 + 'END:VEVENT', 164 + ]; 165 + const events = extractVEvents(lines); 166 + expect(events).toHaveLength(1); 167 + expect(events[0]!.allDay).toBe(true); 168 + }); 169 + 170 + it('extracts description', () => { 171 + const lines = [ 172 + 'BEGIN:VEVENT', 173 + 'SUMMARY:Test', 174 + 'DESCRIPTION:Some details\\nWith newline', 175 + 'DTSTART:20260415T100000', 176 + 'END:VEVENT', 177 + ]; 178 + const events = extractVEvents(lines); 179 + expect(events[0]!.description).toBe('Some details\nWith newline'); 180 + }); 181 + 182 + it('extracts RRULE', () => { 183 + const lines = [ 184 + 'BEGIN:VEVENT', 185 + 'SUMMARY:Weekly', 186 + 'DTSTART:20260415T100000', 187 + 'RRULE:FREQ=WEEKLY;COUNT=4', 188 + 'END:VEVENT', 189 + ]; 190 + const events = extractVEvents(lines); 191 + expect(events[0]!.rrule).toBe('FREQ=WEEKLY;COUNT=4'); 192 + }); 193 + 194 + it('extracts DURATION', () => { 195 + const lines = [ 196 + 'BEGIN:VEVENT', 197 + 'SUMMARY:Quick', 198 + 'DTSTART:20260415T100000', 199 + 'DURATION:PT30M', 200 + 'END:VEVENT', 201 + ]; 202 + const events = extractVEvents(lines); 203 + expect(events[0]!.duration).toBe('PT30M'); 204 + }); 205 + 206 + it('extracts multiple events', () => { 207 + const lines = [ 208 + 'BEGIN:VCALENDAR', 209 + 'BEGIN:VEVENT', 210 + 'SUMMARY:Event 1', 211 + 'DTSTART:20260415T100000', 212 + 'END:VEVENT', 213 + 'BEGIN:VEVENT', 214 + 'SUMMARY:Event 2', 215 + 'DTSTART:20260416T110000', 216 + 'END:VEVENT', 217 + 'END:VCALENDAR', 218 + ]; 219 + const events = extractVEvents(lines); 220 + expect(events).toHaveLength(2); 221 + expect(events[0]!.summary).toBe('Event 1'); 222 + expect(events[1]!.summary).toBe('Event 2'); 223 + }); 224 + 225 + it('skips events without DTSTART', () => { 226 + const lines = [ 227 + 'BEGIN:VEVENT', 228 + 'SUMMARY:No date', 229 + 'END:VEVENT', 230 + ]; 231 + const events = extractVEvents(lines); 232 + expect(events).toHaveLength(0); 233 + }); 234 + 235 + it('handles DTSTART with TZID parameter', () => { 236 + const lines = [ 237 + 'BEGIN:VEVENT', 238 + 'SUMMARY:TZ Event', 239 + 'DTSTART;TZID=America/New_York:20260415T140000', 240 + 'END:VEVENT', 241 + ]; 242 + const events = extractVEvents(lines); 243 + expect(events).toHaveLength(1); 244 + expect(events[0]!.dtstart).toBe('20260415T140000'); 245 + }); 246 + 247 + it('unescapes description text', () => { 248 + const lines = [ 249 + 'BEGIN:VEVENT', 250 + 'SUMMARY:Escaped', 251 + 'DESCRIPTION:Hello\\, world\\; test\\\\done', 252 + 'DTSTART:20260415T100000', 253 + 'END:VEVENT', 254 + ]; 255 + const events = extractVEvents(lines); 256 + expect(events[0]!.description).toBe('Hello, world; test\\done'); 257 + }); 258 + }); 259 + 260 + // --------------------------------------------------------------------------- 261 + // parseIcsFile (integration) 262 + // --------------------------------------------------------------------------- 263 + 264 + describe('parseIcsFile', () => { 265 + it('parses a minimal .ics file', () => { 266 + const ics = [ 267 + 'BEGIN:VCALENDAR', 268 + 'VERSION:2.0', 269 + 'PRODID:-//Test//Test//EN', 270 + 'BEGIN:VEVENT', 271 + 'SUMMARY:Lunch', 272 + 'DTSTART:20260415T120000', 273 + 'DTEND:20260415T130000', 274 + 'END:VEVENT', 275 + 'END:VCALENDAR', 276 + ].join('\r\n'); 277 + 278 + const result = parseIcsFile(ics); 279 + expect(result.errors).toHaveLength(0); 280 + expect(result.sourceEventCount).toBe(1); 281 + expect(result.events).toHaveLength(1); 282 + expect(result.events[0]!.title).toBe('Lunch'); 283 + expect(result.events[0]!.date).toBe('2026-04-15'); 284 + expect(result.events[0]!.startTime).toBe('12:00'); 285 + expect(result.events[0]!.endTime).toBe('13:00'); 286 + expect(result.events[0]!.allDay).toBe(false); 287 + }); 288 + 289 + it('parses an all-day event', () => { 290 + const ics = [ 291 + 'BEGIN:VCALENDAR', 292 + 'BEGIN:VEVENT', 293 + 'SUMMARY:Vacation', 294 + 'DTSTART;VALUE=DATE:20260720', 295 + 'DTEND;VALUE=DATE:20260725', 296 + 'END:VEVENT', 297 + 'END:VCALENDAR', 298 + ].join('\r\n'); 299 + 300 + const result = parseIcsFile(ics); 301 + expect(result.events).toHaveLength(1); 302 + expect(result.events[0]!.allDay).toBe(true); 303 + expect(result.events[0]!.date).toBe('2026-07-20'); 304 + expect(result.events[0]!.startTime).toBe(''); 305 + expect(result.events[0]!.endTime).toBe(''); 306 + }); 307 + 308 + it('uses DURATION when DTEND is missing', () => { 309 + const ics = [ 310 + 'BEGIN:VCALENDAR', 311 + 'BEGIN:VEVENT', 312 + 'SUMMARY:Quick call', 313 + 'DTSTART:20260415T143000', 314 + 'DURATION:PT30M', 315 + 'END:VEVENT', 316 + 'END:VCALENDAR', 317 + ].join('\r\n'); 318 + 319 + const result = parseIcsFile(ics); 320 + expect(result.events[0]!.startTime).toBe('14:30'); 321 + expect(result.events[0]!.endTime).toBe('15:00'); 322 + }); 323 + 324 + it('defaults to 1 hour when no DTEND or DURATION', () => { 325 + const ics = [ 326 + 'BEGIN:VCALENDAR', 327 + 'BEGIN:VEVENT', 328 + 'SUMMARY:Open ended', 329 + 'DTSTART:20260415T090000', 330 + 'END:VEVENT', 331 + 'END:VCALENDAR', 332 + ].join('\r\n'); 333 + 334 + const result = parseIcsFile(ics); 335 + expect(result.events[0]!.startTime).toBe('09:00'); 336 + expect(result.events[0]!.endTime).toBe('10:00'); 337 + }); 338 + 339 + it('expands a simple daily recurring event', () => { 340 + const ics = [ 341 + 'BEGIN:VCALENDAR', 342 + 'BEGIN:VEVENT', 343 + 'SUMMARY:Daily standup', 344 + 'DTSTART:20260415T090000', 345 + 'DTEND:20260415T091500', 346 + 'RRULE:FREQ=DAILY;COUNT=3', 347 + 'END:VEVENT', 348 + 'END:VCALENDAR', 349 + ].join('\r\n'); 350 + 351 + const result = parseIcsFile(ics); 352 + // COUNT=3 means 3 occurrences total (original + 2 expanded) 353 + expect(result.events.length).toBeGreaterThanOrEqual(3); 354 + expect(result.sourceEventCount).toBe(1); 355 + expect(result.expandedCount).toBeGreaterThanOrEqual(2); 356 + 357 + // All should have the same title 358 + for (const evt of result.events) { 359 + expect(evt.title).toBe('Daily standup'); 360 + } 361 + 362 + // Each should have a unique date 363 + const dates = result.events.map(e => e.date); 364 + expect(dates[0]).toBe('2026-04-15'); 365 + expect(dates[1]).toBe('2026-04-16'); 366 + expect(dates[2]).toBe('2026-04-17'); 367 + }); 368 + 369 + it('assigns rotating colors to different events', () => { 370 + const ics = [ 371 + 'BEGIN:VCALENDAR', 372 + 'BEGIN:VEVENT', 373 + 'SUMMARY:A', 374 + 'DTSTART:20260415T100000', 375 + 'END:VEVENT', 376 + 'BEGIN:VEVENT', 377 + 'SUMMARY:B', 378 + 'DTSTART:20260416T110000', 379 + 'END:VEVENT', 380 + 'END:VCALENDAR', 381 + ].join('\r\n'); 382 + 383 + const result = parseIcsFile(ics); 384 + expect(result.events).toHaveLength(2); 385 + // Different source events should get different colors 386 + expect(result.events[0]!.color).not.toBe(result.events[1]!.color); 387 + }); 388 + 389 + it('returns error for non-iCal content', () => { 390 + const result = parseIcsFile('Hello, this is not a calendar file'); 391 + expect(result.errors).toHaveLength(1); 392 + expect(result.errors[0]).toContain('VCALENDAR'); 393 + expect(result.events).toHaveLength(0); 394 + }); 395 + 396 + it('returns error when no VEVENT found', () => { 397 + const ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR'; 398 + const result = parseIcsFile(ics); 399 + expect(result.errors).toHaveLength(1); 400 + expect(result.errors[0]).toContain('No events'); 401 + }); 402 + 403 + it('handles Google Calendar export format', () => { 404 + const ics = [ 405 + 'BEGIN:VCALENDAR', 406 + 'PRODID:-//Google Inc//Google Calendar 70.9054//EN', 407 + 'VERSION:2.0', 408 + 'CALSCALE:GREGORIAN', 409 + 'METHOD:PUBLISH', 410 + 'BEGIN:VEVENT', 411 + 'DTSTART;TZID=America/Los_Angeles:20260415T090000', 412 + 'DTEND;TZID=America/Los_Angeles:20260415T100000', 413 + 'SUMMARY:Team sync', 414 + 'DESCRIPTION:Weekly team sync meeting', 415 + 'STATUS:CONFIRMED', 416 + 'END:VEVENT', 417 + 'END:VCALENDAR', 418 + ].join('\r\n'); 419 + 420 + const result = parseIcsFile(ics); 421 + expect(result.errors).toHaveLength(0); 422 + expect(result.events).toHaveLength(1); 423 + expect(result.events[0]!.title).toBe('Team sync'); 424 + expect(result.events[0]!.description).toBe('Weekly team sync meeting'); 425 + }); 426 + 427 + it('handles Apple Calendar export with folded lines', () => { 428 + const ics = [ 429 + 'BEGIN:VCALENDAR', 430 + 'VERSION:2.0', 431 + 'BEGIN:VEVENT', 432 + 'SUMMARY:A very long event title that gets folded across mult', 433 + ' iple lines in the iCal format', 434 + 'DTSTART:20260415T140000', 435 + 'DTEND:20260415T150000', 436 + 'END:VEVENT', 437 + 'END:VCALENDAR', 438 + ].join('\r\n'); 439 + 440 + const result = parseIcsFile(ics); 441 + expect(result.events[0]!.title).toBe( 442 + 'A very long event title that gets folded across multiple lines in the iCal format' 443 + ); 444 + }); 445 + 446 + it('preserves description with newlines', () => { 447 + const ics = [ 448 + 'BEGIN:VCALENDAR', 449 + 'BEGIN:VEVENT', 450 + 'SUMMARY:Notes', 451 + 'DTSTART:20260415T100000', 452 + 'DESCRIPTION:Line 1\\nLine 2\\nLine 3', 453 + 'END:VEVENT', 454 + 'END:VCALENDAR', 455 + ].join('\r\n'); 456 + 457 + const result = parseIcsFile(ics); 458 + expect(result.events[0]!.description).toBe('Line 1\nLine 2\nLine 3'); 459 + }); 460 + 461 + it('gives each event a unique id', () => { 462 + const ics = [ 463 + 'BEGIN:VCALENDAR', 464 + 'BEGIN:VEVENT', 465 + 'SUMMARY:A', 466 + 'DTSTART:20260415T100000', 467 + 'END:VEVENT', 468 + 'BEGIN:VEVENT', 469 + 'SUMMARY:B', 470 + 'DTSTART:20260416T110000', 471 + 'END:VEVENT', 472 + 'END:VCALENDAR', 473 + ].join('\r\n'); 474 + 475 + const result = parseIcsFile(ics); 476 + const ids = result.events.map(e => e.id); 477 + expect(new Set(ids).size).toBe(ids.length); 478 + }); 479 + 480 + it('handles event with no summary', () => { 481 + const ics = [ 482 + 'BEGIN:VCALENDAR', 483 + 'BEGIN:VEVENT', 484 + 'DTSTART:20260415T100000', 485 + 'DTEND:20260415T110000', 486 + 'END:VEVENT', 487 + 'END:VCALENDAR', 488 + ].join('\r\n'); 489 + 490 + const result = parseIcsFile(ics); 491 + expect(result.events[0]!.title).toBe('Imported Event'); 492 + }); 493 + });