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() : "";