diff --git a/lib/calendar-feed-diff.ts b/lib/calendar-feed-diff.ts new file mode 100644 index 000000000..dd1811312 --- /dev/null +++ b/lib/calendar-feed-diff.ts @@ -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): Partial { + 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 = { 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 = {}; + for (const k of Object.keys(v).sort()) { + if (k === '@type' || RULE_DEFAULTS[k] === (v as Record)[k]) continue; + out[k] = (v as Record)[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): string { + const out: Record = {}; + for (const [recurrenceId, patch] of Object.entries(fields.recurrenceOverrides ?? {})) { + const p = (patch ?? {}) as Record; + out[recurrenceId] = p.excluded + ? 'excluded' + : OVERRIDE_FIELDS.map((k) => (k in p ? p[k] : fields[k]) ?? null); + } + return canonical(out); +} + +const names = (map: Record | 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, stored: Partial): 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); +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 685ce7492..d06f4ab77 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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; diff --git a/stores/__tests__/calendar-subscription-refresh.test.ts b/stores/__tests__/calendar-subscription-refresh.test.ts new file mode 100644 index 000000000..d5458e8fc --- /dev/null +++ b/stores/__tests__/calendar-subscription-refresh.test.ts @@ -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()), + 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[] = [ + { 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>; + +function setUp(version: '0.16' | '1.0', parsed: Partial[], 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(); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 5973ebbcc..5b5acb9f4 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -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()( } } + // 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));