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 ( - + > + + + {cal.name} + + {isSubscriptionCalendar(cal.id) && ( + + )} + + + {/* 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")}

- -
+ <> +
+ + +
+ + {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(); }} + /> + +
+

{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 ( +
+ + ); +} 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) => ( +
+ ); +} + +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} + /> +
+ +
+ + +
+ +
+ + +
+
+ ); +} + +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 })} +

+ + +
+ ); + } + + if (clearingId === cal.id) { + return ( +
+ +

+ {t('confirm_clear', { name: cal.name })} +

+ + +
+ ); + } + + return ( +
+ {/* Color swatch - clickable to change color */} +
+
+ + + +
+ {cal.name} + {(() => { + const caldavUrl = buildCalDavUrl(cal.id); + if (!caldavUrl) return null; + return ( +
+ + + {caldavUrl} + + +
+ ); + })()} +
+ + {cal.isDefault && ( + + {t('default')} + + )} + +
+ + + {!cal.isDefault && ( + + )} +
+
+ ); + })} + + {isCreating ? ( + setIsCreating(false)} + isLoading={isLoading} + /> + ) : ( +
+ + + +
+ )} +
+ + {/* iCal Subscriptions */} + {icalSubscriptions.length > 0 && ( +
+

+ + {tSub('section_title')} +

+ {icalSubscriptions.map((sub) => { + if (deletingSubId === sub.id) { + return ( +
+ +

+ {tSub('confirm_delete', { name: sub.name })} +

+ + +
+ ); + } + + return ( +
+ + +
+ {sub.name} + + {sub.url} + + {sub.lastRefreshed && ( + + {tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })} + + )} +
+
+ + +
+
+ ); + })} +
+ )} + + {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, }), } )