828 lines
38 KiB
TypeScript
828 lines
38 KiB
TypeScript
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<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
|
const calendar = state.kind === "edit" ? state.calendar : null;
|
|
const isEdit = Boolean(calendar);
|
|
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(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<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
|
|
const [username, setUsername] = useState(source?.username ?? "");
|
|
const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
|
|
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
|
|
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<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
|
|
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
|
|
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
|
|
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<boolean> {
|
|
if (saveDisabled) return false;
|
|
return onSave(currentPayload());
|
|
}
|
|
|
|
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
|
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<HTMLSelectElement>) {
|
|
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 (
|
|
<Dialog
|
|
open
|
|
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
|
|
className="calendar-event-dialog"
|
|
footerClassName="calendar-event-dialog-footer"
|
|
closeDisabled={saving}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<div>
|
|
{calendar && canDelete &&
|
|
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving}>
|
|
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
|
|
</Button>
|
|
}
|
|
</div>
|
|
<div className="calendar-dialog-actions">
|
|
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
|
|
<Button type="submit" form={formId} variant="primary" disabled={saveDisabled}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button>
|
|
</div>
|
|
</>
|
|
}>
|
|
|
|
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
|
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
|
|
{!isEdit &&
|
|
<SegmentedControl
|
|
className="calendar-source-switch"
|
|
size="equal"
|
|
width="fill"
|
|
ariaLabel="i18n:govoplan-calendar.calendar_source_type.92cdb42f"
|
|
value={sourceSwitchMode(sourceMode)}
|
|
disabled={saving}
|
|
onChange={selectSourceMode}
|
|
options={[
|
|
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
|
|
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
|
|
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
|
|
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
|
|
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
|
|
]}
|
|
/>
|
|
}
|
|
<div className="calendar-dialog-name-color-row">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.name.709a2322</span>
|
|
<input value={name} onChange={(item) => setName(item.target.value)} required maxLength={255} autoFocus disabled={saving || !canWrite} />
|
|
</label>
|
|
<label className="calendar-dialog-color-label">
|
|
<span>i18n:govoplan-calendar.color.1d0c8304</span>
|
|
<ColorPickerField value={color} onChange={setColor} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
{sourceMode === "local" ?
|
|
<section className="calendar-source-pane">
|
|
<h3>i18n:govoplan-calendar.local_calendar.ed3f72f8</h3>
|
|
<p className="calendar-form-note">i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504</p>
|
|
</section> :
|
|
|
|
<section className="calendar-source-pane">
|
|
<div className="calendar-source-pane-heading">
|
|
<h3>{calendarSourcePaneTitle(sourceMode)}</h3>
|
|
{source &&
|
|
<span className={[
|
|
"calendar-sync-status",
|
|
syncing ? "is-syncing" : "",
|
|
source.last_status === "error" && !syncing ? "is-error" : ""].
|
|
filter(Boolean).join(" ")}>
|
|
{syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"}
|
|
</span>
|
|
}
|
|
</div>
|
|
<div className="calendar-caldav-setup">
|
|
<label className="calendar-dialog-wide">
|
|
<span>{calendarSourceUrlLabel(sourceMode)}</span>
|
|
<input
|
|
value={davUrl}
|
|
onChange={(item) => handleDavUrlChange(item.target.value)}
|
|
placeholder={calendarSourceUrlPlaceholder(sourceMode)}
|
|
required
|
|
maxLength={1000}
|
|
disabled={saving || !canEditSource} />
|
|
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.authentication.ee1acfa5</span>
|
|
<select value={effectiveAuthType} onChange={(item) => setAuthType(item.target.value as CalendarCalDavAuthType)} disabled={saving || !canEditSource || sourceMode === "graph"}>
|
|
{sourceMode !== "graph" && <option value="basic">i18n:govoplan-calendar.basic.aa2c96da</option>}
|
|
{sourceMode !== "graph" && sourceMode !== "ews" && <option value="none">i18n:govoplan-calendar.none.6eef6648</option>}
|
|
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
|
|
</select>
|
|
</label>
|
|
{effectiveAuthType !== "none" &&
|
|
<label>
|
|
<span>Reusable credential</span>
|
|
<select
|
|
value={credentialEnvelopeId}
|
|
disabled={saving || !canEditSource}
|
|
onChange={(event) => {
|
|
const credentialId = event.target.value;
|
|
setCredentialEnvelopeId(credentialId);
|
|
setPassword("");
|
|
setBearerToken("");
|
|
const credential = availableCredentials.find((item) => item.id === credentialId);
|
|
const credentialUsername = credential?.public_data?.username;
|
|
if (credentialUsername) setUsername(String(credentialUsername));
|
|
}}>
|
|
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
|
|
{availableCredentials.map((credential) =>
|
|
<option key={credential.id} value={credential.id}>
|
|
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
|
|
</option>
|
|
)}
|
|
</select>
|
|
</label>
|
|
}
|
|
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
|
|
{effectiveAuthType === "basic" &&
|
|
<>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.username.84c29015</span>
|
|
<input value={username} onChange={(item) => setUsername(item.target.value)} required maxLength={255} disabled={saving || !canEditSource || Boolean(credentialEnvelopeId)} />
|
|
</label>
|
|
{!credentialEnvelopeId &&
|
|
<label>
|
|
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
|
|
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
|
|
</label>
|
|
}
|
|
</>
|
|
}
|
|
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
|
|
<label>
|
|
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
|
|
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
|
|
</label>
|
|
}
|
|
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
|
|
{sourceMode === "caldav" &&
|
|
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
|
|
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
|
|
</Button>
|
|
}
|
|
{effectiveCollectionUrl && <span>{effectiveCollectionUrl}</span>}
|
|
</div>
|
|
</div>
|
|
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
|
|
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
|
|
<label>
|
|
<span>i18n:govoplan-calendar.calendar.adab5090</span>
|
|
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
|
|
{discoveredCalendars.map((candidate) =>
|
|
<option key={candidate.collection_url} value={candidate.collection_url}>
|
|
{candidate.display_name || candidate.collection_url}
|
|
</option>
|
|
)}
|
|
</select>
|
|
</label>
|
|
}
|
|
<details className="calendar-advanced-settings">
|
|
<summary>i18n:govoplan-calendar.advanced.4d064726</summary>
|
|
<div className="calendar-dialog-grid-two">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
|
|
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
|
|
</label>
|
|
</div>
|
|
<div className="calendar-sync-settings">
|
|
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
|
|
<label>
|
|
<span>i18n:govoplan-calendar.interval.011efcd5</span>
|
|
<input type="number" min={1} step={1} value={syncIntervalMinutes} onChange={(item) => setSyncIntervalMinutes(Math.max(1, Number(item.target.value) || 1))} disabled={saving || !canEditMutableSourceSettings} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
|
|
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
|
|
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
|
|
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
|
|
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
|
|
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
|
|
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
{source &&
|
|
<div className="calendar-sync-status-panel">
|
|
<dl>
|
|
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
|
|
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
|
|
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
|
|
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
|
|
</dl>
|
|
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
|
|
<div className="calendar-sync-actions">
|
|
<Button
|
|
type="button"
|
|
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
|
|
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
|
|
disabled={saving || syncing || !canSyncSources}>
|
|
|
|
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
|
|
</Button>
|
|
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
|
|
i18n:govoplan-calendar.full_sync.21b89c76
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
|
|
</section>
|
|
}
|
|
</form>
|
|
</Dialog>);
|
|
|
|
}
|
|
|
|
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<void>;}) {
|
|
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<CalendarDeleteEventAction>("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 (
|
|
<Dialog
|
|
open
|
|
role="alertdialog"
|
|
title={`${actionLabel} calendar`}
|
|
className="calendar-delete-dialog"
|
|
footerClassName="calendar-event-dialog-footer"
|
|
closeDisabled={saving}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
|
|
<Button type="button" variant="danger" onClick={confirm} disabled={confirmDisabled}>
|
|
<Trash2 size={16} /> {saving ? "i18n:govoplan-calendar.working.049ac820" : actionLabel}
|
|
</Button>
|
|
</>
|
|
}>
|
|
|
|
<div className="calendar-delete-dialog-body">
|
|
<p className="calendar-delete-warning">
|
|
{loadingEventCount ?
|
|
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
|
|
calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
|
|
</p>
|
|
{!loadingEventCount && eventCount === null && <p className="calendar-form-note">i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055</p>}
|
|
{canMoveEvents &&
|
|
<fieldset className="calendar-delete-options" disabled={saving || loadingEventCount}>
|
|
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="calendar-delete-event-action"
|
|
value="delete"
|
|
checked={eventAction === "delete"}
|
|
onChange={() => setEventAction("delete")} />
|
|
|
|
<span>{calendarEventDeleteOptionLabel(calendar)}</span>
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="calendar-delete-event-action"
|
|
value="move"
|
|
checked={eventAction === "move"}
|
|
onChange={() => setEventAction("move")} />
|
|
|
|
<span>{calendarBulkMoveOptionLabel(externalAction)}</span>
|
|
</label>
|
|
{eventAction === "move" &&
|
|
<>
|
|
<label className="calendar-delete-target-label">
|
|
<span>i18n:govoplan-calendar.target_calendar.e533fe1d</span>
|
|
<select value={targetCalendarId} onChange={(item) => setTargetCalendarId(item.target.value)} required>
|
|
{moveTargets.map((item) =>
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
)}
|
|
</select>
|
|
</label>
|
|
{calendar.is_default &&
|
|
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
|
|
}
|
|
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
|
|
</>
|
|
}
|
|
</fieldset>
|
|
}
|
|
{!loadingEventCount && hasEvents && moveTargets.length === 0 &&
|
|
<p className="calendar-form-note">{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"}</p>
|
|
}
|
|
</div>
|
|
</Dialog>);
|
|
|
|
}
|
|
|
|
|
|
export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit<CalendarSyncSourceCreatePayload, "calendar_id"> {
|
|
const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url);
|
|
const result: Omit<CalendarSyncSourceCreatePayload, "calendar_id"> = {
|
|
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<string, unknown>, key: string): string {
|
|
const value = metadata[key];
|
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
}
|
|
|
|
function metadataFlag(metadata: Record<string, unknown>, key: string): boolean {
|
|
return metadata[key] === true;
|
|
}
|