diff --git a/lib/__tests__/calendar-subscription-sync.test.ts b/lib/__tests__/calendar-subscription-sync.test.ts new file mode 100644 index 000000000..0d44a7100 --- /dev/null +++ b/lib/__tests__/calendar-subscription-sync.test.ts @@ -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 { + 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({}); + }); +}); diff --git a/lib/calendar-subscription-sync.ts b/lib/calendar-subscription-sync.ts new file mode 100644 index 000000000..0dd39c7b1 --- /dev/null +++ b/lib/calendar-subscription-sync.ts @@ -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; +} + +/** + * 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(); + const now = new Date().toISOString(); + + for (const item of value) { + if (typeof item !== 'object' || item === null) continue; + const s = item as Record; + 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 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [id, deletedAt] of Object.entries(value as Record)) { + 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 = {}; + 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(); + 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 }; +} diff --git a/stores/__tests__/calendar-subscription-sync.test.ts b/stores/__tests__/calendar-subscription-sync.test.ts new file mode 100644 index 000000000..dfd4119f3 --- /dev/null +++ b/stores/__tests__/calendar-subscription-sync.test.ts @@ -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 { + 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 }); + }); +}); diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts index c4d689ea1..873aed0ff 100644 --- a/stores/__tests__/settings-store-sync.test.ts +++ b/stores/__tests__/settings-store-sync.test.ts @@ -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']); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 18a586946..31765f188 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -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 | null | undef /** Identifies the login a subscription belongs to: server plus login name. */ export function subscriptionOwner(client: Pick): string { - return `${client.getServerUrl().replace(/\/+$/, '').toLowerCase()}|${client.getUsername().toLowerCase()}`; + return subscriptionOwnerFor(client.getServerUrl(), client.getUsername()); } type CalendarStoreSet = (partial: Partial | ((state: CalendarStore) => Partial)) => 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; addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise; updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise; removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise; @@ -618,6 +627,12 @@ interface CalendarStore { clearICalSubscriptions: () => void; refreshAllSubscriptions: (client: IJMAPClient) => Promise; 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, }; function getSafeCalendarViewMode(value: unknown): CalendarViewMode { @@ -1355,6 +1371,7 @@ export const useCalendarStore = create()( }); 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()( 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()( } // 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()( } } + 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()( 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()( // 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()( 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) +); + diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 4b952f1a3..bff3b1660 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -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 | 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; + }; + 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 { 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()( } }; - 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()( 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()( }, 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()( 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()( 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(() => {}); } /**