feat: implement parseICS with ical.js

This commit is contained in:
timeTable2 dev
2026-07-13 18:26:14 +08:00
parent 76e08d67d8
commit 138b89e3bf
3 changed files with 745 additions and 0 deletions

121
src/lib/ical-io.js Normal file
View File

@@ -0,0 +1,121 @@
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,
vtimezones: vcalendar.getAllSubcomponents('vtimezone'),
}
// Events
const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent)
return { meta, events }
}