diff --git a/locales/rn/en.json b/locales/rn/en.json index d4449b41..edb4c1dc 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -123,6 +123,7 @@ "shared_with": "Shared with" }, "subscription": { + "account_changed": "The account changed while the subscription was syncing. It syncs again in its own account.", "add_feed": "Add a feed", "feed_too_large": "Feed is too large", "fetch_failed": "Could not fetch feed (HTTP {status})", diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 1fcb97c6..05202363 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1203,8 +1203,9 @@ export async function createCalendar( name: string, color?: string, description?: string, + targetAccountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = targetAccountId || jmapClient.accountId; const props: Record = { name, color, isVisible: true, isSubscribed: true }; if (description?.trim()) props.description = description.trim(); const res = await jmapClient.request( @@ -1247,8 +1248,8 @@ export async function setDefaultCalendar( * The name of the user's calendar `id`, or null when the account has no * calendar by that id. */ -export async function getCalendarName(id: string): Promise { - const accountId = jmapClient.accountId; +export async function getCalendarName(id: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']], USING, diff --git a/src/lib/__tests__/calendar-feed-diff.test.ts b/src/lib/__tests__/calendar-feed-diff.test.ts new file mode 100644 index 00000000..d78e4da2 --- /dev/null +++ b/src/lib/__tests__/calendar-feed-diff.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { feedChanged, feedFields } from '../calendar-feed-diff'; + +// What CalendarEvent/parse gives for a feed, and what Stalwart 0.16 and 1.0 +// give back for the same events once imported (the webmail's +// calendar-subscription-refresh test, from both servers). +const PARSED: 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' }, +]; + +function stored(version: '0.16' | '1.0'): Partial[] { + return PARSED.map((p) => { + const event = { ...p, id: `id-${p.uid}`, calendarIds: { sub: true }, useDefaultAlerts: false } as Partial; + if (p.uid === 'e7') event.duration = 'PT0S'; + if (p.uid === 'e5') { + event.recurrenceRules = [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 6, interval: 1, byDay: [{ '@type': 'NDay', day: 'mo' }] }] as never; + 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; + }); +} + +describe('feedChanged', () => { + it.each(['0.16', '1.0'] as const)('finds nothing changed in a feed imported unchanged (%s)', (version) => { + const back = stored(version); + PARSED.forEach((p, i) => expect(feedChanged(p, back[i]), p.uid).toBe(false)); + }); + + it.each(['0.16', '1.0'] as const)('sees a moved event, a new place, a longer series and a moved occurrence (%s)', (version) => { + const back = stored(version); + expect(feedChanged({ ...PARSED[0], start: '2026-11-10T11:00:00' }, back[0])).toBe(true); + expect(feedChanged({ ...PARSED[0], locations: { x: { '@type': 'Location', name: 'Room 2' } } as never }, back[0])).toBe(true); + expect(feedChanged({ ...PARSED[2], recurrenceRules: [{ frequency: 'weekly', count: 8, byDay: [{ day: 'mo' }] }] as never }, back[2])).toBe(true); + expect(feedChanged({ + ...PARSED[2], + recurrenceOverrides: { ...PARSED[2].recurrenceOverrides, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved again' } } as never, + }, back[2])).toBe(true); + expect(feedChanged({ ...PARSED[1], title: 'All day renamed' }, back[1])).toBe(true); + }); +}); + +describe('feedFields', () => { + it('writes what the feed decides and leaves the alerts alone', () => { + const fields = feedFields({ ...PARSED[3], alerts: { a: { '@type': 'Alert' } } as never }); + expect(fields).toMatchObject({ title: 'Zero length', duration: 'PT0S', timeZone: 'Etc/UTC', status: 'confirmed', locations: null }); + expect(fields).not.toHaveProperty('alerts'); + expect(fields).not.toHaveProperty('excludedRecurrenceRules'); + expect(feedFields(PARSED[1])).toMatchObject({ start: '2026-11-12T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }); + }); +}); diff --git a/src/lib/calendar-feed-diff.ts b/src/lib/calendar-feed-diff.ts new file mode 100644 index 00000000..6b2cf499 --- /dev/null +++ b/src/lib/calendar-feed-diff.ts @@ -0,0 +1,77 @@ +import type { CalendarEvent } from '../api/types'; +import { normalizeAllDayDuration } from './calendar-utils'; + +/** + * 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. Mirrors the + * webmail's lib/calendar-feed-diff. + */ +export function feedFields(event: Partial): Partial { + const allDay = !!event.showWithoutTime; + return { + title: event.title ?? '', + description: event.description ?? '', + start: allDay && event.start ? `${event.start.slice(0, 10)}T00:00:00` : event.start, + // CalendarEvent/parse drops a DTEND equal to DTSTART, and the servers + // store that event with PT0S. + duration: allDay ? normalizeAllDayDuration(event.duration) : (event.duration || 'PT0S'), + timeZone: allDay ? null : (event.timeZone ?? null), + showWithoutTime: allDay, + status: event.status ?? 'confirmed', + locations: event.locations ?? null, + virtualLocations: event.virtualLocations ?? null, + recurrenceRules: event.recurrenceRules ?? null, + // Not excludedRecurrenceRules: 0.16 refuses that property even as null, + // and RFC 5545 has retired EXRULE. + recurrenceOverrides: event.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); +} + +function names(map: Record | null | undefined, key: 'name' | 'uri'): string[] { + return 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/src/stores/__tests__/calendar-subscriptions-store.test.ts b/src/stores/__tests__/calendar-subscriptions-store.test.ts index 7d827c1c..6eb7f5e3 100644 --- a/src/stores/__tests__/calendar-subscriptions-store.test.ts +++ b/src/stores/__tests__/calendar-subscriptions-store.test.ts @@ -7,21 +7,25 @@ vi.mock('../../api/calendar', () => ({ parseCalendarBlob: vi.fn(), queryEvents: vi.fn(), getEventIdentities: vi.fn(), + getEvents: vi.fn(async () => []), deleteEvents: vi.fn(), updateEvent: vi.fn(), updateCalendar: vi.fn(), })); vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) })); -vi.mock('../../api/jmap-client', () => ({ - jmapClient: { accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org' }, +const client = vi.hoisted(() => ({ + accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org', })); +vi.mock('../../api/jmap-client', () => ({ jmapClient: client })); const calendarStore = vi.hoisted(() => ({ + generation: 0, importEvents: vi.fn(async (_events: unknown[], _calendarId: string) => ({ created: 1, updated: 0 })), fetchCalendars: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), })); vi.mock('../calendar-store', () => ({ useCalendarStore: { getState: () => calendarStore }, + calendarStoreGeneration: () => calendarStore.generation, })); vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -87,7 +91,7 @@ describe('updateSubscription', () => { await useCalendarSubscriptionsStore.getState().updateSubscription('s', { name: 'Holidays', color: '#FF2968FF' }); - expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' }); + expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' }, 'acc-1'); expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays'); }); }); @@ -145,7 +149,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000'); + expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000', undefined, 'acc-1'); expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'e1' })], 'n1'); expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); expect(only().lastSyncAt).not.toBeNull(); @@ -196,12 +200,12 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c'); + expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c', 'acc-1'); expect(calendarApi.queryEvents).not.toHaveBeenCalled(); expect(calendarApi.deleteEvents).not.toHaveBeenCalled(); expect(calendarApi.updateEvent).not.toHaveBeenCalled(); // The feed goes into a new mirror, as for an unlinked subscription. - expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000'); + expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000', undefined, 'acc-1'); expect(calendarStore.importEvents.mock.calls.map(([, calendarId]) => calendarId)).toEqual(['n1']); expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); }); @@ -228,7 +232,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']); + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1'); }); it('destroys only a calendar that carries the subscription name on remove', async () => { @@ -238,7 +242,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); expect(calendarApi.deleteCalendar).not.toHaveBeenCalled(); await useCalendarSubscriptionsStore.getState().removeSubscription('mine-2'); - expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c'); + expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c', 'acc-1'); }); it('renames only a calendar that carries the subscription name', async () => { @@ -264,10 +268,10 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { vi.mocked(calendarApi.queryEvents).mockResolvedValue(['x1']); vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'x1', uid: 'gone', calendarIds: { c: true } }] as never); await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']); + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1'); expect(calendarApi.createCalendar).not.toHaveBeenCalled(); await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); - expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c'); + expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c', 'acc-1'); }); it('keeps its name when the mirror\'s name cannot be read', async () => { @@ -360,3 +364,87 @@ describe('subscriptions made before they recorded their account', () => { expect(only()).toEqual({ ...legacy, accountId: 'acc-1' }); }); }); + +describe('refreshing a subscription', () => { + const ME = 'user@mail.example.org'; + const sub = (): CalendarSubscription => ({ + id: 'feed', name: 'Holidays', url: 'https://feeds.example/holidays.ics', calendarId: 'b', + accountId: 'acc-1', localAccountId: ME, lastSyncAt: 5, lastError: null, + }); + const only = () => useCalendarSubscriptionsStore.getState().subscriptions[0]; + const feed = [ + { uid: 'kept', title: 'Moved upstream', start: '2027-01-02T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + { uid: 'same', title: 'Unchanged', start: '2027-01-03T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + { uid: 'mine', title: 'Feed copy', start: '2027-01-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + { uid: 'new', title: 'New upstream', start: '2027-01-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + client.accountId = 'acc-1'; + calendarStore.generation = 0; + useCalendarSubscriptionsStore.setState({ syncing: {}, subscriptions: [sub()] }); + globalThis.fetch = vi.fn(async () => ({ + ok: true, status: 200, headers: { get: () => null }, text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', + })) as unknown as typeof fetch; + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays'); + vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue(feed as never); + vi.mocked(calendarApi.queryEvents).mockResolvedValue(['c', 'd', 'e', 'f']); + vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([ + { id: 'c', uid: 'kept', calendarIds: { b: true } }, + { id: 'd', uid: 'same', calendarIds: { b: true } }, + // The user's own event with the feed's UID, also in their calendar. + { id: 'e', uid: 'mine', calendarIds: { b: true, personal: true } }, + { id: 'f', uid: 'gone', calendarIds: { b: true } }, + ] as never); + vi.mocked(calendarApi.getEvents).mockResolvedValue([ + { id: 'c', uid: 'kept', title: 'Before', start: '2027-01-02T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { b: true } }, + { id: 'd', uid: 'same', title: 'Unchanged', start: '2027-01-03T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', status: 'confirmed', calendarIds: { b: true } }, + ] as never); + }); + + it('gives the events still in the feed what it changed and imports only the new ones', async () => { + await useCalendarSubscriptionsStore.getState().syncSubscription('feed'); + + expect(calendarApi.getEvents).toHaveBeenCalledWith(['c', 'd'], 'acc-1'); + expect(calendarApi.updateEvent).toHaveBeenCalledTimes(1); + expect(calendarApi.updateEvent).toHaveBeenCalledWith('c', expect.objectContaining({ + title: 'Moved upstream', start: '2027-01-02T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + }), false, 'acc-1'); + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['f'], undefined, 'acc-1'); + expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'new' })], 'b'); + expect(only().lastError).toBeNull(); + }); + + it('stops when the client serves another account, whose calendar and events have the same ids', async () => { + // Both accounts have calendar "b" with events "c" to "f". + vi.mocked(calendarApi.queryEvents).mockImplementationOnce(async () => { + client.accountId = 'acc-2'; + calendarStore.generation += 1; + return ['c', 'd', 'e', 'f']; + }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('feed'); + + expect(calendarApi.queryEvents).toHaveBeenCalledWith(['b'], '', '', 'acc-1'); + expect(calendarApi.getEventIdentities).not.toHaveBeenCalled(); + expect(calendarApi.deleteEvents).not.toHaveBeenCalled(); + expect(calendarApi.updateEvent).not.toHaveBeenCalled(); + expect(calendarStore.importEvents).not.toHaveBeenCalled(); + // Not the subscription's failure: it syncs again in its own account. + expect(only()).toMatchObject({ lastSyncAt: 5, lastError: null }); + }); + + it('stops between its writes when the account changes', async () => { + vi.mocked(calendarApi.deleteEvents).mockImplementationOnce(async () => { + calendarStore.generation += 1; + }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('feed'); + + expect(calendarApi.deleteEvents).toHaveBeenCalledTimes(1); + expect(calendarApi.getEvents).not.toHaveBeenCalled(); + expect(calendarApi.updateEvent).not.toHaveBeenCalled(); + expect(calendarStore.importEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 86df2b92..fa000b60 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -310,6 +310,11 @@ let tasksInFlight: Promise | null = null; // one still in flight. let storeGeneration = 0; +/** The store's reset count: work started under an earlier one is the previous account's. */ +export function calendarStoreGeneration(): number { + return storeGeneration; +} + /** A range load the store's reset overtook: what it read belongs to the account before. */ export class CalendarAccountChangedError extends Error { constructor() { diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 113dffb9..3b5d8083 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -9,6 +9,7 @@ import { parseCalendarBlob, queryEvents, getEventIdentities, + getEvents, deleteEvents, updateEvent, updateCalendar, @@ -16,9 +17,10 @@ import { import { jmapClient } from '../api/jmap-client'; import { uploadBytes } from '../api/blob'; import { generateAccountId } from '../lib/account-utils'; +import { feedChanged, feedFields } from '../lib/calendar-feed-diff'; import { readServerPrimaryAccountId } from '../lib/server-fingerprint'; import { useAccountStore } from './account-store'; -import { useCalendarStore } from './calendar-store'; +import { calendarStoreGeneration, useCalendarStore } from './calendar-store'; import { t } from './locale-store'; export interface CalendarSubscription { @@ -113,9 +115,9 @@ export function currentLocalAccountId(): string | null { // A calendar under the recorded id with another name is not the mirror: the // id may be reused by a replaced server, or the user renamed the calendar. // False when the server can't be asked. -async function isMirrorCalendar(calendarId: string, name: string): Promise { +async function isMirrorCalendar(calendarId: string, name: string, accountId: string): Promise { try { - return (await getCalendarName(calendarId)) === name; + return (await getCalendarName(calendarId, accountId)) === name; } catch { return false; } @@ -172,7 +174,39 @@ function base64(input: string): string { return input; } -async function fetchAndParseFeed(url: string): Promise[]> { +/** + * The account a sync runs for, taken when it starts: the JMAP account the + * client serves and the calendar store's reset count. Calendar and event ids + * repeat across accounts (two accounts can both have calendar "b" with events + * "c" and "d"), so a sync still under way after an account switch must stop + * before its next request: it would drop the next account's events and + * import the feed into it. + */ +interface SyncScope { + accountId: string; + generation: number; +} + +function syncScope(): SyncScope | null { + const accountId = currentAccountId(); + return accountId ? { accountId, generation: calendarStoreGeneration() } : null; +} + +/** The account changed since the sync started. */ +class SubscriptionAccountChangedError extends Error { + constructor() { + super(t('calendar.subscription.account_changed', 'The account changed while the subscription was syncing. It syncs again in its own account.')); + this.name = 'SubscriptionAccountChangedError'; + } +} + +function assertScope(scope: SyncScope): void { + if (currentAccountId() !== scope.accountId || calendarStoreGeneration() !== scope.generation) { + throw new SubscriptionAccountChangedError(); + } +} + +async function fetchAndParseFeed(url: string, scope: SyncScope): Promise[]> { const { url: feedUrl, headers } = normalizeFeedUrl(url); const res = await fetch(feedUrl, { headers }); if (!res.ok) { @@ -186,41 +220,98 @@ async function fetchAndParseFeed(url: string): Promise[]> if (!/BEGIN:VCALENDAR/i.test(text)) { throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed')); } + assertScope(scope); const bytes = new TextEncoder().encode(text); - const { blobId } = await uploadBytes(bytes, 'text/calendar'); - const events = await parseCalendarBlob(blobId); + const { blobId } = await uploadBytes(bytes, 'text/calendar', scope.accountId); + assertScope(scope); + const events = await parseCalendarBlob(blobId, scope.accountId); + assertScope(scope); return events.filter((e) => !!e.start); } -// Reconcile the mirror calendar with the remote feed: drop events whose UID -// disappeared upstream (unlinking rather than deleting when the event also -// lives in another calendar), then import the current set (importEvents -// dedupes by UID, so unchanged events are left in place). Callers make sure -// `calendarId` is the mirror: nothing goes into, nor is dropped from, another -// calendar. One made for this sync has nothing to drop. -async function syncFeedIntoCalendar(calendarId: string, url: string, dropStale: boolean): Promise { - const parsed = await fetchAndParseFeed(url); +/** + * Reconcile the mirror calendar with the remote feed. With `linkedBefore` + * (the mirror existed before this sync): drop events whose UID disappeared + * upstream (unlinking rather than deleting when the event also lives in + * another calendar), and give the events still in the feed what it changed + * (time, title, place, recurrence; see lib/calendar-feed-diff). Only those in + * the mirror alone: one also linked to another calendar is the user's own + * event with the same UID and stays. Then import the events the mirror lacks + * (importEvents links a UID another calendar holds). Callers make sure + * `calendarId` is the mirror: nothing goes into, nor is dropped from, + * another calendar. One made for this sync has nothing to drop or update. + * Every request names the account the sync started in, and the sync stops + * once the client serves another (SubscriptionAccountChangedError). + */ +async function syncFeedIntoCalendar( + calendarId: string, + url: string, + linkedBefore: boolean, + scope: SyncScope, +): Promise { + const { accountId } = scope; + const parsed = await fetchAndParseFeed(url, scope); + let toImport = parsed; + let wrote = false; - if (dropStale) { + if (linkedBefore) { const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]); try { - const ids = await queryEvents([calendarId], '', ''); - const existing = await getEventIdentities(ids); + const ids = await queryEvents([calendarId], '', '', accountId); + assertScope(scope); + const existing = await getEventIdentities(ids, accountId); + assertScope(scope); + const inMirror = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]); + toImport = parsed.filter((e) => !e.uid || !inMirror.has(e.uid)); + const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); const toDelete: string[] = []; for (const e of stale) { const others = { ...(e.calendarIds || {}) }; delete others[calendarId]; - if (Object.keys(others).length === 0) toDelete.push(e.id); - else await updateEvent(e.id, { calendarIds: others }); + if (Object.keys(others).length === 0) { + toDelete.push(e.id); + } else { + await updateEvent(e.id, { calendarIds: others }, undefined, accountId); + wrote = true; + assertScope(scope); + } } - if (toDelete.length > 0) await deleteEvents(toDelete); - } catch { + if (toDelete.length > 0) { + await deleteEvents(toDelete, undefined, accountId); + wrote = true; + assertScope(scope); + } + + const kept = existing.filter((e) => + e.uid && parsedUids.has(e.uid) && Object.keys(e.calendarIds || {}).length === 1); + if (kept.length > 0) { + const stored = await getEvents(kept.map((e) => e.id), accountId); + assertScope(scope); + const byUid = new Map(stored.filter((e) => e.uid).map((e) => [e.uid!, e])); + for (const incoming of parsed) { + const current = incoming.uid ? byUid.get(incoming.uid) : undefined; + if (!current || !feedChanged(incoming, current)) continue; + byUid.delete(incoming.uid!); + try { + await updateEvent(current.id, feedFields(incoming), false, accountId); + wrote = true; + } catch { + // The next refresh tries again; the rest of the feed goes on. + } + assertScope(scope); + } + } + } catch (err) { + if (err instanceof SubscriptionAccountChangedError) throw err; // Non-fatal: if we can't enumerate existing events, still import new ones. } } - await useCalendarStore.getState().importEvents(parsed, calendarId); + assertScope(scope); + // importEvents shows what it brought in; deletions and changes alone need a reload. + if (toImport.length > 0) await useCalendarStore.getState().importEvents(toImport, calendarId); + else if (wrote) await useCalendarStore.getState().refresh(); } export const useCalendarSubscriptionsStore = create()( @@ -230,32 +321,44 @@ export const useCalendarSubscriptionsStore = create()( syncing: {}, addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => { + const scope = syncScope(); + const localAccountId = currentLocalAccountId(); // Create a dedicated local calendar to hold the feed's events. - const calendar = await createCalendar(name, color); + const calendar = await createCalendar(name, color, undefined, scope?.accountId); const sub: CalendarSubscription = { id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, name, url: url.trim(), color, calendarId: calendar.id, - accountId: currentAccountId() ?? undefined, - localAccountId: currentLocalAccountId() ?? undefined, + accountId: scope?.accountId, + localAccountId: localAccountId ?? undefined, refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES, lastSyncAt: null, lastError: null, }; - // Make the new calendar visible immediately. - await useCalendarStore.getState().fetchCalendars(); try { + if (!scope) throw new SubscriptionAccountChangedError(); + assertScope(scope); + // Make the new calendar visible immediately. + await useCalendarStore.getState().fetchCalendars(); set({ syncing: { ...get().syncing, [sub.id]: true } }); - await syncFeedIntoCalendar(calendar.id, sub.url, false); + await syncFeedIntoCalendar(calendar.id, sub.url, false, scope); set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] }); await useCalendarStore.getState().refresh(); return { ...sub, lastSyncAt: Date.now() }; } catch (err) { + if (err instanceof SubscriptionAccountChangedError) { + // The mirror is in the account the sync started in, which the + // client no longer serves: a request to remove it would reach + // the next account. Kept as that account's subscription, it + // fills its mirror on that account's next sync. + set({ subscriptions: [...get().subscriptions, sub] }); + throw err; + } // Roll back: a bad URL / 404 must not leave a phantom calendar behind. try { - await deleteCalendar(calendar.id); + await deleteCalendar(calendar.id, scope?.accountId); } catch { // best-effort } @@ -284,13 +387,16 @@ export const useCalendarSubscriptionsStore = create()( // changes: a sync recognises the mirror by the subscription's name, // and would take the old-named calendar for another one. if (sub.calendarId && (next.name !== sub.name || next.color !== sub.color)) { - if ((await getCalendarName(sub.calendarId)) === sub.name) { + // Without a session this throws, as the name can't be read. + const scope = syncScope(); + if ((await getCalendarName(sub.calendarId, scope?.accountId)) === sub.name && scope) { + assertScope(scope); // Only what changed: Stalwart 1.0 refuses a colour that is not // #rgb, #rrggbb or a CSS name, which another client may have set. await updateCalendar(sub.calendarId, { ...(next.name !== sub.name ? { name: next.name } : {}), ...(next.color !== sub.color ? { color: next.color } : {}), - }); + }, scope.accountId); void useCalendarStore.getState().fetchCalendars().catch(() => undefined); } } @@ -303,9 +409,11 @@ export const useCalendarSubscriptionsStore = create()( set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) }); // Only a calendar recognisably the mirror is destroyed, with its // events: under a replaced server the recorded id can name another. - if (sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name))) { + const scope = syncScope(); + if (scope && sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name, scope.accountId))) { try { - await deleteCalendar(sub.calendarId); + assertScope(scope); + await deleteCalendar(sub.calendarId, scope.accountId); } catch { // best-effort } @@ -323,12 +431,16 @@ export const useCalendarSubscriptionsStore = create()( const localAccountId = currentLocalAccountId(); if (sub.localAccountId && sub.localAccountId !== localAccountId) return; if (!sub.calendarId && !localAccountId) return; + const scope = syncScope(); + if (!scope) return; set({ syncing: { ...get().syncing, [id]: true } }); try { - const jmapAccountId = currentAccountId(); + const jmapAccountId = scope.accountId; let calendarId = sub.calendarId; // Throws when the server can't be asked; the next sync tries again. - if (calendarId && (await getCalendarName(calendarId)) !== sub.name) { + const mirrorName = calendarId ? await getCalendarName(calendarId, jmapAccountId) : null; + assertScope(scope); + if (calendarId && mirrorName !== sub.name) { // The recorded id names another calendar, or none: the server was // replaced unseen, or the mirror was renamed or removed elsewhere. // The feed goes into its own mirror only: a new one, made as for @@ -338,23 +450,25 @@ export const useCalendarSubscriptionsStore = create()( && !(localAccountId && (await ownsLegacySubscription(sub, localAccountId, [jmapAccountId])))) { return; } + assertScope(scope); calendarId = null; } else if (calendarId && !sub.localAccountId && localAccountId) { // Its mirror is in this account, so it is this account's alone // from now on, not every account's with the same JMAP id. - const owner = { localAccountId, accountId: sub.accountId ?? jmapAccountId ?? undefined }; + const owner = { localAccountId, accountId: sub.accountId ?? jmapAccountId }; set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...owner } : s)) }); } if (!calendarId) { // As addSubscription does. Linked right away, so a feed that // fails below doesn't get another calendar on the next sync. - calendarId = (await createCalendar(sub.name, sub.color)).id; - const linked = { calendarId, accountId: jmapAccountId ?? undefined, localAccountId: localAccountId ?? undefined }; + calendarId = (await createCalendar(sub.name, sub.color, undefined, jmapAccountId)).id; + const linked = { calendarId, accountId: jmapAccountId, localAccountId: localAccountId ?? undefined }; set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) }); + assertScope(scope); await useCalendarStore.getState().fetchCalendars(); } const linkedBefore = calendarId === sub.calendarId; - await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore); + await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore, scope); set({ subscriptions: get().subscriptions.map((s) => s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s, @@ -362,6 +476,9 @@ export const useCalendarSubscriptionsStore = create()( }); if (!linkedBefore) await useCalendarStore.getState().refresh(); } catch (err) { + // Not this subscription's failure: the client serves another + // account now, and the sync runs again in its own. + if (err instanceof SubscriptionAccountChangedError) return; const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription'); set({ subscriptions: get().subscriptions.map((s) =>