feat: implement serializeICS with round-trip support

This commit is contained in:
timeTable2 dev
2026-07-13 18:30:52 +08:00
parent 138b89e3bf
commit 7e708932b1
2 changed files with 183 additions and 0 deletions

View File

@@ -119,3 +119,128 @@ export function parseICS(text) {
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 (clone jCal to avoid moving from original parent)
for (const vtz of meta.vtimezones || []) {
vcalendar.addSubcomponent(new ICAL.Component(vtz.jCal))
}
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()
}