fix: apply subscribed feed changes to imported events and stop a feed sync when the account changes

This commit is contained in:
Linus Rath
2026-10-29 02:40:13 +01:00
parent 80962e3079
commit 8a71495e1a
7 changed files with 406 additions and 52 deletions
+1
View File
@@ -123,6 +123,7 @@
"shared_with": "Shared with" "shared_with": "Shared with"
}, },
"subscription": { "subscription": {
"account_changed": "The account changed while the subscription was syncing. It syncs again in its own account.",
"add_feed": "Add a feed", "add_feed": "Add a feed",
"feed_too_large": "Feed is too large", "feed_too_large": "Feed is too large",
"fetch_failed": "Could not fetch feed (HTTP {status})", "fetch_failed": "Could not fetch feed (HTTP {status})",
+4 -3
View File
@@ -1203,8 +1203,9 @@ export async function createCalendar(
name: string, name: string,
color?: string, color?: string,
description?: string, description?: string,
targetAccountId?: string,
): Promise<Calendar> { ): Promise<Calendar> {
const accountId = jmapClient.accountId; const accountId = targetAccountId || jmapClient.accountId;
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true }; const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
if (description?.trim()) props.description = description.trim(); if (description?.trim()) props.description = description.trim();
const res = await jmapClient.request( 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 * The name of the user's calendar `id`, or null when the account has no
* calendar by that id. * calendar by that id.
*/ */
export async function getCalendarName(id: string): Promise<string | null> { export async function getCalendarName(id: string, targetAccountId?: string): Promise<string | null> {
const accountId = jmapClient.accountId; const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request( const res = await jmapClient.request(
[['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']], [['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']],
USING, USING,
@@ -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<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' },
];
function stored(version: '0.16' | '1.0'): Partial<CalendarEvent>[] {
return PARSED.map((p) => {
const event = { ...p, id: `id-${p.uid}`, calendarIds: { sub: true }, useDefaultAlerts: false } as Partial<CalendarEvent>;
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 });
});
});
+77
View File
@@ -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<CalendarEvent>): Partial<CalendarEvent> {
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<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);
}
function names(map: Record<string, { name?: string; uri?: string }> | 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<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);
}
@@ -7,21 +7,25 @@ vi.mock('../../api/calendar', () => ({
parseCalendarBlob: vi.fn(), parseCalendarBlob: vi.fn(),
queryEvents: vi.fn(), queryEvents: vi.fn(),
getEventIdentities: vi.fn(), getEventIdentities: vi.fn(),
getEvents: vi.fn(async () => []),
deleteEvents: vi.fn(), deleteEvents: vi.fn(),
updateEvent: vi.fn(), updateEvent: vi.fn(),
updateCalendar: vi.fn(), updateCalendar: vi.fn(),
})); }));
vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) })); vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) }));
vi.mock('../../api/jmap-client', () => ({ const client = vi.hoisted(() => ({
jmapClient: { accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org' }, accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org',
})); }));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
const calendarStore = vi.hoisted(() => ({ const calendarStore = vi.hoisted(() => ({
generation: 0,
importEvents: vi.fn(async (_events: unknown[], _calendarId: string) => ({ created: 1, updated: 0 })), importEvents: vi.fn(async (_events: unknown[], _calendarId: string) => ({ created: 1, updated: 0 })),
fetchCalendars: vi.fn(async () => undefined), fetchCalendars: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
})); }));
vi.mock('../calendar-store', () => ({ vi.mock('../calendar-store', () => ({
useCalendarStore: { getState: () => calendarStore }, useCalendarStore: { getState: () => calendarStore },
calendarStoreGeneration: () => calendarStore.generation,
})); }));
vi.mock('@react-native-async-storage/async-storage', () => ({ vi.mock('@react-native-async-storage/async-storage', () => ({
default: { default: {
@@ -87,7 +91,7 @@ describe('updateSubscription', () => {
await useCalendarSubscriptionsStore.getState().updateSubscription('s', { name: 'Holidays', color: '#FF2968FF' }); 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'); 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'); 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(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'e1' })], 'n1');
expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null });
expect(only().lastSyncAt).not.toBeNull(); 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'); await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c'); expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c', 'acc-1');
expect(calendarApi.queryEvents).not.toHaveBeenCalled(); expect(calendarApi.queryEvents).not.toHaveBeenCalled();
expect(calendarApi.deleteEvents).not.toHaveBeenCalled(); expect(calendarApi.deleteEvents).not.toHaveBeenCalled();
expect(calendarApi.updateEvent).not.toHaveBeenCalled(); expect(calendarApi.updateEvent).not.toHaveBeenCalled();
// The feed goes into a new mirror, as for an unlinked subscription. // 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(calendarStore.importEvents.mock.calls.map(([, calendarId]) => calendarId)).toEqual(['n1']);
expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); 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'); 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 () => { 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'); await useCalendarSubscriptionsStore.getState().removeSubscription('mine');
expect(calendarApi.deleteCalendar).not.toHaveBeenCalled(); expect(calendarApi.deleteCalendar).not.toHaveBeenCalled();
await useCalendarSubscriptionsStore.getState().removeSubscription('mine-2'); 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 () => { 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.queryEvents).mockResolvedValue(['x1']);
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'x1', uid: 'gone', calendarIds: { c: true } }] as never); vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'x1', uid: 'gone', calendarIds: { c: true } }] as never);
await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']); expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1');
expect(calendarApi.createCalendar).not.toHaveBeenCalled(); expect(calendarApi.createCalendar).not.toHaveBeenCalled();
await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); 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 () => { 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' }); 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();
});
});
+5
View File
@@ -310,6 +310,11 @@ let tasksInFlight: Promise<void> | null = null;
// one still in flight. // one still in flight.
let storeGeneration = 0; 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. */ /** A range load the store's reset overtook: what it read belongs to the account before. */
export class CalendarAccountChangedError extends Error { export class CalendarAccountChangedError extends Error {
constructor() { constructor() {
+156 -39
View File
@@ -9,6 +9,7 @@ import {
parseCalendarBlob, parseCalendarBlob,
queryEvents, queryEvents,
getEventIdentities, getEventIdentities,
getEvents,
deleteEvents, deleteEvents,
updateEvent, updateEvent,
updateCalendar, updateCalendar,
@@ -16,9 +17,10 @@ import {
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { uploadBytes } from '../api/blob'; import { uploadBytes } from '../api/blob';
import { generateAccountId } from '../lib/account-utils'; import { generateAccountId } from '../lib/account-utils';
import { feedChanged, feedFields } from '../lib/calendar-feed-diff';
import { readServerPrimaryAccountId } from '../lib/server-fingerprint'; import { readServerPrimaryAccountId } from '../lib/server-fingerprint';
import { useAccountStore } from './account-store'; import { useAccountStore } from './account-store';
import { useCalendarStore } from './calendar-store'; import { calendarStoreGeneration, useCalendarStore } from './calendar-store';
import { t } from './locale-store'; import { t } from './locale-store';
export interface CalendarSubscription { 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 // 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. // id may be reused by a replaced server, or the user renamed the calendar.
// False when the server can't be asked. // False when the server can't be asked.
async function isMirrorCalendar(calendarId: string, name: string): Promise<boolean> { async function isMirrorCalendar(calendarId: string, name: string, accountId: string): Promise<boolean> {
try { try {
return (await getCalendarName(calendarId)) === name; return (await getCalendarName(calendarId, accountId)) === name;
} catch { } catch {
return false; return false;
} }
@@ -172,7 +174,39 @@ function base64(input: string): string {
return input; return input;
} }
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> { /**
* 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<Partial<CalendarEvent>[]> {
const { url: feedUrl, headers } = normalizeFeedUrl(url); const { url: feedUrl, headers } = normalizeFeedUrl(url);
const res = await fetch(feedUrl, { headers }); const res = await fetch(feedUrl, { headers });
if (!res.ok) { if (!res.ok) {
@@ -186,41 +220,98 @@ async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]>
if (!/BEGIN:VCALENDAR/i.test(text)) { if (!/BEGIN:VCALENDAR/i.test(text)) {
throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed')); 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 bytes = new TextEncoder().encode(text);
const { blobId } = await uploadBytes(bytes, 'text/calendar'); const { blobId } = await uploadBytes(bytes, 'text/calendar', scope.accountId);
const events = await parseCalendarBlob(blobId); assertScope(scope);
const events = await parseCalendarBlob(blobId, scope.accountId);
assertScope(scope);
return events.filter((e) => !!e.start); 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 * Reconcile the mirror calendar with the remote feed. With `linkedBefore`
// lives in another calendar), then import the current set (importEvents * (the mirror existed before this sync): drop events whose UID disappeared
// dedupes by UID, so unchanged events are left in place). Callers make sure * upstream (unlinking rather than deleting when the event also lives in
// `calendarId` is the mirror: nothing goes into, nor is dropped from, another * another calendar), and give the events still in the feed what it changed
// calendar. One made for this sync has nothing to drop. * (time, title, place, recurrence; see lib/calendar-feed-diff). Only those in
async function syncFeedIntoCalendar(calendarId: string, url: string, dropStale: boolean): Promise<void> { * the mirror alone: one also linked to another calendar is the user's own
const parsed = await fetchAndParseFeed(url); * 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<void> {
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[]); const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
try { try {
const ids = await queryEvents([calendarId], '', ''); const ids = await queryEvents([calendarId], '', '', accountId);
const existing = await getEventIdentities(ids); 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 stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
const toDelete: string[] = []; const toDelete: string[] = [];
for (const e of stale) { for (const e of stale) {
const others = { ...(e.calendarIds || {}) }; const others = { ...(e.calendarIds || {}) };
delete others[calendarId]; delete others[calendarId];
if (Object.keys(others).length === 0) toDelete.push(e.id); if (Object.keys(others).length === 0) {
else await updateEvent(e.id, { calendarIds: others }); toDelete.push(e.id);
} else {
await updateEvent(e.id, { calendarIds: others }, undefined, accountId);
wrote = true;
assertScope(scope);
}
} }
if (toDelete.length > 0) await deleteEvents(toDelete); if (toDelete.length > 0) {
} catch { 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. // 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<SubscriptionsState>()( export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
@@ -230,32 +321,44 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
syncing: {}, syncing: {},
addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => { addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => {
const scope = syncScope();
const localAccountId = currentLocalAccountId();
// Create a dedicated local calendar to hold the feed's events. // 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 = { const sub: CalendarSubscription = {
id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
name, name,
url: url.trim(), url: url.trim(),
color, color,
calendarId: calendar.id, calendarId: calendar.id,
accountId: currentAccountId() ?? undefined, accountId: scope?.accountId,
localAccountId: currentLocalAccountId() ?? undefined, localAccountId: localAccountId ?? undefined,
refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES, refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES,
lastSyncAt: null, lastSyncAt: null,
lastError: null, lastError: null,
}; };
// Make the new calendar visible immediately.
await useCalendarStore.getState().fetchCalendars();
try { 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 } }); 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() }] }); set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] });
await useCalendarStore.getState().refresh(); await useCalendarStore.getState().refresh();
return { ...sub, lastSyncAt: Date.now() }; return { ...sub, lastSyncAt: Date.now() };
} catch (err) { } 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. // Roll back: a bad URL / 404 must not leave a phantom calendar behind.
try { try {
await deleteCalendar(calendar.id); await deleteCalendar(calendar.id, scope?.accountId);
} catch { } catch {
// best-effort // best-effort
} }
@@ -284,13 +387,16 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
// changes: a sync recognises the mirror by the subscription's name, // changes: a sync recognises the mirror by the subscription's name,
// and would take the old-named calendar for another one. // and would take the old-named calendar for another one.
if (sub.calendarId && (next.name !== sub.name || next.color !== sub.color)) { 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 // Only what changed: Stalwart 1.0 refuses a colour that is not
// #rgb, #rrggbb or a CSS name, which another client may have set. // #rgb, #rrggbb or a CSS name, which another client may have set.
await updateCalendar(sub.calendarId, { await updateCalendar(sub.calendarId, {
...(next.name !== sub.name ? { name: next.name } : {}), ...(next.name !== sub.name ? { name: next.name } : {}),
...(next.color !== sub.color ? { color: next.color } : {}), ...(next.color !== sub.color ? { color: next.color } : {}),
}); }, scope.accountId);
void useCalendarStore.getState().fetchCalendars().catch(() => undefined); void useCalendarStore.getState().fetchCalendars().catch(() => undefined);
} }
} }
@@ -303,9 +409,11 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) }); set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
// Only a calendar recognisably the mirror is destroyed, with its // Only a calendar recognisably the mirror is destroyed, with its
// events: under a replaced server the recorded id can name another. // 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 { try {
await deleteCalendar(sub.calendarId); assertScope(scope);
await deleteCalendar(sub.calendarId, scope.accountId);
} catch { } catch {
// best-effort // best-effort
} }
@@ -323,12 +431,16 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
const localAccountId = currentLocalAccountId(); const localAccountId = currentLocalAccountId();
if (sub.localAccountId && sub.localAccountId !== localAccountId) return; if (sub.localAccountId && sub.localAccountId !== localAccountId) return;
if (!sub.calendarId && !localAccountId) return; if (!sub.calendarId && !localAccountId) return;
const scope = syncScope();
if (!scope) return;
set({ syncing: { ...get().syncing, [id]: true } }); set({ syncing: { ...get().syncing, [id]: true } });
try { try {
const jmapAccountId = currentAccountId(); const jmapAccountId = scope.accountId;
let calendarId = sub.calendarId; let calendarId = sub.calendarId;
// Throws when the server can't be asked; the next sync tries again. // 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 // The recorded id names another calendar, or none: the server was
// replaced unseen, or the mirror was renamed or removed elsewhere. // replaced unseen, or the mirror was renamed or removed elsewhere.
// The feed goes into its own mirror only: a new one, made as for // The feed goes into its own mirror only: a new one, made as for
@@ -338,23 +450,25 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
&& !(localAccountId && (await ownsLegacySubscription(sub, localAccountId, [jmapAccountId])))) { && !(localAccountId && (await ownsLegacySubscription(sub, localAccountId, [jmapAccountId])))) {
return; return;
} }
assertScope(scope);
calendarId = null; calendarId = null;
} else if (calendarId && !sub.localAccountId && localAccountId) { } else if (calendarId && !sub.localAccountId && localAccountId) {
// Its mirror is in this account, so it is this account's alone // 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. // 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)) }); set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...owner } : s)) });
} }
if (!calendarId) { if (!calendarId) {
// As addSubscription does. Linked right away, so a feed that // As addSubscription does. Linked right away, so a feed that
// fails below doesn't get another calendar on the next sync. // fails below doesn't get another calendar on the next sync.
calendarId = (await createCalendar(sub.name, sub.color)).id; calendarId = (await createCalendar(sub.name, sub.color, undefined, jmapAccountId)).id;
const linked = { calendarId, accountId: jmapAccountId ?? undefined, localAccountId: localAccountId ?? undefined }; const linked = { calendarId, accountId: jmapAccountId, localAccountId: localAccountId ?? undefined };
set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) }); set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) });
assertScope(scope);
await useCalendarStore.getState().fetchCalendars(); await useCalendarStore.getState().fetchCalendars();
} }
const linkedBefore = calendarId === sub.calendarId; const linkedBefore = calendarId === sub.calendarId;
await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore); await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore, scope);
set({ set({
subscriptions: get().subscriptions.map((s) => subscriptions: get().subscriptions.map((s) =>
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s, s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
@@ -362,6 +476,9 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
}); });
if (!linkedBefore) await useCalendarStore.getState().refresh(); if (!linkedBefore) await useCalendarStore.getState().refresh();
} catch (err) { } 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'); const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription');
set({ set({
subscriptions: get().subscriptions.map((s) => subscriptions: get().subscriptions.map((s) =>