feat: implement serializeICS with round-trip support
This commit is contained in:
@@ -119,3 +119,128 @@ export function parseICS(text) {
|
|||||||
|
|
||||||
return { meta, events }
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { parseICS } from '../src/lib/ical-io.js'
|
import { parseICS } from '../src/lib/ical-io.js'
|
||||||
|
import { serializeICS } from '../src/lib/ical-io.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
||||||
@@ -48,3 +49,60 @@ describe('parseICS', () => {
|
|||||||
expect(events[60].uid).toBe('uid60')
|
expect(events[60].uid).toBe('uid60')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('serializeICS round-trip', () => {
|
||||||
|
it('round-trips: parse(serialize(parse(x))) equals parse(x)', () => {
|
||||||
|
const original = parseICS(GOLDEN)
|
||||||
|
const text = serializeICS(original)
|
||||||
|
const reparsed = parseICS(text)
|
||||||
|
|
||||||
|
expect(reparsed.events).toHaveLength(original.events.length)
|
||||||
|
// Spot-check first event
|
||||||
|
const e0 = reparsed.events[0]
|
||||||
|
expect(e0.summary).toBe(original.events[0].summary)
|
||||||
|
expect(e0.dtstartDate).toBe(original.events[0].dtstartDate)
|
||||||
|
expect(e0.dtstartTime).toBe(original.events[0].dtstartTime)
|
||||||
|
expect(e0.tzid).toBe(original.events[0].tzid)
|
||||||
|
expect(e0._raw.dtstamp).toBe(original.events[0]._raw.dtstamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves VTIMEZONE in output', () => {
|
||||||
|
const { meta } = parseICS(serializeICS(parseICS(GOLDEN)))
|
||||||
|
expect(meta.vtimezones).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes a manually-built recurring event', () => {
|
||||||
|
const ev = {
|
||||||
|
uid: 'test-1',
|
||||||
|
summary: 'Test Event',
|
||||||
|
location: 'Room A',
|
||||||
|
description: 'Desc',
|
||||||
|
dtstartDate: '2026-07-27',
|
||||||
|
dtstartTime: '09:00',
|
||||||
|
dtendTime: '10:30',
|
||||||
|
tzid: 'UTC',
|
||||||
|
rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-10-19' },
|
||||||
|
exdates: ['2026-09-21'],
|
||||||
|
_raw: { dtstamp: '20260711T085008Z' },
|
||||||
|
}
|
||||||
|
const text = serializeICS({
|
||||||
|
meta: {
|
||||||
|
prodid: '-//Test//EN', version: '2.0', calscale: 'GREGORIAN',
|
||||||
|
method: null, xWrCalname: null, xWrTimezone: null, vtimezones: [],
|
||||||
|
},
|
||||||
|
events: [ev],
|
||||||
|
})
|
||||||
|
expect(text).toContain('BEGIN:VEVENT')
|
||||||
|
expect(text).toContain('SUMMARY:Test Event')
|
||||||
|
expect(text).toContain('RRULE:FREQ=WEEKLY')
|
||||||
|
expect(text).toContain('BYDAY=MO')
|
||||||
|
expect(text).toContain('EXDATE')
|
||||||
|
expect(text).toContain('DTSTART:20260727T090000Z')
|
||||||
|
|
||||||
|
// Re-parse and verify
|
||||||
|
const reparsed = parseICS(text)
|
||||||
|
expect(reparsed.events[0].rrule.freq).toBe('WEEKLY')
|
||||||
|
expect(reparsed.events[0].rrule.byday).toBe('MO')
|
||||||
|
expect(reparsed.events[0].exdates).toContain('2026-09-21')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user