feat(03-02): implement buildVeventString VEVENT builder (Task 1 GREEN)
- buildVeventString(NewEventParams) → { uid, icsString } using ical.js ICAL.Component
- All-day events use ICAL.Time({ isDate: true }) → VALUE=DATE, no TZID, no time (D-13)
- Timed events use ICAL.Time.fromJSDate(date, true) → DTSTART:...Z, no TZID (D-13)
- RRULE serialized via ICAL.Recur.fromString + ICAL.Property (prevents char-split bug)
- Exports: buildVeventString, NewEventParams, RRULE_PRESETS (daily/weekly/monthly/yearly)
- Uses crypto.randomUUID() for UID generation; appends @familysync suffix
- All 7 vevent.test.ts assertions GREEN
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* VEVENT builder — constructs a valid VCALENDAR/VEVENT iCalendar string for PUT to Fastmail.
|
||||||
|
*
|
||||||
|
* D-13 contract (mirrors sync.ts parser in reverse):
|
||||||
|
* - All-day events: use ICAL.Time({ isDate: true }) → produces VALUE=DATE, no TZID, no time (D-13)
|
||||||
|
* - Timed events: use ICAL.Time.fromJSDate(date, true) (useUTC=true) → DTSTART:...Z suffix, no TZID
|
||||||
|
* - NEVER coerce DATE to DATETIME (Pitfall #3)
|
||||||
|
*
|
||||||
|
* T-03-03 (Tampering): ical.js ICAL.Component handles line-folding + escaping (commas, semicolons,
|
||||||
|
* newlines in summary/location/description). Never hand-roll ICS strings.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545)
|
||||||
|
* - https://github.com/kewisch/ical.js/blob/main/lib/ical/component.js
|
||||||
|
* - https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ICAL from 'ical.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
export interface NewEventParams {
|
||||||
|
uid?: string // omit = generate new UUID (appended with @familysync)
|
||||||
|
summary: string
|
||||||
|
allDay: boolean
|
||||||
|
// All-day: YYYY-MM-DD string (or Date — only the date portion is used)
|
||||||
|
// Timed: JS Date representing a UTC instant
|
||||||
|
dtstart: string | Date
|
||||||
|
dtend: string | Date
|
||||||
|
location?: string
|
||||||
|
description?: string
|
||||||
|
rruleString?: string // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07)
|
||||||
|
dtstamp?: Date // omit = now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple RRULE preset strings for whole-series recurring events (D-11 / CAL-07).
|
||||||
|
* "weekly on Monday" example: 'FREQ=WEEKLY;BYDAY=MO' (not a key in this map; compose manually).
|
||||||
|
*/
|
||||||
|
export const RRULE_PRESETS: Record<string, string> = {
|
||||||
|
daily: 'FREQ=DAILY',
|
||||||
|
weekly: 'FREQ=WEEKLY',
|
||||||
|
monthly: 'FREQ=MONTHLY',
|
||||||
|
yearly: 'FREQ=YEARLY',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a VCALENDAR/VEVENT iCalendar string from form parameters.
|
||||||
|
*
|
||||||
|
* @returns { uid, icsString } — uid is the generated or provided UID;
|
||||||
|
* icsString is the full VCALENDAR string ready for PUT to Fastmail.
|
||||||
|
*/
|
||||||
|
export function buildVeventString(params: NewEventParams): { uid: string; icsString: string } {
|
||||||
|
const uid = params.uid ?? `${randomUUID()}@familysync`
|
||||||
|
|
||||||
|
// --- VCALENDAR wrapper ---
|
||||||
|
const cal = new ICAL.Component(['vcalendar', [], []])
|
||||||
|
cal.updatePropertyWithValue('version', '2.0')
|
||||||
|
cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN')
|
||||||
|
|
||||||
|
// --- VEVENT ---
|
||||||
|
const vevent = new ICAL.Component('vevent')
|
||||||
|
vevent.addPropertyWithValue('uid', uid)
|
||||||
|
vevent.addPropertyWithValue('summary', params.summary)
|
||||||
|
|
||||||
|
// DTSTAMP: always present (RFC 5545 §3.8.7.2 — required property)
|
||||||
|
const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true)
|
||||||
|
vevent.addPropertyWithValue('dtstamp', dtstamp)
|
||||||
|
|
||||||
|
if (params.allDay) {
|
||||||
|
// DATE value (not DATETIME) — isDate:true produces VALUE=DATE, no time component (D-13 contract)
|
||||||
|
const startStr =
|
||||||
|
typeof params.dtstart === 'string'
|
||||||
|
? params.dtstart
|
||||||
|
: params.dtstart.toISOString().slice(0, 10)
|
||||||
|
const endStr =
|
||||||
|
typeof params.dtend === 'string'
|
||||||
|
? params.dtend
|
||||||
|
: params.dtend.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number]
|
||||||
|
const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number]
|
||||||
|
|
||||||
|
const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true })
|
||||||
|
const endTime = new ICAL.Time({ year: ey, month: em, day: ed, isDate: true })
|
||||||
|
vevent.addPropertyWithValue('dtstart', startTime)
|
||||||
|
vevent.addPropertyWithValue('dtend', endTime)
|
||||||
|
} else {
|
||||||
|
// DATETIME in UTC (useUTC=true → Z suffix; TZID is NOT added by ical.js) (D-13 contract)
|
||||||
|
const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true)
|
||||||
|
const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true)
|
||||||
|
vevent.addPropertyWithValue('dtstart', startTime)
|
||||||
|
vevent.addPropertyWithValue('dtend', endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: RRULE (CAL-07 — whole-series recurring events only in v1, D-11)
|
||||||
|
// Must use ICAL.Recur.fromString + ICAL.Property to get correct serialization.
|
||||||
|
// addPropertyWithValue('rrule', string) serializes the string character-by-character
|
||||||
|
// instead of as a RECUR value type.
|
||||||
|
if (params.rruleString) {
|
||||||
|
const recur = ICAL.Recur.fromString(params.rruleString)
|
||||||
|
const rruleProp = new ICAL.Property('rrule')
|
||||||
|
rruleProp.setValue(recur)
|
||||||
|
vevent.addProperty(rruleProp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields — included only when provided (no empty properties in ICS)
|
||||||
|
if (params.location) {
|
||||||
|
vevent.addPropertyWithValue('location', params.location)
|
||||||
|
}
|
||||||
|
if (params.description) {
|
||||||
|
vevent.addPropertyWithValue('description', params.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
cal.addSubcomponent(vevent)
|
||||||
|
|
||||||
|
return { uid, icsString: cal.toString() }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user