import ICAL from 'ical.js' // ------------------------------------------------------------------ // // Timezone registration (ical.js does not bundle IANA zones) // ------------------------------------------------------------------ // function registerTimezones(vcalendar) { for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) { const tzid = vtz.getFirstPropertyValue('tzid') if (tzid) { ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid })) } } } // ------------------------------------------------------------------ // // Helpers // ------------------------------------------------------------------ // function pad2(n) { return String(n).padStart(2, '0') } function timeToISODate(t) { return `${t.year}-${pad2(t.month)}-${pad2(t.day)}` } function timeToHHMM(t) { return `${pad2(t.hour)}:${pad2(t.minute)}` } function getTzid(component, propName) { const prop = component.getFirstProperty(propName) if (!prop) return 'UTC' const tzid = prop.getParameter('tzid') return tzid || 'UTC' } // ------------------------------------------------------------------ // // Parse a single VEVENT component -> plain EventModel // ------------------------------------------------------------------ // function parseEvent(veventComp) { const event = new ICAL.Event(veventComp) const start = event.startDate const end = event.endDate // Determine tzid const tzid = getTzid(veventComp, 'dtstart') // RRULE const recur = veventComp.getFirstPropertyValue('rrule') let rrule = null if (recur) { const bydayArr = recur.getComponent('byday') // string[] let untilDate = null if (recur.until) { const u = recur.until untilDate = `${u.year}-${pad2(u.month)}-${pad2(u.day)}` } rrule = { freq: recur.freq, interval: recur.interval || 1, byday: bydayArr.length > 0 ? bydayArr[0] : null, untilDate, } } // EXDATEs const exdates = [] for (const prop of veventComp.getAllProperties('exdate')) { for (const t of prop.getValues()) { exdates.push(timeToISODate(t)) } } // _raw: capture DTSTAMP and any other unmapped properties // Use toICALString() to preserve the compact RFC5545 form ("20260711T085008Z") // rather than toString()'s ISO 8601 form ("2026-07-11T08:50:08Z"). const _raw = {} const dtstamp = veventComp.getFirstPropertyValue('dtstamp') if (dtstamp) _raw.dtstamp = dtstamp.toICALString() return { uid: veventComp.getFirstPropertyValue('uid') || '', summary: veventComp.getFirstPropertyValue('summary') || '', location: veventComp.getFirstPropertyValue('location') || '', description: veventComp.getFirstPropertyValue('description') || '', dtstartDate: timeToISODate(start), dtstartTime: timeToHHMM(start), dtendTime: timeToHHMM(end), tzid, rrule, exdates, _raw, } } // ------------------------------------------------------------------ // // Parse full ICS text -> { meta, events } // ------------------------------------------------------------------ // export function parseICS(text) { const jcal = ICAL.parse(text) const vcalendar = new ICAL.Component(jcal) // Register timezones from VTIMEZONEs before constructing zoned Times registerTimezones(vcalendar) // Meta const meta = { prodid: vcalendar.getFirstPropertyValue('prodid') || '', version: vcalendar.getFirstPropertyValue('version') || '2.0', calscale: vcalendar.getFirstPropertyValue('calscale') || 'GREGORIAN', method: vcalendar.getFirstPropertyValue('method') || null, xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null, xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null, // Store raw jCal arrays (not ICAL.Component instances) so the meta stays // plain-serializable for Vue reactivity and JSON snapshot cloning. vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal), } // Events const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent) return { meta, events } } // ------------------------------------------------------------------ // // Helpers for serialization // ------------------------------------------------------------------ // function parseISODate(s) { const [y, m, d] = s.split('-').map(Number) return { year: y, month: m, day: d } } function parseHHMM(s) { const [h, m] = s.split(':').map(Number) return { hour: h, minute: m } } function getZone(tzid) { if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone const z = ICAL.TimezoneService.get(tzid) return z || ICAL.Timezone.utcTimezone } function makeTime(dateStr, timeStr, tzid) { const d = parseISODate(dateStr) const t = parseHHMM(timeStr) return new ICAL.Time( { year: d.year, month: d.month, day: d.day, hour: t.hour, minute: t.minute, isDate: false }, getZone(tzid), ) } // Parse a compact RFC5545 date-time string (e.g. "20260711T085008Z" or // "20260711T085008") back into an ICAL.Time. ical.js's public Time.fromString // rejects the compact form, so we parse it manually and hand the Time object // to updatePropertyWithValue (which writes it back in compact form). function parseCompactDateTime(s) { const isUtc = s.endsWith('Z') const core = isUtc ? s.slice(0, -1) : s const t = core.split('T') const dPart = t[0] const tPart = t[1] || '' return new ICAL.Time( { year: Number(dPart.slice(0, 4)), month: Number(dPart.slice(4, 6)), day: Number(dPart.slice(6, 8)), hour: Number(tPart.slice(0, 2)) || 0, minute: Number(tPart.slice(2, 4)) || 0, second: Number(tPart.slice(4, 6)) || 0, isDate: false, }, isUtc ? ICAL.Timezone.utcTimezone : ICAL.Timezone.localTimezone, ) } // ------------------------------------------------------------------ // // Serialize { meta, events } -> ICS text // ------------------------------------------------------------------ // export function serializeICS({ meta, events }) { const vcalendar = new ICAL.Component('vcalendar') vcalendar.updatePropertyWithValue('prodid', meta.prodid || '-//timeTable2//EN') vcalendar.updatePropertyWithValue('version', meta.version || '2.0') if (meta.calscale) vcalendar.updatePropertyWithValue('calscale', meta.calscale) if (meta.method) vcalendar.updatePropertyWithValue('method', meta.method) if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname) if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone) // Re-add VTIMEZONEs. meta.vtimezones stores raw jCal arrays (see parseICS), // so wrap each directly in a new Component (avoids moving the original). for (const vtzJCal of meta.vtimezones || []) { vcalendar.addSubcomponent(new ICAL.Component(vtzJCal)) } for (const ev of events) { const vevent = new ICAL.Component('vevent') vevent.updatePropertyWithValue('uid', ev.uid) if (ev.summary) vevent.updatePropertyWithValue('summary', ev.summary) if (ev.location) vevent.updatePropertyWithValue('location', ev.location) if (ev.description) vevent.updatePropertyWithValue('description', ev.description) // DTSTART const dtstart = makeTime(ev.dtstartDate, ev.dtstartTime, ev.tzid) const dsProp = vevent.addPropertyWithValue('dtstart', dtstart) if (ev.tzid && ev.tzid !== 'UTC') dsProp.setParameter('tzid', ev.tzid) // DTEND const dtend = makeTime(ev.dtstartDate, ev.dtendTime, ev.tzid) const deProp = vevent.addPropertyWithValue('dtend', dtend) if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid) // DTSTAMP - _raw.dtstamp is stored in compact RFC5545 form // ("20260711T085008Z"); pass an ICAL.Time object so ical.js decorates it // correctly and writes it back in the same compact form. Passing the raw // string fails (ical.js 2.x rejects compact form in fromDateTimeString). const dtstampRaw = ev._raw?.dtstamp const dtstampTime = dtstampRaw ? parseCompactDateTime(dtstampRaw) : ICAL.Time.now() vevent.updatePropertyWithValue('dtstamp', dtstampTime) // RRULE if (ev.rrule) { const r = ev.rrule const recurData = { freq: r.freq, interval: r.interval || 1 } if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday if (r.untilDate) { // UNTIL as UTC (RFC 5545): build zoned time at 23:59 then convert const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid) recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone) } const recur = new ICAL.Recur(recurData) vevent.addPropertyWithValue('rrule', recur) } // EXDATEs (in DTSTART's timezone) for (const exDate of ev.exdates || []) { const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid) const exProp = vevent.addPropertyWithValue('exdate', exTime) if (ev.tzid && ev.tzid !== 'UTC') exProp.setParameter('tzid', ev.tzid) } vcalendar.addSubcomponent(vevent) } return vcalendar.toString() }