Implement Open-Xchange calendar profiles

This commit is contained in:
2026-08-02 05:59:06 +02:00
parent 66a6df4f05
commit 4e05ab2c3e
11 changed files with 476 additions and 23 deletions
@@ -42,10 +42,11 @@ import {
normalizeHexColor,
} from "./calendarViewModel";
export type CalendarSourceMode = "local" | CalendarSyncSourceKind;
export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange";
type CalendarSourceSwitchMode =
| "local"
| "caldav"
| "open_xchange"
| "ics"
| "graph"
| "ews";
@@ -75,6 +76,9 @@ export type CalendarCalDavFormPayload = {
sync_interval_seconds: number;
sync_direction: CalendarCalDavSyncDirection;
conflict_policy: CalendarCalDavConflictPolicy;
connector_profile_ref: string;
identity_mapping_ref: string;
resource_calendar_ref: string;
};
export type CalendarCollectionFormPayload = {
sourceMode: CalendarSourceMode;
@@ -115,7 +119,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>;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 [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
@@ -132,6 +136,9 @@ export function CalendarCollectionDialog({
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 [connectorProfileRef, setConnectorProfileRef] = useState(metadataValue(source?.metadata, "connector_profile_ref"));
const [identityMappingRef, setIdentityMappingRef] = useState(metadataValue(source?.metadata, "identity_mapping_ref"));
const [resourceCalendarRef, setResourceCalendarRef] = useState(metadataValue(source?.metadata, "resource_calendar_ref"));
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
const [discovering, setDiscovering] = useState(false);
@@ -173,7 +180,10 @@ export function CalendarCollectionDialog({
syncEnabled,
syncIntervalMinutes,
syncDirection,
conflictPolicy
conflictPolicy,
connectorProfileRef,
identityMappingRef,
resourceCalendarRef
};
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
@@ -223,7 +233,10 @@ export function CalendarCollectionDialog({
sync_enabled: syncEnabled,
sync_interval_seconds: syncIntervalMinutes * 60,
sync_direction: syncDirection,
conflict_policy: conflictPolicy
conflict_policy: conflictPolicy,
connector_profile_ref: connectorProfileRef,
identity_mapping_ref: identityMappingRef,
resource_calendar_ref: resourceCalendarRef
}
};
}
@@ -259,7 +272,8 @@ export function CalendarCollectionDialog({
setSyncDirection("inbound");
return;
}
if (next === "caldav") {
if (next === "caldav" || next === "open_xchange") {
if (next === "open_xchange") setAuthType("basic");
setSyncDirection("two_way");
}
}
@@ -355,6 +369,7 @@ export function CalendarCollectionDialog({
options={[
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
{ id: "open_xchange", label: "i18n:govoplan-calendar.open_xchange.637e5b7b", 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 }
@@ -455,8 +470,24 @@ export function CalendarCollectionDialog({
<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" &&
{sourceMode === "open_xchange" &&
<>
<label>
<span>i18n:govoplan-calendar.connector_profile_reference.e7697602</span>
<input value={connectorProfileRef} onChange={(item) => setConnectorProfileRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81</span>
<input value={identityMappingRef} onChange={(item) => setIdentityMappingRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.resource_calendar_reference.4df67054</span>
<input value={resourceCalendarRef} onChange={(item) => setResourceCalendarRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
</label>
</>
}
<div className={calendarSourceUsesCalDav(sourceMode) ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{calendarSourceUsesCalDav(sourceMode) &&
<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>
@@ -465,7 +496,7 @@ export function CalendarCollectionDialog({
</div>
</div>
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
{calendarSourceUsesCalDav(sourceMode) && discoveredCalendars.length > 0 &&
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
@@ -493,14 +524,14 @@ export function CalendarCollectionDialog({
</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"}>
<select value={calendarSourceUsesCalDav(sourceMode) ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || !calendarSourceUsesCalDav(sourceMode)}>
<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"}>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || !calendarSourceUsesCalDav(sourceMode)}>
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
</select>
@@ -667,9 +698,9 @@ export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload:
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",
sync_direction: calendarSourceUsesCalDav(sourceMode) ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy,
metadata: {}
metadata: calendarSourceMetadata(sourceMode, payload)
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
@@ -681,11 +712,19 @@ export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload:
return result;
}
export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
export function syncSourceConnectionUpdatePayload(
sourceMode: CalendarSourceMode,
payload: CalendarCalDavFormPayload,
currentMetadata: Record<string, unknown> | null | undefined,
): 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
username: payload.auth_type === "basic" ? payload.username.trim() || null : null,
metadata: {
...(currentMetadata ?? {}),
...calendarSourceMetadata(sourceMode, payload)
}
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
@@ -703,6 +742,7 @@ function credentialEnvelopeRef(credentialId: string): string {
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
if (sourceMode === "open_xchange") return "caldav";
return sourceMode === "local" ? "caldav" : sourceMode;
}
@@ -718,6 +758,7 @@ function syncTransientPayload(authType: CalendarCalDavAuthType, password: string
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
if (sourceMode === "open_xchange") return "i18n:govoplan-calendar.open_xchange_source.4cc03862";
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";
@@ -726,6 +767,7 @@ function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
if (sourceMode === "open_xchange") return "i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf";
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";
@@ -733,11 +775,41 @@ function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
if (sourceMode === "open_xchange") return "https://groupware.example.org/caldav/";
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 calendarSourceUsesCalDav(sourceMode: CalendarSourceMode): boolean {
return sourceMode === "caldav" || sourceMode === "open_xchange";
}
function calendarSourceModeForSource(source: CalendarSyncSource): CalendarSourceMode {
return source.source_kind === "caldav" && metadataValue(source.metadata, "integration_profile") === "open_xchange"
? "open_xchange"
: source.source_kind;
}
function calendarSourceMetadata(
sourceMode: CalendarSourceMode,
payload: CalendarCalDavFormPayload,
): Record<string, unknown> {
if (sourceMode !== "open_xchange") return {};
return {
integration_profile: "open_xchange",
transport_kind: "caldav",
connector_profile_ref: payload.connector_profile_ref.trim() || null,
identity_mapping_ref: payload.identity_mapping_ref.trim() || null,
resource_calendar_ref: payload.resource_calendar_ref.trim() || null
};
}
function metadataValue(metadata: Record<string, unknown> | null | undefined, key: string): string {
const value = metadata?.[key];
return typeof value === "string" ? value.trim() : "";
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
}