feat(calendar): expose loss-safe bulk move modes

This commit is contained in:
2026-07-20 17:01:02 +02:00
parent a6cd64e3f9
commit 2ad6e9d60e
3 changed files with 70 additions and 5 deletions

View File

@@ -182,10 +182,12 @@ export type CalendarCollectionCreatePayload = {
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>; export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
export type CalendarDeleteEventAction = "delete" | "move"; export type CalendarDeleteEventAction = "delete" | "move";
export type CalendarBulkMoveExternalAction = "detach_keep_remote" | "copy_to_remote" | "remote_move";
export type CalendarCollectionDeletePayload = { export type CalendarCollectionDeletePayload = {
event_action?: CalendarDeleteEventAction; event_action?: CalendarDeleteEventAction;
target_calendar_id?: string | null; target_calendar_id?: string | null;
make_target_default?: boolean; make_target_default?: boolean;
external_action?: CalendarBulkMoveExternalAction | null;
}; };
export type CalendarEventCreatePayload = { export type CalendarEventCreatePayload = {

View File

@@ -46,6 +46,7 @@ import {
type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryCandidate,
type CalendarCalDavDiscoveryPayload, type CalendarCalDavDiscoveryPayload,
type CalendarCalDavSyncDirection, type CalendarCalDavSyncDirection,
type CalendarBulkMoveExternalAction,
type CalendarCollection, type CalendarCollection,
type CalendarCollectionDeletePayload, type CalendarCollectionDeletePayload,
type CalendarDeleteEventAction, type CalendarDeleteEventAction,
@@ -897,6 +898,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<CalendarCollectionDeleteDialog <CalendarCollectionDeleteDialog
state={calendarDeleteDialog} state={calendarDeleteDialog}
calendars={calendars} calendars={calendars}
syncSources={syncSources}
saving={saving} saving={saving}
onCancel={() => setCalendarDeleteDialog(null)} onCancel={() => setCalendarDeleteDialog(null)}
onDelete={handleCalendarDelete} /> onDelete={handleCalendarDelete} />
@@ -1713,6 +1715,7 @@ function CalendarCollectionDialog({
function CalendarCollectionDeleteDialog({ function CalendarCollectionDeleteDialog({
state, state,
calendars, calendars,
syncSources,
saving, saving,
onCancel, onCancel,
onDelete onDelete
@@ -1722,9 +1725,13 @@ function CalendarCollectionDeleteDialog({
}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) { }: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) {
const { calendar, eventCount, loadingEventCount } = state; const { calendar, eventCount, loadingEventCount } = state;
const moveTargets = calendars.filter((item) => item.id !== calendar.id); 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 firstMoveTargetId = moveTargets[0]?.id || "";
const hasEvents = (eventCount ?? 0) > 0; const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = hasEvents && moveTargets.length > 0; const canMoveEvents = hasEvents && moveTargets.length > 0;
@@ -1734,12 +1741,15 @@ function CalendarCollectionDeleteDialog({
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete"; const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId; const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
const actionLabel = calendarDeleteActionLabel(calendar); const actionLabel = calendarDeleteActionLabel(calendar);
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
function confirm() { function confirm() {
void onDelete(calendar, { void onDelete(calendar, {
event_action: effectiveEventAction, event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null, target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
external_action: effectiveEventAction === "move" ? externalAction : null
}); });
} }
@@ -1789,7 +1799,7 @@ function CalendarCollectionDeleteDialog({
checked={eventAction === "move"} checked={eventAction === "move"}
onChange={() => setEventAction("move")} /> onChange={() => setEventAction("move")} />
<span>i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09</span> <span>{calendarBulkMoveOptionLabel(externalAction)}</span>
</label> </label>
{eventAction === "move" && {eventAction === "move" &&
<> <>
@@ -1807,10 +1817,16 @@ function CalendarCollectionDeleteDialog({
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span> <span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
</label> </label>
} }
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</> </>
} }
</fieldset> </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> </div>
</Dialog>); </Dialog>);
@@ -2473,6 +2489,39 @@ 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"; 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( function calendarDeleteWarning(
calendar: CalendarCollection, calendar: CalendarCollection,
eventCount: number, eventCount: number,

View File

@@ -46,6 +46,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.delete.f6fdbe48": "Delete", "i18n:govoplan-calendar.delete.f6fdbe48": "Delete",
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting", "i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
"i18n:govoplan-calendar.description.55f8ebc8": "Description", "i18n:govoplan-calendar.description.55f8ebc8": "Description",
"i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7": "Detach local events and keep remote events",
"i18n:govoplan-calendar.direction.fd8e45ba": "Direction", "i18n:govoplan-calendar.direction.fd8e45ba": "Direction",
"i18n:govoplan-calendar.discover.4827ea22": "Discover", "i18n:govoplan-calendar.discover.4827ea22": "Discover",
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...", "i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
@@ -67,12 +68,15 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
"i18n:govoplan-calendar.events": "events", "i18n:govoplan-calendar.events": "events",
"i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.", "i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.",
"i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937": "Events remain in GovOPlaN; no external calendar is changed.",
"i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint", "i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint",
"i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source", "i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source",
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange", "i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON", "i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri", "i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
"i18n:govoplan-calendar.full_sync.21b89c76": "Full sync", "i18n:govoplan-calendar.full_sync.21b89c76": "Full sync",
"i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16": "GovOPlaN moves the events locally and queues durable CalDAV copies. Delivery failures remain visible and retryable.",
"i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b": "GovOPlaN moves the local event copies to the target calendar and unlinks this source. The remote calendar and its events remain unchanged.",
"i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL", "i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL",
"i18n:govoplan-calendar.graph.9a7405eb": "Graph", "i18n:govoplan-calendar.graph.9a7405eb": "Graph",
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON", "i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
@@ -96,6 +100,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source", "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
"i18n:govoplan-calendar.mon.24b2a099": "Mon", "i18n:govoplan-calendar.mon.24b2a099": "Mon",
"i18n:govoplan-calendar.month.082bc378": "Month", "i18n:govoplan-calendar.month.082bc378": "Month",
"i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004": "Move and copy events to the external calendar",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar", "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar",
"i18n:govoplan-calendar.name.709a2322": "Name", "i18n:govoplan-calendar.name.709a2322": "Name",
"i18n:govoplan-calendar.never.80c3052d": "Never", "i18n:govoplan-calendar.never.80c3052d": "Never",
@@ -103,9 +108,11 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.new.6403f2b7": "New", "i18n:govoplan-calendar.new.6403f2b7": "New",
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync", "i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
"i18n:govoplan-calendar.next.bc981983": "Next", "i18n:govoplan-calendar.next.bc981983": "Next",
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b": "No compatible target calendar is available. Add a local calendar or an active two-way CalDAV calendar.",
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.", "i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars", "i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
"i18n:govoplan-calendar.no_events.e339ba73": "No events", "i18n:govoplan-calendar.no_events.e339ba73": "No events",
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65": "No local target calendar is available. Add a local calendar to detach these events without changing the remote calendar.",
"i18n:govoplan-calendar.none.6eef6648": "None", "i18n:govoplan-calendar.none.6eef6648": "None",
"i18n:govoplan-calendar.not_configured.811931bb": "Not configured", "i18n:govoplan-calendar.not_configured.811931bb": "Not configured",
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled", "i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
@@ -223,6 +230,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.delete.f6fdbe48": "Löschen", "i18n:govoplan-calendar.delete.f6fdbe48": "Löschen",
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting", "i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
"i18n:govoplan-calendar.description.55f8ebc8": "Beschreibung", "i18n:govoplan-calendar.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7": "Lokale Termine abtrennen und entfernte Termine beibehalten",
"i18n:govoplan-calendar.direction.fd8e45ba": "Direction", "i18n:govoplan-calendar.direction.fd8e45ba": "Direction",
"i18n:govoplan-calendar.discover.4827ea22": "Discover", "i18n:govoplan-calendar.discover.4827ea22": "Discover",
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...", "i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
@@ -244,12 +252,15 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
"i18n:govoplan-calendar.events": "events", "i18n:govoplan-calendar.events": "events",
"i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.", "i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.",
"i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937": "Die Termine bleiben in GovOPlaN; kein externer Kalender wird geändert.",
"i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint", "i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint",
"i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source", "i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source",
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange", "i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON", "i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri", "i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
"i18n:govoplan-calendar.full_sync.21b89c76": "Full sync", "i18n:govoplan-calendar.full_sync.21b89c76": "Full sync",
"i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16": "GovOPlaN verschiebt die Termine lokal und reiht dauerhafte CalDAV-Kopien ein. Zustellfehler bleiben sichtbar und können erneut versucht werden.",
"i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b": "GovOPlaN verschiebt die lokalen Terminkopien in den Zielkalender und trennt diese Quelle. Der entfernte Kalender und seine Termine bleiben unverändert.",
"i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL", "i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL",
"i18n:govoplan-calendar.graph.9a7405eb": "Graph", "i18n:govoplan-calendar.graph.9a7405eb": "Graph",
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON", "i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
@@ -273,16 +284,19 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source", "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
"i18n:govoplan-calendar.mon.24b2a099": "Mon", "i18n:govoplan-calendar.mon.24b2a099": "Mon",
"i18n:govoplan-calendar.month.082bc378": "Monat", "i18n:govoplan-calendar.month.082bc378": "Monat",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar", "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004": "Termine verschieben und in den externen Kalender kopieren",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Termine in einen anderen Kalender verschieben",
"i18n:govoplan-calendar.name.709a2322": "Name", "i18n:govoplan-calendar.name.709a2322": "Name",
"i18n:govoplan-calendar.never.80c3052d": "Never", "i18n:govoplan-calendar.never.80c3052d": "Never",
"i18n:govoplan-calendar.new_event.2ef3795c": "New event", "i18n:govoplan-calendar.new_event.2ef3795c": "New event",
"i18n:govoplan-calendar.new.6403f2b7": "New", "i18n:govoplan-calendar.new.6403f2b7": "New",
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync", "i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
"i18n:govoplan-calendar.next.bc981983": "Weiter", "i18n:govoplan-calendar.next.bc981983": "Weiter",
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b": "Kein kompatibler Zielkalender ist verfügbar. Fügen Sie einen lokalen Kalender oder einen aktiven CalDAV-Kalender mit bidirektionaler Synchronisierung hinzu.",
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.", "i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars", "i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
"i18n:govoplan-calendar.no_events.e339ba73": "No events", "i18n:govoplan-calendar.no_events.e339ba73": "No events",
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65": "Kein lokaler Zielkalender ist verfügbar. Fügen Sie einen lokalen Kalender hinzu, um diese Termine abzutrennen, ohne den entfernten Kalender zu ändern.",
"i18n:govoplan-calendar.none.6eef6648": "Keine", "i18n:govoplan-calendar.none.6eef6648": "Keine",
"i18n:govoplan-calendar.not_configured.811931bb": "Nicht konfiguriert", "i18n:govoplan-calendar.not_configured.811931bb": "Nicht konfiguriert",
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled", "i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",