feat: implement serializeICS with round-trip support
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user