From caed067cda966e1b5d32ee985159d3be7ce06b1c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 15 Mar 2026 00:14:58 +0100
Subject: [PATCH] feat: implement calendar management features including
create, update, and delete functionalities
---
app/[locale]/calendar/page.tsx | 16 +-
app/[locale]/settings/page.tsx | 3 +-
app/api/fetch-ical/route.ts | 108 ++++
.../calendar/calendar-sidebar-panel.tsx | 90 ++-
components/calendar/event-card.tsx | 3 +-
components/calendar/ical-import-modal.tsx | 159 ++++-
.../calendar/ical-subscription-modal.tsx | 200 ++++++
.../settings/calendar-management-settings.tsx | 611 ++++++++++++++++++
lib/jmap/client.ts | 70 +-
locales/de/common.json | 34 +
locales/en/common.json | 67 ++
locales/es/common.json | 34 +
locales/fr/common.json | 34 +
locales/it/common.json | 34 +
locales/ja/common.json | 34 +
locales/nl/common.json | 34 +
locales/pt/common.json | 34 +
stores/calendar-store.ts | 275 ++++++++
18 files changed, 1772 insertions(+), 68 deletions(-)
create mode 100644 app/api/fetch-ical/route.ts
create mode 100644 components/calendar/ical-subscription-modal.tsx
create mode 100644 components/settings/calendar-management-settings.tsx
diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx
index cc3908cb..7e49c884 100644
--- a/app/[locale]/calendar/page.tsx
+++ b/app/[locale]/calendar/page.tsx
@@ -51,7 +51,8 @@ export default function CalendarPage() {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
- setSelectedDate, setViewMode, toggleCalendarVisibility,
+ setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
+ refreshAllSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat } = useSettingsStore();
const { identities } = useIdentityStore();
@@ -97,6 +98,16 @@ export default function CalendarPage() {
}
}, [client, fetchCalendars]);
+ // Auto-refresh iCal subscriptions
+ useEffect(() => {
+ if (!client) return;
+ // Refresh on mount (respects per-subscription interval)
+ refreshAllSubscriptions(client);
+ // Check again every 5 minutes
+ const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000);
+ return () => clearInterval(interval);
+ }, [client, refreshAllSubscriptions]);
+
const dateRange = useMemo(() => {
const d = selectedDate;
switch (viewMode) {
@@ -712,6 +723,9 @@ export default function CalendarPage() {
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
+ onColorChange={client ? (calendarId, color) => {
+ updateCalendar(client, calendarId, { color });
+ } : undefined}
/>
)}
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx
index 410ed688..9a23caa1 100644
--- a/app/[locale]/settings/page.tsx
+++ b/app/[locale]/settings/page.tsx
@@ -11,6 +11,7 @@ import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
+import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -84,7 +85,7 @@ export default function SettingsPage() {
{activeTab === 'security' && }
{activeTab === 'identities' && }
{activeTab === 'vacation' && }
- {activeTab === 'calendar' && }
+ {activeTab === 'calendar' && <>
>}
{activeTab === 'filters' && }
{activeTab === 'templates' && }
{activeTab === 'folders' && }
diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts
new file mode 100644
index 00000000..40c1aac9
--- /dev/null
+++ b/app/api/fetch-ical/route.ts
@@ -0,0 +1,108 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
+const FETCH_TIMEOUT_MS = 15000;
+
+function isValidExternalUrl(urlString: string): boolean {
+ let url: URL;
+ try {
+ url = new URL(urlString);
+ } catch {
+ return false;
+ }
+
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ return false;
+ }
+
+ const hostname = url.hostname.toLowerCase();
+
+ // Block private/internal hostnames
+ if (
+ hostname === 'localhost' ||
+ hostname === '127.0.0.1' ||
+ hostname === '::1' ||
+ hostname === '0.0.0.0' ||
+ hostname.endsWith('.local') ||
+ hostname.endsWith('.internal') ||
+ hostname.endsWith('.arpa') ||
+ hostname.startsWith('10.') ||
+ hostname.startsWith('192.168.') ||
+ hostname.startsWith('169.254.') ||
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
+ ) {
+ return false;
+ }
+
+ // Block URLs with credentials
+ if (url.username || url.password) {
+ return false;
+ }
+
+ return true;
+}
+
+export async function POST(request: NextRequest) {
+ let body: { url?: string };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+
+ const { url } = body;
+
+ if (!url || typeof url !== 'string') {
+ return NextResponse.json({ error: 'URL is required' }, { status: 400 });
+ }
+
+ if (!isValidExternalUrl(url)) {
+ return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
+ }
+
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ 'Accept': 'text/calendar, application/ics, text/plain, */*',
+ 'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
+ },
+ redirect: 'follow',
+ });
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: `Remote server returned ${response.status}` },
+ { status: 502 }
+ );
+ }
+
+ const contentLength = response.headers.get('content-length');
+ if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
+ return NextResponse.json({ error: 'File too large' }, { status: 413 });
+ }
+
+ const buffer = await response.arrayBuffer();
+ if (buffer.byteLength > MAX_RESPONSE_SIZE) {
+ return NextResponse.json({ error: 'File too large' }, { status: 413 });
+ }
+
+ return new NextResponse(buffer, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/calendar',
+ 'Content-Length': buffer.byteLength.toString(),
+ },
+ });
+ } catch (error: unknown) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
+ }
+ return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
+ }
+}
diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx
index 3dd998b1..b073c005 100644
--- a/components/calendar/calendar-sidebar-panel.tsx
+++ b/components/calendar/calendar-sidebar-panel.tsx
@@ -1,21 +1,49 @@
"use client";
+import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
+import { Globe } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
+import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
+import { useCalendarStore } from "@/stores/calendar-store";
interface CalendarSidebarPanelProps {
calendars: Calendar[];
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
+ onColorChange?: (calendarId: string, color: string) => void;
}
export function CalendarSidebarPanel({
calendars,
selectedCalendarIds,
onToggleVisibility,
+ onColorChange,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
+ const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
+
+ const [colorPickerId, setColorPickerId] = useState(null);
+ const colorPickerRef = useRef(null);
+
+ useEffect(() => {
+ if (!colorPickerId) return;
+ const handleClick = (e: MouseEvent) => {
+ if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
+ setColorPickerId(null);
+ }
+ };
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setColorPickerId(null);
+ };
+ document.addEventListener('mousedown', handleClick);
+ document.addEventListener('keydown', handleKey);
+ return () => {
+ document.removeEventListener('mousedown', handleClick);
+ document.removeEventListener('keydown', handleKey);
+ };
+ }, [colorPickerId]);
if (calendars.length === 0) return null;
@@ -30,25 +58,53 @@ export function CalendarSidebarPanel({
const color = cal.color || "#3b82f6";
return (
-
+
+ {/* Color picker popover on right-click */}
+ {colorPickerId === cal.id && onColorChange && (
+
+
{t("management.change_color")}
+
{
+ onColorChange(cal.id, c);
+ setColorPickerId(null);
+ }}
+ allowCustom
+ />
+
+ )}
+
);
})}
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index 851b8d87..f0574793 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -30,7 +30,8 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
-function parseDuration(duration: string): number {
+function parseDuration(duration: string | undefined): number {
+ if (!duration) return 0;
let totalMinutes = 0;
const weekMatch = duration.match(/(\d+)W/);
const hourMatch = duration.match(/(\d+)H/);
diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx
index fae028a0..3ec240ed 100644
--- a/components/calendar/ical-import-modal.tsx
+++ b/components/calendar/ical-import-modal.tsx
@@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
-import { X, Upload, Check, Loader2, RefreshCw } from "lucide-react";
+import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
@@ -20,6 +20,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
+type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
@@ -38,6 +39,9 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState(null);
+ const [importMode, setImportMode] = useState("file");
+ const [urlInput, setUrlInput] = useState("");
+ const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef(null);
const modalRef = useRef(null);
@@ -103,6 +107,57 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
if (file) handleFile(file);
}, [handleFile]);
+ const handleUrlFetch = useCallback(async () => {
+ const trimmed = urlInput.trim();
+ if (!trimmed) return;
+
+ try {
+ new URL(trimmed);
+ } catch {
+ setError(t("invalid_url"));
+ return;
+ }
+
+ setError(null);
+ setIsFetchingUrl(true);
+ setIsParsing(true);
+
+ try {
+ const response = await fetch("/api/fetch-ical", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url: trimmed }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.error || t("url_fetch_failed"));
+ }
+
+ const blob = await response.blob();
+ const file = new File([blob], "calendar.ics", { type: "text/calendar" });
+ const uploaded = await client.uploadBlob(file);
+ const accountId = client.getCalendarsAccountId();
+ const events = await client.parseCalendarEvents(accountId, uploaded.blobId);
+
+ if (events.length === 0) {
+ setError(t("no_events"));
+ setIsFetchingUrl(false);
+ setIsParsing(false);
+ return;
+ }
+
+ setParsedEvents(events);
+ setSelectedIndices(new Set(events.map((_, i) => i)));
+ setStep("preview");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t("url_fetch_failed"));
+ } finally {
+ setIsFetchingUrl(false);
+ setIsParsing(false);
+ }
+ }, [urlInput, client, t]);
+
const toggleEvent = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
@@ -202,29 +257,85 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
{step === "select" && !isParsing && (
-
fileInputRef.current?.click()}
- onDragOver={handleDragOver}
- onDragLeave={handleDragLeave}
- onDrop={handleDrop}
- className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
- isDragging
- ? "border-primary bg-primary/5"
- : "border-border hover:border-primary/50 hover:bg-muted/50"
- }`}
- >
-
-
{t("select_file")}
-
{t("drop_file")}
-
{t("supported_formats")}
-
-
+ <>
+
+ { setImportMode("file"); setError(null); }}
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
+ importMode === "file"
+ ? "border-primary text-primary"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ {t("tab_file")}
+
+ { setImportMode("url"); setError(null); }}
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
+ importMode === "url"
+ ? "border-primary text-primary"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ {t("tab_url")}
+
+
+
+ {importMode === "file" && (
+
fileInputRef.current?.click()}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
+ isDragging
+ ? "border-primary bg-primary/5"
+ : "border-border hover:border-primary/50 hover:bg-muted/50"
+ }`}
+ >
+
+
{t("select_file")}
+
{t("drop_file")}
+
{t("supported_formats")}
+
+
+ )}
+
+ {importMode === "url" && (
+
+
{t("url_description")}
+
+ setUrlInput(e.target.value)}
+ placeholder={t("url_placeholder")}
+ className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
+ onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
+ />
+
+ {isFetchingUrl ? (
+
+ ) : (
+ t("fetch")
+ )}
+
+
+
{t("url_hint")}
+
+ )}
+ >
)}
{isParsing && (
diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx
new file mode 100644
index 00000000..401ccfc9
--- /dev/null
+++ b/components/calendar/ical-subscription-modal.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { X, Loader2, Globe } from "lucide-react";
+import type { JMAPClient } from "@/lib/jmap/client";
+import { useCalendarStore } from "@/stores/calendar-store";
+import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
+import { toast } from "@/stores/toast-store";
+
+interface ICalSubscriptionModalProps {
+ client: JMAPClient;
+ onClose: () => void;
+}
+
+export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
+ const t = useTranslations("calendar.subscription");
+ const tCommon = useTranslations("common");
+ const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
+
+ const [url, setUrl] = useState("");
+ const [name, setName] = useState("");
+ const [color, setColor] = useState("#3b82f6");
+ const [refreshInterval, setRefreshInterval] = useState(60);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState
(null);
+ const modalRef = useRef(null);
+
+ const isValid = url.trim().length > 0 && name.trim().length > 0;
+
+ const handleSubmit = useCallback(async () => {
+ const trimmedUrl = url.trim();
+ if (!trimmedUrl || !name.trim()) return;
+
+ try {
+ new URL(trimmedUrl);
+ } catch {
+ setError(t("invalid_url"));
+ return;
+ }
+
+ setError(null);
+ setIsSubmitting(true);
+
+ try {
+ 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"));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
+
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", handleKey);
+ return () => window.removeEventListener("keydown", handleKey);
+ }, [onClose]);
+
+ useEffect(() => {
+ const modal = modalRef.current;
+ if (!modal) return;
+ const focusableEls = modal.querySelectorAll(
+ 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
+ );
+ const firstEl = focusableEls[0];
+ const lastEl = focusableEls[focusableEls.length - 1];
+
+ const handler = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ if (e.shiftKey && document.activeElement === firstEl) {
+ e.preventDefault();
+ lastEl?.focus();
+ } else if (!e.shiftKey && document.activeElement === lastEl) {
+ e.preventDefault();
+ firstEl?.focus();
+ }
+ };
+ modal.addEventListener("keydown", handler);
+ firstEl?.focus();
+ return () => modal.removeEventListener("keydown", handler);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
{t("title")}
+
+
+
+
+
+
+
+
{t("description")}
+
+
+
+ setUrl(e.target.value)}
+ placeholder={t("url_placeholder")}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
+ disabled={isSubmitting}
+ onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
+ />
+
+
+
+
+ setName(e.target.value)}
+ placeholder={t("name_placeholder")}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
+ disabled={isSubmitting}
+ onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ {tCommon("cancel")}
+
+
+ {isSubmitting ? (
+ <>
+
+ {t("subscribing")}
+ >
+ ) : (
+ t("subscribe")
+ )}
+
+
+
+
+ );
+}
diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx
new file mode 100644
index 00000000..dc02e548
--- /dev/null
+++ b/components/settings/calendar-management-settings.tsx
@@ -0,0 +1,611 @@
+"use client";
+
+import { useState, useRef, useEffect } from 'react';
+import { useTranslations } from 'next-intl';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { toast } from '@/stores/toast-store';
+import { SettingsSection } from './settings-section';
+import { Plus, Pencil, Trash2, Check, X, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { ICalImportModal } from '@/components/calendar/ical-import-modal';
+import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
+
+const CALENDAR_COLORS = [
+ "#3b82f6", // blue
+ "#ef4444", // red
+ "#22c55e", // green
+ "#f59e0b", // amber
+ "#8b5cf6", // violet
+ "#ec4899", // pink
+ "#14b8a6", // teal
+ "#f97316", // orange
+ "#06b6d4", // cyan
+ "#84cc16", // lime
+ "#6366f1", // indigo
+ "#a855f7", // purple
+ "#e11d48", // rose
+ "#0ea5e9", // sky
+ "#10b981", // emerald
+ "#d946ef", // fuchsia
+];
+
+function CalendarColorPicker({
+ value,
+ onChange,
+ allowCustom,
+}: {
+ value: string;
+ onChange: (color: string) => void;
+ allowCustom?: boolean;
+}) {
+ const selectedIsPreset = CALENDAR_COLORS.includes(value);
+
+ return (
+
+ {CALENDAR_COLORS.map((color) => (
+ onChange(color)}
+ className={cn(
+ "w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
+ value === color && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
+ )}
+ style={{ backgroundColor: color }}
+ aria-label={color}
+ />
+ ))}
+ {allowCustom && (
+
+ )}
+
+ );
+}
+
+function CalendarEditForm({
+ initial,
+ onSave,
+ onCancel,
+ isLoading,
+}: {
+ initial?: { name: string; color: string };
+ onSave: (data: { name: string; color: string }) => void;
+ onCancel: () => void;
+ isLoading: boolean;
+}) {
+ const t = useTranslations('calendar.management');
+ const [name, setName] = useState(initial?.name || '');
+ const [color, setColor] = useState(initial?.color || '#3b82f6');
+
+ const isValid = name.trim().length > 0;
+
+ return (
+
+
+
+ setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && isValid) onSave({ name: name.trim(), color });
+ if (e.key === 'Escape') onCancel();
+ }}
+ placeholder={t('name_placeholder')}
+ className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ autoFocus
+ disabled={isLoading}
+ />
+
+
+
+
+
+
+
+
+ isValid && onSave({ name: name.trim(), color })}
+ disabled={isLoading || !isValid}
+ className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
+ >
+ {initial ? t('save') : t('create')}
+
+
+ {t('cancel')}
+
+
+
+ );
+}
+
+export { CalendarColorPicker, CALENDAR_COLORS };
+
+export function CalendarManagementSettings() {
+ const t = useTranslations('calendar.management');
+ const { client, serverUrl, username } = useAuthStore();
+ const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
+
+ const [isCreating, setIsCreating] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [clearingId, setClearingId] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [colorPickerId, setColorPickerId] = useState(null);
+ const [showImportModal, setShowImportModal] = useState(false);
+ const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
+ const [deletingSubId, setDeletingSubId] = useState(null);
+ const [refreshingSubId, setRefreshingSubId] = useState(null);
+ const tImport = useTranslations('calendar.import');
+ const tSub = useTranslations('calendar.subscription');
+ const colorPickerRef = useRef(null);
+
+ // Load calendars if not yet loaded
+ useEffect(() => {
+ if (client && calendars.length === 0) {
+ fetchCalendars(client);
+ }
+ }, [client, calendars.length, fetchCalendars]);
+
+ const handleRefreshSubscription = async (subId: string) => {
+ if (!client) return;
+ setRefreshingSubId(subId);
+ try {
+ await refreshICalSubscription(client, subId);
+ toast.success(tSub('refresh_success'));
+ } catch {
+ toast.error(tSub('refresh_error'));
+ } finally {
+ setRefreshingSubId(null);
+ }
+ };
+
+ const handleDeleteSubscription = async (subId: string) => {
+ if (!client) return;
+ setIsLoading(true);
+ try {
+ await removeICalSubscription(client, subId);
+ setDeletingSubId(null);
+ toast.success(tSub('deleted'));
+ } catch {
+ toast.error(tSub('delete_error'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Close color picker on click outside
+ useEffect(() => {
+ if (!colorPickerId) return;
+ const handleClick = (e: MouseEvent) => {
+ if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
+ setColorPickerId(null);
+ }
+ };
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setColorPickerId(null);
+ };
+ document.addEventListener('mousedown', handleClick);
+ document.addEventListener('keydown', handleKey);
+ return () => {
+ document.removeEventListener('mousedown', handleClick);
+ document.removeEventListener('keydown', handleKey);
+ };
+ }, [colorPickerId]);
+
+ const handleCreate = async (data: { name: string; color: string }) => {
+ if (!client) return;
+ setIsLoading(true);
+ try {
+ await createCalendar(client, {
+ name: data.name,
+ color: data.color,
+ isVisible: true,
+ isSubscribed: true,
+ });
+ setIsCreating(false);
+ toast.success(t('calendar_created'));
+ } catch {
+ toast.error(t('error_create'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleUpdate = async (calendarId: string, data: { name: string; color: string }) => {
+ if (!client) return;
+ setIsLoading(true);
+ try {
+ await updateCalendar(client, calendarId, { name: data.name, color: data.color });
+ setEditingId(null);
+ toast.success(t('calendar_updated'));
+ } catch {
+ toast.error(t('error_update'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleColorChange = async (calendarId: string, color: string) => {
+ if (!client) return;
+ try {
+ await updateCalendar(client, calendarId, { color });
+ toast.success(t('color_updated'));
+ } catch {
+ toast.error(t('error_update'));
+ }
+ setColorPickerId(null);
+ };
+
+ const handleDelete = async (calendarId: string) => {
+ if (!client) return;
+ setIsLoading(true);
+ try {
+ await removeCalendar(client, calendarId);
+ setDeletingId(null);
+ toast.success(t('calendar_deleted'));
+ } catch {
+ toast.error(t('error_delete'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClear = async (calendarId: string) => {
+ if (!client) return;
+ setIsLoading(true);
+ try {
+ const count = await clearCalendarEvents(client, calendarId);
+ setClearingId(null);
+ toast.success(t('events_cleared', { count }));
+ } catch {
+ toast.error(t('error_clear'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const buildCalDavUrl = (calendarId: string) => {
+ if (!serverUrl || !username) return null;
+ const base = serverUrl.replace(/\/$/, '');
+ return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
+ };
+
+ const handleCopyUrl = async (url: string) => {
+ try {
+ await navigator.clipboard.writeText(url);
+ toast.success(t('url_copied'));
+ } catch {
+ // Fallback for non-HTTPS contexts
+ const textArea = document.createElement('textarea');
+ textArea.value = url;
+ textArea.style.position = 'fixed';
+ textArea.style.opacity = '0';
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ toast.success(t('url_copied'));
+ }
+ };
+
+ return (
+
+
+ {calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
+ const color = cal.color || '#3b82f6';
+
+ if (editingId === cal.id) {
+ return (
+
handleUpdate(cal.id, data)}
+ onCancel={() => setEditingId(null)}
+ isLoading={isLoading}
+ />
+ );
+ }
+
+ if (deletingId === cal.id) {
+ return (
+
+
+
+ {t('confirm_delete', { name: cal.name })}
+
+
handleDelete(cal.id)}
+ disabled={isLoading}
+ className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
+ >
+ {t('delete')}
+
+
setDeletingId(null)}
+ className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
+ >
+ {t('cancel')}
+
+
+ );
+ }
+
+ if (clearingId === cal.id) {
+ return (
+
+
+
+ {t('confirm_clear', { name: cal.name })}
+
+
handleClear(cal.id)}
+ disabled={isLoading}
+ className="px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
+ >
+ {t('clear_events')}
+
+
setClearingId(null)}
+ className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
+ >
+ {t('cancel')}
+
+
+ );
+ }
+
+ return (
+
+ {/* Color swatch - clickable to change color */}
+
+
setColorPickerId(colorPickerId === cal.id ? null : cal.id)}
+ className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
+ style={{ backgroundColor: color }}
+ title={t('change_color')}
+ />
+
+ {/* Inline color picker popover */}
+ {colorPickerId === cal.id && (
+
+ handleColorChange(cal.id, c)}
+ allowCustom
+ />
+
+ )}
+
+
+
+
+
+
{cal.name}
+ {(() => {
+ const caldavUrl = buildCalDavUrl(cal.id);
+ if (!caldavUrl) return null;
+ return (
+
+
+
+ {caldavUrl}
+
+ {
+ e.stopPropagation();
+ handleCopyUrl(caldavUrl);
+ }}
+ className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
+ title={t('copy_url')}
+ >
+
+
+
+ );
+ })()}
+
+
+ {cal.isDefault && (
+
+ {t('default')}
+
+ )}
+
+
+
setEditingId(cal.id)}
+ className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
+ title={t('edit')}
+ >
+
+
+
setClearingId(cal.id)}
+ className="p-1.5 rounded-md hover:bg-amber-500/10 text-muted-foreground hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
+ title={t('clear_events')}
+ >
+
+
+ {!cal.isDefault && (
+
setDeletingId(cal.id)}
+ className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
+ title={t('delete')}
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+ {isCreating ? (
+ setIsCreating(false)}
+ isLoading={isLoading}
+ />
+ ) : (
+
+
setIsCreating(true)}
+ className="flex items-center gap-2 flex-1 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
+ >
+
+ {t('add_calendar')}
+
+
setShowImportModal(true)}
+ className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
+ >
+
+ {tImport('title')}
+
+
setShowSubscriptionModal(true)}
+ className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
+ >
+
+ {tSub('title')}
+
+
+ )}
+
+
+ {/* iCal Subscriptions */}
+ {icalSubscriptions.length > 0 && (
+
+
+
+ {tSub('section_title')}
+
+ {icalSubscriptions.map((sub) => {
+ if (deletingSubId === sub.id) {
+ return (
+
+
+
+ {tSub('confirm_delete', { name: sub.name })}
+
+
handleDeleteSubscription(sub.id)}
+ disabled={isLoading}
+ className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
+ >
+ {t('delete')}
+
+
setDeletingSubId(null)}
+ className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
+ >
+ {t('cancel')}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {sub.name}
+
+ {sub.url}
+
+ {sub.lastRefreshed && (
+
+ {tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
+
+ )}
+
+
+ handleRefreshSubscription(sub.id)}
+ disabled={refreshingSubId === sub.id}
+ className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
+ title={tSub('refresh')}
+ >
+
+
+ setDeletingSubId(sub.id)}
+ className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
+ title={tSub('unsubscribe')}
+ >
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {showImportModal && client && (
+ setShowImportModal(false)}
+ />
+ )}
+
+ {showSubscriptionModal && client && (
+ setShowSubscriptionModal(false)}
+ />
+ )}
+
+ );
+}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 0f5b3726..9f2fa182 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -2052,7 +2052,8 @@ export class JMAPClient {
const response = await this.request([
["Calendar/set", {
accountId,
- destroy: [calendarId]
+ destroy: [calendarId],
+ onDestroyRemoveEvents: true
}, "0"]
], this.calendarUsing());
@@ -2070,30 +2071,31 @@ export class JMAPClient {
}
async getCalendarEvents(calendarIds?: string[]): Promise {
- try {
- const accountId = this.getCalendarsAccountId();
+ const accountId = this.getCalendarsAccountId();
- const queryArgs: Record = { accountId, limit: 1000 };
- if (calendarIds && calendarIds.length > 0) {
- queryArgs.filter = { inCalendars: calendarIds };
- }
-
- const response = await this.request([
- ["CalendarEvent/query", queryArgs, "0"],
- ["CalendarEvent/get", {
- accountId,
- "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
- }, "1"]
- ], this.calendarUsing());
-
- if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
- return (response.methodResponses[1][1].list || []) as CalendarEvent[];
- }
- return [];
- } catch (error) {
- console.error('Failed to get calendar events:', error);
- return [];
+ const queryArgs: Record = { accountId, limit: 1000 };
+ if (calendarIds && calendarIds.length > 0) {
+ queryArgs.filter = { inCalendars: calendarIds };
}
+
+ const response = await this.request([
+ ["CalendarEvent/query", queryArgs, "0"],
+ ["CalendarEvent/get", {
+ accountId,
+ "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
+ }, "1"]
+ ], this.calendarUsing());
+
+ // Check for JMAP method-level errors
+ if (response.methodResponses?.[0]?.[0] === "error") {
+ const error = response.methodResponses[0][1];
+ throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
+ }
+
+ if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
+ return (response.methodResponses[1][1].list || []) as CalendarEvent[];
+ }
+ return [];
}
async queryCalendarEvents(
@@ -2107,7 +2109,7 @@ export class JMAPClient {
const queryArgs: Record = {
accountId,
filter,
- limit: limit || 100,
+ limit: limit || 1000,
};
if (sort) {
queryArgs.sort = sort;
@@ -2279,6 +2281,26 @@ export class JMAPClient {
throw new Error("Failed to delete calendar event");
}
+ async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
+ if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
+
+ const accountId = this.getCalendarsAccountId();
+ const response = await this.request([
+ ["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
+ ], this.calendarUsing());
+
+ const destroyed: string[] = [];
+ const notDestroyed: string[] = [];
+
+ if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
+ const result = response.methodResponses[0][1];
+ if (result.destroyed) destroyed.push(...result.destroyed);
+ if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
+ }
+
+ return { destroyed, notDestroyed };
+ }
+
async downloadBlob(blobId: string, name?: string, type?: string): Promise {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {});
diff --git a/locales/de/common.json b/locales/de/common.json
index bac553fe..002b27bf 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Weiter",
"import": {
"title": "Kalender importieren",
+ "tab_file": "Datei",
+ "tab_url": "URL",
"select_file": ".ics-Datei auswählen",
"drop_file": "oder Datei hier ablegen",
"supported_formats": "iCalendar (.ics) Dateien werden unterstützt",
+ "url_description": "Geben Sie die URL eines externen iCalendar (.ics) Feeds ein, um Termine zu importieren.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Unterstützt CalDAV und iCalendar (.ics) URLs",
+ "fetch": "Abrufen",
+ "invalid_url": "Bitte geben Sie eine gültige URL ein",
+ "url_fetch_failed": "Kalender konnte nicht von der URL abgerufen werden",
"parsing": "Kalenderdatei wird analysiert...",
"parsed_events": "{count} Termine gefunden",
"no_events": "Keine Termine in der Datei gefunden",
@@ -1613,6 +1621,32 @@
"error": "Kalender konnte nicht importiert werden",
"file_too_large": "Datei überschreitet das 5-MB-Limit",
"invalid_format": "Ungültiges Kalenderdateiformat"
+ },
+ "management": {
+ "title": "Kalenderverwaltung",
+ "description": "Erstellen, umbenennen und anpassen Ihrer Kalender. Rechtsklick auf einen Kalender in der Seitenleiste, um die Farbe schnell zu ändern.",
+ "name": "Name",
+ "name_placeholder": "Kalendername",
+ "color": "Farbe",
+ "change_color": "Farbe ändern",
+ "add_calendar": "Kalender hinzufügen",
+ "edit": "Bearbeiten",
+ "delete": "Löschen",
+ "save": "Speichern",
+ "create": "Erstellen",
+ "cancel": "Abbrechen",
+ "default": "Standard",
+ "confirm_delete": "\"{name}\" löschen? Alle Termine in diesem Kalender werden entfernt.",
+ "calendar_created": "Kalender erstellt",
+ "calendar_updated": "Kalender aktualisiert",
+ "calendar_deleted": "Kalender gelöscht",
+ "color_updated": "Kalenderfarbe aktualisiert",
+ "error_create": "Kalender konnte nicht erstellt werden",
+ "error_update": "Kalender konnte nicht aktualisiert werden",
+ "error_delete": "Kalender konnte nicht gelöscht werden",
+ "caldav_url": "CalDAV-URL",
+ "copy_url": "CalDAV-URL kopieren",
+ "url_copied": "CalDAV-URL in die Zwischenablage kopiert"
}
},
"advanced_search": {
diff --git a/locales/en/common.json b/locales/en/common.json
index 77a2aca5..7d695d82 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Next",
"import": {
"title": "Import Calendar",
+ "tab_file": "File",
+ "tab_url": "URL",
"select_file": "Select .ics file",
"drop_file": "or drop file here",
"supported_formats": "Supports iCalendar (.ics) files",
+ "url_description": "Enter the URL of an external iCalendar (.ics) feed to import events.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Supports CalDAV and iCalendar (.ics) URLs",
+ "fetch": "Fetch",
+ "invalid_url": "Please enter a valid URL",
+ "url_fetch_failed": "Failed to fetch calendar from URL",
"parsing": "Parsing calendar file...",
"parsed_events": "{count} events found",
"no_events": "No events found in file",
@@ -1613,6 +1621,65 @@
"error": "Failed to import calendar",
"file_too_large": "File exceeds 5MB limit",
"invalid_format": "Invalid calendar file format"
+ },
+ "management": {
+ "title": "Calendar Management",
+ "description": "Create, rename, and customize your calendars. Right-click a calendar in the sidebar to quickly change its color.",
+ "name": "Name",
+ "name_placeholder": "Calendar name",
+ "color": "Color",
+ "change_color": "Change color",
+ "add_calendar": "Add calendar",
+ "edit": "Edit",
+ "delete": "Delete",
+ "save": "Save",
+ "create": "Create",
+ "cancel": "Cancel",
+ "default": "Default",
+ "confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.",
+ "confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.",
+ "clear_events": "Clear events",
+ "events_cleared": "{count} events cleared",
+ "error_clear": "Failed to clear calendar events",
+ "calendar_created": "Calendar created",
+ "calendar_updated": "Calendar updated",
+ "calendar_deleted": "Calendar deleted",
+ "color_updated": "Calendar color updated",
+ "error_create": "Failed to create calendar",
+ "error_update": "Failed to update calendar",
+ "error_delete": "Failed to delete calendar",
+ "caldav_url": "CalDAV URL",
+ "copy_url": "Copy CalDAV URL",
+ "url_copied": "CalDAV URL copied to clipboard"
+ },
+ "subscription": {
+ "title": "iCal Subscription",
+ "section_title": "iCal Subscriptions",
+ "description": "Subscribe to an external iCalendar feed. Events will be synced automatically into their own calendar.",
+ "url_label": "Calendar URL",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "name_label": "Calendar name",
+ "name_placeholder": "e.g. Public Holidays",
+ "color_label": "Color",
+ "refresh_interval": "Refresh interval",
+ "interval_15": "Every 15 minutes",
+ "interval_30": "Every 30 minutes",
+ "interval_60": "Every hour",
+ "interval_360": "Every 6 hours",
+ "interval_1440": "Every day",
+ "subscribe": "Subscribe",
+ "subscribing": "Subscribing...",
+ "invalid_url": "Please enter a valid URL",
+ "success": "Subscribed to \"{name}\"",
+ "error": "Failed to add subscription",
+ "refresh": "Refresh now",
+ "refresh_success": "Subscription refreshed",
+ "refresh_error": "Failed to refresh subscription",
+ "unsubscribe": "Unsubscribe",
+ "confirm_delete": "Unsubscribe from \"{name}\"? The calendar and all its events will be removed.",
+ "deleted": "Subscription removed",
+ "delete_error": "Failed to remove subscription",
+ "last_refreshed": "Last updated: {time}"
}
},
"advanced_search": {
diff --git a/locales/es/common.json b/locales/es/common.json
index 5ff45f8e..b5873bd6 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Siguiente",
"import": {
"title": "Importar calendario",
+ "tab_file": "Archivo",
+ "tab_url": "URL",
"select_file": "Seleccionar archivo .ics",
"drop_file": "o arrastra el archivo aquí",
"supported_formats": "Archivos iCalendar (.ics) compatibles",
+ "url_description": "Introduce la URL de un feed iCalendar (.ics) externo para importar eventos.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Compatible con URLs CalDAV e iCalendar (.ics)",
+ "fetch": "Obtener",
+ "invalid_url": "Introduce una URL válida",
+ "url_fetch_failed": "No se pudo obtener el calendario desde la URL",
"parsing": "Analizando archivo de calendario...",
"parsed_events": "{count} eventos encontrados",
"no_events": "No se encontraron eventos en el archivo",
@@ -1613,6 +1621,32 @@
"error": "Error al importar el calendario",
"file_too_large": "El archivo supera el límite de 5 MB",
"invalid_format": "Formato de archivo de calendario no válido"
+ },
+ "management": {
+ "title": "Gestión de calendarios",
+ "description": "Crea, renombra y personaliza tus calendarios. Haz clic derecho en un calendario en la barra lateral para cambiar su color rápidamente.",
+ "name": "Nombre",
+ "name_placeholder": "Nombre del calendario",
+ "color": "Color",
+ "change_color": "Cambiar color",
+ "add_calendar": "Añadir calendario",
+ "edit": "Editar",
+ "delete": "Eliminar",
+ "save": "Guardar",
+ "create": "Crear",
+ "cancel": "Cancelar",
+ "default": "Predeterminado",
+ "confirm_delete": "¿Eliminar \"{name}\"? Se eliminarán todos los eventos de este calendario.",
+ "calendar_created": "Calendario creado",
+ "calendar_updated": "Calendario actualizado",
+ "calendar_deleted": "Calendario eliminado",
+ "color_updated": "Color del calendario actualizado",
+ "error_create": "Error al crear el calendario",
+ "error_update": "Error al actualizar el calendario",
+ "error_delete": "Error al eliminar el calendario",
+ "caldav_url": "URL de CalDAV",
+ "copy_url": "Copiar URL de CalDAV",
+ "url_copied": "URL de CalDAV copiada al portapapeles"
}
},
"advanced_search": {
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 92201d49..4e5e602d 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Suivant",
"import": {
"title": "Importer un calendrier",
+ "tab_file": "Fichier",
+ "tab_url": "URL",
"select_file": "Sélectionner un fichier .ics",
"drop_file": "ou déposez le fichier ici",
"supported_formats": "Fichiers iCalendar (.ics) supportés",
+ "url_description": "Entrez l'URL d'un flux iCalendar (.ics) externe pour importer des événements.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Prend en charge les URLs CalDAV et iCalendar (.ics)",
+ "fetch": "Récupérer",
+ "invalid_url": "Veuillez entrer une URL valide",
+ "url_fetch_failed": "Impossible de récupérer le calendrier depuis l'URL",
"parsing": "Analyse du fichier en cours...",
"parsed_events": "{count} événements trouvés",
"no_events": "Aucun événement trouvé dans le fichier",
@@ -1613,6 +1621,32 @@
"error": "Échec de l'importation du calendrier",
"file_too_large": "Le fichier dépasse la limite de 5 Mo",
"invalid_format": "Format de fichier calendrier invalide"
+ },
+ "management": {
+ "title": "Gestion des calendriers",
+ "description": "Créez, renommez et personnalisez vos calendriers. Clic droit sur un calendrier dans la barre latérale pour changer rapidement sa couleur.",
+ "name": "Nom",
+ "name_placeholder": "Nom du calendrier",
+ "color": "Couleur",
+ "change_color": "Changer la couleur",
+ "add_calendar": "Ajouter un calendrier",
+ "edit": "Modifier",
+ "delete": "Supprimer",
+ "save": "Enregistrer",
+ "create": "Créer",
+ "cancel": "Annuler",
+ "default": "Par défaut",
+ "confirm_delete": "Supprimer \"{name}\" ? Tous les événements de ce calendrier seront supprimés.",
+ "calendar_created": "Calendrier créé",
+ "calendar_updated": "Calendrier mis à jour",
+ "calendar_deleted": "Calendrier supprimé",
+ "color_updated": "Couleur du calendrier mise à jour",
+ "error_create": "Échec de la création du calendrier",
+ "error_update": "Échec de la mise à jour du calendrier",
+ "error_delete": "Échec de la suppression du calendrier",
+ "caldav_url": "URL CalDAV",
+ "copy_url": "Copier l'URL CalDAV",
+ "url_copied": "URL CalDAV copiée dans le presse-papiers"
}
},
"advanced_search": {
diff --git a/locales/it/common.json b/locales/it/common.json
index dea325ef..d3df2d81 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Successivo",
"import": {
"title": "Importa calendario",
+ "tab_file": "File",
+ "tab_url": "URL",
"select_file": "Seleziona file .ics",
"drop_file": "o trascina il file qui",
"supported_formats": "File iCalendar (.ics) supportati",
+ "url_description": "Inserisci l'URL di un feed iCalendar (.ics) esterno per importare eventi.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Supporta URL CalDAV e iCalendar (.ics)",
+ "fetch": "Recupera",
+ "invalid_url": "Inserisci un URL valido",
+ "url_fetch_failed": "Impossibile recuperare il calendario dall'URL",
"parsing": "Analisi del file in corso...",
"parsed_events": "{count} eventi trovati",
"no_events": "Nessun evento trovato nel file",
@@ -1613,6 +1621,32 @@
"error": "Importazione del calendario fallita",
"file_too_large": "Il file supera il limite di 5 MB",
"invalid_format": "Formato del file calendario non valido"
+ },
+ "management": {
+ "title": "Gestione calendari",
+ "description": "Crea, rinomina e personalizza i tuoi calendari. Fai clic destro su un calendario nella barra laterale per cambiarne rapidamente il colore.",
+ "name": "Nome",
+ "name_placeholder": "Nome del calendario",
+ "color": "Colore",
+ "change_color": "Cambia colore",
+ "add_calendar": "Aggiungi calendario",
+ "edit": "Modifica",
+ "delete": "Elimina",
+ "save": "Salva",
+ "create": "Crea",
+ "cancel": "Annulla",
+ "default": "Predefinito",
+ "confirm_delete": "Eliminare \"{name}\"? Tutti gli eventi in questo calendario verranno rimossi.",
+ "calendar_created": "Calendario creato",
+ "calendar_updated": "Calendario aggiornato",
+ "calendar_deleted": "Calendario eliminato",
+ "color_updated": "Colore del calendario aggiornato",
+ "error_create": "Impossibile creare il calendario",
+ "error_update": "Impossibile aggiornare il calendario",
+ "error_delete": "Impossibile eliminare il calendario",
+ "caldav_url": "URL CalDAV",
+ "copy_url": "Copia URL CalDAV",
+ "url_copied": "URL CalDAV copiato negli appunti"
}
},
"advanced_search": {
diff --git a/locales/ja/common.json b/locales/ja/common.json
index cddaf5c3..a87b4454 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "次へ",
"import": {
"title": "カレンダーをインポート",
+ "tab_file": "ファイル",
+ "tab_url": "URL",
"select_file": ".icsファイルを選択",
"drop_file": "またはファイルをここにドロップ",
"supported_formats": "iCalendar (.ics) ファイルに対応",
+ "url_description": "外部のiCalendar (.ics) フィードのURLを入力してイベントをインポートします。",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "CalDAVおよびiCalendar (.ics) URLに対応",
+ "fetch": "取得",
+ "invalid_url": "有効なURLを入力してください",
+ "url_fetch_failed": "URLからカレンダーを取得できませんでした",
"parsing": "カレンダーファイルを解析中...",
"parsed_events": "{count}件のイベントが見つかりました",
"no_events": "ファイルにイベントが見つかりません",
@@ -1613,6 +1621,32 @@
"error": "カレンダーのインポートに失敗しました",
"file_too_large": "ファイルサイズが5MBを超えています",
"invalid_format": "無効なカレンダーファイル形式"
+ },
+ "management": {
+ "title": "カレンダー管理",
+ "description": "カレンダーの作成、名前変更、カスタマイズができます。サイドバーのカレンダーを右クリックして色を素早く変更できます。",
+ "name": "名前",
+ "name_placeholder": "カレンダー名",
+ "color": "色",
+ "change_color": "色を変更",
+ "add_calendar": "カレンダーを追加",
+ "edit": "編集",
+ "delete": "削除",
+ "save": "保存",
+ "create": "作成",
+ "cancel": "キャンセル",
+ "default": "デフォルト",
+ "confirm_delete": "\"{name}\"を削除しますか?このカレンダーのすべてのイベントが削除されます。",
+ "calendar_created": "カレンダーを作成しました",
+ "calendar_updated": "カレンダーを更新しました",
+ "calendar_deleted": "カレンダーを削除しました",
+ "color_updated": "カレンダーの色を更新しました",
+ "error_create": "カレンダーの作成に失敗しました",
+ "error_update": "カレンダーの更新に失敗しました",
+ "error_delete": "カレンダーの削除に失敗しました",
+ "caldav_url": "CalDAV URL",
+ "copy_url": "CalDAV URLをコピー",
+ "url_copied": "CalDAV URLをクリップボードにコピーしました"
}
},
"advanced_search": {
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 645137da..093c3966 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Volgende",
"import": {
"title": "Agenda importeren",
+ "tab_file": "Bestand",
+ "tab_url": "URL",
"select_file": "Selecteer .ics-bestand",
"drop_file": "of sleep het bestand hierheen",
"supported_formats": "iCalendar (.ics) bestanden worden ondersteund",
+ "url_description": "Voer de URL in van een externe iCalendar (.ics) feed om evenementen te importeren.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Ondersteunt CalDAV en iCalendar (.ics) URLs",
+ "fetch": "Ophalen",
+ "invalid_url": "Voer een geldige URL in",
+ "url_fetch_failed": "Kan agenda niet ophalen van URL",
"parsing": "Agendabestand wordt verwerkt...",
"parsed_events": "{count} evenementen gevonden",
"no_events": "Geen evenementen gevonden in bestand",
@@ -1613,6 +1621,32 @@
"error": "Agenda importeren mislukt",
"file_too_large": "Bestand overschrijdt de limiet van 5 MB",
"invalid_format": "Ongeldig agendabestandsformaat"
+ },
+ "management": {
+ "title": "Agendabeheer",
+ "description": "Maak, hernoem en pas uw agenda's aan. Klik met de rechtermuisknop op een agenda in de zijbalk om snel de kleur te wijzigen.",
+ "name": "Naam",
+ "name_placeholder": "Agendanaam",
+ "color": "Kleur",
+ "change_color": "Kleur wijzigen",
+ "add_calendar": "Agenda toevoegen",
+ "edit": "Bewerken",
+ "delete": "Verwijderen",
+ "save": "Opslaan",
+ "create": "Aanmaken",
+ "cancel": "Annuleren",
+ "default": "Standaard",
+ "confirm_delete": "\"{name}\" verwijderen? Alle afspraken in deze agenda worden verwijderd.",
+ "calendar_created": "Agenda aangemaakt",
+ "calendar_updated": "Agenda bijgewerkt",
+ "calendar_deleted": "Agenda verwijderd",
+ "color_updated": "Agendakleur bijgewerkt",
+ "error_create": "Agenda aanmaken mislukt",
+ "error_update": "Agenda bijwerken mislukt",
+ "error_delete": "Agenda verwijderen mislukt",
+ "caldav_url": "CalDAV-URL",
+ "copy_url": "CalDAV-URL kopiëren",
+ "url_copied": "CalDAV-URL gekopieerd naar klembord"
}
},
"advanced_search": {
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 4befd9fd..81fd5395 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1598,9 +1598,17 @@
"nav_next": "Próximo",
"import": {
"title": "Importar calendário",
+ "tab_file": "Arquivo",
+ "tab_url": "URL",
"select_file": "Selecionar arquivo .ics",
"drop_file": "ou arraste o arquivo aqui",
"supported_formats": "Arquivos iCalendar (.ics) suportados",
+ "url_description": "Insira a URL de um feed iCalendar (.ics) externo para importar eventos.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Suporta URLs CalDAV e iCalendar (.ics)",
+ "fetch": "Buscar",
+ "invalid_url": "Insira uma URL válida",
+ "url_fetch_failed": "Não foi possível buscar o calendário da URL",
"parsing": "Analisando arquivo de calendário...",
"parsed_events": "{count} eventos encontrados",
"no_events": "Nenhum evento encontrado no arquivo",
@@ -1613,6 +1621,32 @@
"error": "Falha ao importar calendário",
"file_too_large": "Arquivo excede o limite de 5 MB",
"invalid_format": "Formato de arquivo de calendário inválido"
+ },
+ "management": {
+ "title": "Gerenciamento de calendários",
+ "description": "Crie, renomeie e personalize seus calendários. Clique com o botão direito em um calendário na barra lateral para alterar rapidamente sua cor.",
+ "name": "Nome",
+ "name_placeholder": "Nome do calendário",
+ "color": "Cor",
+ "change_color": "Alterar cor",
+ "add_calendar": "Adicionar calendário",
+ "edit": "Editar",
+ "delete": "Excluir",
+ "save": "Salvar",
+ "create": "Criar",
+ "cancel": "Cancelar",
+ "default": "Padrão",
+ "confirm_delete": "Excluir \"{name}\"? Todos os eventos neste calendário serão removidos.",
+ "calendar_created": "Calendário criado",
+ "calendar_updated": "Calendário atualizado",
+ "calendar_deleted": "Calendário excluído",
+ "color_updated": "Cor do calendário atualizada",
+ "error_create": "Falha ao criar calendário",
+ "error_update": "Falha ao atualizar calendário",
+ "error_delete": "Falha ao excluir calendário",
+ "caldav_url": "URL CalDAV",
+ "copy_url": "Copiar URL CalDAV",
+ "url_copied": "URL CalDAV copiada para a área de transferência"
}
},
"advanced_search": {
diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts
index 6e21d548..2e165ba5 100644
--- a/stores/calendar-store.ts
+++ b/stores/calendar-store.ts
@@ -6,6 +6,16 @@ import { debug } from '@/lib/debug';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
+export interface ICalSubscription {
+ id: string;
+ url: string;
+ calendarId: string;
+ name: string;
+ color: string;
+ refreshInterval: number; // minutes
+ lastRefreshed: string | null;
+}
+
interface CalendarStore {
calendars: Calendar[];
events: CalendarEvent[];
@@ -27,11 +37,23 @@ interface CalendarStore {
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise;
importEvents: (client: JMAPClient, events: Partial[], calendarId: string) => Promise;
+ updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial) => Promise;
+ createCalendar: (client: JMAPClient, calendar: Partial) => Promise;
+ removeCalendar: (client: JMAPClient, calendarId: string) => Promise;
+ clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise;
setSelectedDate: (date: Date) => void;
setViewMode: (mode: CalendarViewMode) => void;
toggleCalendarVisibility: (calendarId: string) => void;
setSelectedEventId: (id: string | null) => void;
clearState: () => void;
+
+ // iCal subscriptions
+ icalSubscriptions: ICalSubscription[];
+ addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise;
+ removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise;
+ refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise;
+ refreshAllSubscriptions: (client: JMAPClient) => Promise;
+ isSubscriptionCalendar: (calendarId: string) => boolean;
}
const initialState = {
@@ -45,6 +67,7 @@ const initialState = {
supportsCalendar: false,
error: null as string | null,
dateRange: null as { start: string; end: string } | null,
+ icalSubscriptions: [] as ICalSubscription[],
};
export const useCalendarStore = create()(
@@ -265,6 +288,92 @@ export const useCalendarStore = create()(
setSelectedDate: (date) => set({ selectedDate: date }),
setViewMode: (mode) => set({ viewMode: mode }),
+ updateCalendar: async (client, calendarId, updates) => {
+ set({ error: null });
+ try {
+ await client.updateCalendar(calendarId, updates);
+ set((state) => ({
+ calendars: state.calendars.map(c =>
+ c.id === calendarId ? { ...c, ...updates } : c
+ ),
+ }));
+ } catch (error) {
+ debug.error('Failed to update calendar:', error);
+ set({ error: 'Failed to update calendar' });
+ throw error;
+ }
+ },
+
+ createCalendar: async (client, calendar) => {
+ set({ error: null });
+ try {
+ const created = await client.createCalendar(calendar);
+ set((state) => ({
+ calendars: [...state.calendars, created],
+ selectedCalendarIds: [...state.selectedCalendarIds, created.id],
+ }));
+ return created;
+ } catch (error) {
+ debug.error('Failed to create calendar:', error);
+ set({ error: 'Failed to create calendar' });
+ return null;
+ }
+ },
+
+ removeCalendar: async (client, calendarId) => {
+ set({ error: null });
+ try {
+ await client.deleteCalendar(calendarId);
+ set((state) => ({
+ calendars: state.calendars.filter(c => c.id !== calendarId),
+ selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
+ events: state.events.filter(e => !e.calendarIds?.[calendarId]),
+ }));
+ } catch (error) {
+ debug.error('Failed to delete calendar:', error);
+ set({ error: 'Failed to delete calendar' });
+ throw error;
+ }
+ },
+
+ clearCalendarEvents: async (client, calendarId) => {
+ set({ error: null });
+ try {
+ let totalDeleted = 0;
+ // Loop to handle pagination (getCalendarEvents has a 1000 limit)
+ let hasMore = true;
+ while (hasMore) {
+ // Query all events and filter client-side by calendarId
+ // to avoid relying on server-side inCalendars filter support
+ const allEvents = await client.getCalendarEvents();
+ const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]);
+ if (calendarEvents.length === 0) break;
+
+ const ids = calendarEvents.map(e => e.id);
+ const { destroyed } = await client.batchDeleteCalendarEvents(ids);
+ totalDeleted += destroyed.length;
+
+ // If we couldn't destroy any events, stop to avoid infinite loop
+ if (destroyed.length === 0) {
+ debug.warn('Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
+ break;
+ }
+
+ // If we got fewer than the limit, we've fetched everything
+ if (allEvents.length < 1000) hasMore = false;
+ }
+
+ set((state) => ({
+ events: state.events.filter(e => !e.calendarIds?.[calendarId]),
+ }));
+ return totalDeleted;
+ } catch (error) {
+ debug.error('Failed to clear calendar events:', error);
+ set({ error: 'Failed to clear calendar events' });
+ throw error;
+ }
+ },
+
toggleCalendarVisibility: (calendarId) => set((state) => {
const ids = state.selectedCalendarIds;
return {
@@ -276,6 +385,171 @@ export const useCalendarStore = create()(
setSelectedEventId: (id) => set({ selectedEventId: id }),
+ // iCal subscriptions
+ isSubscriptionCalendar: (calendarId) => {
+ return get().icalSubscriptions.some(s => s.calendarId === calendarId);
+ },
+
+ addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
+ try {
+ // Create a new calendar for this subscription
+ const calendar = await client.createCalendar({
+ name,
+ color,
+ isVisible: true,
+ isSubscribed: true,
+ });
+ if (!calendar) throw new Error('Failed to create calendar');
+
+ const subscription: ICalSubscription = {
+ id: crypto.randomUUID(),
+ url,
+ calendarId: calendar.id,
+ name,
+ color,
+ refreshInterval,
+ lastRefreshed: null,
+ };
+
+ set((state) => ({
+ calendars: [...state.calendars, calendar],
+ selectedCalendarIds: [...state.selectedCalendarIds, calendar.id],
+ icalSubscriptions: [...state.icalSubscriptions, subscription],
+ }));
+
+ // Do initial fetch
+ try {
+ await get().refreshICalSubscription(client, subscription.id);
+ } catch {
+ // Subscription created, initial fetch failed - user can retry
+ debug.warn('Initial subscription fetch failed for:', name);
+ }
+
+ return subscription;
+ } catch (error) {
+ debug.error('Failed to add iCal subscription:', error);
+ return null;
+ }
+ },
+
+ removeICalSubscription: async (client, subscriptionId) => {
+ const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
+ if (!sub) return;
+
+ try {
+ await client.deleteCalendar(sub.calendarId);
+ } catch (error) {
+ debug.error('Failed to delete subscription calendar:', error);
+ // Continue removing subscription record even if calendar delete fails
+ }
+
+ set((state) => ({
+ icalSubscriptions: state.icalSubscriptions.filter(s => s.id !== subscriptionId),
+ calendars: state.calendars.filter(c => c.id !== sub.calendarId),
+ selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== sub.calendarId),
+ events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
+ }));
+ },
+
+ refreshICalSubscription: async (client, subscriptionId) => {
+ const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
+ if (!sub) return;
+
+ try {
+ const response = await fetch('/api/fetch-ical', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: sub.url }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.error || 'Failed to fetch calendar');
+ }
+
+ const blob = await response.blob();
+ const file = new File([blob], 'subscription.ics', { type: 'text/calendar' });
+ const uploaded = await client.uploadBlob(file);
+ const accountId = client.getCalendarsAccountId();
+ const parsedEvents = await client.parseCalendarEvents(accountId, uploaded.blobId);
+
+ // Fetch ALL server-side events and filter client-side for this calendar
+ // (avoids relying on server-side inCalendars filter support)
+ const allServerEvents = await client.getCalendarEvents();
+ const serverEvents = allServerEvents.filter(e => e.calendarIds?.[sub.calendarId]);
+
+ // Build a map of incoming UIDs for diffing
+ const incomingUids = new Set(parsedEvents.map(e => e.uid).filter(Boolean));
+
+ // Build a map of existing UIDs on server
+ const existingByUid = new Map();
+ for (const e of serverEvents) {
+ if (e.uid) {
+ const list = existingByUid.get(e.uid) || [];
+ list.push(e);
+ existingByUid.set(e.uid, list);
+ }
+ }
+
+ // Delete events that are no longer in the feed
+ const idsToDelete = serverEvents
+ .filter(e => !e.uid || !incomingUids.has(e.uid))
+ .map(e => e.id);
+ if (idsToDelete.length > 0) {
+ await client.batchDeleteCalendarEvents(idsToDelete);
+ }
+
+ // Import only events that don't already exist on server
+ const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
+
+ // Remove stale local events for this calendar
+ set((state) => ({
+ events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
+ }));
+
+ // Import new events
+ if (eventsToImport.length > 0) {
+ await get().importEvents(client, eventsToImport, sub.calendarId);
+ }
+
+ // Re-fetch ALL events from server and filter for this calendar
+ const allUpdatedEvents = await client.getCalendarEvents();
+ const updatedEvents = allUpdatedEvents.filter(e => e.calendarIds?.[sub.calendarId]);
+ set((state) => {
+ const otherEvents = state.events.filter(e => !e.calendarIds?.[sub.calendarId]);
+ return { events: [...otherEvents, ...updatedEvents] };
+ });
+
+ // Update last refreshed timestamp
+ set((state) => ({
+ icalSubscriptions: state.icalSubscriptions.map(s =>
+ s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
+ ),
+ }));
+ } catch (error) {
+ debug.error('Failed to refresh iCal subscription:', sub.name, error);
+ throw error;
+ }
+ },
+
+ refreshAllSubscriptions: async (client) => {
+ const { icalSubscriptions } = get();
+ const now = Date.now();
+
+ for (const sub of icalSubscriptions) {
+ const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0;
+ const intervalMs = sub.refreshInterval * 60 * 1000;
+
+ if (now - lastRefreshed >= intervalMs) {
+ try {
+ await get().refreshICalSubscription(client, sub.id);
+ } catch {
+ debug.warn('Failed to refresh subscription:', sub.name);
+ }
+ }
+ }
+ },
+
clearState: () => {
set({
...initialState,
@@ -291,6 +565,7 @@ export const useCalendarStore = create()(
partialize: (state) => ({
selectedCalendarIds: state.selectedCalendarIds,
viewMode: state.viewMode,
+ icalSubscriptions: state.icalSubscriptions,
}),
}
)