Merge pull request #1064 from sanitz/feat/sync-ical-subscriptions

feat(calendar): sync iCal subscriptions via encrypted settings store
This commit is contained in:
Linus Rath
2026-10-15 23:54:37 +02:00
6 changed files with 702 additions and 18 deletions
@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import {
mergeSyncedSubscriptions,
parseSyncedSubscriptions,
parseSubscriptionTombstones,
SUBSCRIPTION_TOMBSTONE_TTL_MS,
} from '../calendar-subscription-sync';
import type { ICalSubscription } from '@/stores/calendar-store';
function makeSubscription(overrides: Partial<ICalSubscription> = {}): ICalSubscription {
return {
id: 'sub-test-id',
url: 'https://example.com/calendar.ics',
calendarId: 'cal-123',
name: 'Test Subscription',
color: '#3b82f6',
refreshInterval: 60,
lastRefreshed: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
const NOW = new Date('2026-03-01T00:00:00Z');
describe('parseSyncedSubscriptions', () => {
it('returns null for non-array values (pre-sync build)', () => {
expect(parseSyncedSubscriptions(undefined)).toBeNull();
expect(parseSyncedSubscriptions({ not: 'an array' })).toBeNull();
});
it('preserves valid subscriptions and normalizes webcal URLs', () => {
const result = parseSyncedSubscriptions([
makeSubscription({ id: 'sub-1', url: 'webcal://example.com/feed.ics' }),
]);
expect(result).toHaveLength(1);
expect(result![0].id).toBe('sub-1');
expect(result![0].url).toBe('https://example.com/feed.ics');
expect(result![0].updatedAt).toBe('2026-01-01T00:00:00Z');
});
it('keeps the owning login so another device only acts through it', () => {
const owner = 'https://mail.example.com|alice@example.com';
const [parsed] = parseSyncedSubscriptions([makeSubscription({ owner })])!;
expect(parsed.owner).toBe(owner);
const [unowned] = parseSyncedSubscriptions([{ ...makeSubscription(), owner: 42 }])!;
expect(unowned.owner).toBeUndefined();
});
it('drops entries without required fields or duplicate ids', () => {
const result = parseSyncedSubscriptions([
makeSubscription({ id: 'a' }),
makeSubscription({ id: 'a', name: 'Duplicate' }),
{ ...makeSubscription(), id: undefined },
{ ...makeSubscription(), url: '' },
{ ...makeSubscription(), url: 'ftp://example.com/unsupported.ics' },
{ ...makeSubscription(), calendarId: '' },
{ ...makeSubscription(), name: ' ' },
'invalid string entry',
]);
expect(result!.map((s) => s.id)).toEqual(['a']);
});
it('normalizes non-finite or negative refresh intervals to default (60m)', () => {
const result = parseSyncedSubscriptions([
makeSubscription({ id: 's1', refreshInterval: -10 }),
makeSubscription({ id: 's2', refreshInterval: NaN as unknown as number }),
makeSubscription({ id: 's3', refreshInterval: Infinity as unknown as number }),
makeSubscription({ id: 's4', refreshInterval: 0 }),
]);
expect(result?.every((s) => s.refreshInterval === 60)).toBe(true);
});
});
describe('parseSubscriptionTombstones', () => {
it('returns empty record for non-record values', () => {
expect(parseSubscriptionTombstones(undefined)).toEqual({});
expect(parseSubscriptionTombstones(['a'])).toEqual({});
});
it('drops invalid date values', () => {
expect(
parseSubscriptionTombstones({ a: '2026-01-02T00:00:00Z', b: 'invalid', c: 123 })
).toEqual({ a: '2026-01-02T00:00:00Z' });
});
});
describe('mergeSyncedSubscriptions', () => {
it('adds subscriptions present in incoming state', () => {
const merged = mergeSyncedSubscriptions(
{ icalSubscriptions: [makeSubscription({ id: 'local' })], deletedSubscriptionIds: {} },
{ icalSubscriptions: [makeSubscription({ id: 'remote' })], deletedSubscriptionIds: {} },
NOW
);
expect(merged.icalSubscriptions.map((s) => s.id).sort()).toEqual(['local', 'remote']);
});
it('preserves subscriptions from multiple distinct accounts during merge', () => {
const merged = mergeSyncedSubscriptions(
{ icalSubscriptions: [makeSubscription({ id: 'sub-1', accountId: 'acc-1' })], deletedSubscriptionIds: {} },
{ icalSubscriptions: [makeSubscription({ id: 'sub-2', accountId: 'acc-2' })], deletedSubscriptionIds: {} },
NOW
);
expect(merged.icalSubscriptions).toHaveLength(2);
expect(merged.icalSubscriptions.map((s) => s.accountId)).toEqual(['acc-1', 'acc-2']);
});
it('keeps newer copy per id, preferring local on tie', () => {
const merged = mergeSyncedSubscriptions(
{
icalSubscriptions: [
makeSubscription({ id: 'a', name: 'Local newer', updatedAt: '2026-02-01T00:00:00Z' }),
makeSubscription({ id: 'b', name: 'Local tie' }),
],
deletedSubscriptionIds: {},
},
{
icalSubscriptions: [
makeSubscription({ id: 'a', name: 'Remote older', updatedAt: '2026-01-15T00:00:00Z' }),
makeSubscription({ id: 'b', name: 'Remote tie' }),
],
deletedSubscriptionIds: {},
},
NOW
);
const byId = Object.fromEntries(merged.icalSubscriptions.map((s) => [s.id, s.name]));
expect(byId).toEqual({ a: 'Local newer', b: 'Local tie' });
});
it('applies incoming tombstone to local subscription', () => {
const merged = mergeSyncedSubscriptions(
{ icalSubscriptions: [makeSubscription({ id: 'a' })], deletedSubscriptionIds: {} },
{ icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } },
NOW
);
expect(merged.icalSubscriptions).toEqual([]);
expect(merged.deletedSubscriptionIds).toEqual({ a: '2026-02-01T00:00:00Z' });
});
it('tombstone wins over subscription on exact timestamp tie (defensive deletion)', () => {
const timestamp = '2026-02-01T00:00:00.000Z';
const merged = mergeSyncedSubscriptions(
{ icalSubscriptions: [makeSubscription({ id: 'a', updatedAt: timestamp })], deletedSubscriptionIds: {} },
{ icalSubscriptions: [], deletedSubscriptionIds: { a: timestamp } },
NOW
);
expect(merged.icalSubscriptions).toEqual([]);
expect(merged.deletedSubscriptionIds).toEqual({ a: timestamp });
});
it('resurrects subscription edited after its deletion and clears tombstone', () => {
const merged = mergeSyncedSubscriptions(
{
icalSubscriptions: [makeSubscription({ id: 'a', updatedAt: '2026-03-01T00:00:00Z' })],
deletedSubscriptionIds: {},
},
{ icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } },
NOW
);
expect(merged.icalSubscriptions.map((s) => s.id)).toEqual(['a']);
expect(merged.deletedSubscriptionIds).toEqual({});
});
it('prunes tombstones older than TTL', () => {
const expired = new Date(NOW.getTime() - SUBSCRIPTION_TOMBSTONE_TTL_MS - 1000).toISOString();
const merged = mergeSyncedSubscriptions(
{ icalSubscriptions: [], deletedSubscriptionIds: { old: expired } },
{ icalSubscriptions: [], deletedSubscriptionIds: {} },
NOW
);
expect(merged.deletedSubscriptionIds).toEqual({});
});
});
+129
View File
@@ -0,0 +1,129 @@
import type { ICalSubscription } from '@/stores/calendar-store';
/**
* Tombstones older than this are pruned from the synced blob. A device that
* stays offline longer than this may resurrect a deletion - the accepted
* trade-off for keeping the blob bounded.
*/
export const SUBSCRIPTION_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
/**
* Identifies the login a subscription belongs to: server plus login name,
* the same key calendar-store records as `ICalSubscription.owner`.
*/
export function subscriptionOwnerFor(serverUrl: string, username: string): string {
return `${serverUrl.replace(/\/+$/, '').toLowerCase()}|${username.toLowerCase()}`;
}
export interface SyncedSubscriptionState {
icalSubscriptions: ICalSubscription[];
/** Subscription id -> ISO time it was deleted. */
deletedSubscriptionIds: Record<string, string>;
}
/**
* Structurally validates an `icalSubscriptions` value from a synced settings blob or
* settings-file import. Returns null when the value is not an array (a blob
* from a build that predates subscription sync), so callers can leave the local
* store untouched; malformed entries are dropped, ids are preserved.
*/
export function parseSyncedSubscriptions(value: unknown): ICalSubscription[] | null {
if (!Array.isArray(value)) return null;
const subscriptions: ICalSubscription[] = [];
const seen = new Set<string>();
const now = new Date().toISOString();
for (const item of value) {
if (typeof item !== 'object' || item === null) continue;
const s = item as Record<string, unknown>;
if (typeof s.id !== 'string' || !s.id || seen.has(s.id)) continue;
if (typeof s.url !== 'string' || !s.url.trim()) continue;
const normalizedUrl = s.url.trim().replace(/^webcals?:\/\//i, 'https://');
if (!/^https?:\/\//i.test(normalizedUrl)) continue;
if (typeof s.calendarId !== 'string' || !s.calendarId.trim()) continue;
if (typeof s.name !== 'string' || !s.name.trim()) continue;
seen.add(s.id);
const refreshInterval = typeof s.refreshInterval === 'number' && Number.isFinite(s.refreshInterval) && s.refreshInterval > 0
? s.refreshInterval
: 60;
subscriptions.push({
id: s.id,
url: normalizedUrl,
calendarId: s.calendarId.trim(),
name: s.name.trim(),
color: typeof s.color === 'string' && s.color ? s.color : '#3b82f6',
refreshInterval,
lastRefreshed: typeof s.lastRefreshed === 'string' ? s.lastRefreshed : null,
accountId: typeof s.accountId === 'string' && s.accountId ? s.accountId : undefined,
owner: typeof s.owner === 'string' && s.owner ? s.owner : undefined,
updatedAt: typeof s.updatedAt === 'string' && s.updatedAt ? s.updatedAt : (typeof s.lastRefreshed === 'string' && s.lastRefreshed ? s.lastRefreshed : now),
});
}
return subscriptions;
}
/** Structurally validates a `deletedSubscriptionIds` map from a synced blob. */
export function parseSubscriptionTombstones(value: unknown): Record<string, string> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [id, deletedAt] of Object.entries(value as Record<string, unknown>)) {
if (typeof deletedAt === 'string' && !Number.isNaN(Date.parse(deletedAt))) {
out[id] = deletedAt;
}
}
return out;
}
/**
* Merges a synced subscription state into the local one. Per subscription id the
* newer `updatedAt` wins (ties keep the local copy); a deletion tombstone
* beats a subscription unless the subscription was edited after the deletion, which
* resurrects it and clears the tombstone. Merging (rather than replacing)
* keeps a stale per-account blob from wiping subscriptions created under another
* account or on another device.
*/
export function mergeSyncedSubscriptions(
local: SyncedSubscriptionState,
incoming: SyncedSubscriptionState,
now: Date = new Date()
): SyncedSubscriptionState {
const cutoff = now.getTime() - SUBSCRIPTION_TOMBSTONE_TTL_MS;
// Union of tombstones, newest deletion time per id, pruned by TTL.
const tombstones: Record<string, string> = {};
for (const source of [local.deletedSubscriptionIds, incoming.deletedSubscriptionIds]) {
for (const [id, deletedAt] of Object.entries(source)) {
if (Date.parse(deletedAt) < cutoff) continue;
if (!tombstones[id] || Date.parse(deletedAt) > Date.parse(tombstones[id])) {
tombstones[id] = deletedAt;
}
}
}
const byId = new Map<string, ICalSubscription>();
for (const s of local.icalSubscriptions) byId.set(s.id, s);
for (const s of incoming.icalSubscriptions) {
const existing = byId.get(s.id);
const existingTime = existing?.updatedAt ? Date.parse(existing.updatedAt) : 0;
const incomingTime = s.updatedAt ? Date.parse(s.updatedAt) : 0;
if (!existing || incomingTime > existingTime) {
byId.set(s.id, s);
}
}
const icalSubscriptions: ICalSubscription[] = [];
for (const s of byId.values()) {
const deletedAt = tombstones[s.id];
if (deletedAt !== undefined) {
const updatedTime = s.updatedAt ? Date.parse(s.updatedAt) : 0;
if (updatedTime <= Date.parse(deletedAt)) continue;
delete tombstones[s.id];
}
icalSubscriptions.push(s);
}
return { icalSubscriptions, deletedSubscriptionIds: tombstones };
}
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useCalendarStore, type ICalSubscription } from '../calendar-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
function makeSubscription(overrides: Partial<ICalSubscription> = {}): ICalSubscription {
return {
id: 'sub-test-id',
url: 'https://example.com/calendar.ics',
calendarId: 'cal-123',
name: 'Test Subscription',
color: '#3b82f6',
refreshInterval: 60,
lastRefreshed: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
const mockClient: IJMAPClient = {
deleteCalendar: async () => {},
getAccountId: () => 'acc-1',
getServerUrl: () => 'https://mail.example.com',
getUsername: () => 'alice@example.com',
} as unknown as IJMAPClient;
const OWNER = 'https://mail.example.com|alice@example.com';
beforeEach(() => {
useCalendarStore.setState({
calendars: [],
events: [],
icalSubscriptions: [],
deletedSubscriptionIds: {},
});
});
describe('removeICalSubscription', () => {
it('records a tombstone so the deletion can propagate through sync', async () => {
useCalendarStore.setState({
icalSubscriptions: [makeSubscription({ id: 'sub-a', owner: OWNER })],
});
await useCalendarStore.getState().removeICalSubscription(mockClient, 'sub-a');
const state = useCalendarStore.getState();
expect(state.icalSubscriptions).toEqual([]);
expect(Object.keys(state.deletedSubscriptionIds)).toEqual(['sub-a']);
expect(Date.parse(state.deletedSubscriptionIds['sub-a'])).not.toBeNaN();
});
});
describe('applySyncedState', () => {
it('merges by id on server loads instead of clobbering local subscriptions', () => {
useCalendarStore.setState({
icalSubscriptions: [makeSubscription({ id: 'local-sub' })],
});
useCalendarStore.getState().applySyncedState(
[makeSubscription({ id: 'remote-sub' })],
{},
{ merge: true }
);
const ids = useCalendarStore.getState().icalSubscriptions.map((s) => s.id).sort();
expect(ids).toEqual(['local-sub', 'remote-sub']);
});
it('replaces wholesale on file imports', () => {
useCalendarStore.setState({
icalSubscriptions: [makeSubscription({ id: 'local-sub' })],
});
useCalendarStore.getState().applySyncedState(
[makeSubscription({ id: 'imported-sub' })],
{},
{ merge: false }
);
expect(useCalendarStore.getState().icalSubscriptions.map((s) => s.id)).toEqual(['imported-sub']);
});
it('leaves the store untouched when the blob has no subscriptions array', () => {
useCalendarStore.setState({
icalSubscriptions: [makeSubscription({ id: 'local-sub' })],
});
useCalendarStore.getState().applySyncedState(undefined, undefined, { merge: true });
expect(useCalendarStore.getState().icalSubscriptions.map((s) => s.id)).toEqual(['local-sub']);
});
it('removes subscriptions deleted on another device', () => {
const deletedAt = new Date(Date.now() - 1000).toISOString();
useCalendarStore.setState({
icalSubscriptions: [
makeSubscription({ id: 'sub-a' }),
makeSubscription({ id: 'sub-b' }),
],
});
useCalendarStore.getState().applySyncedState(
[makeSubscription({ id: 'sub-b' })],
{ 'sub-a': deletedAt },
{ merge: true }
);
const state = useCalendarStore.getState();
expect(state.icalSubscriptions.map((s) => s.id)).toEqual(['sub-b']);
expect(state.deletedSubscriptionIds).toEqual({ 'sub-a': deletedAt });
});
});
@@ -6,6 +6,7 @@ vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
import { useSettingsStore } from '../settings-store';
import { useTemplateStore } from '../template-store';
import { useCalendarStore } from '../calendar-store';
describe('settings sync debounce', () => {
beforeEach(() => {
@@ -17,6 +18,7 @@ describe('settings sync debounce', () => {
useSettingsStore.getState().disableSync();
useSettingsStore.setState({ sendDelaySeconds: 0, settingsSyncDisabled: false });
useTemplateStore.setState({ templates: [], recentTemplateIds: [], deletedTemplateIds: {} });
useCalendarStore.setState({ icalSubscriptions: [], deletedSubscriptionIds: {} });
});
it('flushes a pending snapshot for the account that scheduled it', async () => {
@@ -266,3 +268,136 @@ describe('template push after a server load', () => {
expect(await login()).toHaveLength(0);
});
});
describe('calendar subscription sync', () => {
beforeEach(() => {
apiFetch.mockReset();
apiFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
useSettingsStore.getState().disableSync();
useSettingsStore.setState({ settingsSyncDisabled: false });
useCalendarStore.setState({ icalSubscriptions: [], deletedSubscriptionIds: {} });
});
it('merges server-loaded subscriptions instead of replacing local ones', () => {
useCalendarStore.setState({
icalSubscriptions: [{
id: 'local-1',
url: 'https://example.com/local.ics',
calendarId: 'cal-local',
name: 'Local',
color: '#3b82f6',
refreshInterval: 60,
lastRefreshed: null,
}],
});
const ok = useSettingsStore.getState().importSettings(
JSON.stringify({
icalSubscriptions: [{
id: 'remote-1',
url: 'https://example.com/remote.ics',
calendarId: 'cal-remote',
name: 'Remote',
color: '#ef4444',
refreshInterval: 60,
lastRefreshed: null,
}],
deletedSubscriptionIds: {},
}),
{ serverAccountId: 'acct-1' }
);
expect(ok).toBe(true);
const ids = useCalendarStore.getState().icalSubscriptions.map((s) => s.id).sort();
expect(ids).toEqual(['local-1', 'remote-1']);
});
it('leaves local subscriptions untouched when importing a pre-subscription settings blob', () => {
useCalendarStore.setState({
icalSubscriptions: [{
id: 'keep-1',
url: 'https://example.com/keep.ics',
calendarId: 'cal-keep',
name: 'Keep',
color: '#3b82f6',
refreshInterval: 60,
lastRefreshed: null,
}],
});
const ok = useSettingsStore.getState().importSettings(
JSON.stringify({ sendDelaySeconds: 10 }),
{ serverAccountId: 'acct-1' }
);
expect(ok).toBe(true);
expect(useCalendarStore.getState().icalSubscriptions).toHaveLength(1);
});
it('round-trips subscriptions through exportSettings', () => {
useCalendarStore.setState({
icalSubscriptions: [{
id: 'export-1',
url: 'https://example.com/export.ics',
calendarId: 'cal-export',
name: 'Exported Sub',
color: '#3b82f6',
refreshInterval: 60,
lastRefreshed: null,
}],
});
const exported = JSON.parse(useSettingsStore.getState().exportSettings());
expect(exported.icalSubscriptions).toHaveLength(1);
expect(exported.icalSubscriptions[0].name).toBe('Exported Sub');
expect(exported.deletedSubscriptionIds).toEqual({});
});
it('pushes only the active login\'s subscriptions into its server blob', async () => {
const sub = (id: string, owner?: string) => ({
id, url: `https://example.com/${id}.ics`, calendarId: `cal-${id}`, name: id,
color: '#3b82f6', refreshInterval: 60, lastRefreshed: null, owner,
});
useSettingsStore.getState().enableSync('Alice@Example.com', 'https://mail.example.com/');
// Secret feed URLs of another login must not land in this login's blob,
// and unowned (pre-owner) entries are claimed locally before they sync.
useCalendarStore.setState({
icalSubscriptions: [
sub('mine', 'https://mail.example.com|alice@example.com'),
sub('bobs', 'https://other.example.org|bob@example.org'),
sub('unowned'),
],
});
await useSettingsStore.getState().flushSync();
const body = JSON.parse((apiFetch.mock.calls[0][1] as RequestInit).body as string);
expect(body.settings.icalSubscriptions.map((s: { id: string }) => s.id)).toEqual(['mine']);
});
it('ignores other logins\' subscriptions in a server blob written before owners were synced', async () => {
const sub = (id: string, owner?: string) => ({
id, url: `https://example.com/${id}.ics`, calendarId: `cal-${id}`, name: id,
color: '#3b82f6', refreshInterval: 60, lastRefreshed: null, owner,
});
apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({
settings: {
icalSubscriptions: [
sub('mine', 'https://mail.example.com|alice@example.com'),
sub('bobs', 'https://other.example.org|bob@example.org'),
sub('unowned'),
],
deletedSubscriptionIds: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
await useSettingsStore.getState().loadFromServer('alice@example.com', 'https://mail.example.com');
// Unowned entries stay: calendar-store only adopts them once their
// calendar id and name are found in this login's account.
const ids = useCalendarStore.getState().icalSubscriptions.map((s) => s.id).sort();
expect(ids).toEqual(['mine', 'unowned']);
});
});
+86 -7
View File
@@ -28,6 +28,13 @@ import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
import { getClientByLocalAccountId } from './client-registry';
import { calendarHooks } from '@/lib/plugin-hooks';
import { eventToView } from '@/lib/plugin-projection';
import {
parseSyncedSubscriptions,
parseSubscriptionTombstones,
mergeSyncedSubscriptions,
subscriptionOwnerFor,
} from '@/lib/calendar-subscription-sync';
import { registerCalendarSubscriptionSyncBridge } from './settings-store';
/**
* When the Pro shell aggregates calendars/events from every connected
@@ -496,7 +503,7 @@ function getStoreEventDebugSnapshot(event: Partial<CalendarEvent> | null | undef
/** Identifies the login a subscription belongs to: server plus login name. */
export function subscriptionOwner(client: Pick<IJMAPClient, 'getServerUrl' | 'getUsername'>): string {
return `${client.getServerUrl().replace(/\/+$/, '').toLowerCase()}|${client.getUsername().toLowerCase()}`;
return subscriptionOwnerFor(client.getServerUrl(), client.getUsername());
}
type CalendarStoreSet = (partial: Partial<CalendarStore> | ((state: CalendarStore) => Partial<CalendarStore>)) => void;
@@ -548,6 +555,7 @@ export interface ICalSubscription {
color: string;
refreshInterval: number; // minutes
lastRefreshed: string | null;
updatedAt?: string;
}
/**
@@ -608,6 +616,7 @@ interface CalendarStore {
// iCal subscriptions
icalSubscriptions: ICalSubscription[];
deletedSubscriptionIds: Record<string, string>;
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise<void>;
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
@@ -618,6 +627,12 @@ interface CalendarStore {
clearICalSubscriptions: () => void;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
isSubscriptionCalendar: (calendarId: string) => boolean;
applySyncedState: (
subscriptions: unknown,
deletedSubscriptionIds: unknown,
opts: { merge: boolean; owner?: string }
) => void;
}
const initialState = {
@@ -633,6 +648,7 @@ const initialState = {
error: null as string | null,
dateRange: null as { start: string; end: string } | null,
icalSubscriptions: [] as ICalSubscription[],
deletedSubscriptionIds: {} as Record<string, string>,
};
function getSafeCalendarViewMode(value: unknown): CalendarViewMode {
@@ -1355,6 +1371,7 @@ export const useCalendarStore = create<CalendarStore>()(
});
if (!calendar) throw new Error('Failed to create calendar');
const now = new Date().toISOString();
const subscription: ICalSubscription = {
id: generateUUID(),
url: normalizedUrl,
@@ -1365,13 +1382,18 @@ export const useCalendarStore = create<CalendarStore>()(
color,
refreshInterval,
lastRefreshed: null,
updatedAt: now,
};
set((state) => ({
calendars: [...state.calendars, calendar!],
selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id],
icalSubscriptions: [...state.icalSubscriptions, subscription],
}));
set((state) => {
const { [subscription.id]: _, ...remainingTombstones } = state.deletedSubscriptionIds;
return {
calendars: [...state.calendars, calendar!],
selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id],
icalSubscriptions: [...state.icalSubscriptions, subscription],
deletedSubscriptionIds: remainingTombstones,
};
});
// Initial fetch - roll back the calendar create if it fails so we
// don't leave a phantom calendar around after a bad URL / 404 / etc.
@@ -1429,7 +1451,11 @@ export const useCalendarStore = create<CalendarStore>()(
}
// Update local subscription record
const updated = { ...sub, ...normalizedUpdates };
const updated: ICalSubscription = {
...sub,
...normalizedUpdates,
updatedAt: new Date().toISOString(),
};
set((state) => ({
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
calendars: state.calendars.map(c => {
@@ -1464,8 +1490,10 @@ export const useCalendarStore = create<CalendarStore>()(
}
}
const now = new Date().toISOString();
set((state) => ({
icalSubscriptions: state.icalSubscriptions.filter(s => s.id !== subscriptionId),
deletedSubscriptionIds: { ...state.deletedSubscriptionIds, [subscriptionId]: now },
calendars: state.calendars.filter(c => c.id !== sub.calendarId),
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== sub.calendarId),
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
@@ -1473,6 +1501,40 @@ export const useCalendarStore = create<CalendarStore>()(
void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId, change: 'removed' });
},
applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => {
const parsed = parseSyncedSubscriptions(subscriptions);
if (parsed === null) return;
// A server blob speaks only for its own login. Older blobs may still
// carry other logins' subscriptions; unowned ones stay and are
// adopted by claimSubscription() only if their calendar is found.
const parsedSubs = opts.owner
? parsed.filter((s) => !s.owner || s.owner === opts.owner)
: parsed;
const parsedTombstones = parseSubscriptionTombstones(deletedSubscriptionIds);
if (opts.merge) {
const merged = mergeSyncedSubscriptions(
{
icalSubscriptions: get().icalSubscriptions,
deletedSubscriptionIds: get().deletedSubscriptionIds,
},
{
icalSubscriptions: parsedSubs,
deletedSubscriptionIds: parsedTombstones,
}
);
set({
icalSubscriptions: merged.icalSubscriptions,
deletedSubscriptionIds: merged.deletedSubscriptionIds,
});
} else {
set({
icalSubscriptions: parsedSubs,
deletedSubscriptionIds: parsedTombstones,
});
}
},
refreshICalSubscription: async (client, subscriptionId) => {
const existing = refreshInFlight.get(subscriptionId);
if (existing) return existing;
@@ -1627,10 +1689,12 @@ export const useCalendarStore = create<CalendarStore>()(
// They're now scoped per-account via sub.accountId - wiping them
// here would lose them from localStorage on every switch.
const preservedSubs = get().icalSubscriptions;
const preservedTombstones = get().deletedSubscriptionIds;
set({
...initialState,
selectedDate: displayNow(),
icalSubscriptions: preservedSubs,
deletedSubscriptionIds: preservedTombstones,
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
useCalendarNotificationStore.getState().clearAll();
@@ -1655,6 +1719,7 @@ export const useCalendarStore = create<CalendarStore>()(
selectedCalendarIds: state.selectedCalendarIds,
viewMode: state.viewMode,
icalSubscriptions: state.icalSubscriptions,
deletedSubscriptionIds: state.deletedSubscriptionIds,
}),
}
)
@@ -1858,3 +1923,17 @@ async function importEventsUnhooked(
return imported + linked;
}
registerCalendarSubscriptionSyncBridge(
{
getSyncedState: () => ({
icalSubscriptions: useCalendarStore.getState().icalSubscriptions,
deletedSubscriptionIds: useCalendarStore.getState().deletedSubscriptionIds,
}),
applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => {
useCalendarStore.getState().applySyncedState(subscriptions, deletedSubscriptionIds, opts);
},
},
(listener) => useCalendarStore.subscribe(listener)
);
+68 -11
View File
@@ -8,6 +8,7 @@ import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type Notification
import { apiFetch } from '@/lib/browser-navigation';
import { settingsHooks } from '@/lib/plugin-hooks';
import { generateAccountId } from '@/lib/account-utils';
import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync';
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
import {
DEFAULT_SUB_ADDRESS_DELIMITER,
@@ -132,6 +133,32 @@ function localTemplatesAhead(serverSettings: Record<string, unknown> | null): bo
templateStateKey(serverSettings.templates, serverSettings.deletedTemplateIds);
}
// --- Calendar subscription sync bridge ---------------------------------------
// iCal subscriptions ride along in the synced settings blob to avoid turning
// into writable/lost calendars on browser cache clear or new devices.
interface CalendarSubscriptionSyncBridge {
getSyncedState: () => {
icalSubscriptions: Array<{ owner?: string }>;
deletedSubscriptionIds: Record<string, string>;
};
applySyncedState: (
subscriptions: unknown,
deletedSubscriptionIds: unknown,
opts: { merge: boolean; owner?: string }
) => void;
}
let calendarSubscriptionSyncBridge: CalendarSubscriptionSyncBridge | null = null;
let onCalendarSubscriptionStoreChange: (() => void) | null = null;
export function registerCalendarSubscriptionSyncBridge(
bridge: CalendarSubscriptionSyncBridge,
subscribe: (listener: () => void) => void
): void {
calendarSubscriptionSyncBridge = bridge;
subscribe(() => onCalendarSubscriptionStoreChange?.());
}
async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise<void> {
syncLog('Syncing settings to server for', job.username);
const res = await apiFetch('/api/settings', {
@@ -684,8 +711,8 @@ interface SettingsState {
) => void;
resetToDefaults: () => void;
/** `forSync` builds the snapshot pushed to the server; it is not a user export. */
exportSettings: (opts?: { forSync?: boolean }) => string;
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => boolean;
exportSettings: (opts?: { forSync?: boolean; subscriptionOwner?: string }) => string;
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => boolean;
// Folder icons
setFolderIcon: (mailboxId: string, icon: string) => void;
@@ -993,9 +1020,10 @@ export const useSettingsStore = create<SettingsState>()(
}
};
const buildExport = (): string => {
const buildExport = (opts?: { subscriptionOwner?: string }): string => {
const state = get();
const templateSync = templateSyncBridge?.getSyncedState();
const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState();
const settings = {
fontSize: state.fontSize,
density: state.density,
@@ -1121,6 +1149,21 @@ export const useSettingsStore = create<SettingsState>()(
deletedTemplateIds: templateSync.deletedTemplateIds,
}
: {}),
// Omitted entirely if the bridge has not registered, so an importing
// device leaves its calendar subscriptions alone.
...(subscriptionSync
? {
// A server blob carries only its own login's subscriptions:
// feed URLs are secrets, and another login's (or an unowned
// one's) would reappear there after that login signs out.
icalSubscriptions: opts?.subscriptionOwner
? subscriptionSync.icalSubscriptions.filter(
(sub) => sub.owner === opts.subscriptionOwner,
)
: subscriptionSync.icalSubscriptions,
deletedSubscriptionIds: subscriptionSync.deletedSubscriptionIds,
}
: {}),
};
return JSON.stringify(settings, null, 2);
};
@@ -1146,14 +1189,14 @@ export const useSettingsStore = create<SettingsState>()(
},
exportSettings: (opts) => {
const json = buildExport();
const json = buildExport(opts);
if (!opts?.forSync) {
void settingsHooks.onSettingsExport.emit({ keys: Object.keys(JSON.parse(json)) });
}
return json;
},
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => {
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => {
try {
const settings = JSON.parse(json);
@@ -1267,6 +1310,11 @@ export const useSettingsStore = create<SettingsState>()(
settings.deletedTemplateIds,
{ merge: Boolean(opts?.serverAccountId) }
);
calendarSubscriptionSyncBridge?.applySyncedState(
settings.icalSubscriptions,
settings.deletedSubscriptionIds,
{ merge: Boolean(opts?.serverAccountId), owner: opts?.subscriptionOwner }
);
// A load from the server reports itself as a sync (loadFromServer);
// only a file import the user picked is an import.
@@ -1489,6 +1537,7 @@ export const useSettingsStore = create<SettingsState>()(
get().importSettings(JSON.stringify(settings), {
serverAccountId: accountId,
keepCrossAccountViews,
subscriptionOwner: subscriptionOwnerFor(serverUrl, username),
});
isLoadingFromServer = false;
writeSettingsOwner(accountId);
@@ -1751,7 +1800,10 @@ if (typeof window !== 'undefined') {
pendingSync = {
username: syncUsername,
serverUrl: syncServerUrl,
settings: JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true })),
settings: JSON.parse(useSettingsStore.getState().exportSettings({
forSync: true,
subscriptionOwner: subscriptionOwnerFor(syncServerUrl, syncUsername),
})),
};
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(async () => {
@@ -1792,12 +1844,17 @@ if (typeof window !== 'undefined') {
if (useSettingsStore.getState().settingsSyncDisabled) return;
triggerSync();
};
// Ensure template-store is loaded (and the bridge registered) even before
// any UI component imports it, so the first sync push already carries the
// templates. Best effort: the UI imports the store itself when it needs it,
// and under vitest a short test file can finish (and tear its environment
// down) before this chain has loaded, which rejects the import.
// And when iCal subscriptions change (they ride along in the synced blob).
onCalendarSubscriptionStoreChange = () => {
if (useSettingsStore.getState().settingsSyncDisabled) return;
triggerSync();
};
// Ensure template-store and calendar-store are loaded (and bridges registered) even before
// any UI component imports them, so the first sync push already carries them.
import('./template-store').catch(() => {});
import('./calendar-store').catch(() => {});
}
/**