@@ -224,6 +224,15 @@ export function expandRrule(
224224
225225// ── document parse / serialize ────────────────────────────────────────────
226226
227+ type ProtoEvent = Partial < IcsEvent > & {
228+ rrule ?: string ;
229+ durMs ?: number ;
230+ exdates ?: Set < number > ;
231+ /** RECURRENCE-ID: this VEVENT overrides ONE instance of the same-UID series */
232+ recurrenceId ?: number ;
233+ cancelled ?: boolean ;
234+ } ;
235+
227236export function parseIcs (
228237 text : string ,
229238 opts : { fromMs ?: number ; toMs ?: number } = { } ,
@@ -232,51 +241,28 @@ export function parseIcs(
232241 const toMs = opts . toMs ?? Date . now ( ) + 2 * 365 * DAY_MS ;
233242 const lines = unfoldLines ( text ) ;
234243 const cal : IcsCalendar = { events : [ ] } ;
235- let ev : Partial < IcsEvent > & { rrule ?: string ; durMs ?: number ; exdates ?: Set < number > } | null = null ;
236- let uidSeq = 0 ;
244+
245+ // pass 1 — collect every VEVENT as a proto record
246+ const protos : ProtoEvent [ ] = [ ] ;
247+ let ev : ProtoEvent | null = null ;
237248 for ( const line of lines ) {
238249 const p = parseLine ( line ) ;
239250 if ( ! p ) continue ;
240251 if ( p . name === "X-WR-CALNAME" ) cal . name = unescapeText ( p . value ) . trim ( ) ;
241252 else if ( p . name === "BEGIN" && p . value . toUpperCase ( ) === "VEVENT" ) ev = { } ;
242253 else if ( p . name === "END" && p . value . toUpperCase ( ) === "VEVENT" ) {
243- if ( ev && ev . startMs !== undefined ) {
244- const allDay = ev . allDay ?? false ;
245- const endMs =
246- ev . endMs ??
247- ( ev . durMs != null ? ev . startMs + ev . durMs : ev . startMs + ( allDay ? DAY_MS : HOUR_MS ) ) ;
248- const base : IcsEvent = {
249- uid : ev . uid ?? `ics-${ ++ uidSeq } ` ,
250- summary : ev . summary ?? "(untitled)" ,
251- location : ev . location ,
252- description : ev . description ,
253- startMs : ev . startMs ,
254- endMs,
255- allDay,
256- } ;
257- if ( ev . rrule ) {
258- const starts = expandRrule ( ev . rrule , base . startMs , fromMs , toMs ) ;
259- if ( starts === null ) {
260- cal . events . push ( { ...base , recurring : true } ) ;
261- } else {
262- const dur = base . endMs - base . startMs ;
263- let i = 0 ;
264- for ( const s of starts ) {
265- if ( ev . exdates ?. has ( s ) ) continue ;
266- cal . events . push ( { ...base , uid : `${ base . uid } #${ i ++ } ` , startMs : s , endMs : s + dur } ) ;
267- }
268- }
269- } else if ( ! ev . exdates ?. has ( base . startMs ) ) {
270- cal . events . push ( base ) ;
271- }
272- }
254+ if ( ev && ev . startMs !== undefined ) protos . push ( ev ) ;
273255 ev = null ;
274256 } else if ( ev ) {
275257 if ( p . name === "UID" ) ev . uid = p . value . trim ( ) ;
276258 else if ( p . name === "SUMMARY" ) ev . summary = unescapeText ( p . value ) . trim ( ) ;
277259 else if ( p . name === "LOCATION" ) ev . location = unescapeText ( p . value ) . trim ( ) ;
278260 else if ( p . name === "DESCRIPTION" ) ev . description = unescapeText ( p . value ) . trim ( ) ;
279- else if ( p . name === "DTSTART" ) {
261+ else if ( p . name === "STATUS" ) ev . cancelled = p . value . trim ( ) . toUpperCase ( ) === "CANCELLED" ;
262+ else if ( p . name === "RECURRENCE-ID" ) {
263+ const d = parseIcsDate ( p . value . trim ( ) , p . params ) ;
264+ if ( d ) ev . recurrenceId = d . ms ;
265+ } else if ( p . name === "DTSTART" ) {
280266 const d = parseIcsDate ( p . value . trim ( ) , p . params ) ;
281267 if ( d ) {
282268 ev . startMs = d . ms ;
@@ -296,6 +282,75 @@ export function parseIcs(
296282 }
297283 }
298284 }
285+
286+ // pass 2 — emit. Overrides (same UID + RECURRENCE-ID) REPLACE the matching
287+ // expanded instance of their series (Google exports every edited instance
288+ // of a recurring event this way); STATUS:CANCELLED overrides delete it;
289+ // overrides whose master never expanded (outside horizon, unsupported
290+ // rule, partial export) emit standalone rather than vanishing.
291+ let uidSeq = 0 ;
292+ const finish = ( p : ProtoEvent , uid : string ) : IcsEvent => {
293+ const allDay = p . allDay ?? false ;
294+ const endMs =
295+ p . endMs ??
296+ ( p . durMs != null ? p . startMs ! + p . durMs : p . startMs ! + ( allDay ? DAY_MS : HOUR_MS ) ) ;
297+ return {
298+ uid,
299+ summary : p . summary ?? "(untitled)" ,
300+ location : p . location ,
301+ description : p . description ,
302+ startMs : p . startMs ! ,
303+ endMs,
304+ allDay,
305+ } ;
306+ } ;
307+ const ovByUid = new Map < string , Map < number , ProtoEvent > > ( ) ;
308+ for ( const p of protos ) {
309+ if ( p . recurrenceId === undefined || ! p . uid ) continue ;
310+ let m = ovByUid . get ( p . uid ) ;
311+ if ( ! m ) ovByUid . set ( p . uid , ( m = new Map ( ) ) ) ;
312+ m . set ( p . recurrenceId , p ) ;
313+ }
314+ const consumed = new Set < ProtoEvent > ( ) ;
315+ for ( const p of protos ) {
316+ if ( p . recurrenceId !== undefined ) continue ; // overrides emit via masters
317+ const uid = p . uid ?? `ics-${ ++ uidSeq } ` ;
318+ const overrides = p . uid ? ovByUid . get ( p . uid ) : undefined ;
319+ if ( p . rrule ) {
320+ const starts = expandRrule ( p . rrule , p . startMs ! , fromMs , toMs ) ;
321+ if ( starts === null ) {
322+ if ( ! p . cancelled ) cal . events . push ( { ...finish ( p , uid ) , recurring : true } ) ;
323+ continue ;
324+ }
325+ const dur = ( p . endMs ?? p . startMs ! + ( p . durMs ?? ( p . allDay ? DAY_MS : HOUR_MS ) ) ) - p . startMs ! ;
326+ let i = 0 ;
327+ for ( const s of starts ) {
328+ if ( p . exdates ?. has ( s ) ) continue ;
329+ const ov = overrides ?. get ( s ) ;
330+ if ( ov ) {
331+ consumed . add ( ov ) ;
332+ if ( ! ov . cancelled ) cal . events . push ( finish ( ov , `${ uid } #r${ s } ` ) ) ;
333+ continue ;
334+ }
335+ cal . events . push ( { ...finish ( p , `${ uid } #${ i ++ } ` ) , startMs : s , endMs : s + dur } ) ;
336+ }
337+ continue ;
338+ }
339+ if ( p . cancelled || p . exdates ?. has ( p . startMs ! ) ) continue ;
340+ // even a non-recurring master can be overridden (degenerate but legal)
341+ const ov = overrides ?. get ( p . startMs ! ) ;
342+ if ( ov ) {
343+ consumed . add ( ov ) ;
344+ if ( ! ov . cancelled ) cal . events . push ( finish ( ov , `${ uid } #r${ p . startMs } ` ) ) ;
345+ continue ;
346+ }
347+ cal . events . push ( finish ( p , uid ) ) ;
348+ }
349+ for ( const p of protos ) {
350+ if ( p . recurrenceId === undefined || consumed . has ( p ) || p . cancelled ) continue ;
351+ const uid = p . uid ?? `ics-${ ++ uidSeq } ` ;
352+ cal . events . push ( finish ( p , `${ uid } #r${ p . recurrenceId } ` ) ) ; // orphan override
353+ }
299354 return cal ;
300355}
301356
0 commit comments