diff --git a/src/lib/ical-io.js b/src/lib/ical-io.js index 1414528..6899ea6 100644 --- a/src/lib/ical-io.js +++ b/src/lib/ical-io.js @@ -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() +} diff --git a/test/ical-io.test.js b/test/ical-io.test.js index 44cdbe6..e1e24fd 100644 --- a/test/ical-io.test.js +++ b/test/ical-io.test.js @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { parseICS } from '../src/lib/ical-io.js' +import { serializeICS } from '../src/lib/ical-io.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8') @@ -48,3 +49,60 @@ describe('parseICS', () => { 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') + }) +})