feat: add iCal subscription editing and batch event import
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user