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"
|
"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
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user