feat: add iCal subscription editing and batch event import

This commit is contained in:
Linus Rath
2026-03-29 18:27:16 +02:00
parent 5583d95ecc
commit 4c853f176b
9 changed files with 278 additions and 109 deletions
+15 -1
View File
@@ -67,7 +67,7 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error, isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
const taskStore = useTaskStore(); const taskStore = useTaskStore();
@@ -83,6 +83,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null); const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>(); const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>(); const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -948,6 +949,7 @@ export default function CalendarPage() {
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
/> />
</div> </div>
@@ -1114,6 +1116,18 @@ export default function CalendarPage() {
/> />
)} )}
{editingSubscription && client && (() => {
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
if (!sub) return null;
return (
<ICalSubscriptionModal
client={client}
editSubscription={sub}
onClose={() => setEditingSubscription(null)}
/>
);
})()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
+13 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react"; import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void; onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null; client?: IJMAPClient | null;
} }
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onSubscribe, onSubscribe,
onEditSubscription,
client, client,
}: CalendarSidebarPanelProps) { }: CalendarSidebarPanelProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
ref={contextMenuRef} ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48" className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
> >
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button <button
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors" className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
+29 -14
View File
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react"; import { X, Loader2, Globe } from "lucide-react";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps { interface ICalSubscriptionModalProps {
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
editSubscription?: ICalSubscription;
} }
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) { export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription"); const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState(""); const isEdit = !!editSubscription;
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6"); const [url, setUrl] = useState(editSubscription?.url || "");
const [refreshInterval, setRefreshInterval] = useState(60); const [name, setName] = useState(editSubscription?.name || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval); if (isEdit && editSubscription) {
if (subscription) { const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
toast.success(t("success", { name: name.trim() })); if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
if (name.trim() !== editSubscription.name) updates.name = name.trim();
if (color !== editSubscription.color) updates.color = color;
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
await updateICalSubscription(client, editSubscription.id, updates);
toast.success(t("updated", { name: name.trim() }));
onClose(); onClose();
} else { } else {
setError(t("error")); const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
} }
} catch { } catch {
setError(t("error")); setError(isEdit ? t("update_error") : t("error"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" /> <Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2> <h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin mr-2" /> <Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")} {isEdit ? t("saving") : t("subscribing")}
</> </>
) : ( ) : (
t("subscribe") isEdit ? t("save") : t("subscribe")
)} )}
</Button> </Button>
</div> </div>
@@ -164,6 +164,7 @@ export function CalendarManagementSettings() {
const [colorPickerId, setColorPickerId] = useState<string | null>(null); const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null); const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null); const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import'); const tImport = useTranslations('calendar.import');
@@ -629,6 +630,14 @@ export function CalendarManagementSettings() {
)} )}
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingSubscription(sub)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tSub('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button <button
type="button" type="button"
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
@@ -667,6 +676,14 @@ export function CalendarManagementSettings() {
onClose={() => setShowSubscriptionModal(false)} onClose={() => setShowSubscriptionModal(false)}
/> />
)} )}
{editingSubscription && client && (
<ICalSubscriptionModal
client={client}
editSubscription={editingSubscription}
onClose={() => setEditingSubscription(null)}
/>
)}
</SettingsSection> </SettingsSection>
); );
} }
+9
View File
@@ -597,6 +597,15 @@ export class DemoJMAPClient implements IJMAPClient {
return full; return full;
} }
async batchCreateCalendarEvents(events: Partial<CalendarEvent>[]): Promise<{ created: CalendarEvent[]; failed: string[] }> {
const created: CalendarEvent[] = [];
for (const event of events) {
const full = await this.createCalendarEvent(event);
created.push(full);
}
return { created, failed: [] };
}
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> { async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
const event = this.data.calendarEvents.find(e => e.id === eventId); const event = this.data.calendarEvents.find(e => e.id === eventId);
if (!event) throw new Error('Event not found'); if (!event) throw new Error('Event not found');
+1
View File
@@ -193,6 +193,7 @@ export interface IJMAPClient {
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>; getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>; getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>; createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
updateCalendarEvent( updateCalendarEvent(
eventId: string, eventId: string,
updates: Partial<CalendarEvent>, updates: Partial<CalendarEvent>,
+69
View File
@@ -3276,6 +3276,75 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to create calendar event"); throw new Error("Failed to create calendar event");
} }
/**
* Batch-create multiple calendar events in a single JMAP request.
* Returns arrays of successfully created events and failed creation keys.
*/
async batchCreateCalendarEvents(
events: Partial<CalendarEvent>[],
targetAccountId?: string,
): Promise<{ created: CalendarEvent[]; failed: string[] }> {
if (events.length === 0) return { created: [], failed: [] };
const accountId = targetAccountId || this.getCalendarsAccountId();
// Build the create map: { "new-0": event0, "new-1": event1, ... }
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
createMap[`new-${i}`] = clean;
}
debug.log('CalendarEvent/batchCreate', { count: events.length, accountId });
const response = await this.request([
["CalendarEvent/set", { accountId, create: createMap }, "0"]
], this.calendarUsing());
const createdIds: string[] = [];
const failed: string[] = [];
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
for (let i = 0; i < events.length; i++) {
const key = `new-${i}`;
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
}
}
}
if (createdIds.length === 0) {
return { created: [], failed };
}
// Fetch all created events in a single CalendarEvent/get
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: createdIds,
}, "0"]
], this.calendarUsing());
let createdEvents: CalendarEvent[] = [];
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
}
debug.log('CalendarEvent/batchCreate result', {
requested: events.length,
created: createdEvents.length,
failed: failed.length,
});
return { created: createdEvents, failed };
}
async updateCalendarEvent( async updateCalendarEvent(
eventId: string, eventId: string,
updates: Partial<CalendarEvent>, updates: Partial<CalendarEvent>,
+6
View File
@@ -2107,6 +2107,12 @@
"interval_1440": "Every day", "interval_1440": "Every day",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"subscribing": "Subscribing...", "subscribing": "Subscribing...",
"save": "Save changes",
"saving": "Saving...",
"edit": "Edit",
"edit_title": "Edit Subscription",
"updated": "Updated \"{name}\"",
"update_error": "Failed to update subscription",
"invalid_url": "Please enter a valid URL", "invalid_url": "Please enter a valid URL",
"success": "Subscribed to \"{name}\"", "success": "Subscribed to \"{name}\"",
"error": "Failed to add subscription", "error": "Failed to add subscription",
+119 -93
View File
@@ -133,6 +133,7 @@ interface CalendarStore {
// iCal subscriptions // iCal subscriptions
icalSubscriptions: ICalSubscription[]; icalSubscriptions: ICalSubscription[];
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>; addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise<void>;
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>; removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>; refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>; refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
@@ -377,108 +378,101 @@ export const useCalendarStore = create<CalendarStore>()(
}, },
importEvents: async (client, events, calendarId) => { importEvents: async (client, events, calendarId) => {
let imported = 0;
// Resolve shared calendar IDs // Resolve shared calendar IDs
const cal = get().calendars.find(c => c.id === calendarId); const cal = get().calendars.find(c => c.id === calendarId);
const realCalendarId = cal?.originalId || calendarId; const realCalendarId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId; const targetAccountId = cal?.accountId;
// Prepare all events for batch creation
const prepared: Partial<CalendarEvent>[] = [];
for (const event of events) { for (const event of events) {
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>); const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
try { let cleanParticipants: Record<string, CalendarParticipant> | null = null;
let cleanParticipants: Record<string, CalendarParticipant> | null = null; if (src.participants) {
if (src.participants) { cleanParticipants = {};
cleanParticipants = {}; for (const [key, p] of Object.entries(src.participants)) {
for (const [key, p] of Object.entries(src.participants)) { const participant: Record<string, unknown> = {
const participant: Record<string, unknown> = { '@type': 'Participant',
'@type': 'Participant', name: p.name,
name: p.name, email: p.email,
email: p.email, calendarAddress: p.calendarAddress,
calendarAddress: p.calendarAddress, description: p.description,
description: p.description, sendTo: p.sendTo,
sendTo: p.sendTo, kind: p.kind,
kind: p.kind, roles: p.roles,
roles: p.roles, participationStatus: p.participationStatus,
participationStatus: p.participationStatus, participationComment: p.participationComment,
participationComment: p.participationComment, expectReply: p.expectReply,
expectReply: p.expectReply, scheduleAgent: p.scheduleAgent,
scheduleAgent: p.scheduleAgent, scheduleForceSend: p.scheduleForceSend,
scheduleForceSend: p.scheduleForceSend, scheduleId: p.scheduleId,
scheduleId: p.scheduleId, delegatedTo: p.delegatedTo,
delegatedTo: p.delegatedTo, delegatedFrom: p.delegatedFrom,
delegatedFrom: p.delegatedFrom, memberOf: p.memberOf,
memberOf: p.memberOf, locationId: p.locationId,
locationId: p.locationId, language: p.language,
language: p.language, links: p.links,
links: p.links, };
}; Object.keys(participant).forEach(k => {
Object.keys(participant).forEach(k => { if (participant[k] === undefined || participant[k] === null) delete participant[k];
if (participant[k] === undefined || participant[k] === null) delete participant[k]; });
}); cleanParticipants[key] = participant as unknown as CalendarParticipant;
cleanParticipants[key] = participant as unknown as CalendarParticipant;
}
} }
}
const data: Partial<CalendarEvent> = { const data: Partial<CalendarEvent> = {
calendarIds: { [realCalendarId]: true }, calendarIds: { [realCalendarId]: true },
uid: src.uid, uid: src.uid,
title: src.title, title: src.title,
description: src.description, description: src.description,
descriptionContentType: src.descriptionContentType, descriptionContentType: src.descriptionContentType,
start: src.start, start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration, duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
timeZone: src.showWithoutTime ? null : src.timeZone, timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime, showWithoutTime: src.showWithoutTime,
status: src.status, status: src.status,
freeBusyStatus: src.freeBusyStatus, freeBusyStatus: src.freeBusyStatus,
privacy: src.privacy, privacy: src.privacy,
color: src.color, color: src.color,
keywords: src.keywords, keywords: src.keywords,
categories: src.categories, categories: src.categories,
locale: src.locale, locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined), replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations, locations: src.locations,
virtualLocations: src.virtualLocations, virtualLocations: src.virtualLocations,
links: src.links, links: src.links,
recurrenceRules: src.recurrenceRules, recurrenceRules: src.recurrenceRules,
recurrenceOverrides: src.recurrenceOverrides, recurrenceOverrides: src.recurrenceOverrides,
excludedRecurrenceRules: src.excludedRecurrenceRules, excludedRecurrenceRules: src.excludedRecurrenceRules,
alerts: src.alerts, alerts: src.alerts,
participants: cleanParticipants, participants: cleanParticipants,
}; };
Object.keys(data).forEach(k => { Object.keys(data).forEach(k => {
const v = (data as Record<string, unknown>)[k]; const v = (data as Record<string, unknown>)[k];
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k]; if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
}); });
const created = await client.createCalendarEvent(data, undefined, targetAccountId); prepared.push(data);
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId); }
set((state) => ({ events: [...state.events, mappedCreated] }));
imported++; if (prepared.length === 0) return 0;
} catch (error) {
const msg = error instanceof Error ? error.message : ''; // Batch create in chunks of 50 to avoid oversized requests
if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) { const BATCH_SIZE = 50;
const { events: storeEvents } = get(); let imported = 0;
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid); for (let i = 0; i < prepared.length; i += BATCH_SIZE) {
if (alreadyInStore) { const batch = prepared.slice(i, i + BATCH_SIZE);
imported++; try {
continue; const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId);
} const mapped = created.map(e => mapServerEventToStoreEvent(e, get().calendars, targetAccountId));
try { if (mapped.length > 0) {
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId); set((state) => ({ events: [...state.events, ...mapped] }));
const matching = all.filter((e) => e.uid === src.uid);
if (matching.length > 0) {
const existingIds = new Set(storeEvents.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
if (newEvents.length > 0) {
set((state) => ({ events: [...state.events, ...newEvents] }));
}
imported++;
continue;
}
} catch {
// fall through to error
}
} }
debug.error('Failed to import event:', event.title, error); imported += created.length;
if (failed.length > 0) {
debug.warn(`Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`);
}
} catch (error) {
debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error);
} }
} }
return imported; return imported;
@@ -678,6 +672,38 @@ export const useCalendarStore = create<CalendarStore>()(
} }
}, },
updateICalSubscription: async (client, subscriptionId, updates) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;
// Update the calendar on the server if name or color changed
if (updates.name || updates.color) {
const calUpdates: Record<string, unknown> = {};
if (updates.name) calUpdates.name = updates.name;
if (updates.color) calUpdates.color = updates.color;
await client.updateCalendar(sub.calendarId, calUpdates);
}
// Update local subscription record
const updated = { ...sub, ...updates };
set((state) => ({
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
calendars: state.calendars.map(c => {
if (c.id !== sub.calendarId) return c;
return {
...c,
...(updates.name ? { name: updates.name } : {}),
...(updates.color ? { color: updates.color } : {}),
};
}),
}));
// If URL changed, refresh to fetch events from new source
if (updates.url && updates.url !== sub.url) {
await get().refreshICalSubscription(client, subscriptionId);
}
},
removeICalSubscription: async (client, subscriptionId) => { removeICalSubscription: async (client, subscriptionId) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId); const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return; if (!sub) return;