@@ -335,9 +335,14 @@ export function replaceICalProperty(icalData: string, key: string, newLine: stri
335335 const newLines = newLine !== null ? newLine . split ( / \r ? \n / ) : [ ] ;
336336 lines . splice ( foundIdx , foundEndIdx - foundIdx , ...newLines ) ;
337337 } else if ( newLine !== null ) {
338- // Insert before END:VEVENT
338+ // Insert before the first sub-component (e.g. VALARM) when present —
339+ // RFC 5545 ABNF is `eventprop *alarmc`, so properties must precede alarms.
340+ let insertAt = veventEnd ;
341+ for ( let i = veventStart + 1 ; i < veventEnd ; i ++ ) {
342+ if ( lines [ i ] . trim ( ) . startsWith ( 'BEGIN:' ) ) { insertAt = i ; break ; }
343+ }
339344 const newLines = newLine . split ( / \r ? \n / ) ;
340- lines . splice ( veventEnd , 0 , ...newLines ) ;
345+ lines . splice ( insertAt , 0 , ...newLines ) ;
341346 }
342347
343348 return lines . join ( lineEnding ) ;
@@ -478,13 +483,15 @@ export function removeOrphanedVTimezones(icalData: string): string {
478483 if ( lines [ i ] . trim ( ) === 'END:VTIMEZONE' ) { inTz = false ; continue ; }
479484 if ( ! inTz ) nonTzLines . push ( lines [ i ] ) ;
480485 }
481- const nonTzContent = nonTzLines . join ( '\n' ) ;
486+ // Unfold before scanning so a reference split across a folded line isn't
487+ // missed, and check both bare and quoted parameter forms.
488+ const nonTzContent = nonTzLines . join ( '\n' ) . replace ( / \n [ \t ] / g, '' ) ;
482489
483490 // Check each VTIMEZONE for references
484491 const orphaned = tzBlocks . filter ( tz => {
485492 if ( ! tz . tzid ) return false ;
486- // Check for ;TZID=< tzid> references in any property
487- return ! nonTzContent . includes ( `;TZID=${ tz . tzid } ` ) ;
493+ return ! nonTzContent . includes ( ` ;TZID=${ tz . tzid } ` ) &&
494+ ! nonTzContent . includes ( `;TZID=" ${ tz . tzid } " ` ) ;
488495 } ) ;
489496
490497 // Remove orphaned blocks in reverse order
@@ -535,8 +542,9 @@ export function removeExceptionVEvents(icalData: string, orphanedRecurrenceIds:
535542 if ( ! block . recurrenceId ) return false ; // master VEVENT — never remove
536543 const recIdFormatted = formatICalDate ( block . recurrenceId ) ;
537544 if ( ! recIdFormatted ) return false ;
538- // Compare as UTC ISO string — handles both date-only and datetime
539- const recDate = new Date ( recIdFormatted ) ;
545+ // Compare in a fixed UTC frame — naive datetimes must not be interpreted
546+ // in the process's local timezone (must match orphan-detection's frame).
547+ const recDate = parseICalDateAsUTC ( recIdFormatted ) ;
540548 const recDateStr = recDate . toISOString ( ) . replace ( / \. \d { 3 } Z $ / , 'Z' ) ;
541549 return orphanedDateStrings . includes ( recDateStr ) ;
542550 } ) ;
@@ -701,10 +709,17 @@ export function unescapeICalText(value: string): string {
701709 */
702710export function escapeICalText ( value : string ) : string {
703711 return value
712+ // Normalize CRLF and BARE CR to LF first — a lone \r would otherwise pass
713+ // through untouched and act as a line terminator for downstream parsers,
714+ // reopening the property-injection class the date paths are guarded against.
715+ . replace ( / \r \n ? / g, '\n' )
716+ // Strip remaining control characters (HTAB is legal in iCal TEXT; LF is
717+ // escaped below).
718+ . replace ( / [ \x00 - \x08 \x0B \x0C \x0E - \x1F \x7F ] / g, '' )
704719 . replace ( / \\ / g, '\\\\' )
705720 . replace ( / ; / g, '\\;' )
706721 . replace ( / , / g, '\\,' )
707- . replace ( / \r ? \ n/ g, '\\n' ) ;
722+ . replace ( / \n / g, '\\n' ) ;
708723}
709724
710725/**
@@ -857,7 +872,7 @@ function formatDateTimeProperty(
857872 if ( originalVevent ) {
858873 const rawLines = parseAllICalProperties ( originalVevent , propName ) ;
859874 if ( rawLines . length > 0 ) {
860- const tzMatch = rawLines [ 0 ] . match ( / ; T Z I D = ( [ ^ ; : ] + ) / ) ;
875+ const tzMatch = rawLines [ 0 ] . match ( / ; T Z I D = ( " [ ^ " ] * " | [ ^ ; : ] + ) / ) ;
861876 if ( tzMatch ) {
862877 return { line : foldICalLine ( `${ propName } ;TZID=${ tzMatch [ 1 ] } :${ icalTime } ` , lineEnding ) , isDateOnly : false } ;
863878 }
@@ -866,7 +881,7 @@ function formatDateTimeProperty(
866881 if ( propName === 'DTEND' ) {
867882 const startLines = parseAllICalProperties ( originalVevent , 'DTSTART' ) ;
868883 if ( startLines . length > 0 ) {
869- const tzMatch = startLines [ 0 ] . match ( / ; T Z I D = ( [ ^ ; : ] + ) / ) ;
884+ const tzMatch = startLines [ 0 ] . match ( / ; T Z I D = ( " [ ^ " ] * " | [ ^ ; : ] + ) / ) ;
870885 if ( tzMatch ) {
871886 return { line : foldICalLine ( `${ propName } ;TZID=${ tzMatch [ 1 ] } :${ icalTime } ` , lineEnding ) , isDateOnly : false } ;
872887 }
@@ -920,6 +935,43 @@ function nextDay(dateStr: string): string {
920935 return d . toISOString ( ) . slice ( 0 , 10 ) ;
921936}
922937
938+ /**
939+ * Parse an ISO-ish date/datetime string in a fixed UTC frame.
940+ * Naive datetimes ("2026-03-20T09:30:00") are interpreted as UTC — matching
941+ * rrule's naive-as-UTC convention — instead of the process's local timezone,
942+ * which `new Date(...)` would use. Without this, orphaned-exception detection
943+ * compares RECURRENCE-IDs and RRULE occurrences in two different timezone
944+ * frames whenever TZ != UTC, flagging valid exceptions as orphans.
945+ */
946+ export function parseICalDateAsUTC ( iso : string ) : Date {
947+ if ( / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( iso ) ) return new Date ( iso + 'T00:00:00Z' ) ;
948+ if ( / Z $ | [ + - ] \d { 2 } : ? \d { 2 } $ / . test ( iso ) ) return new Date ( iso ) ;
949+ return new Date ( iso + 'Z' ) ;
950+ }
951+
952+ /**
953+ * Reorder VEVENT blocks so the master (no RECURRENCE-ID) comes first.
954+ * RFC 5545/4791 do not guarantee component ordering — a resource authored by
955+ * a third-party client may list an overridden instance before the master.
956+ * All in-place patch helpers target the first VEVENT, so without this
957+ * normalization an exception-first payload would have its exception patched
958+ * (and the recurring-event guard skipped) instead of the master.
959+ */
960+ export function normalizeMasterVEventFirst ( icalData : string ) : string {
961+ const vevents = icalData . match ( / B E G I N : V E V E N T [ \s \S ] * ?E N D : V E V E N T / g) || [ ] ;
962+ if ( vevents . length < 2 ) return icalData ;
963+ const first = vevents [ 0 ] ;
964+ if ( ! first || ! / ^ R E C U R R E N C E - I D [ ; : ] / m. test ( first ) ) return icalData ;
965+ const master = vevents . find ( v => ! / ^ R E C U R R E N C E - I D [ ; : ] / m. test ( v ) ) ;
966+ if ( ! master ) return icalData ;
967+ // Swap the two blocks. Function replacements avoid `$`-pattern expansion.
968+ const SENTINEL = '\u0000MASTER-VEVENT\u0000' ;
969+ let out = icalData . replace ( master , ( ) => SENTINEL ) ;
970+ out = out . replace ( first , ( ) => master ) ;
971+ out = out . replace ( SENTINEL , ( ) => first ) ;
972+ return out ;
973+ }
974+
923975export class CalDAVCalendarClient {
924976 private config : CalDAVConfig ;
925977 private client : DAVClient | null = null ;
@@ -1164,14 +1216,18 @@ export class CalDAVCalendarClient {
11641216 const lineEnding = detectLineEnding ( obj . data ) ;
11651217 const fold = ( line : string ) => foldICalLine ( line , lineEnding ) ;
11661218
1219+ // All patch helpers target the FIRST VEVENT — make sure that's the master,
1220+ // not an overridden instance (component order is not guaranteed by RFC).
1221+ const normalizedData = normalizeMasterVEventFirst ( obj . data ) ;
1222+
11671223 // Capture original VEVENT before any patching for reads
1168- const originalVevent = extractVEvent ( obj . data ) ;
1224+ const originalVevent = extractVEvent ( normalizedData ) ;
11691225 if ( ! originalVevent ) {
11701226 throw new Error ( 'Cannot update event: no VEVENT block found' ) ;
11711227 }
11721228
11731229 const existingUid = parseICalValue ( originalVevent , 'UID' ) || eventId ;
1174- let data = obj . data ;
1230+ let data = normalizedData ;
11751231
11761232 // --- Recurring event guard ---
11771233 const hasRRule = / ^ R R U L E [ ; : ] / m. test ( originalVevent ) ;
@@ -1207,7 +1263,10 @@ export class CalDAVCalendarClient {
12071263 if ( ! recIdRaw ) continue ;
12081264 const recIdFormatted = formatICalDate ( recIdRaw ) ;
12091265 if ( ! recIdFormatted ) continue ;
1210- const recDate = new Date ( recIdFormatted ) ;
1266+ // Parse naive datetimes as UTC to match rrule's naive-as-UTC
1267+ // convention — new Date() would use the process's local TZ and
1268+ // flag every valid exception as an orphan when TZ != UTC.
1269+ const recDate = parseICalDateAsUTC ( recIdFormatted ) ;
12111270 // Check if this recurrence-id still matches an occurrence
12121271 const matches = rule . between (
12131272 new Date ( recDate . getTime ( ) - 1000 ) ,
0 commit comments