mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: apply a subscribed calendar feed's changes to events it still carries
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
|
||||
/**
|
||||
* What a subscribed iCal feed decides about an event it still carries, in
|
||||
* the form the subscription import writes it. Refreshing a subscription
|
||||
* writes these, and only when the feed changed one of them: alerts,
|
||||
* participants and anything else stay as the server holds them.
|
||||
*/
|
||||
export function feedFields(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
const src = sanitizeOutgoingCalendarEventData(event);
|
||||
const allDay = !!src.showWithoutTime;
|
||||
return {
|
||||
title: src.title ?? '',
|
||||
description: src.description ?? '',
|
||||
start: src.start,
|
||||
// As importEventsUnhooked: CalendarEvent/parse drops a DTEND equal to
|
||||
// DTSTART, the servers store that event with PT0S.
|
||||
duration: allDay ? normalizeAllDayDuration(src.duration) : (src.duration || 'PT0S'),
|
||||
timeZone: allDay ? null : (src.timeZone ?? null),
|
||||
showWithoutTime: allDay,
|
||||
status: src.status ?? 'confirmed',
|
||||
locations: src.locations ?? null,
|
||||
virtualLocations: src.virtualLocations ?? null,
|
||||
recurrenceRules: src.recurrenceRules ?? null,
|
||||
// Not excludedRecurrenceRules: 0.16 refuses that property even as null,
|
||||
// and RFC 5545 has retired EXRULE.
|
||||
recurrenceOverrides: src.recurrenceOverrides ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const RULE_DEFAULTS: Record<string, unknown> = { interval: 1, rscale: 'gregorian', skip: 'omit', firstDayOfWeek: 'mo' };
|
||||
|
||||
/** JSON with sorted keys, without `@type` and a rule's default values. */
|
||||
function canonical(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v) => {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return v;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(v).sort()) {
|
||||
if (k === '@type' || RULE_DEFAULTS[k] === (v as Record<string, unknown>)[k]) continue;
|
||||
out[k] = (v as Record<string, unknown>)[k];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// What an override may change and both servers give back as is. 0.16 adds
|
||||
// `updated` (its own time) and repeats the base duration; 1.0 leaves out
|
||||
// what equals the series, so a missing value is the series' value.
|
||||
const OVERRIDE_FIELDS = ['title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'status'] as const;
|
||||
|
||||
function overridesKey(fields: Partial<CalendarEvent>): string {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [recurrenceId, patch] of Object.entries(fields.recurrenceOverrides ?? {})) {
|
||||
const p = (patch ?? {}) as Record<string, unknown>;
|
||||
out[recurrenceId] = p.excluded
|
||||
? 'excluded'
|
||||
: OVERRIDE_FIELDS.map((k) => (k in p ? p[k] : fields[k]) ?? null);
|
||||
}
|
||||
return canonical(out);
|
||||
}
|
||||
|
||||
const names = (map: Record<string, { name?: string; uri?: string }> | null | undefined, key: 'name' | 'uri') =>
|
||||
Object.values(map ?? {}).map((entry) => entry?.[key] ?? '').sort();
|
||||
|
||||
/** Whether the feed's copy of an event differs from the stored one in what the feed decides. */
|
||||
export function feedChanged(incoming: Partial<CalendarEvent>, stored: Partial<CalendarEvent>): boolean {
|
||||
const a = feedFields(incoming);
|
||||
const b = feedFields(stored);
|
||||
const scalar = ['title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'status'] as const;
|
||||
if (scalar.some((k) => (a[k] ?? null) !== (b[k] ?? null))) return true;
|
||||
if (canonical(names(a.locations as never, 'name')) !== canonical(names(b.locations as never, 'name'))) return true;
|
||||
if (canonical(names(a.virtualLocations as never, 'uri')) !== canonical(names(b.virtualLocations as never, 'uri'))) return true;
|
||||
if (canonical(a.recurrenceRules) !== canonical(b.recurrenceRules)) return true;
|
||||
return overridesKey(a) !== overridesKey(b);
|
||||
}
|
||||
@@ -927,6 +927,8 @@ export interface CalendarEventFilter {
|
||||
operator?: 'AND' | 'OR' | 'NOT';
|
||||
conditions?: CalendarEventFilter[];
|
||||
inCalendars?: string[];
|
||||
/** One calendar: what Stalwart (0.16 and 1.0) takes; 0.16 refuses `inCalendars`. */
|
||||
inCalendar?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
text?: string;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/lib/browser-navigation', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: vi.fn(async () => new Response('BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', { status: 200 })),
|
||||
}));
|
||||
|
||||
import { useCalendarStore } from '../calendar-store';
|
||||
|
||||
// A subscribed feed that moved an event, renamed it or changed its series
|
||||
// used to change nothing: the refresh skipped every UID it already had.
|
||||
// The stored copies below are what Stalwart 0.16 and 1.0 gave back for the
|
||||
// same feed import (CalendarEvent/parse, then the import's create, then
|
||||
// CalendarEvent/get), so an unchanged feed must cause no write on either.
|
||||
|
||||
const PARSED_016: Partial<CalendarEvent>[] = [
|
||||
{ uid: 'e1', title: 'Timed Berlin', description: 'Line one\nLine two', start: '2026-11-10T09:00:00', duration: 'PT1H30M', timeZone: 'Europe/Berlin', status: 'confirmed', locations: { '5960afaa': { '@type': 'Location', name: 'Room 1' } } as never },
|
||||
{ uid: 'e3', title: 'All day', start: '2026-11-12T00:00:00', duration: 'P1D', showWithoutTime: true },
|
||||
{
|
||||
uid: 'e5', title: 'Weekly', start: '2026-11-02T08:00:00', duration: 'PT45M', timeZone: 'Europe/Berlin',
|
||||
recurrenceRules: [{ frequency: 'weekly', count: 6, byDay: [{ day: 'mo' }] }] as never,
|
||||
recurrenceOverrides: {
|
||||
'2026-11-09T08:00:00': { excluded: true },
|
||||
'2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', updated: '2026-10-20T10:00:00Z', duration: 'PT45M', title: 'Weekly moved' },
|
||||
} as never,
|
||||
},
|
||||
{ uid: 'e7', title: 'Zero length', description: 'Zero', start: '2026-11-19T12:00:00', timeZone: 'Etc/UTC' },
|
||||
{
|
||||
uid: 'e8', title: 'With alarm', start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Etc/UTC', status: 'tentative',
|
||||
alerts: { k1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } as never,
|
||||
},
|
||||
];
|
||||
|
||||
const stored = (version: '0.16' | '1.0'): CalendarEvent[] => PARSED_016.map((p) => {
|
||||
const event = { ...p, id: `id-${p.uid}`, calendarIds: { 'cal-sub': true }, useDefaultAlerts: false } as CalendarEvent;
|
||||
if (p.uid === 'e7') event.duration = 'PT0S';
|
||||
if (p.uid === 'e5') {
|
||||
event.recurrenceOverrides = (version === '0.16'
|
||||
// 0.16: its own `updated`, keys in another order.
|
||||
? { '2026-11-09T08:00:00': { excluded: true }, '2026-11-16T08:00:00': { title: 'Weekly moved', updated: '2026-10-26T13:31:01Z', duration: 'PT45M', start: '2026-11-16T10:00:00' } }
|
||||
// 1.0: only what differs from the series.
|
||||
: { '2026-11-09T08:00:00': { excluded: true }, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved' } }) as never;
|
||||
}
|
||||
return event;
|
||||
});
|
||||
|
||||
let client: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
function setUp(version: '0.16' | '1.0', parsed: Partial<CalendarEvent>[], extra: CalendarEvent[] = []) {
|
||||
const events = [...stored(version), ...extra];
|
||||
client = {
|
||||
getServerUrl: vi.fn(() => 'https://mail.example.com'),
|
||||
getUsername: vi.fn(() => 'alice@example.com'),
|
||||
getAccountId: vi.fn(() => 'acc'),
|
||||
getCalendarsAccountId: vi.fn(() => 'acc'),
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'blob' })),
|
||||
parseCalendarEvents: vi.fn(async () => parsed),
|
||||
getCalendarEvents: vi.fn(async () => events.map(({ id, uid, calendarIds }) => ({ id, uid, calendarIds }))),
|
||||
queryCalendarEvents: vi.fn(async () => events),
|
||||
updateCalendarEvent: vi.fn(async () => {}),
|
||||
batchDeleteCalendarEvents: vi.fn(async () => {}),
|
||||
createCalendarEvents: vi.fn(async () => ({ created: [], failed: [] })),
|
||||
};
|
||||
useCalendarStore.setState({
|
||||
calendars: [], events: [], dateRange: null,
|
||||
icalSubscriptions: [{
|
||||
id: 'sub', url: 'https://feed.example/x.ics', calendarId: 'cal-sub', name: 'Feed', color: '#3b82f6',
|
||||
refreshInterval: 60, lastRefreshed: null, updatedAt: '2026-10-01T00:00:00Z', owner: 'https://mail.example.com|alice@example.com',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
const refresh = () => useCalendarStore.getState().refreshICalSubscription(client as unknown as IJMAPClient, 'sub');
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('refreshing an iCal subscription', () => {
|
||||
it.each(['0.16', '1.0'] as const)('writes nothing when the feed did not change (%s)', async (version) => {
|
||||
setUp(version, PARSED_016);
|
||||
await refresh();
|
||||
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
|
||||
expect(client.batchDeleteCalendarEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['0.16', '1.0'] as const)('applies what the feed changed to the events it still carries (%s)', async (version) => {
|
||||
const changed = PARSED_016.map((p) => {
|
||||
if (p.uid === 'e1') return { ...p, start: '2026-11-10T11:00:00', locations: { x: { '@type': 'Location', name: 'Room 2' } } as never };
|
||||
if (p.uid === 'e5') {
|
||||
return {
|
||||
...p,
|
||||
recurrenceRules: [{ frequency: 'weekly', count: 8, byDay: [{ day: 'mo' }] }] as never,
|
||||
recurrenceOverrides: { ...p.recurrenceOverrides, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved again' } } as never,
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
setUp(version, changed);
|
||||
await refresh();
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('id-e1', expect.objectContaining({
|
||||
start: '2026-11-10T11:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H30M', locations: { x: { '@type': 'Location', name: 'Room 2' } },
|
||||
}), false);
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('id-e5', expect.objectContaining({
|
||||
recurrenceRules: [{ frequency: 'weekly', count: 8, byDay: [{ day: 'mo' }] }],
|
||||
recurrenceOverrides: expect.objectContaining({ '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved again' } }),
|
||||
}), false);
|
||||
// The user's alerts are not the feed's to reset.
|
||||
for (const [, patch] of client.updateCalendarEvent.mock.calls) expect(patch).not.toHaveProperty('alerts');
|
||||
});
|
||||
|
||||
it("leaves the user's own event with the feed's UID as it is", async () => {
|
||||
const own = { id: 'id-mine', uid: 'mine', calendarIds: { 'cal-sub': true, personal: true }, title: 'My copy', start: '2026-11-01T09:00:00', duration: 'PT1H' } as unknown as CalendarEvent;
|
||||
setUp('1.0', [...PARSED_016, { uid: 'mine', title: 'Feed copy', start: '2026-11-01T10:00:00', duration: 'PT1H' }], [own]);
|
||||
await refresh();
|
||||
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { getEventStartDate, normalizeAllDayDuration } from '@/lib/calendar-utils
|
||||
import { displayNow } from '@/lib/timezone';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { feedChanged, feedFields } from '@/lib/calendar-feed-diff';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { SchedulingDeniedError } from '@/lib/jmap/scheduling-error';
|
||||
import { hasAnsweredAttendees, participantLimitOf } from '@/lib/calendar-participants';
|
||||
@@ -1925,6 +1926,26 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Events still in the feed take what the feed changed (time, title,
|
||||
// place, recurrence, ...). Only those in the subscription calendar
|
||||
// alone: one also linked to another calendar is the user's own
|
||||
// event with the same UID (see importEventsUnhooked) and stays.
|
||||
const keptUids = new Set(parsedEvents.map(e => e.uid).filter((uid): uid is string =>
|
||||
!!uid && !!existingByUid.get(uid)?.some(e => Object.keys(e.calendarIds || {}).length === 1)));
|
||||
if (keptUids.size > 0) {
|
||||
const stored = await client.queryCalendarEvents({ inCalendar: sub.calendarId });
|
||||
for (const incoming of parsedEvents) {
|
||||
if (!incoming.uid || !keptUids.delete(incoming.uid)) continue;
|
||||
const current = stored.find(e => e.uid === incoming.uid && Object.keys(e.calendarIds || {}).length === 1);
|
||||
if (!current || !feedChanged(incoming, current)) continue;
|
||||
try {
|
||||
await client.updateCalendarEvent(current.originalId || current.id, feedFields(incoming), false);
|
||||
} catch (err) {
|
||||
debug.warn('calendar', 'Failed to apply a feed change to a subscribed event:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import only events that don't already exist on server
|
||||
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user