mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: apply recurrence overrides as patches when the app expands a series
Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects, with keys like participants/p1/participationStatus and nulls that remove. Expanding a series on the device spread them onto the occurrence, so a declined occurrence stayed accepted, a changed room stayed the same and the pointer keys became junk properties. Moved or resized occurrences also kept the time and length the rule gave them, and were placed in the range by their original date. - apply overrides as patches: pointer paths decoded per RFC 6901, null deletes, shorter paths first, a whole participants map completed from the series - time an occurrence from its patched start, time zone and duration, and range-check a moved occurrence by where it is now - fall back to the JSCalendar duration when the master's utcStart or utcEnd don't parse (0.16.23 returned the year 32548 for a counted series), and never keep the master's instants on another occurrence - merge a change into an existing override without keys that are prefixes of one another, and match an occurrence listed twice against the members its override patches The device expands series on 0.16.6 to 0.16.19, after a created series and when a range can't be expanded on the server. 0.16 returns whole override values, which apply the same way.
This commit is contained in:
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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/<id>/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<CalendarEvent>,
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/<id>/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<string, Partial<CalendarEvent>>([['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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CalendarEvent> & { 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<CalendarEvent> | 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<string, unknown>)
|
||||
: 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 ──────────
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const copy = (Array.isArray(target) ? [...target] : { ...target }) as Record<string, unknown>;
|
||||
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<string, unknown>)[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<T extends Partial<CalendarEvent>>(event: T, patch: Record<string, unknown>): T {
|
||||
let result: Record<string, unknown> = { ...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<string, unknown>, updates: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>)[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<string, unknown> = { ...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<string, unknown>)[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<CalendarEvent, 'duration'>,
|
||||
@@ -479,12 +569,11 @@ export function buildFallbackOverridePatch(
|
||||
if (!key) return null;
|
||||
const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record<string, unknown>;
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user