import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react"; import { ApiError, Button, ConfirmDialog, DataGridPaginationBar, Dialog, DismissibleAlert, ExplorerTree, fetchAuthGroups, formatDateTime, FormField, LoadingFrame, PasswordField, SegmentedControl, SelectionList, SelectionListItem, StatusBadge, ToggleSwitch, hasScope, type ApiSettings, type AuthInfo, type AuthUpdate, type FormatDateTimeOptions } from "@govoplan/core-webui"; import { createAddressBook, createAddressList, createAddressListEntry, createCardDavSyncSource, createContact, deleteAddressBook, deleteAddressList, deleteAddressListEntry, deleteContact, deleteAddressSyncSource, discoverCardDavAddressBooks, exportAddressBookVcards, exportContactVcard, importAddressBookVcards, listAddressBooks, listAddressCredentials, listAddressListEntries, listAddressLists, listAddressSyncConflicts, listAddressSyncDiagnostics, listAddressSyncSources, listAddressSyncTombstones, listContacts, listContactsPage, previewAddressSyncSource, restoreAddressBook, restoreAddressList, restoreContact, resolveAddressSyncConflict, runAddressSyncSource, updateAddressBook, updateAddressList, updateAddressSyncSource, updateContact, type AddressCardDavAddressBook, type AddressBook, type AddressBookScope, type AddressCredentialEnvelope, type AddressList, type AddressListEntry, type AddressSyncConflict, type AddressSyncDiagnostic, type AddressSyncPlan, type AddressSyncSource, type AddressSyncTombstone, type Contact } from "../../api/addresses"; type Props = { settings: ApiSettings; auth: AuthInfo; onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void; }; type BookDialogState = { mode: "create" | "edit"; book?: AddressBook; }; type BookFormState = { scope_type: AddressBookScope; group_id: string; name: string; description: string; }; type ContactDialogState = { mode: "create" | "edit"; contact?: Contact; }; type ListDialogState = { mode: "create" | "edit"; list?: AddressList; }; type ContactEmailRow = { rowId: string; label: string; email: string; is_primary: boolean; }; type ContactPhoneRow = { rowId: string; label: string; phone: string; is_primary: boolean; }; type ContactPostalAddressRow = { rowId: string; label: string; street: string; postal_code: string; locality: string; region: string; country: string; is_primary: boolean; }; type ContactFormState = { display_name: string; given_name: string; family_name: string; organization: string; role_title: string; emails: ContactEmailRow[]; phones: ContactPhoneRow[]; postal_addresses: ContactPostalAddressRow[]; tags: string; note: string; }; type ListFormState = { name: string; description: string; }; type CardDavFormState = { collection_url: string; display_name: string; auth_type: "none" | "basic" | "bearer"; credential_envelope_id: string; username: string; password: string; bearer_token: string; sync_direction: "read_only" | "import" | "export" | "two_way"; }; type SyncInspectorState = { source: AddressSyncSource; } | null; type AddressListTargetOption = { key: string; label: string; payload: { contact_email_id?: string | null; contact_postal_address_id?: string | null; label?: string | null; }; }; type ConfirmState = | { kind: "book"; book: AddressBook } | { kind: "list"; list: AddressList } | { kind: "contact"; contact: Contact } | { kind: "sync-source"; source: AddressSyncSource } | null; type AddressTreeNode = { id: string; kind: "source" | "scope" | "book" | "list"; label: string; sourceGroup?: "local" | "linked"; scope?: AddressBookScope; book?: AddressBook; list?: AddressList; children: AddressTreeNode[]; }; type ConflictMergeChoice = "local" | "remote"; const EMPTY_BOOK_FORM: BookFormState = { scope_type: "user", group_id: "", name: "", description: "" }; const EMPTY_LIST_FORM: ListFormState = { name: "", description: "" }; const EMPTY_CARDDAV_FORM: CardDavFormState = { collection_url: "", display_name: "", auth_type: "basic", credential_envelope_id: "", username: "", password: "", bearer_token: "", sync_direction: "read_only" }; const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id"; const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const; let contactRowSequence = 0; function nextContactRowId(prefix: string): string { contactRowSequence += 1; return `${prefix}-${contactRowSequence}`; } function emptyEmailRow(isPrimary = true): ContactEmailRow { return { rowId: nextContactRowId("email"), label: "work", email: "", is_primary: isPrimary }; } function emptyPhoneRow(isPrimary = true): ContactPhoneRow { return { rowId: nextContactRowId("phone"), label: "work", phone: "", is_primary: isPrimary }; } function emptyPostalAddressRow(isPrimary = true): ContactPostalAddressRow { return { rowId: nextContactRowId("address"), label: "work", street: "", postal_code: "", locality: "", region: "", country: "", is_primary: isPrimary }; } function emptyContactForm(): ContactFormState { return { display_name: "", given_name: "", family_name: "", organization: "", role_title: "", emails: [emptyEmailRow()], phones: [emptyPhoneRow()], postal_addresses: [emptyPostalAddressRow()], tags: "", note: "" }; } const EMPTY_CONTACT_FORM: ContactFormState = emptyContactForm(); function errorMessage(error: unknown): string { if (error instanceof ApiError) { try { const parsed = JSON.parse(error.body) as { detail?: unknown }; if (typeof parsed.detail === "string") return parsed.detail; } catch { return error.message; } } return error instanceof Error ? error.message : String(error); } function scopeLabel(scope: AddressBookScope): string { if (scope === "user") return "Personal"; if (scope === "group") return "Group"; if (scope === "tenant") return "Tenant"; return "System"; } function primaryEmail(contact: Contact): string { return contact.emails.find((email) => email.is_primary)?.email ?? contact.emails[0]?.email ?? ""; } function primaryPhone(contact: Contact): string { return contact.phones.find((phone) => phone.is_primary)?.phone ?? contact.phones[0]?.phone ?? ""; } function sourceGroupForBook(book: AddressBook): "local" | "linked" { return book.source_kind === "local" ? "local" : "linked"; } function sourceGroupLabel(sourceGroup: "local" | "linked"): string { return sourceGroup === "local" ? "Local" : "Linked"; } function syncSourceLabel(source: AddressSyncSource): string { return source.display_name || source.external_address_book_ref || source.connector_type; } const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = { fallback: "Never", year: undefined, month: undefined, day: undefined, hour: undefined, minute: undefined, timeZoneName: undefined, dateStyle: "short", timeStyle: "short" }; function planSummary(plan: AddressSyncPlan | null): string { if (!plan) return "No preview loaded."; const { stats } = plan; return `${stats.created} create, ${stats.updated} update, ${stats.deleted} delete, ${stats.conflicts} conflict, ${stats.unchanged} unchanged, ${stats.errors} error`; } function syncActionLabel(action: string): string { if (action === "remote_create") return "Remote create"; if (action === "remote_update") return "Remote update"; if (action === "remote_delete") return "Remote delete"; return action; } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function conflictPayload(value: Record | null | undefined): Record | null { if (!isRecord(value)) return null; return isRecord(value.payload) ? value.payload : null; } function conflictRemotePayload(conflict: AddressSyncConflict): Record | null { const remoteValuePayload = conflictPayload(conflict.remote_value); if (remoteValuePayload) return remoteValuePayload; const metadataPayload = conflict.metadata?.remote_payload; return isRecord(metadataPayload) ? metadataPayload : null; } function canApplyRemoteConflict(conflict: AddressSyncConflict): boolean { return Boolean(conflictRemotePayload(conflict)); } function canMergeConflict(conflict: AddressSyncConflict): boolean { return Boolean(conflictPayload(conflict.local_value) && conflictRemotePayload(conflict)); } function defaultConflictMergeChoices(conflict: AddressSyncConflict): Record { return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice])); } function buildMergedConflictPayload(conflict: AddressSyncConflict, choices: Record): Record | null { const localPayload = conflictPayload(conflict.local_value); const remotePayload = conflictRemotePayload(conflict); if (!localPayload || !remotePayload) return null; const merged: Record = { ...localPayload }; for (const field of CONFLICT_PAYLOAD_FIELDS) { merged[field] = choices[field] === "remote" ? remotePayload[field] : localPayload[field]; } return merged; } function shortJson(value: unknown): string { if (value === null || value === undefined || value === "") return "—"; if (Array.isArray(value)) { if (value.length === 0) return "—"; return value.map((item) => shortJson(item)).join("; "); } if (isRecord(value)) { const entries = Object.entries(value) .filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== "") .map(([key, entryValue]) => `${key}: ${shortJson(entryValue)}`); return entries.length ? entries.join(", ") : "—"; } return String(value); } function conflictFieldRows(conflict: AddressSyncConflict): Array<{ field: string; local: string; remote: string; differs: boolean }> { const localPayload = conflictPayload(conflict.local_value); const remotePayload = conflictRemotePayload(conflict); if (!localPayload && !remotePayload) { return [ { field: conflict.field_path, local: shortJson(conflict.local_value), remote: shortJson(conflict.remote_value), differs: shortJson(conflict.local_value) !== shortJson(conflict.remote_value) } ]; } return CONFLICT_PAYLOAD_FIELDS.map((field) => { const local = shortJson(localPayload?.[field]); const remote = shortJson(remotePayload?.[field]); return { field, local, remote, differs: local !== remote }; }); } function buildAddressTree(books: AddressBook[], addressLists: AddressList[]): AddressTreeNode[] { const listsByBook = new Map(); for (const list of addressLists) { const current = listsByBook.get(list.address_book_id) ?? []; current.push(list); listsByBook.set(list.address_book_id, current); } return (["local", "linked"] as const).map((sourceGroup) => { const sourceBooks = books.filter((book) => sourceGroupForBook(book) === sourceGroup); const scopeNodes = (["user", "group", "tenant", "system"] as const).map((scope) => { const scopeBooks = sourceBooks.filter((book) => book.scope_type === scope); return { id: `scope:${sourceGroup}:${scope}`, kind: "scope" as const, label: scopeLabel(scope), sourceGroup, scope, children: scopeBooks.map((book) => ({ id: `book:${book.id}`, kind: "book" as const, label: book.name, sourceGroup, scope: book.scope_type, book, children: (listsByBook.get(book.id) ?? []).map((list) => ({ id: `list:${list.id}`, kind: "list" as const, label: list.name, sourceGroup, scope: book.scope_type, list, children: [] })) })) }; }).filter((node) => node.children.length > 0); return { id: `source:${sourceGroup}`, kind: "source" as const, label: sourceGroupLabel(sourceGroup), sourceGroup, children: scopeNodes }; }).filter((node) => node.children.length > 0); } function expandedAddressTreeIds(nodes: AddressTreeNode[]): Set { const expanded = new Set(); function visit(node: AddressTreeNode) { if (node.children.length > 0) expanded.add(node.id); node.children.forEach(visit); } nodes.forEach(visit); return expanded; } function addressListEntryKey(entry: Pick): string { if (entry.contact_email_id) return `${entry.contact_id}:email:${entry.contact_email_id}`; if (entry.contact_postal_address_id) return `${entry.contact_id}:postal:${entry.contact_postal_address_id}`; return `${entry.contact_id}:contact`; } function listEntryContactIds(entries: AddressListEntry[]): Set { return new Set(entries.map((entry) => entry.contact_id)); } function contactListEntries(entries: AddressListEntry[], contactId: string): AddressListEntry[] { return entries.filter((entry) => entry.contact_id === contactId); } function listFormFromList(list?: AddressList): ListFormState { if (!list) return EMPTY_LIST_FORM; return { name: list.name, description: list.description ?? "" }; } function formatPostalAddress(address: Contact["postal_addresses"][number]): string { return [address.street, [address.postal_code, address.locality].filter(Boolean).join(" "), address.region, address.country] .filter((part) => part && part.trim()) .join(", "); } function addressListTargetOptions(contact: Contact): AddressListTargetOption[] { const options: AddressListTargetOption[] = [{ key: "contact", label: "Whole contact", payload: { label: null } }]; for (const email of contact.emails) { if (!email.id) continue; const label = `${email.label || "email"}${email.is_primary ? " primary" : ""}: ${email.email}`; options.push({ key: `email:${email.id}`, label, payload: { contact_email_id: email.id, label: email.label || null } }); } for (const address of contact.postal_addresses) { if (!address.id) continue; const formatted = formatPostalAddress(address) || "Postal address"; const label = `${address.label || "address"}${address.is_primary ? " primary" : ""}: ${formatted}`; options.push({ key: `postal:${address.id}`, label, payload: { contact_postal_address_id: address.id, label: address.label || null } }); } return options; } function entryTargetLabel(entry: AddressListEntry): string { if (entry.target_kind === "email") return entry.email ? `Email: ${entry.email}` : "Email target"; if (entry.target_kind === "postal_address") return entry.postal_address ? `Postal: ${entry.postal_address}` : "Postal address target"; return "Whole contact"; } function bookFormFromBook(book?: AddressBook): BookFormState { if (!book) return EMPTY_BOOK_FORM; return { scope_type: book.scope_type, group_id: book.scope_type === "group" ? book.scope_id ?? "" : "", name: book.name, description: book.description ?? "" }; } function contactFormFromContact(contact?: Contact): ContactFormState { if (!contact) return emptyContactForm(); return { display_name: contact.display_name, given_name: contact.given_name ?? "", family_name: contact.family_name ?? "", organization: contact.organization ?? "", role_title: contact.role_title ?? "", emails: normalizePrimaryRows(contact.emails.map((email) => ({ rowId: nextContactRowId("email"), label: email.label ?? "work", email: email.email, is_primary: email.is_primary })), emptyEmailRow), phones: normalizePrimaryRows(contact.phones.map((phone) => ({ rowId: nextContactRowId("phone"), label: phone.label ?? "work", phone: phone.phone, is_primary: phone.is_primary })), emptyPhoneRow), postal_addresses: normalizePrimaryRows(contact.postal_addresses.map((postal) => ({ rowId: nextContactRowId("address"), label: postal.label ?? "work", street: postal.street ?? "", postal_code: postal.postal_code ?? "", locality: postal.locality ?? "", region: postal.region ?? "", country: postal.country ?? "", is_primary: postal.is_primary })), emptyPostalAddressRow), tags: contact.tags.join(", "), note: contact.note ?? "" }; } function normalizePrimaryFlags(rows: T[]): T[] { if (!rows.length) return rows; const primaryIndex = rows.findIndex((row) => row.is_primary); return rows.map((row, index) => ({ ...row, is_primary: primaryIndex >= 0 ? index === primaryIndex : index === 0 })); } function normalizePrimaryRows(rows: T[], fallback: () => T): T[] { const nextRows = rows.length ? rows : [fallback()]; return normalizePrimaryFlags(nextRows); } function tagsFromForm(value: string): string[] { const seen = new Set(); return value.split(",").map((tag) => tag.trim()).filter((tag) => { const key = tag.toLowerCase(); if (!tag || seen.has(key)) return false; seen.add(key); return true; }); } function postalRowHasContent(row: ContactPostalAddressRow): boolean { return [row.street, row.postal_code, row.locality, row.region, row.country].some((value) => value.trim()); } function contactFormHasIdentity(form: ContactFormState): boolean { return Boolean( form.display_name.trim() || form.given_name.trim() || form.family_name.trim() || form.emails.some((email) => email.email.trim()) ); } function safeFilename(value: string): string { return (value.trim().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "address-book") + ".vcf"; } function downloadText(filename: string, content: string, type = "text/vcard;charset=utf-8") { const blob = new Blob([content], { type }); const url = window.URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.append(anchor); anchor.click(); anchor.remove(); window.URL.revokeObjectURL(url); } function disabledReason(...conditions: Array<[boolean, string]>): string { return conditions.find(([applies]) => applies)?.[1] ?? ""; } export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { const [books, setBooks] = useState([]); const [addressLists, setAddressLists] = useState([]); const [addressListEntries, setAddressListEntries] = useState([]); const [syncSources, setSyncSources] = useState([]); const [contacts, setContacts] = useState([]); const [contactTotal, setContactTotal] = useState(0); const [contactPage, setContactPage] = useState(1); const [contactPageSize, setContactPageSize] = useState(50); const [selectedBookId, setSelectedBookId] = useState(""); const [selectedListId, setSelectedListId] = useState(""); const [selectedContactId, setSelectedContactId] = useState(""); const [expandedTreeIds, setExpandedTreeIds] = useState>(() => new Set()); const [query, setQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [bookDialog, setBookDialog] = useState(null); const [bookForm, setBookForm] = useState(EMPTY_BOOK_FORM); const [listDialog, setListDialog] = useState(null); const [listForm, setListForm] = useState(EMPTY_LIST_FORM); const [contactDialog, setContactDialog] = useState(null); const [contactForm, setContactForm] = useState(EMPTY_CONTACT_FORM); const [memberDialogOpen, setMemberDialogOpen] = useState(false); const [memberCandidates, setMemberCandidates] = useState([]); const [memberQuery, setMemberQuery] = useState(""); const [memberTargetByContactId, setMemberTargetByContactId] = useState>({}); const [dropTargetListId, setDropTargetListId] = useState(""); const [importOpen, setImportOpen] = useState(false); const [vcardContent, setVcardContent] = useState(""); const [cardDavOpen, setCardDavOpen] = useState(false); const [cardDavForm, setCardDavForm] = useState(EMPTY_CARDDAV_FORM); const [cardDavDiscovery, setCardDavDiscovery] = useState([]); const [cardDavCredentials, setCardDavCredentials] = useState([]); const [cardDavCredentialsError, setCardDavCredentialsError] = useState(""); const [syncInspector, setSyncInspector] = useState(null); const [syncPlan, setSyncPlan] = useState(null); const [syncDiagnostics, setSyncDiagnostics] = useState([]); const [syncTombstones, setSyncTombstones] = useState([]); const [syncConflicts, setSyncConflicts] = useState([]); const [conflictDialog, setConflictDialog] = useState(null); const [conflictMergeChoices, setConflictMergeChoices] = useState>({}); const [confirmState, setConfirmState] = useState(null); const canWriteBooks = hasScope(auth, "addresses:address_book:write"); const canDeleteBooks = hasScope(auth, "addresses:address_book:delete"); const canAdminBooks = hasScope(auth, "addresses:address_book:admin"); const canWriteLists = hasScope(auth, "addresses:address_list:write"); const canDeleteLists = hasScope(auth, "addresses:address_list:delete"); const canWriteContacts = hasScope(auth, "addresses:contact:write"); const canDeleteContacts = hasScope(auth, "addresses:contact:delete"); const canReadSync = hasScope(auth, "addresses:sync:read"); const canWriteSync = hasScope(auth, "addresses:sync:write"); useEffect(() => { if (!cardDavOpen || !canWriteSync) { setCardDavCredentials([]); setCardDavCredentialsError(""); return; } let active = true; listAddressCredentials(settings) .then((credentials) => { if (active) { setCardDavCredentials(credentials); setCardDavCredentialsError(""); } }) .catch((err) => { if (active) { setCardDavCredentials([]); setCardDavCredentialsError(errorMessage(err)); } }); return () => { active = false; }; }, [ canWriteSync, cardDavOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey ]); useEffect(() => { if (auth.groups_loaded || !onAuthChange) return; let active = true; fetchAuthGroups(settings) .then((next) => {if (active) onAuthChange(next);}) .catch(() => undefined); return () => {active = false;}; }, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null; const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null; const selectedBookSyncSources = useMemo( () => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [], [selectedBook, syncSources] ); const selectedSyncSource = selectedBookSyncSources[0] ?? null; const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]); const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]); const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]); const visibleContacts = contacts; const memberCandidateContacts = useMemo(() => { const normalizedQuery = memberQuery.trim().toLowerCase(); return memberCandidates .filter((contact) => !contact.deleted_at) .filter((contact) => addressListTargetOptions(contact).some((option) => !selectedListEntryKeys.has(`${contact.id}:${option.key}`))) .filter((contact) => { if (!normalizedQuery) return true; return [ contact.display_name, contact.given_name, contact.family_name, contact.organization, contact.role_title, primaryEmail(contact), primaryPhone(contact), contact.tags.join(" ") ].some((value) => (value ?? "").toLowerCase().includes(normalizedQuery)); }); }, [memberCandidates, memberQuery, selectedListContactIds]); const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null; const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : []; const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : ""; const loadingReason = loading ? "Address books are loading." : ""; const savingReason = saving ? "An address-book action is already in progress." : ""; const createBookReason = disabledReason( [!canWriteBooks, "You need permission to manage address books."], [saving, savingReason] ); const refreshReason = disabledReason( [loading, loadingReason], [saving, savingReason] ); const toggleArchiveReason = disabledReason( [loading, loadingReason], [saving, savingReason] ); const exportBookReason = disabledReason( [!selectedBook, "Select an address book before exporting vCards."], [saving, savingReason] ); const importBookReason = disabledReason( [!selectedBook, "Select an address book before importing vCards."], [!canWriteContacts, "You need permission to manage contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before importing contacts."], [saving, savingReason] ); const connectCardDavReason = disabledReason( [!selectedBook, "Select an address book before connecting CardDAV."], [!canWriteSync, "You need permission to manage address sync."], [Boolean(selectedBook?.deleted_at), "Restore this address book before connecting sync."], [saving, savingReason] ); const inspectSyncReason = disabledReason( [!selectedSyncSource, "This address book has no sync source."], [!canReadSync, "You need permission to view address sync."], [saving, savingReason] ); const previewSyncReason = disabledReason( [!selectedSyncSource, "This address book has no sync source."], [!canReadSync, "You need permission to view address sync."], [saving, savingReason] ); const runSyncReason = disabledReason( [!selectedSyncSource, "This address book has no sync source."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason] ); const createContactReason = disabledReason( [!selectedBook, "Select an address book before adding a contact."], [!canWriteContacts, "You need permission to manage contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts."], [saving, savingReason] ); const createListReason = disabledReason( [!selectedBook, "Select an address book before adding a list."], [!canWriteLists, "You need permission to manage address lists."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before adding lists."], [saving, savingReason] ); const listSaveReason = disabledReason( [saving, savingReason], [!listForm.name.trim(), "Enter an address-list name before saving."] ); const addMembersReason = disabledReason( [!selectedList, "Select an address list before adding contacts."], [!canWriteLists, "You need permission to manage address lists."], [Boolean(selectedList?.read_only), "This address list is read-only."], [Boolean(selectedList?.deleted_at), "Restore this address list before adding contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts to lists."], [saving, savingReason] ); const bookSaveReason = disabledReason( [saving, savingReason], [!bookForm.name.trim(), "Enter an address book name before saving."] ); const contactSaveReason = disabledReason( [saving, savingReason], [!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."] ); const dialogCancelReason = disabledReason([saving, savingReason]); const vcardImportReason = disabledReason( [saving, savingReason], [!selectedBook, "Select an address book before importing vCards."], [!vcardContent.trim(), "Paste vCard content before importing."] ); const addContactRowReason = disabledReason([saving, savingReason]); const removeEmailRowReason = disabledReason( [saving, savingReason], [contactForm.emails.length === 1, "Keep at least one email row. Empty rows are ignored on save."] ); const removePhoneRowReason = disabledReason( [saving, savingReason], [contactForm.phones.length === 1, "Keep at least one phone row. Empty rows are ignored on save."] ); const removePostalAddressRowReason = disabledReason( [saving, savingReason], [contactForm.postal_addresses.length === 1, "Keep at least one postal address row. Empty rows are ignored on save."] ); function restoreBookReason(_book: AddressBook): string { return disabledReason( [!canWriteBooks, "You need permission to manage address books."], [saving, savingReason] ); } function editBookReason(book: AddressBook): string { return disabledReason( [!canWriteBooks, "You need permission to manage address books."], [book.read_only, "This address book is read-only."], [saving, savingReason] ); } function deleteBookReason(book: AddressBook): string { return disabledReason( [!canDeleteBooks, "You need permission to delete address books."], [book.read_only, "This address book is read-only."], [saving, savingReason] ); } function editListReason(list: AddressList): string { return disabledReason( [!canWriteLists, "You need permission to manage address lists."], [list.read_only, "This address list is read-only."], [Boolean(list.deleted_at), "Restore this address list before editing it."], [saving, savingReason] ); } function deleteListReason(list: AddressList): string { return disabledReason( [!canDeleteLists, "You need permission to delete address lists."], [list.read_only, "This address list is read-only."], [Boolean(list.deleted_at), "This address list is already archived."], [saving, savingReason] ); } function restoreListReason(list: AddressList): string { return disabledReason( [!canWriteLists, "You need permission to manage address lists."], [list.read_only, "This address list is read-only."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [saving, savingReason] ); } function addContactToListReason(list: AddressList | null, contact: Contact | null, targetKey = ""): string { return disabledReason( [!list, "Select an address list before adding contacts."], [!contact, "Select a contact before adding it to a list."], [!canWriteLists, "You need permission to manage address lists."], [Boolean(list?.read_only), "This address list is read-only."], [Boolean(list?.deleted_at), "Restore this address list before adding contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts to lists."], [Boolean(contact?.deleted_at), "Restore this contact before adding it to a list."], [Boolean(list && contact && list.address_book_id !== contact.address_book_id), "Address-list entries must use contacts from the same address book."], [Boolean(contact && targetKey && selectedListEntryKeys.has(`${contact.id}:${targetKey}`) && list?.id === selectedList?.id), "This target is already in the selected address list."], [saving, savingReason] ); } function removeContactFromListReason(): string { return disabledReason( [!selectedList, "Select an address list before removing contacts."], [selectedContactListEntries.length === 0, "This contact is not in the selected address list."], [!canWriteLists, "You need permission to manage address lists."], [Boolean(selectedList?.read_only), "This address list is read-only."], [Boolean(selectedList?.deleted_at), "Restore this address list before removing contacts."], [saving, savingReason] ); } function restoreContactReason(): string { return disabledReason( [!canWriteContacts, "You need permission to manage contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before restoring contacts."], [saving, savingReason] ); } function exportContactReason(): string { return disabledReason([saving, savingReason]); } function editContactReason(): string { return disabledReason( [!canWriteContacts, "You need permission to manage contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before editing contacts."], [saving, savingReason] ); } function deleteContactReason(): string { return disabledReason( [!canDeleteContacts, "You need permission to delete contacts."], [Boolean(selectedBook?.read_only), "This address book is read-only."], [Boolean(selectedBook?.deleted_at), "Restore this address book before deleting contacts."], [saving, savingReason] ); } function availableTargetOptionsForContact(contact: Contact): AddressListTargetOption[] { return addressListTargetOptions(contact).filter((option) => !selectedListEntryKeys.has(`${contact.id}:${option.key}`)); } function selectedTargetOptionForContact(contact: Contact): AddressListTargetOption | null { const options = availableTargetOptionsForContact(contact); if (options.length === 0) return null; const selectedKey = memberTargetByContactId[contact.id]; return options.find((option) => option.key === selectedKey) ?? options[0]; } function setMemberTarget(contactId: string, targetKey: string) { setMemberTargetByContactId((current) => ({ ...current, [contactId]: targetKey })); } const refreshBooks = useCallback(async () => { const [nextBooks, nextAddressLists, nextSyncSources] = await Promise.all([ listAddressBooks(settings, { includeDeleted: showArchived }), listAddressLists(settings, { includeDeleted: showArchived }), canReadSync ? listAddressSyncSources(settings, { includeDisabled: true }) : Promise.resolve([]) ]); setBooks(nextBooks); setAddressLists(nextAddressLists); setSyncSources(nextSyncSources); setSelectedBookId((current) => nextBooks.some((book) => book.id === current) ? current : nextBooks[0]?.id ?? ""); setSelectedListId((current) => nextAddressLists.some((list) => list.id === current) ? current : ""); return nextBooks; }, [canReadSync, settings, showArchived]); const refreshContacts = useCallback(async (bookId: string, search: string) => { if (!bookId) { setContacts([]); setContactTotal(0); return; } const response = await listContactsPage(settings, { addressBookId: bookId, addressListId: selectedListId || undefined, query: search, limit: contactPageSize, offset: (contactPage - 1) * contactPageSize, includeDeleted: showArchived }); const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize)); if (contactPage > pageCount) { setContactPage(pageCount); return; } setContacts(response.contacts); setContactTotal(response.total); }, [contactPage, contactPageSize, selectedListId, settings, showArchived]); const refreshListEntries = useCallback(async (listId: string) => { if (!listId) { setAddressListEntries([]); return []; } const nextEntries = await listAddressListEntries(settings, listId); setAddressListEntries(nextEntries); return nextEntries; }, [settings]); const refreshAll = useCallback(async () => { setLoading(true); setError(""); try { const nextBooks = await refreshBooks(); const nextSelected = nextBooks.some((book) => book.id === selectedBookId) ? selectedBookId : nextBooks[0]?.id ?? ""; await refreshContacts(nextSelected, query); } catch (err) { setError(errorMessage(err)); } finally { setLoading(false); } }, [query, refreshBooks, refreshContacts, selectedBookId]); useEffect(() => { let active = true; async function loadInitial() { setLoading(true); setError(""); try { await refreshBooks(); } catch (err) { if (active) setError(errorMessage(err)); } finally { if (active) setLoading(false); } } void loadInitial(); return () => {active = false;}; }, [refreshBooks]); useEffect(() => { void refreshContacts(selectedBookId, query).catch((err) => setError(errorMessage(err))); }, [query, refreshContacts, selectedBookId]); useEffect(() => { const defaultExpanded = expandedAddressTreeIds(addressTreeNodes); if (defaultExpanded.size === 0) return; setExpandedTreeIds((current) => { const next = new Set(current); for (const id of defaultExpanded) next.add(id); return next; }); }, [addressTreeNodes]); useEffect(() => { let active = true; async function loadListEntries() { if (!selectedListId || selectedList?.deleted_at) { setAddressListEntries([]); return; } try { const nextEntries = await listAddressListEntries(settings, selectedListId); if (active) setAddressListEntries(nextEntries); } catch (err) { if (active) { setAddressListEntries([]); setError(errorMessage(err)); } } } void loadListEntries(); return () => {active = false;}; }, [selectedList?.deleted_at, selectedListId, settings]); useEffect(() => { if (!selectedContactId) return; if (!visibleContacts.some((contact) => contact.id === selectedContactId)) setSelectedContactId(""); }, [selectedContactId, visibleContacts]); function openCreateBookDialog() { setBookForm({ ...EMPTY_BOOK_FORM, group_id: auth.groups[0]?.id ?? "" }); setBookDialog({ mode: "create" }); } function openEditBookDialog(book: AddressBook) { setBookForm(bookFormFromBook(book)); setBookDialog({ mode: "edit", book }); } function openCreateListDialog() { setListForm(EMPTY_LIST_FORM); setListDialog({ mode: "create" }); } function openEditListDialog(list: AddressList) { setListForm(listFormFromList(list)); setListDialog({ mode: "edit", list }); } function openCreateContactDialog() { setContactForm(emptyContactForm()); setContactDialog({ mode: "create" }); } function openEditContactDialog(contact: Contact) { setContactForm(contactFormFromContact(contact)); setContactDialog({ mode: "edit", contact }); } function updateEmailRow(rowId: string, patch: Partial>) { setContactForm((current) => ({ ...current, emails: current.emails.map((row) => row.rowId === rowId ? { ...row, ...patch } : row) })); } function updatePhoneRow(rowId: string, patch: Partial>) { setContactForm((current) => ({ ...current, phones: current.phones.map((row) => row.rowId === rowId ? { ...row, ...patch } : row) })); } function updatePostalAddressRow(rowId: string, patch: Partial>) { setContactForm((current) => ({ ...current, postal_addresses: current.postal_addresses.map((row) => row.rowId === rowId ? { ...row, ...patch } : row) })); } function setPrimaryEmailRow(rowId: string) { setContactForm((current) => ({ ...current, emails: current.emails.map((row) => ({ ...row, is_primary: row.rowId === rowId })) })); } function setPrimaryPhoneRow(rowId: string) { setContactForm((current) => ({ ...current, phones: current.phones.map((row) => ({ ...row, is_primary: row.rowId === rowId })) })); } function setPrimaryPostalAddressRow(rowId: string) { setContactForm((current) => ({ ...current, postal_addresses: current.postal_addresses.map((row) => ({ ...row, is_primary: row.rowId === rowId })) })); } function addEmailRow() { setContactForm((current) => ({ ...current, emails: [...current.emails, emptyEmailRow(false)] })); } function addPhoneRow() { setContactForm((current) => ({ ...current, phones: [...current.phones, emptyPhoneRow(false)] })); } function addPostalAddressRow() { setContactForm((current) => ({ ...current, postal_addresses: [...current.postal_addresses, emptyPostalAddressRow(false)] })); } function removeEmailRow(rowId: string) { setContactForm((current) => ({ ...current, emails: normalizePrimaryRows(current.emails.filter((row) => row.rowId !== rowId), emptyEmailRow) })); } function removePhoneRow(rowId: string) { setContactForm((current) => ({ ...current, phones: normalizePrimaryRows(current.phones.filter((row) => row.rowId !== rowId), emptyPhoneRow) })); } function removePostalAddressRow(rowId: string) { setContactForm((current) => ({ ...current, postal_addresses: normalizePrimaryRows(current.postal_addresses.filter((row) => row.rowId !== rowId), emptyPostalAddressRow) })); } function openTreeNode(node: AddressTreeNode) { if (node.kind === "book" && node.book) { setContactPage(1); setSelectedBookId(node.book.id); setSelectedListId(""); setSelectedContactId(""); return; } if (node.kind === "list" && node.list) { setContactPage(1); setSelectedBookId(node.list.address_book_id); setSelectedListId(node.list.id); setSelectedContactId(""); return; } toggleTreeNode(node); } function toggleTreeNode(node: AddressTreeNode) { if (node.children.length === 0) return; setExpandedTreeIds((current) => { const next = new Set(current); if (next.has(node.id)) next.delete(node.id); else next.add(node.id); return next; }); } async function submitBook(event: FormEvent) { event.preventDefault(); setSaving(true); setError(""); setNotice(""); try { if (bookDialog?.mode === "edit" && bookDialog.book) { await updateAddressBook(settings, bookDialog.book.id, { name: bookForm.name, description: bookForm.description || null }); } else { await createAddressBook(settings, { scope_type: bookForm.scope_type, group_id: bookForm.scope_type === "group" ? bookForm.group_id : null, name: bookForm.name, description: bookForm.description || null }); } setBookDialog(null); await refreshAll(); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function submitList(event: FormEvent) { event.preventDefault(); if (!selectedBook) return; setSaving(true); setError(""); setNotice(""); try { let savedList: AddressList; if (listDialog?.mode === "edit" && listDialog.list) { savedList = await updateAddressList(settings, listDialog.list.id, { name: listForm.name, description: listForm.description || null }); } else { savedList = await createAddressList(settings, selectedBook.id, { name: listForm.name, description: listForm.description || null }); } setListDialog(null); setSelectedBookId(savedList.address_book_id); setSelectedListId(savedList.id); setExpandedTreeIds((current) => { const next = new Set(current); next.add(`book:${savedList.address_book_id}`); return next; }); await refreshBooks(); await refreshListEntries(savedList.id); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function submitContact(event: FormEvent) { event.preventDefault(); if (!selectedBook) return; setSaving(true); setError(""); setNotice(""); const emails = normalizePrimaryFlags(contactForm.emails .filter((email) => email.email.trim()) .map((email) => ({ label: email.label || null, email: email.email.trim(), is_primary: email.is_primary }))); const phones = normalizePrimaryFlags(contactForm.phones .filter((phone) => phone.phone.trim()) .map((phone) => ({ label: phone.label || null, phone: phone.phone.trim(), is_primary: phone.is_primary }))); const postal_addresses = normalizePrimaryFlags(contactForm.postal_addresses .filter(postalRowHasContent) .map((address) => ({ label: address.label || null, street: address.street || null, postal_code: address.postal_code || null, locality: address.locality || null, region: address.region || null, country: address.country || null, is_primary: address.is_primary }))); const payload = { display_name: contactForm.display_name || null, given_name: contactForm.given_name || null, family_name: contactForm.family_name || null, organization: contactForm.organization || null, role_title: contactForm.role_title || null, note: contactForm.note || null, tags: tagsFromForm(contactForm.tags), emails, phones, postal_addresses, provenance: {} }; try { let savedContact: Contact; if (contactDialog?.mode === "edit" && contactDialog.contact) { savedContact = await updateContact(settings, contactDialog.contact.id, payload); } else { savedContact = await createContact(settings, selectedBook.id, payload); } setContactDialog(null); setSelectedContactId(savedContact.id); await refreshBooks(); await refreshContacts(selectedBook.id, query); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function confirmDelete() { if (!confirmState) return; setSaving(true); setError(""); setNotice(""); try { if (confirmState.kind === "book") { await deleteAddressBook(settings, confirmState.book.id); setConfirmState(null); await refreshAll(); } else if (confirmState.kind === "list") { await deleteAddressList(settings, confirmState.list.id); setConfirmState(null); if (!showArchived && selectedListId === confirmState.list.id) { setSelectedListId(""); setAddressListEntries([]); } await refreshBooks(); } else if (confirmState.kind === "contact") { await deleteContact(settings, confirmState.contact.id); if (selectedContactId === confirmState.contact.id) setSelectedContactId(""); setConfirmState(null); await refreshBooks(); await refreshContacts(selectedBookId, query); } else { await deleteAddressSyncSource(settings, confirmState.source.id); if (syncInspector?.source.id === confirmState.source.id) { setSyncInspector(null); setSyncPlan(null); setSyncDiagnostics([]); setSyncTombstones([]); setSyncConflicts([]); setConflictDialog(null); } setConfirmState(null); setNotice("Sync source disconnected. Local contacts were kept."); await refreshBooks(); await refreshContacts(selectedBookId, query); } } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function restoreDeletedList(list: AddressList) { setSaving(true); setError(""); setNotice(""); try { const restored = await restoreAddressList(settings, list.id); setNotice(`Restored "${list.name}".`); setSelectedBookId(restored.address_book_id); setSelectedListId(restored.id); await refreshBooks(); await refreshListEntries(restored.id); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function restoreBook(book: AddressBook) { setSaving(true); setError(""); setNotice(""); try { await restoreAddressBook(settings, book.id); setNotice(`Restored "${book.name}".`); await refreshAll(); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function restoreDeletedContact(contact: Contact) { setSaving(true); setError(""); setNotice(""); try { await restoreContact(settings, contact.id); setNotice(`Restored "${contact.display_name}".`); await refreshBooks(); await refreshContacts(selectedBookId, query); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function openAddMembersDialog() { if (!selectedBook || !selectedList) return; setMemberQuery(""); setMemberTargetByContactId({}); setMemberDialogOpen(true); setError(""); try { setMemberCandidates(await listContacts(settings, { addressBookId: selectedBook.id, limit: 500, includeDeleted: showArchived })); } catch (err) { setError(errorMessage(err)); } } async function addContactToAddressList( list: AddressList, contact: Contact, targetOption: AddressListTargetOption | null = null, successMessage = `Added "${contact.display_name}" to "${list.name}".` ) { const option = targetOption ?? selectedTargetOptionForContact(contact); const reason = addContactToListReason(list, contact, option?.key ?? ""); if (reason) { setError(reason); return; } if (!option) { setError("This contact has no available target left for the selected list."); return; } setSaving(true); setError(""); setNotice(""); try { const currentEntries = list.id === selectedListId ? addressListEntries : await listAddressListEntries(settings, list.id); if (currentEntries.some((entry) => addressListEntryKey(entry) === `${contact.id}:${option.key}`)) { setNotice(`"${option.label}" is already in "${list.name}".`); return; } await createAddressListEntry(settings, list.id, { contact_id: contact.id, ...option.payload }); setNotice(successMessage); await refreshBooks(); if (list.id === selectedListId) await refreshListEntries(list.id); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function removeAddressListEntry(entry: AddressListEntry) { if (!selectedList || !selectedContact) return; setSaving(true); setError(""); setNotice(""); try { await deleteAddressListEntry(settings, entry.id); setNotice(`Removed "${entryTargetLabel(entry)}" for "${selectedContact.display_name}" from "${selectedList.name}".`); await refreshBooks(); await refreshListEntries(selectedList.id); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } function handleContactDragStart(event: ReactDragEvent, contact: Contact) { event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData(ADDRESS_CONTACT_DRAG_TYPE, contact.id); event.dataTransfer.setData("text/plain", contact.display_name); } function dragContactFromEvent(event: ReactDragEvent): Contact | null { const contactId = event.dataTransfer.getData(ADDRESS_CONTACT_DRAG_TYPE); if (!contactId) return null; return contacts.find((contact) => contact.id === contactId) ?? memberCandidates.find((contact) => contact.id === contactId) ?? null; } function handleTreeDragOver(event: ReactDragEvent, node: AddressTreeNode) { if (node.kind !== "list" || !node.list) return; const hasContact = Array.from(event.dataTransfer.types).includes(ADDRESS_CONTACT_DRAG_TYPE); if (!hasContact) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDropTargetListId(node.list.id); } function handleTreeDragLeave(_event: ReactDragEvent, node: AddressTreeNode) { if (node.kind === "list" && node.list?.id === dropTargetListId) setDropTargetListId(""); } async function handleTreeDrop(event: ReactDragEvent, node: AddressTreeNode) { if (node.kind !== "list" || !node.list) return; event.preventDefault(); setDropTargetListId(""); const contact = dragContactFromEvent(event); if (!contact) return; await addContactToAddressList(node.list, contact, null, `Added "${contact.display_name}" to "${node.list.name}" by drag and drop.`); } async function exportSelectedBook() { if (!selectedBook) return; setSaving(true); setError(""); setNotice(""); try { const content = await exportAddressBookVcards(settings, selectedBook.id); downloadText(safeFilename(selectedBook.name), content); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function exportOneContact(contact: Contact) { setSaving(true); setError(""); setNotice(""); try { const content = await exportContactVcard(settings, contact.id); downloadText(safeFilename(contact.display_name), content); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function submitVcardImport(event: FormEvent) { event.preventDefault(); if (!selectedBook) return; setSaving(true); setError(""); setNotice(""); try { const result = await importAddressBookVcards(settings, selectedBook.id, vcardContent); setImportOpen(false); setVcardContent(""); const issueCount = result.issues.length; setNotice(`Imported ${result.imported} contact${result.imported === 1 ? "" : "s"}${result.skipped ? `, skipped ${result.skipped}` : ""}${issueCount ? ` (${issueCount} import issue${issueCount === 1 ? "" : "s"})` : ""}.`); await refreshBooks(); await refreshContacts(selectedBook.id, query); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } function openCardDavDialog() { setCardDavForm(EMPTY_CARDDAV_FORM); setCardDavDiscovery([]); setCardDavCredentialsError(""); setCardDavOpen(true); } function cardDavPayload() { const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id ? `credential-envelope:${cardDavForm.credential_envelope_id}` : null; return { url: cardDavForm.collection_url, auth_type: cardDavForm.auth_type, username: cardDavForm.username || null, password: credentialRef ? null : cardDavForm.password || null, bearer_token: credentialRef ? null : cardDavForm.bearer_token || null, credential_ref: credentialRef }; } async function discoverCardDavSources() { setSaving(true); setError(""); setNotice(""); try { const discovered = await discoverCardDavAddressBooks(settings, cardDavPayload()); setCardDavDiscovery(discovered); setNotice(`Found ${discovered.length} CardDAV address book${discovered.length === 1 ? "" : "s"}.`); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } function useDiscoveredCardDavBook(item: AddressCardDavAddressBook) { setCardDavForm((current) => ({ ...current, collection_url: item.collection_url, display_name: current.display_name || item.display_name || selectedBook?.name || "CardDAV address book" })); } async function submitCardDavSource(event: FormEvent) { event.preventDefault(); if (!selectedBook) return; setSaving(true); setError(""); setNotice(""); try { await createCardDavSyncSource(settings, selectedBook.id, { collection_url: cardDavForm.collection_url, display_name: cardDavForm.display_name || selectedBook.name, auth_type: cardDavForm.auth_type, username: cardDavForm.username || null, password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null, bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null, credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id ? `credential-envelope:${cardDavForm.credential_envelope_id}` : null, sync_direction: cardDavForm.sync_direction, read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import" }); setCardDavOpen(false); setCardDavDiscovery([]); setNotice("CardDAV source connected."); await refreshBooks(); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function loadSyncDetails(source: AddressSyncSource) { const [diagnostics, tombstones, conflicts] = await Promise.all([ listAddressSyncDiagnostics(settings, source.id), listAddressSyncTombstones(settings, source.id), listAddressSyncConflicts(settings, source.id, "open") ]); setSyncDiagnostics(diagnostics); setSyncTombstones(tombstones); setSyncConflicts(conflicts); } async function openSyncInspector(source: AddressSyncSource) { setSyncInspector({ source }); setSyncPlan(null); setError(""); try { await loadSyncDetails(source); } catch (err) { setError(errorMessage(err)); } } async function previewSelectedSync(source = selectedSyncSource) { if (!source) return; setSaving(true); setError(""); setNotice(""); try { const plan = await previewAddressSyncSource(settings, source.id); setSyncPlan(plan); setSyncInspector({ source: plan.sync_source }); setNotice(`Sync preview: ${planSummary(plan)}.`); await loadSyncDetails(plan.sync_source); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function runSelectedSync(source = selectedSyncSource) { if (!source) return; setSaving(true); setError(""); setNotice(""); try { const plan = await runAddressSyncSource(settings, source.id); setSyncPlan(plan); setSyncInspector({ source: plan.sync_source }); setNotice(`Sync completed: ${planSummary(plan)}.`); await refreshBooks(); await refreshContacts(selectedBookId, query); await loadSyncDetails(plan.sync_source); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function toggleSyncSourceEnabled(source: AddressSyncSource) { setSaving(true); setError(""); setNotice(""); try { const next = await updateAddressSyncSource(settings, source.id, { enabled: !source.enabled }); setSyncInspector({ source: next }); setNotice(next.enabled ? "Sync source enabled." : "Sync source disabled."); await refreshBooks(); await loadSyncDetails(next); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } function openConflictReview(conflict: AddressSyncConflict) { setConflictDialog(conflict); setConflictMergeChoices(defaultConflictMergeChoices(conflict)); } function setConflictMergeChoice(field: string, choice: ConflictMergeChoice) { setConflictMergeChoices((current) => ({ ...current, [field]: choice })); } async function resolveConflictWith( conflict: AddressSyncConflict, resolution: "keep_local" | "use_remote" | "merge" | "manual" | "ignored", status: "resolved" | "ignored" = "resolved", mergedPayload: Record | null = null ) { setSaving(true); setError(""); setNotice(""); try { await resolveAddressSyncConflict(settings, conflict.id, resolution, status, { mergedPayload }); setConflictDialog(null); setConflictMergeChoices({}); setNotice(status === "ignored" ? "Sync conflict ignored." : "Sync conflict resolved."); if (syncInspector) await loadSyncDetails(syncInspector.source); await refreshBooks(); await refreshContacts(selectedBookId, query); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } } async function applyMergedConflict(conflict: AddressSyncConflict) { const mergedPayload = buildMergedConflictPayload(conflict, conflictMergeChoices); if (!mergedPayload) { setError("This conflict does not contain local and remote field payloads that can be merged."); return; } await resolveConflictWith(conflict, "merge", "resolved", mergedPayload); } function renderTreeNodeContent(node: AddressTreeNode) { if (node.kind === "source") { const bookCount = books.filter((book) => sourceGroupForBook(book) === node.sourceGroup).length; return {node.label}{bookCount} book{bookCount === 1 ? "" : "s"}; } if (node.kind === "scope" && node.scope) { const contactCount = books.filter((book) => sourceGroupForBook(book) === node.sourceGroup && book.scope_type === node.scope).reduce((sum, book) => sum + book.contact_count, 0); return {node.label}{contactCount} contact{contactCount === 1 ? "" : "s"}; } if (node.kind === "book" && node.book) { return ( {node.book.name} {node.book.contact_count} contact{node.book.contact_count === 1 ? "" : "s"} · {node.book.source_kind} ); } if (node.kind === "list" && node.list) { return ( {node.list.name} {node.list.entry_count} entr{node.list.entry_count === 1 ? "y" : "ies"} ); } return {node.label}; } function renderSelectedBookPanel() { if (!selectedBook) return

No address book selected.

; return (
{selectedBook.name}

{selectedBook.description || "No description."}

{selectedBook.sync_status && } {selectedBook.read_only && } {selectedBook.deleted_at && }
{selectedBookSyncSources.length > 0 &&
Sync {selectedBookSyncSources.map((source) => (

{syncSourceLabel(source)} · {source.connector_type} · {source.status} {source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}` : ""} {source.last_error ? ` · ${source.last_error}` : ""}

))}
} {selectedList &&
{selectedList.name}

{selectedList.description || "No list description."}

{selectedList.read_only && } {selectedList.deleted_at && }
}
); } function renderSelectedBookActions() { return ( <> {selectedList ? selectedList.deleted_at ? : <> : selectedBook?.deleted_at ? : <> } ); } function renderContactRow(contact: Contact) { const selected = selectedContactId === contact.id; return ( setSelectedContactId(contact.id)} onDragStart={(event) => handleContactDragStart(event, contact)}> {contact.display_name} {primaryEmail(contact) || contact.organization || "No primary email"} {contact.deleted_at ? : contact.tags.slice(0, 2).map((tag) => {tag})} ); } function renderContactDetail() { if (!selectedContact) { return (
No contact selected

Select a contact from the middle list to view details here.

); } return (

{selectedContact.display_name}

{[selectedContact.role_title, selectedContact.organization].filter(Boolean).join(" · ") || "Contact"}

{selectedContact.deleted_at ? : <> }
{selectedContact.deleted_at && } {selectedContact.source_kind && } {selectedContact.tags.map((tag) => {tag})}
{selectedList &&

List membership

{selectedContactListEntries.map((entry) => (

{entryTargetLabel(entry)}

))}
}

Email

{selectedContact.emails.length === 0 ?

No email addresses.

:
{selectedContact.emails.map((email, index) => (
{email.label || "email"}{email.is_primary ? " · primary" : ""}
{email.email}
))}
}

Phone

{selectedContact.phones.length === 0 ?

No phone numbers.

:
{selectedContact.phones.map((phone, index) => (
{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}
{phone.phone}
))}
}

Postal addresses

{selectedContact.postal_addresses.length === 0 ?

No postal addresses.

:
{selectedContact.postal_addresses.map((address, index) => (
{address.label || "address"}{address.is_primary ? " · primary" : ""}
{formatPostalAddress(address) || "No formatted address."}
))}
}
{selectedContact.note &&

Note

{selectedContact.note}

}
); } return (
{error && {error}} {notice && !error && {notice}}

{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}

{contactTotal} contact{contactTotal === 1 ? "" : "s"} {selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}

{selectedList && }
{ setContactPage(1); setQuery(event.target.value); }} placeholder="Search contacts" /> {selectedBook?.read_only && } {selectedBook?.deleted_at && }
{visibleContacts.length === 0 ?
{selectedBook ? "No contacts found." : "Select an address book."}
: {visibleContacts.map(renderContactRow)} }
{ setContactPageSize(pageSize); setContactPage(1); }} />
{renderContactDetail()}
setBookDialog(null)} closeDisabled={saving} footerClassName="button-row compact-actions" footer={ <> }>
void submitBook(event)}>
{bookForm.scope_type === "group" && }
setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus />