mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: apply subscribed feed changes to imported events and stop a feed sync when the account changes
This commit is contained in:
@@ -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})",
|
||||
|
||||
+4
-3
@@ -1203,8 +1203,9 @@ export async function createCalendar(
|
||||
name: string,
|
||||
color?: string,
|
||||
description?: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<Calendar> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const props: Record<string, unknown> = { 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<string | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
export async function getCalendarName(id: string, targetAccountId?: string): Promise<string | null> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']],
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,6 +310,11 @@ let tasksInFlight: Promise<void> | 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() {
|
||||
|
||||
@@ -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<boolean> {
|
||||
async function isMirrorCalendar(calendarId: string, name: string, accountId: string): Promise<boolean> {
|
||||
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<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 res = await fetch(feedUrl, { headers });
|
||||
if (!res.ok) {
|
||||
@@ -186,41 +220,98 @@ async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]>
|
||||
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<void> {
|
||||
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<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[]);
|
||||
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<SubscriptionsState>()(
|
||||
@@ -230,32 +321,44 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
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<SubscriptionsState>()(
|
||||
// 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<SubscriptionsState>()(
|
||||
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<SubscriptionsState>()(
|
||||
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<SubscriptionsState>()(
|
||||
&& !(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<SubscriptionsState>()(
|
||||
});
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user