diff --git a/src/lib/__tests__/override-patch.test.ts b/src/lib/__tests__/override-patch.test.ts new file mode 100644 index 00000000..db847503 --- /dev/null +++ b/src/lib/__tests__/override-patch.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { applyOverridePatch } from '../recurrence-instances'; +import type { CalendarEvent } from '../../api/types'; + +// A recurrence override is an RFC 8984 PatchObject: its keys are JSON +// Pointers into the occurrence, and null removes what one points at. Stalwart +// 1.0 returns minimal patches (jmap/src/calendar_event/get.rs, conformance +// test "Figures 2-6"); 0.16 returned whole top-level values. + +const series = { + id: 'b', + title: 'Team sync', + description: 'Agenda', + locations: { l1: { '@type': 'Location', name: 'Room A' } }, + participants: { + org: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' }, + tom: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'needs-action' }, + 'a/b': { '@type': 'Participant', calendarAddress: 'mailto:c@example.org', participationStatus: 'needs-action' }, + 'x~y': { '@type': 'Participant', calendarAddress: 'mailto:d@example.org', participationStatus: 'needs-action' }, + }, +} as unknown as CalendarEvent; + +describe('applyOverridePatch', () => { + it('sets what a pointer names on Stalwart 1.0, leaving the rest and the series untouched', () => { + const before = JSON.stringify(series); + const occurrence = applyOverridePatch(series, { 'participants/tom/participationStatus': 'declined' }); + + expect(occurrence.participants?.tom.participationStatus).toBe('declined'); + expect(occurrence.participants?.org).toBe(series.participants?.org); + expect(occurrence).not.toHaveProperty(['participants/tom/participationStatus']); + expect(JSON.stringify(series)).toBe(before); + }); + + it('reads escaped tokens: ~1 is a slash, ~0 a tilde', () => { + const occurrence = applyOverridePatch(series, { + 'participants/a~1b/participationStatus': 'accepted', + 'participants/x~0y/participationStatus': 'tentative', + }); + expect(occurrence.participants?.['a/b'].participationStatus).toBe('accepted'); + expect(occurrence.participants?.['x~y'].participationStatus).toBe('tentative'); + }); + + it('removes what null points at, at the top and further down', () => { + const occurrence = applyOverridePatch(series, { description: null, 'participants/tom': null, 'links/none': null }); + expect(occurrence).not.toHaveProperty('description'); + expect(Object.keys(occurrence.participants ?? {})).toEqual(['org', 'a/b', 'x~y']); + // Nothing to remove under a member that is not there. + expect(occurrence).not.toHaveProperty('links'); + }); + + it('creates the members a pointer needs', () => { + const occurrence = applyOverridePatch(series, { 'keywords/$flagged': true }); + expect((occurrence as unknown as { keywords: unknown }).keywords).toEqual({ $flagged: true }); + }); + + it('applies whole values the way 0.16 returns them, completing participants from the series', () => { + const occurrence = applyOverridePatch(series, { + title: 'Moved sync', + start: '2026-09-08T14:00:00', + participants: { + tom: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'declined' }, + n1: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org' }, + }, + }); + expect(occurrence.title).toBe('Moved sync'); + expect(occurrence.start).toBe('2026-09-08T14:00:00'); + expect(occurrence.participants).toEqual({ + tom: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'declined' }, + org: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' }, + }); + }); + + it('applies shorter paths first, so a pointer refines a value set whole in the same patch', () => { + const occurrence = applyOverridePatch(series, { + 'locations/l1/name': 'Room B', + locations: { l1: { '@type': 'Location', name: 'Room C', description: 'Second floor' } }, + }); + expect(occurrence.locations).toEqual({ l1: { '@type': 'Location', name: 'Room B', description: 'Second floor' } }); + }); +}); diff --git a/src/lib/__tests__/recurrence-expansion.test.ts b/src/lib/__tests__/recurrence-expansion.test.ts index 29fa8348..f2552624 100644 --- a/src/lib/__tests__/recurrence-expansion.test.ts +++ b/src/lib/__tests__/recurrence-expansion.test.ts @@ -296,3 +296,107 @@ describe('expandRecurringEvents - event timezone DST handling', () => { expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z'); }); }); + +// Overrides as Stalwart 1.0 returns them - RFC 8984 PatchObjects with keys +// like participants//participationStatus and nulls that remove - and +// moved or resized occurrences. The expansion used to spread an override +// onto the occurrence, so pointer keys became junk properties, a declined +// occurrence stayed accepted, and a moved one kept the time, length and +// place in the range the rule gave it. +describe('expandRecurringEvents - override patches', () => { + // A daily 09:00-10:00 in Berlin (UTC+1 in January). + const berlinDaily = (recurrenceOverrides: CalendarEvent['recurrenceOverrides']) => ev({ + start: '2025-01-06T09:00:00', + timeZone: 'Europe/Berlin', + utcStart: '2025-01-06T08:00:00Z', + utcEnd: '2025-01-06T09:00:00Z', + recurrenceRules: [{ frequency: 'daily' }], + recurrenceOverrides, + }); + + it('places a moved occurrence at its own start, in its time zone', () => { + const result = expandRecurringEvents( + [berlinDaily({ '2025-01-07T09:00:00': { start: '2025-01-07T14:30:00' } })], + '2025-01-06T00:00:00', '2025-01-09T00:00:00', + ); + const moved = result.find((e) => e.recurrenceId === '2025-01-07T09:00:00')!; + expect(moved.start).toBe('2025-01-07T14:30:00'); + expect(moved.utcStart).toBe('2025-01-07T13:30:00.000Z'); + expect(moved.utcEnd).toBe('2025-01-07T14:30:00.000Z'); + }); + + it('gives a resized occurrence its own length', () => { + const result = expandRecurringEvents( + [berlinDaily({ '2025-01-07T09:00:00': { duration: 'PT2H30M' } })], + '2025-01-06T00:00:00', '2025-01-09T00:00:00', + ); + const resized = result.find((e) => e.recurrenceId === '2025-01-07T09:00:00')!; + expect(resized.utcStart).toBe('2025-01-07T08:00:00.000Z'); + expect(resized.utcEnd).toBe('2025-01-07T10:30:00.000Z'); + }); + + it('shows an occurrence moved into the range from outside it', () => { + const result = expandRecurringEvents( + [berlinDaily({ '2025-01-12T09:00:00': { start: '2025-01-08T15:00:00' } })], + '2025-01-06T00:00:00', '2025-01-10T00:00:00', + ); + expect(result.map((e) => e.recurrenceId)).toEqual([ + '2025-01-06T09:00:00', '2025-01-07T09:00:00', '2025-01-08T09:00:00', '2025-01-09T09:00:00', + '2025-01-12T09:00:00', + ]); + expect(result[4]).toMatchObject({ start: '2025-01-08T15:00:00', utcStart: '2025-01-08T14:00:00.000Z' }); + }); + + it('times occurrences from the duration when the master\'s instants do not parse (seen on 0.16.23)', () => { + // 0.16.23 answered utcStart/utcEnd of a counted daily series' master with + // the year 32548. + const master = berlinDaily({ '2025-01-07T09:00:00': { start: '2025-01-07T14:30:00' } }); + master.utcStart = '32548-12-04T15:30:07Z'; + master.utcEnd = '32548-12-04T15:30:07Z'; + + const result = expandRecurringEvents([master], '2025-01-06T00:00:00', '2025-01-09T00:00:00'); + + expect(result.map((e) => [e.recurrenceId, e.utcStart, e.utcEnd])).toEqual([ + ['2025-01-06T09:00:00', '2025-01-06T08:00:00.000Z', '2025-01-06T09:00:00.000Z'], + ['2025-01-07T09:00:00', '2025-01-07T13:30:00.000Z', '2025-01-07T14:30:00.000Z'], + ['2025-01-08T09:00:00', '2025-01-08T08:00:00.000Z', '2025-01-08T09:00:00.000Z'], + ]); + }); + + it('leaves out an occurrence moved out of the range', () => { + const result = expandRecurringEvents( + [berlinDaily({ '2025-01-07T09:00:00': { start: '2025-01-20T09:00:00' } })], + '2025-01-06T00:00:00', '2025-01-09T00:00:00', + ); + expect(result.map((e) => e.recurrenceId)).toEqual(['2025-01-06T09:00:00', '2025-01-08T09:00:00']); + }); + + it('applies an override returned as a PatchObject on Stalwart 1.0', () => { + const event = berlinDaily({ + '2025-01-07T09:00:00': { + 'participants/att/participationStatus': 'declined', + 'locations/l1/name': 'Room B', + description: null, + } as unknown as Partial, + }); + event.description = 'Agenda'; + event.locations = { l1: { '@type': 'Location', name: 'Room A' } } as CalendarEvent['locations']; + event.participants = { + org: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' }, + att: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'needs-action' }, + } as unknown as CalendarEvent['participants']; + + const result = expandRecurringEvents([event], '2025-01-06T00:00:00', '2025-01-09T00:00:00'); + + const answered = result.find((e) => e.recurrenceId === '2025-01-07T09:00:00')!; + expect(answered.participants?.att.participationStatus).toBe('declined'); + expect(answered.participants?.org.participationStatus).toBe('accepted'); + expect(answered.locations?.l1.name).toBe('Room B'); + expect(answered).not.toHaveProperty('description'); + // No pointer keys left on the occurrence as junk properties. + expect(Object.keys(answered).filter((k) => k.includes('/'))).toEqual([]); + // The other occurrences keep the series' values. + expect(result[0].participants?.att.participationStatus).toBe('needs-action'); + expect(result[0].locations?.l1.name).toBe('Room A'); + }); +}); diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts index 534a2238..c61df179 100644 --- a/src/lib/__tests__/recurrence-instances.test.ts +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -371,3 +371,65 @@ describe('series keys on one occurrence', () => { expect(isOverrideSeriesKeyRejection(new Error('privacy cannot be set inside recurrenceOverrides.'))).toBe(false); }); }); + +// Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects, with +// keys like participants//participationStatus. RFC 8984 does not allow +// keys that are prefixes of one another in one PatchObject. +describe('overrides returned as PatchObjects', () => { + it('merges a change into an override without keys that are prefixes of one another', () => { + const occurrence = ev({ + id: 'm:2026-09-08T10:00:00', originalId: 'm', recurrenceId: '2026-09-08T10:00:00', + start: '2026-09-08T10:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any], + recurrenceOverrides: { + '2026-09-08T10:00:00': { + start: '2026-09-08T10:00:00', + 'participants/tom/participationStatus': 'declined', + locations: { l1: { '@type': 'Location', name: 'Room A' } }, + } as any, + '2026-09-09T10:00:00': { excluded: true } as any, + }, + }); + const participants = { tom: { '@type': 'Participant', participationStatus: 'accepted' } } as any; + + // A whole participants map replaces the patches beneath it; a pointer + // goes into the whole locations value the entry already sets. + expect(buildFallbackOverridePatch(occurrence, { + participants, + 'locations/l1/name': 'Room B', + } as any)).toEqual({ + 'recurrenceOverrides/2026-09-08T10:00:00': { + start: '2026-09-08T10:00:00', + duration: 'PT1H', + participants, + locations: { l1: { '@type': 'Location', name: 'Room B' } }, + }, + }); + }); + + it('tells the override from the series by the members its PatchObject sets on Stalwart 1.0', () => { + const org = { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' }; + const series = ev({ + id: 's1', baseEventId: 'b', recurrenceId: '2026-09-08T10:00:00', title: 'Team sync', + participants: { + org, att: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'needs-action' }, + } as any, + }); + const declined = ev({ + ...series, + participants: { + org, att: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'declined' }, + } as any, + }); + const bases = new Map>([['b', { + id: 'b', duration: 'PT1H', + recurrenceOverrides: { '2026-09-08T10:00:00': { 'participants/att/participationStatus': 'declined' } as any }, + }]]); + + for (const order of [[series, declined], [declined, series]]) { + const [merged, ...rest] = hydrateRecurrenceInstances(order, bases); + expect(rest).toEqual([]); + expect(merged.participants?.att.participationStatus).toBe('declined'); + expect(merged.participants?.org.participationStatus).toBe('accepted'); + } + }); +}); diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index 2fea3b24..1e112ae5 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -15,7 +15,7 @@ import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns'; import type { CalendarEvent, RecurrenceRule } from '../api/types'; -import { isServerRecurrenceInstance } from './recurrence-instances'; +import { applyOverridePatch, isServerRecurrenceInstance, parseDurationSeconds } from './recurrence-instances'; type NDay = { day: string; nthOfPeriod?: number }; @@ -112,30 +112,67 @@ function expandEvent( | undefined; if (override?.excluded) continue; - occurrences.push(createOccurrence(master, date, recurrenceId, override)); + const occurrence = createOccurrence(master, date, recurrenceId, override); + if (isInRange(occurrence, date, override, rangeStart, rangeEnd)) occurrences.push(occurrence); } } - // RDATE-equivalent: overrides may define dates not produced by any rule. + // RDATE-equivalent: overrides may define dates not produced by any rule, + // and move occurrences into the range from outside it. for (const [recurrenceId, rawOverride] of Object.entries(overrides)) { const override = rawOverride as Partial & { excluded?: boolean }; if (override.excluded) continue; const overrideDate = parseISO(recurrenceId); if (isNaN(overrideDate.getTime())) continue; - if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue; // Overrides take precedence over excludedRecurrenceRules (they re-add // a concrete instance), so only dedupe against already-generated dates. const dateKey = occurrenceDateKey(master, overrideDate); if (seenDates.has(dateKey)) continue; - seenDates.add(dateKey); - occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override)); + const occurrence = createOccurrence(master, overrideDate, recurrenceId, override); + if (!isInRange(occurrence, overrideDate, override, rangeStart, rangeEnd)) continue; + seenDates.add(dateKey); + occurrences.push(occurrence); } return occurrences; } +// Override keys that move or resize an occurrence (their first segment). +const TIMING_KEYS = new Set(['start', 'duration', 'timeZone', 'showWithoutTime']); + +/** + * Whether an occurrence belongs in [rangeStart, rangeEnd). One that its + * override moves or resizes goes by its own start and end - where the rule + * put it says nothing about where it is now. Any other goes by `date`, where + * the rule put it, like the plain occurrences around it. + */ +function isInRange( + occurrence: CalendarEvent, + date: Date, + override: Partial | undefined, + rangeStart: Date, + rangeEnd: Date, +): boolean { + const moved = !!override && Object.keys(override).some((key) => TIMING_KEYS.has(key.split('/', 1)[0])); + if (!moved) return date >= rangeStart && date < rangeEnd; + const timed = !occurrence.showWithoutTime; + const start = (timed ? validDate(occurrence.utcStart) : null) ?? validDate(occurrence.start); + if (!start) return false; + const end = (timed ? validDate(occurrence.utcEnd) : null) + ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000); + if (end <= start) return start >= rangeStart && start < rangeEnd; + return start < rangeEnd && end > rangeStart; +} + +/** `value` parsed, or null when it is missing or does not parse. */ +function validDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = parseISO(value); + return isNaN(date.getTime()) ? null : date; +} + // Cache Intl formatters per IANA timezone id — constructing them is expensive // and expansion runs over hundreds of occurrences. `null` marks ids the // runtime rejected so we don't retry them. @@ -205,55 +242,79 @@ function createOccurrence( ? format(date, "yyyy-MM-dd'T'00:00:00") : format(date, "yyyy-MM-dd'T'HH:mm:ss"); + // The occurrence as the rule generates it, with its override applied as a + // patch: a moved or resized occurrence has its own start, time zone and + // duration from here on. + const generated = { ...master, start: startStr }; + const occurrence = override + ? applyOverridePatch(generated, override as Record) + : generated; + const start = occurrence.start || startStr; + // Compute utcStart/utcEnd for this occurrence so getEventStartDate / // getEventEndDate return the occurrence times rather than the master's. let utcStart: string | undefined; let utcEnd: string | undefined; - if (!master.showWithoutTime) { + const wall = parseISO(start); + if (!occurrence.showWithoutTime && !isNaN(wall.getTime())) { + // Unless the override sets its own duration, the master's length: from + // its instants, else from its duration. 0.16.23 has been seen to return + // instants in the year 32548 for a series' master, which don't parse. let durationMs: number | null = null; - if (master.utcStart && master.utcEnd) { - const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime(); - if (!isNaN(ms)) durationMs = ms; + const ownDuration = override && 'duration' in override ? parseDurationSeconds(occurrence.duration) : null; + const masterStart = validDate(master.utcStart); + const masterEnd = validDate(master.utcEnd); + if (ownDuration !== null) { + durationMs = ownDuration * 1000; + } else if (masterStart && masterEnd) { + durationMs = masterEnd.getTime() - masterStart.getTime(); + } else { + const seconds = parseDurationSeconds(master.duration); + if (seconds !== null) durationMs = seconds * 1000; } // Convert the occurrence's wall time using the event's own timezone, so // occurrences on the other side of a DST transition in that zone keep // their wall time. Reusing the master's fixed UTC offset (the fallback // below) would shift them by the DST delta. - const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null; + const zoned = occurrence.timeZone ? zonedWallTimeToUtc(wall, occurrence.timeZone) : null; if (zoned) { utcStart = zoned.toISOString(); if (durationMs !== null) { utcEnd = new Date(zoned.getTime() + durationMs).toISOString(); } - } else if (master.utcStart && master.start) { + } else if (masterStart && master.start) { // Floating events (or an unrecognized timezone id): keep the master's // offset, which by definition doesn't vary. - const masterLocal = parseISO(master.start); - const masterUtc = parseISO(master.utcStart); - const offsetMs = masterUtc.getTime() - masterLocal.getTime(); - utcStart = new Date(date.getTime() + offsetMs).toISOString(); - if (durationMs !== null) { - utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString(); + const offsetMs = masterStart.getTime() - parseISO(master.start).getTime(); + if (!isNaN(offsetMs)) { + utcStart = new Date(wall.getTime() + offsetMs).toISOString(); + if (durationMs !== null) { + utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString(); + } } } } - return { - ...master, - ...(override || {}), + const result: CalendarEvent = { + ...occurrence, id: `${master.id}:${recurrenceId}`, originalId: master.originalId || master.id, uid: master.uid, calendarIds: master.calendarIds, - start: override?.start || startStr, - ...(utcStart && !override?.utcStart ? { utcStart } : {}), - ...(utcEnd && !override?.utcEnd ? { utcEnd } : {}), + start, recurrenceId, recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides, excludedRecurrenceRules: master.excludedRecurrenceRules, }; + // The master's own instants place the first occurrence, not this one. + if (utcStart) { + result.utcStart = utcStart; + if (utcEnd) result.utcEnd = utcEnd; + else delete result.utcEnd; + } + return result; } // ─── §3.3.3.1 - Implicit byX property addition ────────── diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 8575b316..dafd7bd0 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -22,6 +22,7 @@ import type { CalendarEvent, Participant } from '../api/types'; import { jmapErrorType } from '../api/jmap-result'; +import { pointerTokenValue } from '../api/patch-pointer'; import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from './recurrence-overrides'; /** @@ -147,6 +148,89 @@ export function mergeOverrideParticipants( return merged; } +/** The members a PatchObject key points at (RFC 6901 tokens, decoded). */ +function pointerPath(key: string): string[] { + return key.split('/').map(pointerTokenValue); +} + +/** + * A copy of `target` with the member at `path` set to `value` - or removed, + * when `value` is null - copying every object on the way. Removing beneath + * a member that is not there changes nothing; setting creates it. + */ +function patchedAt(target: object, path: string[], value: unknown): Record { + const copy = (Array.isArray(target) ? [...target] : { ...target }) as Record; + const [name, ...rest] = path; + if (rest.length === 0) { + if (value === null) delete copy[name]; + else copy[name] = value; + return copy; + } + const child = copy[name]; + if (child !== null && typeof child === 'object') copy[name] = patchedAt(child, rest, value); + else if (value !== null) copy[name] = patchedAt({}, rest, value); + return copy; +} + +function valueAt(target: unknown, key: string): unknown { + let value = target; + for (const name of pointerPath(key)) { + if (value === null || typeof value !== 'object') return undefined; + value = (value as Record)[name]; + } + return value; +} + +/** + * `event` with a recurrence override applied. An override is an RFC 8984 + * PatchObject: every key a JSON Pointer into the occurrence, null removing + * what it points at. Stalwart 1.0 returns minimal ones such as + * `participants/p1/participationStatus`; 0.16 returns whole top-level + * values, which apply the same way. Shorter paths go first, so a pointer + * refines a value the same patch sets whole, and a whole `participants` map + * is completed from the series (mergeOverrideParticipants). + */ +export function applyOverridePatch>(event: T, patch: Record): T { + let result: Record = { ...event }; + const keys = Object.keys(patch).sort((a, b) => a.split('/').length - b.split('/').length); + for (const key of keys) { + const value = patch[key]; + if (key === 'participants' && value) { + result.participants = mergeOverrideParticipants( + result.participants as CalendarEvent['participants'], + value as CalendarEvent['participants'], + ); + } else { + result = patchedAt(result, pointerPath(key), value); + } + } + return result as T; +} + +/** + * An override entry with `updates` merged in, without keys that are prefixes + * of one another (RFC 8984 does not allow those in one PatchObject): a key + * replaces whatever the entry patches beneath it, and one beneath a value the + * entry sets goes into that value. + */ +function mergeOverrideEntry(entry: Record, updates: Record): Record { + const merged = { ...entry }; + for (const [key, value] of Object.entries(updates)) { + for (const existing of Object.keys(merged)) { + if (existing.startsWith(`${key}/`)) delete merged[existing]; + } + const holder = Object.keys(merged).find((existing) => key.startsWith(`${existing}/`)); + const held = holder === undefined ? undefined : merged[holder]; + if (holder !== undefined && held !== null && typeof held === 'object') { + merged[holder] = patchedAt(held, pointerPath(key.slice(holder.length + 1)), value); + continue; + } + if (holder !== undefined) delete merged[holder]; + merged[key] = value; + } + return merged; +} + /** * Stalwart lists an occurrence twice under one id - once as the series * generates it, once from its override - when the override ranks below the @@ -176,14 +260,18 @@ function collapseDuplicateInstances( if (!entry) { return group.reduce((a, b) => (Object.keys(b).length > Object.keys(a).length ? b : a)); } - const overrideKeys = Object.keys(entry).filter((k) => k !== 'updated' && !k.includes('/')); - const score = (instance: CalendarEvent) => overrideKeys - .filter((k) => stableJson((instance as unknown as Record)[k]) === stableJson(entry[k])) + // The entry may patch members (Stalwart 1.0 returns overrides as + // PatchObjects such as `participants/p1/participationStatus`): score each + // instance by the members it matches, and take the override's whole + // top-level values for what the entry touches. + const patchKeys = Object.keys(entry).filter((k) => k !== 'updated'); + const score = (instance: CalendarEvent) => patchKeys + .filter((k) => stableJson(valueAt(instance, k)) === stableJson(entry[k])) .length; const override = group.reduce((a, b) => (score(b) > score(a) ? b : a)); const series = group.find((instance) => instance !== override) ?? first; const merged: Record = { ...series }; - for (const k of overrideKeys) { + for (const k of new Set(patchKeys.map(firstPointerSegment))) { merged[k] = k === 'participants' ? mergeOverrideParticipants(series.participants, override.participants) : (override as unknown as Record)[k]; @@ -468,8 +556,10 @@ function overrideEntryPatch( * event: used for occurrences the device expanded, and as the fallback for * servers that reject synthetic ids. The existing override (if any) is kept * underneath so a partial patch (a move that only sets `start`) does not wipe - * fields the occurrence already overrides, and `start`/`duration` are pinned - * the way the server does it for synthetic-id updates. + * fields the occurrence already overrides - merged key by key, as Stalwart + * 1.0 returns it as a PatchObject (mergeOverrideEntry) - and + * `start`/`duration` are pinned the way the server does it for synthetic-id + * updates. */ export function buildFallbackOverridePatch( instance: OccurrenceOverrideContext & Pick, @@ -479,12 +569,11 @@ export function buildFallbackOverridePatch( if (!key) return null; const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record; const { updated: _updated, ...kept } = existing; - return overrideEntryPatch(instance, key, { - ...kept, + return overrideEntryPatch(instance, key, mergeOverrideEntry(kept, { start: instance.start, duration: instance.duration, ...overrideEntryFields(patch), - }); + })); } /** Destroying one occurrence as an override on its base event: exclude it. */