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,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
const taskStore = useTaskStore();
@@ -83,6 +83,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -948,6 +949,7 @@ export default function CalendarPage() {
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
/>
</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} />
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
+13 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react";
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 type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
}
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
onToggleVisibility,
onColorChange,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
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"
>
<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
onClick={() => handleRefreshSubscription(sub.id)}
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 { X, Loader2, Globe } from "lucide-react";
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 { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: IJMAPClient;
onClose: () => void;
editSubscription?: ICalSubscription;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || "");
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 [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
if (isEdit && editSubscription) {
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
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();
} 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 {
setError(t("error"));
setError(isEdit ? t("update_error") : t("error"));
} finally {
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 gap-2">
<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>
<button
onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? (
<>
<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>
</div>
@@ -164,6 +164,7 @@ export function CalendarManagementSettings() {
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
@@ -629,6 +630,14 @@ export function CalendarManagementSettings() {
)}
</div>
<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
type="button"
onClick={() => handleRefreshSubscription(sub.id)}
@@ -667,6 +676,14 @@ export function CalendarManagementSettings() {
onClose={() => setShowSubscriptionModal(false)}
/>
)}
{editingSubscription && client && (
<ICalSubscriptionModal
client={client}
editSubscription={editingSubscription}
onClose={() => setEditingSubscription(null)}
/>
)}
</SettingsSection>
);
}
+9
View File
@@ -597,6 +597,15 @@ export class DemoJMAPClient implements IJMAPClient {
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> {
const event = this.data.calendarEvents.find(e => e.id === eventId);
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[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
updateCalendarEvent(
eventId: string,
updates: Partial<CalendarEvent>,
+69
View File
@@ -3276,6 +3276,75 @@ export class JMAPClient implements IJMAPClient {
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(
eventId: string,
updates: Partial<CalendarEvent>,
+6
View File
@@ -2107,6 +2107,12 @@
"interval_1440": "Every day",
"subscribe": "Subscribe",
"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",
"success": "Subscribed to \"{name}\"",
"error": "Failed to add subscription",
+119 -93
View File
@@ -133,6 +133,7 @@ interface CalendarStore {
// iCal subscriptions
icalSubscriptions: ICalSubscription[];
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>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
@@ -377,108 +378,101 @@ export const useCalendarStore = create<CalendarStore>()(
},
importEvents: async (client, events, calendarId) => {
let imported = 0;
// Resolve shared calendar IDs
const cal = get().calendars.find(c => c.id === calendarId);
const realCalendarId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId;
// Prepare all events for batch creation
const prepared: Partial<CalendarEvent>[] = [];
for (const event of events) {
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
try {
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
if (src.participants) {
cleanParticipants = {};
for (const [key, p] of Object.entries(src.participants)) {
const participant: Record<string, unknown> = {
'@type': 'Participant',
name: p.name,
email: p.email,
calendarAddress: p.calendarAddress,
description: p.description,
sendTo: p.sendTo,
kind: p.kind,
roles: p.roles,
participationStatus: p.participationStatus,
participationComment: p.participationComment,
expectReply: p.expectReply,
scheduleAgent: p.scheduleAgent,
scheduleForceSend: p.scheduleForceSend,
scheduleId: p.scheduleId,
delegatedTo: p.delegatedTo,
delegatedFrom: p.delegatedFrom,
memberOf: p.memberOf,
locationId: p.locationId,
language: p.language,
links: p.links,
};
Object.keys(participant).forEach(k => {
if (participant[k] === undefined || participant[k] === null) delete participant[k];
});
cleanParticipants[key] = participant as unknown as CalendarParticipant;
}
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
if (src.participants) {
cleanParticipants = {};
for (const [key, p] of Object.entries(src.participants)) {
const participant: Record<string, unknown> = {
'@type': 'Participant',
name: p.name,
email: p.email,
calendarAddress: p.calendarAddress,
description: p.description,
sendTo: p.sendTo,
kind: p.kind,
roles: p.roles,
participationStatus: p.participationStatus,
participationComment: p.participationComment,
expectReply: p.expectReply,
scheduleAgent: p.scheduleAgent,
scheduleForceSend: p.scheduleForceSend,
scheduleId: p.scheduleId,
delegatedTo: p.delegatedTo,
delegatedFrom: p.delegatedFrom,
memberOf: p.memberOf,
locationId: p.locationId,
language: p.language,
links: p.links,
};
Object.keys(participant).forEach(k => {
if (participant[k] === undefined || participant[k] === null) delete participant[k];
});
cleanParticipants[key] = participant as unknown as CalendarParticipant;
}
}
const data: Partial<CalendarEvent> = {
calendarIds: { [realCalendarId]: true },
uid: src.uid,
title: src.title,
description: src.description,
descriptionContentType: src.descriptionContentType,
start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,
freeBusyStatus: src.freeBusyStatus,
privacy: src.privacy,
color: src.color,
keywords: src.keywords,
categories: src.categories,
locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations,
virtualLocations: src.virtualLocations,
links: src.links,
recurrenceRules: src.recurrenceRules,
recurrenceOverrides: src.recurrenceOverrides,
excludedRecurrenceRules: src.excludedRecurrenceRules,
alerts: src.alerts,
participants: cleanParticipants,
};
Object.keys(data).forEach(k => {
const v = (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);
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId);
set((state) => ({ events: [...state.events, mappedCreated] }));
imported++;
} catch (error) {
const msg = error instanceof Error ? error.message : '';
if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) {
const { events: storeEvents } = get();
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid);
if (alreadyInStore) {
imported++;
continue;
}
try {
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId);
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
}
const data: Partial<CalendarEvent> = {
calendarIds: { [realCalendarId]: true },
uid: src.uid,
title: src.title,
description: src.description,
descriptionContentType: src.descriptionContentType,
start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,
freeBusyStatus: src.freeBusyStatus,
privacy: src.privacy,
color: src.color,
keywords: src.keywords,
categories: src.categories,
locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations,
virtualLocations: src.virtualLocations,
links: src.links,
recurrenceRules: src.recurrenceRules,
recurrenceOverrides: src.recurrenceOverrides,
excludedRecurrenceRules: src.excludedRecurrenceRules,
alerts: src.alerts,
participants: cleanParticipants,
};
Object.keys(data).forEach(k => {
const v = (data as Record<string, unknown>)[k];
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
});
prepared.push(data);
}
if (prepared.length === 0) return 0;
// Batch create in chunks of 50 to avoid oversized requests
const BATCH_SIZE = 50;
let imported = 0;
for (let i = 0; i < prepared.length; i += BATCH_SIZE) {
const batch = prepared.slice(i, i + BATCH_SIZE);
try {
const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId);
const mapped = created.map(e => mapServerEventToStoreEvent(e, get().calendars, targetAccountId));
if (mapped.length > 0) {
set((state) => ({ events: [...state.events, ...mapped] }));
}
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;
@@ -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) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;