fix: iCal subscription refresh, rollback, and URL normalization
This commit is contained in:
@@ -78,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
|
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
|||||||
+62
-23
@@ -770,9 +770,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
|
addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
|
||||||
|
// Normalize webcal(s):// → https:// so the server-side fetcher
|
||||||
|
// (which only accepts http/https) doesn't reject every refresh.
|
||||||
|
const normalizedUrl = url.replace(/^webcals?:\/\//i, 'https://');
|
||||||
|
|
||||||
|
let calendar: Calendar | null = null;
|
||||||
try {
|
try {
|
||||||
// Create a new calendar for this subscription
|
calendar = await client.createCalendar({
|
||||||
const calendar = await client.createCalendar({
|
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
@@ -782,7 +786,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
const subscription: ICalSubscription = {
|
const subscription: ICalSubscription = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
url,
|
url: normalizedUrl,
|
||||||
calendarId: calendar.id,
|
calendarId: calendar.id,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
@@ -791,22 +795,32 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
};
|
};
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
calendars: [...state.calendars, calendar],
|
calendars: [...state.calendars, calendar!],
|
||||||
selectedCalendarIds: [...state.selectedCalendarIds, calendar.id],
|
selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id],
|
||||||
icalSubscriptions: [...state.icalSubscriptions, subscription],
|
icalSubscriptions: [...state.icalSubscriptions, subscription],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Do initial fetch
|
// Initial fetch — roll back the calendar create if it fails so we
|
||||||
try {
|
// don't leave a phantom calendar around after a bad URL / 404 / etc.
|
||||||
await get().refreshICalSubscription(client, subscription.id);
|
await get().refreshICalSubscription(client, subscription.id);
|
||||||
} catch {
|
|
||||||
// Subscription created, initial fetch failed - user can retry
|
|
||||||
debug.warn('calendar', 'Initial subscription fetch failed for:', name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscription;
|
return subscription;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to add iCal subscription:', error);
|
debug.error('Failed to add iCal subscription:', error);
|
||||||
|
if (calendar) {
|
||||||
|
const calendarId = calendar.id;
|
||||||
|
try {
|
||||||
|
await client.deleteCalendar(calendarId);
|
||||||
|
} catch (rollbackErr) {
|
||||||
|
debug.warn('calendar', 'Rollback failed for subscription calendar:', rollbackErr);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
calendars: state.calendars.filter(c => c.id !== calendarId),
|
||||||
|
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
|
||||||
|
icalSubscriptions: state.icalSubscriptions.filter(s => s.calendarId !== calendarId),
|
||||||
|
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -815,30 +829,35 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
|
|
||||||
|
// Normalize webcal(s):// in the new URL so refreshes don't break.
|
||||||
|
const normalizedUpdates: typeof updates = updates.url
|
||||||
|
? { ...updates, url: updates.url.replace(/^webcals?:\/\//i, 'https://') }
|
||||||
|
: updates;
|
||||||
|
|
||||||
// Update the calendar on the server if name or color changed
|
// Update the calendar on the server if name or color changed
|
||||||
if (updates.name || updates.color) {
|
if (normalizedUpdates.name || normalizedUpdates.color) {
|
||||||
const calUpdates: Record<string, unknown> = {};
|
const calUpdates: Record<string, unknown> = {};
|
||||||
if (updates.name) calUpdates.name = updates.name;
|
if (normalizedUpdates.name) calUpdates.name = normalizedUpdates.name;
|
||||||
if (updates.color) calUpdates.color = updates.color;
|
if (normalizedUpdates.color) calUpdates.color = normalizedUpdates.color;
|
||||||
await client.updateCalendar(sub.calendarId, calUpdates);
|
await client.updateCalendar(sub.calendarId, calUpdates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local subscription record
|
// Update local subscription record
|
||||||
const updated = { ...sub, ...updates };
|
const updated = { ...sub, ...normalizedUpdates };
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
|
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
|
||||||
calendars: state.calendars.map(c => {
|
calendars: state.calendars.map(c => {
|
||||||
if (c.id !== sub.calendarId) return c;
|
if (c.id !== sub.calendarId) return c;
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
...(updates.name ? { name: updates.name } : {}),
|
...(normalizedUpdates.name ? { name: normalizedUpdates.name } : {}),
|
||||||
...(updates.color ? { color: updates.color } : {}),
|
...(normalizedUpdates.color ? { color: normalizedUpdates.color } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// If URL changed, refresh to fetch events from new source
|
// If URL changed, refresh to fetch events from new source
|
||||||
if (updates.url && updates.url !== sub.url) {
|
if (normalizedUpdates.url && normalizedUpdates.url !== sub.url) {
|
||||||
await get().refreshICalSubscription(client, subscriptionId);
|
await get().refreshICalSubscription(client, subscriptionId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -902,13 +921,33 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete events that are no longer in the feed
|
// Events no longer in the feed. If an event lives only in the
|
||||||
const idsToDelete = serverEvents
|
// subscription calendar, delete it. If it is also linked to other
|
||||||
.filter(e => !e.uid || !incomingUids.has(e.uid))
|
// calendars (importEvents links by UID), only unlink the
|
||||||
.map(e => e.id);
|
// subscription calendar so we don't cascade-delete the user's
|
||||||
|
// personal copy.
|
||||||
|
const staleEvents = serverEvents.filter(e => !e.uid || !incomingUids.has(e.uid));
|
||||||
|
const idsToDelete: string[] = [];
|
||||||
|
const eventsToUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
|
||||||
|
for (const e of staleEvents) {
|
||||||
|
const otherCalIds = { ...(e.calendarIds || {}) };
|
||||||
|
delete otherCalIds[sub.calendarId];
|
||||||
|
if (Object.keys(otherCalIds).length === 0) {
|
||||||
|
idsToDelete.push(e.id);
|
||||||
|
} else {
|
||||||
|
eventsToUnlink.push({ id: e.id, calendarIds: otherCalIds });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (idsToDelete.length > 0) {
|
if (idsToDelete.length > 0) {
|
||||||
await client.batchDeleteCalendarEvents(idsToDelete);
|
await client.batchDeleteCalendarEvents(idsToDelete);
|
||||||
}
|
}
|
||||||
|
for (const { id, calendarIds } of eventsToUnlink) {
|
||||||
|
try {
|
||||||
|
await client.updateCalendarEvent(id, { calendarIds } as Partial<CalendarEvent>);
|
||||||
|
} catch (err) {
|
||||||
|
debug.warn('calendar', 'Failed to unlink stale event from subscription calendar:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Import only events that don't already exist on server
|
// Import only events that don't already exist on server
|
||||||
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
||||||
|
|||||||
Reference in New Issue
Block a user