ICAL.Component instances break when wrapped by Vue's reactive proxy (the .jCal getter returns undefined through the proxy, causing serializeICS to crash with 'Cannot read properties of undefined'). Storing vtz.jCal (plain arrays) instead makes meta fully serializable for both reactivity and JSON snapshot cloning.
250 lines
8.9 KiB
JavaScript
250 lines
8.9 KiB
JavaScript
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()
|
|
}
|