fix: scope iCal subscriptions per JMAP account and fix refresh/clear

This commit is contained in:
Linus Rath
2026-05-18 16:39:02 +02:00
parent fa261fecfd
commit b2d24670ff
4 changed files with 118 additions and 16 deletions
@@ -48,7 +48,12 @@ export function CalendarSidebarPanel({
const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
const currentAccountId = client?.getAccountId();
const icalSubscriptions = useMemo(
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
[allSubs, currentAccountId],
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -10,7 +10,7 @@ import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime } from '@/lib/utils';
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
@@ -155,7 +155,14 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
const currentAccountId = client?.getAccountId();
const icalSubscriptions = useMemo(
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
[allSubs, currentAccountId],
);
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
@@ -652,9 +659,14 @@ export function CalendarManagementSettings() {
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{sub.name}</span>
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
{sub.url}
</span>
{(() => {
const safeUrl = redactUrlCredentials(sub.url);
return (
<span className="text-xs text-muted-foreground truncate block" title={safeUrl}>
{safeUrl}
</span>
);
})()}
{sub.lastRefreshed && (
<span className="text-xs text-muted-foreground">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
+19
View File
@@ -8,6 +8,25 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Strip embedded Basic-auth credentials from a URL for display.
* `https://user:pass@host/path` → `https://host/path`. Falls back to
* regex stripping if URL parsing fails.
*/
export function redactUrlCredentials(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
if (parsed.username || parsed.password) {
parsed.username = '';
parsed.password = '';
return parsed.toString();
}
return rawUrl;
} catch {
return rawUrl.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]+@/, '$1');
}
}
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
+75 -9
View File
@@ -11,6 +11,11 @@ import { generateUUID } from '@/lib/utils';
import { apiFetch } from '@/lib/browser-navigation';
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
// In-flight refresh dedup. Concurrent callers (auto-interval +
// manual refresh, two account-switch reloads, etc.) share the same
// promise instead of double-fetching and racing the diff/import phase.
const refreshInFlight = new Map<string, Promise<void>>();
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks'];
@@ -97,6 +102,11 @@ export interface ICalSubscription {
id: string;
url: string;
calendarId: string;
// The JMAP account this subscription belongs to. Optional for back-
// compat with subs persisted before multi-account scoping landed —
// legacy entries with no accountId are shown only in whichever account
// the user has active (treated as floating). New subs always set it.
accountId?: string;
name: string;
color: string;
refreshInterval: number; // minutes
@@ -718,7 +728,7 @@ export const useCalendarStore = create<CalendarStore>()(
const cal = get().calendars.find(c => c.id === calendarId);
const realCalId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId;
let totalDeleted = 0;
let totalRemoved = 0;
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
let hasMore = true;
while (hasMore) {
@@ -728,13 +738,39 @@ export const useCalendarStore = create<CalendarStore>()(
const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]);
if (calendarEvents.length === 0) break;
const ids = calendarEvents.map(e => e.id);
const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId);
totalDeleted += destroyed.length;
// Separate events that live ONLY in this calendar (delete) from
// events also linked to other calendars (unlink only — don't
// cascade-delete the user's copy elsewhere).
const idsToDelete: string[] = [];
const eventsToUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
for (const e of calendarEvents) {
const otherCalIds = { ...(e.calendarIds || {}) };
delete otherCalIds[realCalId];
if (Object.keys(otherCalIds).length === 0) {
idsToDelete.push(e.id);
} else {
eventsToUnlink.push({ id: e.id, calendarIds: otherCalIds });
}
}
// If we couldn't destroy any events, stop to avoid infinite loop
if (destroyed.length === 0) {
debug.warn('calendar', 'Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
let removedThisPass = 0;
if (idsToDelete.length > 0) {
const { destroyed } = await client.batchDeleteCalendarEvents(idsToDelete, targetAccountId);
removedThisPass += destroyed.length;
}
for (const { id, calendarIds } of eventsToUnlink) {
try {
await client.updateCalendarEvent(id, { calendarIds } as Partial<CalendarEvent>, undefined, targetAccountId);
removedThisPass++;
} catch (err) {
debug.warn('calendar', 'Failed to unlink event from cleared calendar:', err);
}
}
totalRemoved += removedThisPass;
// If we couldn't remove anything, stop to avoid infinite loop
if (removedThisPass === 0) {
debug.warn('calendar', 'Could not clear any events, stopping. Remaining:', calendarEvents.length);
break;
}
@@ -745,7 +781,7 @@ export const useCalendarStore = create<CalendarStore>()(
set((state) => ({
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
}));
return totalDeleted;
return totalRemoved;
} catch (error) {
debug.error('Failed to clear calendar events:', error);
set({ error: 'Failed to clear calendar events' });
@@ -788,6 +824,7 @@ export const useCalendarStore = create<CalendarStore>()(
id: generateUUID(),
url: normalizedUrl,
calendarId: calendar.id,
accountId: client.getAccountId(),
name,
color,
refreshInterval,
@@ -882,10 +919,21 @@ export const useCalendarStore = create<CalendarStore>()(
},
refreshICalSubscription: async (client, subscriptionId) => {
const existing = refreshInFlight.get(subscriptionId);
if (existing) return existing;
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;
try {
// Skip if the subscription is scoped to a different JMAP account
// than the one this client is talking to — otherwise we'd create
// events in the wrong account / against a missing calendar.
if (sub.accountId && sub.accountId !== client.getAccountId()) {
debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name });
return;
}
const work = (async () => {
const response = await apiFetch('/api/fetch-ical', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -970,17 +1018,30 @@ export const useCalendarStore = create<CalendarStore>()(
s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
),
}));
})();
refreshInFlight.set(subscriptionId, work);
try {
await work;
} catch (error) {
debug.error('Failed to refresh iCal subscription:', sub.name, error);
throw error;
} finally {
refreshInFlight.delete(subscriptionId);
}
},
refreshAllSubscriptions: async (client) => {
const { icalSubscriptions } = get();
const currentAccountId = client.getAccountId();
const now = Date.now();
for (const sub of icalSubscriptions) {
// Only refresh subs for the current account (or legacy untagged
// subs, which are treated as belonging to whichever account the
// user has active).
if (sub.accountId && sub.accountId !== currentAccountId) continue;
const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0;
const intervalMs = sub.refreshInterval * 60 * 1000;
@@ -995,9 +1056,14 @@ export const useCalendarStore = create<CalendarStore>()(
},
clearState: () => {
// Preserve iCal subscriptions across the account-switch teardown.
// They're now scoped per-account via sub.accountId — wiping them
// here would lose them from localStorage on every switch.
const preservedSubs = get().icalSubscriptions;
set({
...initialState,
selectedDate: new Date(),
icalSubscriptions: preservedSubs,
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
useCalendarNotificationStore.getState().clearAll();