import { useEffect, useMemo, useState, type ChangeEvent, type FormEvent, } from "react"; import { RefreshCw, Trash2 } from "lucide-react"; import { Button, ColorPickerField, Dialog, PasswordField, SegmentedControl, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type ApiSettings, } from "@govoplan/core-webui"; import { listCalendarCredentials, type CalendarBulkMoveExternalAction, type CalendarCalDavAuthType, type CalendarCalDavConflictPolicy, type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryPayload, type CalendarCalDavSyncDirection, type CalendarCollection, type CalendarCollectionDeletePayload, type CalendarCredentialEnvelope, type CalendarDeleteEventAction, type CalendarSyncSource, type CalendarSyncSourceCreatePayload, type CalendarSyncSourceKind, type CalendarSyncSourceUpdatePayload, } from "../../api/calendar"; import { DEFAULT_CALENDAR_COLOR, calendarDraftKey, dateTimeLabel, errorText, normalizeHexColor, } from "./calendarViewModel"; export type CalendarSourceMode = "local" | CalendarSyncSourceKind; type CalendarSourceSwitchMode = | "local" | "caldav" | "ics" | "graph" | "ews"; export type CalendarCollectionDialogState = | { kind: "create" } | { kind: "edit"; calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; }; export type CalendarDeleteDialogState = { calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; }; export type CalendarCalDavFormPayload = { collection_url: string; dav_url: string; display_name: string; auth_type: CalendarCalDavAuthType; username: string; credential_envelope_id: string; password: string; bearer_token: string; sync_enabled: boolean; sync_interval_seconds: number; sync_direction: CalendarCalDavSyncDirection; conflict_policy: CalendarCalDavConflictPolicy; }; export type CalendarCollectionFormPayload = { sourceMode: CalendarSourceMode; name: string; color: string; caldav: CalendarCalDavFormPayload; }; export function CalendarCollectionDialog({ state, settings, source, saving, syncingSourceId, canWrite, canDelete, canManageSources, canSyncSources, onCancel, onSave, onRequestDelete, onSync, onDiscover }: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { const calendar = state.kind === "edit" ? state.calendar : null; const isEdit = Boolean(calendar); const [sourceMode, setSourceMode] = useState(source ? source.source_kind : "local"); const [name, setName] = useState(calendar?.name ?? ""); const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR); const [davUrl, setDavUrl] = useState(source?.collection_url ?? ""); const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? ""); const [displayName, setDisplayName] = useState(source?.display_name ?? ""); const [authType, setAuthType] = useState(source?.auth_type ?? "basic"); const [username, setUsername] = useState(source?.username ?? ""); const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? ""); const [availableCredentials, setAvailableCredentials] = useState([]); const [credentialsError, setCredentialsError] = useState(""); const [password, setPassword] = useState(""); const [bearerToken, setBearerToken] = useState(""); const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true); const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60))); const [syncDirection, setSyncDirection] = useState(source?.sync_direction ?? "two_way"); const [conflictPolicy, setConflictPolicy] = useState(source?.conflict_policy ?? "etag"); const [discoveredCalendars, setDiscoveredCalendars] = useState([]); const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? ""); const [discovering, setDiscovering] = useState(false); const [discoveryError, setDiscoveryError] = useState(""); const formId = "calendar-collection-form"; const isExistingSyncSource = isEdit && Boolean(source); const canEditSource = canManageSources; const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource; const effectiveCollectionUrl = (collectionUrl || davUrl).trim(); const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType; const needsSourceSecret = sourceMode !== "local" && ( effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() || effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim()); const sourceDetailsInvalid = sourceMode !== "local" && ( !isExistingSyncSource && !canEditSource || canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret)); const saveDisabled = saving || !canWrite || !name.trim() || sourceDetailsInvalid; const syncing = source ? syncingSourceId === source.id : false; const collectionDraft = { sourceMode, name, color, davUrl, collectionUrl, displayName, authType, username, credentialEnvelopeId, password, bearerToken, syncEnabled, syncIntervalMinutes, syncDirection, conflictPolicy }; const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []); const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey; useUnsavedDraftGuard({ dirty: collectionDirty, onSave: saveCurrent, onDiscard: onCancel }); useEffect(() => { if (sourceMode === "local" || !canManageSources) { setAvailableCredentials([]); setCredentialsError(""); return; } let active = true; listCalendarCredentials(settings, source?.id) .then((response) => { if (active) setAvailableCredentials(response.credentials); }) .catch((err) => { if (active) { setAvailableCredentials([]); setCredentialsError(errorText(err)); } }); return () => { active = false; }; }, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]); function currentPayload(): CalendarCollectionFormPayload { return { sourceMode, name, color, caldav: { collection_url: effectiveCollectionUrl, dav_url: davUrl, display_name: displayName, auth_type: effectiveAuthType, username, credential_envelope_id: credentialEnvelopeId, password, bearer_token: bearerToken, sync_enabled: syncEnabled, sync_interval_seconds: syncIntervalMinutes * 60, sync_direction: syncDirection, conflict_policy: conflictPolicy } }; } async function saveCurrent(): Promise { if (saveDisabled) return false; return onSave(currentPayload()); } function submit(formEvent: FormEvent) { formEvent.preventDefault(); void saveCurrent(); } function selectSourceMode(next: CalendarSourceMode) { setSourceMode(next); setDiscoveryError(""); setDiscoveredCalendars([]); if (next === "graph") { setAuthType("bearer"); setSyncDirection("inbound"); if (!davUrl.trim()) handleDavUrlChange("me/calendar"); return; } if (next === "ews") { setAuthType("basic"); setSyncDirection("inbound"); if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx"); return; } if (next === "ics" || next === "webcal") { setAuthType("none"); setSyncDirection("inbound"); return; } if (next === "caldav") { setSyncDirection("two_way"); } } function handleDavUrlChange(next: string) { setDavUrl(next); setCollectionUrl(next); setSelectedDiscoveredUrl(""); setDiscoveredCalendars([]); setDiscoveryError(""); } function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) { setSelectedDiscoveredUrl(candidate.collection_url); setCollectionUrl(candidate.collection_url); if (candidate.display_name) { if (!isExistingSyncSource) setDisplayName(candidate.display_name); if (!isEdit && !name.trim()) setName(candidate.display_name); } const discoveredColor = normalizeHexColor(candidate.color); if (!isEdit && discoveredColor) setColor(discoveredColor); } function handleDiscoveredCalendarChange(event: ChangeEvent) { const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value); if (candidate) applyDiscoveredCalendar(candidate); } async function handleDiscover() { const url = davUrl.trim(); if (!url) return; setDiscovering(true); setDiscoveryError(""); try { const payload: CalendarCalDavDiscoveryPayload = { url, source_id: source?.id ?? null, auth_type: authType, username: authType === "basic" ? username.trim() || null : null }; if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId); if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password; if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken; const response = await onDiscover(payload); setDiscoveredCalendars(response.calendars); if (response.calendars.length > 0) { applyDiscoveredCalendar(response.calendars[0]); } else { setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a"); } } catch (err) { setDiscoveryError(errorText(err)); } finally { setDiscovering(false); } } return (
{calendar && canDelete && }
}>
{sourceMode !== "local" && !canManageSources &&

i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa

} {!isEdit && }
{sourceMode === "local" ?

i18n:govoplan-calendar.local_calendar.ed3f72f8

i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504

:

{calendarSourcePaneTitle(sourceMode)}

{source && {syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"} }
{effectiveAuthType !== "none" && } {credentialsError &&

{credentialsError}

} {effectiveAuthType === "basic" && <> {!credentialEnvelopeId && } } {effectiveAuthType === "bearer" && !credentialEnvelopeId && }
{sourceMode === "caldav" && } {effectiveCollectionUrl && {effectiveCollectionUrl}}
{discoveryError &&

{discoveryError}

} {sourceMode === "caldav" && discoveredCalendars.length > 0 && }
i18n:govoplan-calendar.advanced.4d064726
{source &&
i18n:govoplan-calendar.last_attempt.82aee111
{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}
i18n:govoplan-calendar.last_sync.ef0ef267
{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}
i18n:govoplan-calendar.next_sync.88c7af72
{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}
i18n:govoplan-calendar.credential.8bede3ea
{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}
{source.last_error &&

{source.last_error}

}
} {needsSourceSecret &&

i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54

}
}
); } export function CalendarCollectionDeleteDialog({ state, calendars, syncSources, saving, onCancel, onDelete }: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise;}) { const { calendar, eventCount, loadingEventCount } = state; const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source])); const source = syncSourceByCalendarId.get(calendar.id) ?? null; const moveTargets = calendars.filter((item) => item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null) ); const firstMoveTargetId = moveTargets[0]?.id || ""; const hasEvents = (eventCount ?? 0) > 0; const canMoveEvents = hasEvents && moveTargets.length > 0; const [eventAction, setEventAction] = useState("delete"); const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId); const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId)); const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete"; const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId; const actionLabel = calendarDeleteActionLabel(calendar); const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null; const externalAction = calendarBulkMoveExternalAction(source, targetSource); function confirm() { void onDelete(calendar, { event_action: effectiveEventAction, target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null, make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault, external_action: effectiveEventAction === "move" ? externalAction : null }); } return ( }>

