From 2ad6e9d60efa71985bd1bd270cab5255fd42ed77 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 17:01:02 +0200 Subject: [PATCH] feat(calendar): expose loss-safe bulk move modes --- webui/src/api/calendar.ts | 2 + webui/src/features/calendar/CalendarPage.tsx | 57 ++++++++++++++++++-- webui/src/i18n/generatedTranslations.ts | 16 +++++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/webui/src/api/calendar.ts b/webui/src/api/calendar.ts index 0277956..33567af 100644 --- a/webui/src/api/calendar.ts +++ b/webui/src/api/calendar.ts @@ -182,10 +182,12 @@ export type CalendarCollectionCreatePayload = { export type CalendarCollectionUpdatePayload = Partial; export type CalendarDeleteEventAction = "delete" | "move"; +export type CalendarBulkMoveExternalAction = "detach_keep_remote" | "copy_to_remote" | "remote_move"; export type CalendarCollectionDeletePayload = { event_action?: CalendarDeleteEventAction; target_calendar_id?: string | null; make_target_default?: boolean; + external_action?: CalendarBulkMoveExternalAction | null; }; export type CalendarEventCreatePayload = { diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index 3eadf57..91cccc9 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -46,6 +46,7 @@ import { type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryPayload, type CalendarCalDavSyncDirection, + type CalendarBulkMoveExternalAction, type CalendarCollection, type CalendarCollectionDeletePayload, type CalendarDeleteEventAction, @@ -897,6 +898,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; setCalendarDeleteDialog(null)} onDelete={handleCalendarDelete} /> @@ -1713,6 +1715,7 @@ function CalendarCollectionDialog({ function CalendarCollectionDeleteDialog({ state, calendars, + syncSources, saving, onCancel, onDelete @@ -1722,9 +1725,13 @@ function CalendarCollectionDeleteDialog({ -}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise;}) { +}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise;}) { 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 hasEvents = (eventCount ?? 0) > 0; const canMoveEvents = hasEvents && moveTargets.length > 0; @@ -1734,12 +1741,15 @@ function CalendarCollectionDeleteDialog({ 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 + make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault, + external_action: effectiveEventAction === "move" ? externalAction : null }); } @@ -1789,7 +1799,7 @@ function CalendarCollectionDeleteDialog({ checked={eventAction === "move"} onChange={() => setEventAction("move")} /> - i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09 + {calendarBulkMoveOptionLabel(externalAction)} {eventAction === "move" && <> @@ -1807,10 +1817,16 @@ function CalendarCollectionDeleteDialog({ i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b } +

{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"}

+ } ); @@ -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"; } +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, diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index a890751..43d3ba4 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -46,6 +46,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-calendar.delete.f6fdbe48": "Delete", "i18n:govoplan-calendar.deleting.2cda36c9": "Deleting", "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.discover.4827ea22": "Discover", "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.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_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.exchange_web_services_source.53caabf3": "Exchange Web Services source", "i18n:govoplan-calendar.exchange.5b13eac7": "Exchange", "i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON", "i18n:govoplan-calendar.fri.bbd6e32e": "Fri", "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.9a7405eb": "Graph", "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.mon.24b2a099": "Mon", "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.name.709a2322": "Name", "i18n:govoplan-calendar.never.80c3052d": "Never", @@ -103,9 +108,11 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-calendar.new.6403f2b7": "New", "i18n:govoplan-calendar.next_sync.88c7af72": "Next sync", "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_calendars.3a7e4a7a": "No calendars", "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.not_configured.811931bb": "Not configured", "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.deleting.2cda36c9": "Deleting", "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.discover.4827ea22": "Discover", "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.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_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.exchange_web_services_source.53caabf3": "Exchange Web Services source", "i18n:govoplan-calendar.exchange.5b13eac7": "Exchange", "i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON", "i18n:govoplan-calendar.fri.bbd6e32e": "Fri", "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.9a7405eb": "Graph", "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.mon.24b2a099": "Mon", "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.never.80c3052d": "Never", "i18n:govoplan-calendar.new_event.2ef3795c": "New event", "i18n:govoplan-calendar.new.6403f2b7": "New", "i18n:govoplan-calendar.next_sync.88c7af72": "Next sync", "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_calendars.3a7e4a7a": "No calendars", "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.not_configured.811931bb": "Nicht konfiguriert", "i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",