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 tSub = useTranslations("calendar.subscription");
|
||||||
const tMgmt = useTranslations("calendar.management");
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
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 refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useAuthStore } from '@/stores/auth-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 { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
|
||||||
import { ShareCollectionDialog } from './share-collection-dialog';
|
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||||
import type { CalendarRights } from '@/lib/jmap/types';
|
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 { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
@@ -155,7 +155,14 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
|||||||
export function CalendarManagementSettings() {
|
export function CalendarManagementSettings() {
|
||||||
const t = useTranslations('calendar.management');
|
const t = useTranslations('calendar.management');
|
||||||
const { client, serverUrl, username } = useAuthStore();
|
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 [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(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" />
|
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium truncate block">{sub.name}</span>
|
<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}
|
const safeUrl = redactUrlCredentials(sub.url);
|
||||||
</span>
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground truncate block" title={safeUrl}>
|
||||||
|
{safeUrl}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{sub.lastRefreshed && (
|
{sub.lastRefreshed && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
{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));
|
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 {
|
export function generateUUID(): string {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { generateUUID } from '@/lib/utils';
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
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';
|
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||||
|
|
||||||
const CALENDAR_VIEW_MODES: 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;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
calendarId: 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;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
refreshInterval: number; // minutes
|
refreshInterval: number; // minutes
|
||||||
@@ -718,7 +728,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realCalId = cal?.originalId || calendarId;
|
const realCalId = cal?.originalId || calendarId;
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
let totalDeleted = 0;
|
let totalRemoved = 0;
|
||||||
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
@@ -728,13 +738,39 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]);
|
const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]);
|
||||||
if (calendarEvents.length === 0) break;
|
if (calendarEvents.length === 0) break;
|
||||||
|
|
||||||
const ids = calendarEvents.map(e => e.id);
|
// Separate events that live ONLY in this calendar (delete) from
|
||||||
const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId);
|
// events also linked to other calendars (unlink only — don't
|
||||||
totalDeleted += destroyed.length;
|
// 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
|
let removedThisPass = 0;
|
||||||
if (destroyed.length === 0) {
|
if (idsToDelete.length > 0) {
|
||||||
debug.warn('calendar', 'Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,7 +781,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
||||||
}));
|
}));
|
||||||
return totalDeleted;
|
return totalRemoved;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to clear calendar events:', error);
|
debug.error('Failed to clear calendar events:', error);
|
||||||
set({ error: 'Failed to clear calendar events' });
|
set({ error: 'Failed to clear calendar events' });
|
||||||
@@ -788,6 +824,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
url: normalizedUrl,
|
url: normalizedUrl,
|
||||||
calendarId: calendar.id,
|
calendarId: calendar.id,
|
||||||
|
accountId: client.getAccountId(),
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
@@ -882,10 +919,21 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshICalSubscription: async (client, subscriptionId) => {
|
refreshICalSubscription: async (client, subscriptionId) => {
|
||||||
|
const existing = refreshInFlight.get(subscriptionId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||||
if (!sub) return;
|
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', {
|
const response = await apiFetch('/api/fetch-ical', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -970,17 +1018,30 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
|
s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
})();
|
||||||
|
|
||||||
|
refreshInFlight.set(subscriptionId, work);
|
||||||
|
try {
|
||||||
|
await work;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to refresh iCal subscription:', sub.name, error);
|
debug.error('Failed to refresh iCal subscription:', sub.name, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
refreshInFlight.delete(subscriptionId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshAllSubscriptions: async (client) => {
|
refreshAllSubscriptions: async (client) => {
|
||||||
const { icalSubscriptions } = get();
|
const { icalSubscriptions } = get();
|
||||||
|
const currentAccountId = client.getAccountId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const sub of icalSubscriptions) {
|
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 lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0;
|
||||||
const intervalMs = sub.refreshInterval * 60 * 1000;
|
const intervalMs = sub.refreshInterval * 60 * 1000;
|
||||||
|
|
||||||
@@ -995,9 +1056,14 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearState: () => {
|
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({
|
set({
|
||||||
...initialState,
|
...initialState,
|
||||||
selectedDate: new Date(),
|
selectedDate: new Date(),
|
||||||
|
icalSubscriptions: preservedSubs,
|
||||||
});
|
});
|
||||||
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
|
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
|
||||||
useCalendarNotificationStore.getState().clearAll();
|
useCalendarNotificationStore.getState().clearAll();
|
||||||
|
|||||||
Reference in New Issue
Block a user