{loadingEventCount ? "i18n:govoplan-calendar.loading_event_count.716ad3c2" : calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}

{!loadingEventCount && eventCount === null &&

i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055

} {canMoveEvents &&
{eventCount} event{eventCount === 1 ? "" : "s"} {eventAction === "move" && <> {calendar.is_default && }

{calendarBulkMoveConsequence(externalAction)}

}
} {!loadingEventCount && hasEvents && moveTargets.length === 0 &&

{source ? "i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" : "i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}

}
); } export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit { const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url); const result: Omit = { source_kind: sourceKind, collection_url: payload.collection_url.trim(), display_name: payload.display_name.trim() || null, auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type, username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null, sync_enabled: payload.sync_enabled, sync_interval_seconds: Math.max(60, payload.sync_interval_seconds), sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound", conflict_policy: payload.conflict_policy, metadata: {} }; if (payload.credential_envelope_id) { result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id); } else if (result.auth_type === "basic" && payload.password.trim()) { result.password = payload.password; } else if (result.auth_type === "bearer" && payload.bearer_token.trim()) { result.bearer_token = payload.bearer_token; } return result; } export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload { const result: CalendarSyncSourceUpdatePayload = { collection_url: payload.collection_url.trim(), auth_type: payload.auth_type, username: payload.auth_type === "basic" ? payload.username.trim() || null : null }; if (payload.credential_envelope_id) { result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id); } else if (payload.auth_type === "basic" && payload.password.trim()) { result.password = payload.password; } else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) { result.bearer_token = payload.bearer_token; } return result; } function credentialEnvelopeRef(credentialId: string): string { return `credential-envelope:${credentialId}`; } function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind { if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal"; return sourceMode === "local" ? "caldav" : sourceMode; } function sourceSwitchMode(sourceMode: CalendarSourceMode): CalendarSourceSwitchMode { return sourceMode === "webcal" ? "ics" : sourceMode; } function syncTransientPayload(authType: CalendarCalDavAuthType, password: string, bearerToken: string) { if (authType === "basic" && password.trim()) return { password }; if (authType === "bearer" && bearerToken.trim()) return { bearer_token: bearerToken }; return {}; } function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string { if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a"; if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63"; if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383"; if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3"; return "i18n:govoplan-calendar.local_calendar.ed3f72f8"; } function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string { if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180"; if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3"; if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983"; return "i18n:govoplan-calendar.subscription_url.b8b6a1a0"; } function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string { if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav"; if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta"; if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx"; return "webcal://example.org/calendar.ics or https://example.org/calendar.ics"; } function calendarDeleteActionLabel(calendar: CalendarCollection): string { return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48"; } function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string { return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287"; } function calendarMoveTargetIsSupported( source: CalendarSyncSource | null, targetSource: CalendarSyncSource | null) : boolean { if (source) return targetSource === null; return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource); } function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean { return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way"; } function calendarBulkMoveExternalAction( source: CalendarSyncSource | null, targetSource: CalendarSyncSource | null) : CalendarBulkMoveExternalAction | undefined { if (source && !targetSource) return "detach_keep_remote"; if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote"; return undefined; } function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string { if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7"; if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004"; return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09"; } function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string { if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b"; if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16"; return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937"; } function calendarDeleteWarning( calendar: CalendarCollection, eventCount: number, eventAction: CalendarDeleteEventAction, targetCalendarId: string, moveTargets: CalendarCollection[]) : string { const action = calendarIsExternal(calendar) ? "i18n:govoplan-calendar.removing.b7d2c38b" : "i18n:govoplan-calendar.deleting.2cda36c9"; const eventWord = eventCount === 1 ? "i18n:govoplan-calendar.event" : "i18n:govoplan-calendar.events"; if (eventAction === "move") { const target = moveTargets.find((item) => item.id === targetCalendarId); return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1", { value0: action, value1: eventCount, value2: eventWord, value3: target?.name || "i18n:govoplan-calendar.the_selected_calendar.67caa12f" }); } if (calendarIsExternal(calendar)) { return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e", { value0: action, value1: eventCount, value2: eventWord }); } return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1", { value0: action, value1: eventCount, value2: eventWord }); } function calendarIsExternal(calendar: CalendarCollection): boolean { const metadata = calendar.metadata; if (!metadata || typeof metadata !== "object") return false; const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind"); if (sourceKind && sourceKind !== "local") return true; const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind"); if (connectionKind && connectionKind !== "local") return true; return Boolean( metadataFlag(metadata, "external") || metadataFlag(metadata, "remote") || metadataFlag(metadata, "sync") || metadataFlag(metadata, "caldav") || metadataText(metadata, "connector_id") || metadataText(metadata, "connectorId") || metadataText(metadata, "sync_href") || metadataText(metadata, "syncHref") ); } function metadataText(metadata: Record, key: string): string { const value = metadata[key]; return typeof value === "string" ? value.trim().toLowerCase() : ""; } function metadataFlag(metadata: Record, key: string): boolean { return metadata[key] === true; }