Files
timeTableFix/src/lib/ical-io.js
timeTable2 dev 3af6b856e3 fix: store VTIMEZONEs as raw jCal arrays for Vue reactivity safety
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.
2026-07-13 18:53:01 +08:00

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()
}