Implement destructive CalDAV move saga

This commit is contained in:
2026-08-02 17:30:24 +02:00
parent 56d7b2108d
commit a125b666da
16 changed files with 2384 additions and 36 deletions
@@ -5,7 +5,7 @@ import {
type ChangeEvent,
type FormEvent,
} from "react";
import { ListChecks, RefreshCw, Trash2 } from "lucide-react";
import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react";
import {
Button,
ColorPickerField,
@@ -102,6 +102,7 @@ export function CalendarCollectionDialog({
onRequestDelete,
onSync,
onOpenOutbox,
onOpenMigration,
onDiscover
@@ -117,7 +118,7 @@ export function CalendarCollectionDialog({
}: {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>;onOpenOutbox: (source: CalendarSyncSource) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
}: {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>;onOpenOutbox: (source: CalendarSyncSource) => void;onOpenMigration: (batchId: string) => 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 ? calendarSourceModeForSource(source) : "local");
@@ -146,6 +147,8 @@ export function CalendarCollectionDialog({
const [discoveryError, setDiscoveryError] = useState("");
const formId = "calendar-collection-form";
const isExistingSyncSource = isEdit && Boolean(source);
const migrationBatchId = calendar ? calendarMigrationBatchId(calendar) : "";
const migrationLocked = Boolean(migrationBatchId);
const canEditSource = canManageSources;
const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource;
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
@@ -160,6 +163,7 @@ export function CalendarCollectionDialog({
const saveDisabled =
saving ||
migrationLocked ||
!canWrite ||
!name.trim() ||
sourceDetailsInvalid;
@@ -344,7 +348,7 @@ export function CalendarCollectionDialog({
<>
<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}>
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving || migrationLocked}>
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
</Button>
}
@@ -357,6 +361,11 @@ export function CalendarCollectionDialog({
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{migrationLocked &&
<p className="calendar-form-note">
Calendar and event changes are locked while the destructive remote move is being reconciled.
</p>
}
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
{!isEdit &&
<SegmentedControl
@@ -553,11 +562,11 @@ export function CalendarCollectionDialog({
type="button"
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
disabled={saving || syncing || !canSyncSources}>
disabled={saving || syncing || migrationLocked || !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}>
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || migrationLocked || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76
</Button>
{source.source_kind === "caldav" && canManageSources && (
@@ -565,6 +574,11 @@ export function CalendarCollectionDialog({
<ListChecks size={16} /> i18n:govoplan-calendar.outbound_changes.7038a839
</Button>
)}
{migrationBatchId && canManageSources && (
<Button type="button" onClick={() => onOpenMigration(migrationBatchId)} disabled={saving}>
<ArrowRightLeft size={16} /> Remote move
</Button>
)}
</div>
</div>
}
@@ -602,18 +616,28 @@ export function CalendarCollectionDeleteDialog({
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
const [remoteMoveConfirmation, setRemoteMoveConfirmation] = useState("");
const [remoteMoveEvidence, setRemoteMoveEvidence] = useState("");
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);
const isRemoteMove = effectiveEventAction === "move" && externalAction === "remote_move";
const confirmDisabled = saving || loadingEventCount ||
(canMoveEvents && effectiveEventAction === "move" && !targetCalendarId) ||
(isRemoteMove && (
remoteMoveConfirmation !== "MOVE REMOTE EVENTS" ||
remoteMoveEvidence.trim().length < 10
));
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
external_action: effectiveEventAction === "move" ? externalAction : null,
destructive_confirmation: isRemoteMove ? remoteMoveConfirmation : null,
evidence_note: isRemoteMove ? remoteMoveEvidence.trim() : null
});
}
@@ -679,6 +703,32 @@ export function CalendarCollectionDeleteDialog({
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
}
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
{isRemoteMove &&
<div className="calendar-remote-move-confirmation">
<p className="calendar-delete-warning">
This operation copies every CalDAV resource before conditionally deleting the source resources. Calendar and event edits remain locked until the batch completes or is reconciled.
</p>
<label>
<span>Type <code>MOVE REMOTE EVENTS</code> to authorize remote deletion</span>
<input
value={remoteMoveConfirmation}
onChange={(item) => setRemoteMoveConfirmation(item.target.value)}
autoComplete="off"
/>
</label>
<label>
<span>Authorization evidence</span>
<textarea
value={remoteMoveEvidence}
onChange={(item) => setRemoteMoveEvidence(item.target.value)}
minLength={10}
maxLength={2000}
rows={3}
placeholder="Record the approved change, ticket, or maintenance window."
/>
</label>
</div>
}
</>
}
</fieldset>
@@ -828,7 +878,13 @@ function calendarMoveTargetIsSupported(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: boolean {
if (source) return targetSource === null;
if (source) {
return targetSource === null || (
calendarSyncSourceAcceptsCopies(source) &&
targetSource !== null &&
calendarSyncSourceAcceptsCopies(targetSource)
);
}
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
}
@@ -842,18 +898,26 @@ targetSource: CalendarSyncSource | null)
: CalendarBulkMoveExternalAction | undefined {
if (source && !targetSource) return "detach_keep_remote";
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
if (
source &&
targetSource &&
calendarSyncSourceAcceptsCopies(source) &&
calendarSyncSourceAcceptsCopies(targetSource)
) return "remote_move";
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";
if (action === "remote_move") return "Move events between synchronized calendars";
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";
if (action === "remote_move") return "GovOPlaN first copies all CalDAV resources, then deletes source resources with their recorded ETags. Conflicts stop the batch for reconciliation.";
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
}
@@ -895,6 +959,13 @@ function calendarIsExternal(calendar: CalendarCollection): boolean {
);
}
function calendarMigrationBatchId(calendar: CalendarCollection): string {
const remoteMove = calendar.metadata?.remote_move;
if (!remoteMove || typeof remoteMove !== "object" || Array.isArray(remoteMove)) return "";
const batchId = (remoteMove as Record<string, unknown>).batch_id;
return typeof batchId === "string" ? batchId : "";
}
function metadataText(metadata: Record<string, unknown>, key: string): string {
const value = metadata[key];
return typeof value === "string" ? value.trim().toLowerCase() : "";
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCw, XCircle } from "lucide-react";
import {
Button,
Dialog,
DismissibleAlert,
LoadingFrame,
StatusBadge,
type ApiSettings,
} from "@govoplan/core-webui";
import {
cancelCalendarMigration,
getCalendarMigration,
type CalendarMigrationBatch,
} from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel";
export function CalendarMigrationDialog({
settings,
batchId,
onClose,
onChanged,
}: {
settings: ApiSettings;
batchId: string;
onClose: () => void;
onChanged: () => void;
}) {
const [batch, setBatch] = useState<CalendarMigrationBatch | null>(null);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [cancellationEvidence, setCancellationEvidence] = useState("");
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const load = useCallback(async () => {
try {
const next = await getCalendarMigration(settings, batchId);
setBatch(next);
setError("");
if (next.status === "completed" || next.status === "cancelled") {
onChangedRef.current();
}
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}, [batchId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!batch || !["active", "blocked", "cancel_requested"].includes(batch.status)) {
return undefined;
}
const timer = window.setInterval(() => void load(), 3000);
return () => window.clearInterval(timer);
}, [batch, load]);
const progress = useMemo(() => {
if (!batch?.total_resources) return 0;
const completedSteps = batch.copied_resources + batch.deleted_source_resources;
return Math.round((completedSteps / (batch.total_resources * 2)) * 100);
}, [batch]);
async function cancelMigration() {
if (!batch?.can_cancel || cancellationEvidence.trim().length < 10) return;
setWorking(true);
setError("");
try {
const next = await cancelCalendarMigration(
settings,
batch.id,
cancellationEvidence.trim(),
);
setBatch(next);
setCancellationEvidence("");
onChanged();
} catch (err) {
setError(errorText(err));
} finally {
setWorking(false);
}
}
return (
<Dialog
open
title="Remote calendar move"
className="calendar-migration-dialog"
closeDisabled={working}
onClose={onClose}
footer={
<>
<Button type="button" onClick={() => void load()} disabled={loading || working}>
<RefreshCw size={16} /> Refresh
</Button>
<Button type="button" onClick={onClose} disabled={working}>Close</Button>
</>
}
>
{loading && !batch ? (
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
) : (
<div className="calendar-migration-body">
{error && (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
)}
{batch && (
<>
<div className="calendar-migration-heading">
<div>
<strong>{phaseLabel(batch.phase)}</strong>
<span>Updated {dateTimeLabel(new Date(batch.updated_at))}</span>
</div>
<StatusBadge status={batch.status} label={statusLabel(batch.status)} />
</div>
<progress value={progress} max={100} aria-label="Remote move progress" />
<dl className="calendar-migration-summary">
<div><dt>Events</dt><dd>{batch.total_events}</dd></div>
<div><dt>Copied</dt><dd>{batch.copied_resources} / {batch.total_resources}</dd></div>
<div><dt>Source deleted</dt><dd>{batch.deleted_source_resources} / {batch.total_resources}</dd></div>
<div><dt>Conflicts</dt><dd>{batch.conflict_count}</dd></div>
</dl>
{typeof batch.authorization_evidence.note === "string" && (
<p className="calendar-form-note">
Authorization evidence: {batch.authorization_evidence.note}
</p>
)}
{batch.last_error && <p className="calendar-migration-error">{batch.last_error}</p>}
<ol className="calendar-migration-resources">
{batch.resources.map((resource) => (
<li key={resource.id}>
<div>
<StatusBadge status={resource.status} label={statusLabel(resource.status)} />
<code title={resource.source_href}>{resource.source_href}</code>
</div>
<span>
Copy: {statusLabel(resource.destination_operation_status || "pending")}; source: {statusLabel(resource.source_delete_operation_status || "pending")}
</span>
{resource.last_error && <p>{resource.last_error}</p>}
</li>
))}
</ol>
{batch.can_cancel && (
<section className="calendar-migration-cancel">
<label>
<span>Cancellation evidence</span>
<textarea
value={cancellationEvidence}
onChange={(event) => setCancellationEvidence(event.target.value)}
minLength={10}
maxLength={2000}
rows={3}
placeholder="Record why the source must be retained."
/>
</label>
<Button
type="button"
variant="danger"
onClick={() => void cancelMigration()}
disabled={working || cancellationEvidence.trim().length < 10}
>
<XCircle size={16} /> Cancel remote move
</Button>
</section>
)}
</>
)}
</div>
)}
</Dialog>
);
}
function phaseLabel(value: string): string {
return value.replace(/_/g, " ").replace(/^./, (letter: string) => letter.toUpperCase());
}
function statusLabel(value: string): string {
return value.replace(/_/g, " ");
}
+40 -6
View File
@@ -58,6 +58,7 @@ import {
} from "./CalendarCollectionDialogs";
import { CalendarEventDialog } from "./CalendarEventDialog";
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
import { CalendarMigrationDialog } from "./CalendarMigrationDialog";
import {
CalendarTimeGrid,
CalendarWeekRows,
@@ -134,6 +135,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null);
const [migrationBatchId, setMigrationBatchId] = useState("");
const [syncingSourceId, setSyncingSourceId] = useState("");
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
const [draggingEventId, setDraggingEventId] = useState("");
@@ -238,7 +240,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
return () => window.cancelAnimationFrame(frame);
}, [days, mode]);
async function loadCalendars() {
async function loadCalendars(): Promise<CalendarCollection[]> {
setLoading(true);
setError("");
try {
@@ -254,8 +256,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
return next.length > 0 ? next : ids;
});
setEventCalendarId((current) => current && ids.includes(current) ? current : ids[0] ?? "");
return response.calendars;
} catch (err) {
setError(errorText(err));
return [];
} finally {
setLoading(false);
}
@@ -387,11 +391,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
await deleteCalendar(settings, calendar.id, payload);
setCalendarDialog(null);
setCalendarDeleteDialog(null);
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id));
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
setEventCalendarId((current) => current === calendar.id ? "" : current);
await loadCalendars();
const remoteMove = payload.external_action === "remote_move";
if (!remoteMove) {
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id));
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
setEventCalendarId((current) => current === calendar.id ? "" : current);
}
const refreshedCalendars = await loadCalendars();
if (remoteMove) {
const refreshedSource = refreshedCalendars.find((item) => item.id === calendar.id);
const batchId = refreshedSource ? calendarRemoteMoveBatchId(refreshedSource) : "";
if (batchId) setMigrationBatchId(batchId);
}
if (calendars.length > 1 || payload.event_action === "move") {
await loadEvents();
} else {
@@ -1012,6 +1024,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
setOutboxDialog({ calendar: calendarDialog.calendar, source });
}
}}
onOpenMigration={(batchId) => {
setCalendarDialog(null);
setMigrationBatchId(batchId);
}}
onDiscover={handleCalDavDiscovery} />
}
@@ -1022,6 +1038,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
source={outboxDialog.source}
onClose={() => setOutboxDialog(null)} />
}
{migrationBatchId &&
<CalendarMigrationDialog
settings={settings}
batchId={migrationBatchId}
onClose={() => setMigrationBatchId("")}
onChanged={() => {
void loadCalendars();
void loadEvents();
}} />
}
{calendarDeleteDialog &&
<CalendarCollectionDeleteDialog
@@ -1042,6 +1069,13 @@ function compareCalendars(left: CalendarCollection, right: CalendarCollection):
return left.name.localeCompare(right.name);
}
function calendarRemoteMoveBatchId(calendar: CalendarCollection): string {
const remoteMove = calendar.metadata?.remote_move;
if (!remoteMove || typeof remoteMove !== "object" || Array.isArray(remoteMove)) return "";
const batchId = (remoteMove as Record<string, unknown>).batch_id;
return typeof batchId === "string" ? batchId : "";
}
function calendarViewPreferences(
response: CalendarViewPreferencesResponse
): CalendarViewPreferences {