fix: scope iCal subscriptions per JMAP account and fix refresh/clear
This commit is contained in:
@@ -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' }) })}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user