2620 lines
110 KiB
TypeScript
2620 lines
110 KiB
TypeScript
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<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function conflictPayload(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
|
if (!isRecord(value)) return null;
|
|
return isRecord(value.payload) ? value.payload : null;
|
|
}
|
|
|
|
function conflictRemotePayload(conflict: AddressSyncConflict): Record<string, unknown> | 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<string, ConflictMergeChoice> {
|
|
return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice]));
|
|
}
|
|
|
|
function buildMergedConflictPayload(conflict: AddressSyncConflict, choices: Record<string, ConflictMergeChoice>): Record<string, unknown> | null {
|
|
const localPayload = conflictPayload(conflict.local_value);
|
|
const remotePayload = conflictRemotePayload(conflict);
|
|
if (!localPayload || !remotePayload) return null;
|
|
const merged: Record<string, unknown> = { ...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<string, AddressList[]>();
|
|
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<string> {
|
|
const expanded = new Set<string>();
|
|
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<AddressListEntry, "contact_id" | "contact_email_id" | "contact_postal_address_id">): 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<string> {
|
|
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<T extends { is_primary: boolean }>(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<T extends { is_primary: boolean }>(rows: T[], fallback: () => T): T[] {
|
|
const nextRows = rows.length ? rows : [fallback()];
|
|
return normalizePrimaryFlags(nextRows);
|
|
}
|
|
|
|
function tagsFromForm(value: string): string[] {
|
|
const seen = new Set<string>();
|
|
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<AddressBook[]>([]);
|
|
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
|
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
|
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
|
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
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<Set<string>>(() => 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<BookDialogState | null>(null);
|
|
const [bookForm, setBookForm] = useState<BookFormState>(EMPTY_BOOK_FORM);
|
|
const [listDialog, setListDialog] = useState<ListDialogState | null>(null);
|
|
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
|
|
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
|
|
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
|
|
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
|
|
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
|
const [memberQuery, setMemberQuery] = useState("");
|
|
const [memberTargetByContactId, setMemberTargetByContactId] = useState<Record<string, string>>({});
|
|
const [dropTargetListId, setDropTargetListId] = useState("");
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [vcardContent, setVcardContent] = useState("");
|
|
const [cardDavOpen, setCardDavOpen] = useState(false);
|
|
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
|
|
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
|
|
const [cardDavCredentials, setCardDavCredentials] = useState<AddressCredentialEnvelope[]>([]);
|
|
const [cardDavCredentialsError, setCardDavCredentialsError] = useState("");
|
|
const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
|
|
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
|
|
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
|
|
const [syncTombstones, setSyncTombstones] = useState<AddressSyncTombstone[]>([]);
|
|
const [syncConflicts, setSyncConflicts] = useState<AddressSyncConflict[]>([]);
|
|
const [conflictDialog, setConflictDialog] = useState<AddressSyncConflict | null>(null);
|
|
const [conflictMergeChoices, setConflictMergeChoices] = useState<Record<string, ConflictMergeChoice>>({});
|
|
const [confirmState, setConfirmState] = useState<ConfirmState>(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<Omit<ContactEmailRow, "rowId">>) {
|
|
setContactForm((current) => ({
|
|
...current,
|
|
emails: current.emails.map((row) => row.rowId === rowId ? { ...row, ...patch } : row)
|
|
}));
|
|
}
|
|
|
|
function updatePhoneRow(rowId: string, patch: Partial<Omit<ContactPhoneRow, "rowId">>) {
|
|
setContactForm((current) => ({
|
|
...current,
|
|
phones: current.phones.map((row) => row.rowId === rowId ? { ...row, ...patch } : row)
|
|
}));
|
|
}
|
|
|
|
function updatePostalAddressRow(rowId: string, patch: Partial<Omit<ContactPostalAddressRow, "rowId">>) {
|
|
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<HTMLFormElement>) {
|
|
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<HTMLFormElement>) {
|
|
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<HTMLFormElement>) {
|
|
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<HTMLButtonElement>, 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<HTMLElement>): 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<HTMLElement>, 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<HTMLElement>, node: AddressTreeNode) {
|
|
if (node.kind === "list" && node.list?.id === dropTargetListId) setDropTargetListId("");
|
|
}
|
|
|
|
async function handleTreeDrop(event: ReactDragEvent<HTMLElement>, 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<HTMLFormElement>) {
|
|
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<HTMLFormElement>) {
|
|
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<string, unknown> | 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 <span className="address-tree-node-content"><strong>{node.label}</strong><small>{bookCount} book{bookCount === 1 ? "" : "s"}</small></span>;
|
|
}
|
|
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 <span className="address-tree-node-content"><strong>{node.label}</strong><small>{contactCount} contact{contactCount === 1 ? "" : "s"}</small></span>;
|
|
}
|
|
if (node.kind === "book" && node.book) {
|
|
return (
|
|
<span className="address-tree-node-content">
|
|
<strong>{node.book.name}</strong>
|
|
<small>{node.book.contact_count} contact{node.book.contact_count === 1 ? "" : "s"} · {node.book.source_kind}</small>
|
|
</span>
|
|
);
|
|
}
|
|
if (node.kind === "list" && node.list) {
|
|
return (
|
|
<span className="address-tree-node-content">
|
|
<strong>{node.list.name}</strong>
|
|
<small>{node.list.entry_count} entr{node.list.entry_count === 1 ? "y" : "ies"}</small>
|
|
</span>
|
|
);
|
|
}
|
|
return <span>{node.label}</span>;
|
|
}
|
|
|
|
function renderSelectedBookPanel() {
|
|
if (!selectedBook) return <p className="address-empty-note">No address book selected.</p>;
|
|
return (
|
|
<div className="address-source-summary">
|
|
<div>
|
|
<strong>{selectedBook.name}</strong>
|
|
<p>{selectedBook.description || "No description."}</p>
|
|
</div>
|
|
<div className="status-row">
|
|
<StatusBadge status={scopeLabel(selectedBook.scope_type)} />
|
|
<StatusBadge status={selectedBook.source_kind} />
|
|
{selectedBook.sync_status && <StatusBadge status={`sync ${selectedBook.sync_status}`} />}
|
|
{selectedBook.read_only && <StatusBadge status="read-only" />}
|
|
{selectedBook.deleted_at && <StatusBadge status="archived" />}
|
|
</div>
|
|
{selectedBookSyncSources.length > 0 &&
|
|
<div className="address-source-subsummary">
|
|
<strong>Sync</strong>
|
|
{selectedBookSyncSources.map((source) => (
|
|
<p key={source.id}>
|
|
{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}` : ""}
|
|
</p>
|
|
))}
|
|
</div>
|
|
}
|
|
{selectedList &&
|
|
<div className="address-source-subsummary">
|
|
<strong>{selectedList.name}</strong>
|
|
<p>{selectedList.description || "No list description."}</p>
|
|
<div className="status-row">
|
|
<StatusBadge status={`${selectedList.entry_count} entries`} />
|
|
{selectedList.read_only && <StatusBadge status="read-only" />}
|
|
{selectedList.deleted_at && <StatusBadge status="archived" />}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderSelectedBookActions() {
|
|
return (
|
|
<>
|
|
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
|
|
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
|
|
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
|
|
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
|
|
<Button type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
|
|
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
|
|
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
|
|
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
|
|
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
|
|
{selectedList ?
|
|
selectedList.deleted_at ?
|
|
<Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
|
|
<>
|
|
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
|
|
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
|
|
</> :
|
|
selectedBook?.deleted_at ?
|
|
<Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
|
|
<>
|
|
<Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
|
|
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
|
|
</>
|
|
}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function renderContactRow(contact: Contact) {
|
|
const selected = selectedContactId === contact.id;
|
|
return (
|
|
<SelectionListItem
|
|
key={contact.id}
|
|
selected={selected}
|
|
className={`address-contact-row ${contact.deleted_at ? "is-archived-row" : ""}`}
|
|
draggable={!contact.deleted_at && !saving}
|
|
onClick={() => setSelectedContactId(contact.id)}
|
|
onDragStart={(event) => handleContactDragStart(event, contact)}>
|
|
<span className="address-contact-row-main">
|
|
<strong>{contact.display_name}</strong>
|
|
<small>{primaryEmail(contact) || contact.organization || "No primary email"}</small>
|
|
</span>
|
|
<span className="address-contact-row-meta">
|
|
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
|
|
</span>
|
|
</SelectionListItem>
|
|
);
|
|
}
|
|
|
|
function renderContactDetail() {
|
|
if (!selectedContact) {
|
|
return (
|
|
<div className="address-detail-empty">
|
|
<strong>No contact selected</strong>
|
|
<p>Select a contact from the middle list to view details here.</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="address-contact-detail">
|
|
<header className="address-detail-header">
|
|
<div>
|
|
<h2>{selectedContact.display_name}</h2>
|
|
<p>{[selectedContact.role_title, selectedContact.organization].filter(Boolean).join(" · ") || "Contact"}</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
{selectedContact.deleted_at ?
|
|
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
|
|
<>
|
|
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
|
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
|
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
|
</>
|
|
}
|
|
</div>
|
|
</header>
|
|
<div className="status-row">
|
|
{selectedContact.deleted_at && <StatusBadge status="archived" />}
|
|
{selectedContact.source_kind && <StatusBadge status={selectedContact.source_kind} />}
|
|
{selectedContact.tags.map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
|
|
</div>
|
|
{selectedList &&
|
|
<section className="address-detail-section">
|
|
<h3>List membership</h3>
|
|
<div className="address-membership-list">
|
|
{selectedContactListEntries.map((entry) => (
|
|
<div className="address-membership-row" key={entry.id}>
|
|
<p className="muted">{entryTargetLabel(entry)}</p>
|
|
<Button
|
|
type="button"
|
|
variant="danger"
|
|
onClick={() => void removeAddressListEntry(entry)}
|
|
disabledReason={removeContactFromListReason()}>
|
|
<X size={15} /> Remove
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
}
|
|
<section className="address-detail-section">
|
|
<h3>Email</h3>
|
|
{selectedContact.emails.length === 0 ? <p className="muted">No email addresses.</p> :
|
|
<dl className="address-detail-list">
|
|
{selectedContact.emails.map((email, index) => (
|
|
<div key={email.id ?? `${email.email}-${index}`}>
|
|
<dt>{email.label || "email"}{email.is_primary ? " · primary" : ""}</dt>
|
|
<dd>{email.email}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
}
|
|
</section>
|
|
<section className="address-detail-section">
|
|
<h3>Phone</h3>
|
|
{selectedContact.phones.length === 0 ? <p className="muted">No phone numbers.</p> :
|
|
<dl className="address-detail-list">
|
|
{selectedContact.phones.map((phone, index) => (
|
|
<div key={phone.id ?? `${phone.phone}-${index}`}>
|
|
<dt>{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}</dt>
|
|
<dd>{phone.phone}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
}
|
|
</section>
|
|
<section className="address-detail-section">
|
|
<h3>Postal addresses</h3>
|
|
{selectedContact.postal_addresses.length === 0 ? <p className="muted">No postal addresses.</p> :
|
|
<dl className="address-detail-list">
|
|
{selectedContact.postal_addresses.map((address, index) => (
|
|
<div key={address.id ?? `${address.label}-${index}`}>
|
|
<dt>{address.label || "address"}{address.is_primary ? " · primary" : ""}</dt>
|
|
<dd>{formatPostalAddress(address) || "No formatted address."}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
}
|
|
</section>
|
|
{selectedContact.note &&
|
|
<section className="address-detail-section">
|
|
<h3>Note</h3>
|
|
<p>{selectedContact.note}</p>
|
|
</section>
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
|
|
|
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
|
|
<div className="address-book-workspace">
|
|
<aside className="file-tree-panel address-tree-panel" aria-label="Address sources">
|
|
<header className="address-panel-header address-tree-header">
|
|
<div>
|
|
<h2>Address books</h2>
|
|
<p>{books.length} book{books.length === 1 ? "" : "s"}</p>
|
|
</div>
|
|
<div className="button-row compact-actions address-icon-actions">
|
|
{renderSelectedBookActions()}
|
|
</div>
|
|
</header>
|
|
<div className="address-tree-filter-row">
|
|
<ToggleSwitch
|
|
label="Show archived"
|
|
checked={showArchived}
|
|
disabled={Boolean(toggleArchiveReason)}
|
|
onChange={() => {
|
|
setContactPage(1);
|
|
setShowArchived((current) => !current);
|
|
}}
|
|
help="Include archived address books, lists, and contacts."
|
|
/>
|
|
</div>
|
|
<div className="file-tree-list address-tree-list">
|
|
{addressTreeNodes.length === 0 ?
|
|
<div className="address-empty-note">No address books found.</div> :
|
|
<ExplorerTree
|
|
nodes={addressTreeNodes}
|
|
getNodeId={(node) => node.id}
|
|
getNodeLabel={(node) => node.label}
|
|
getNodeChildren={(node) => node.children}
|
|
activeId={activeTreeId}
|
|
expandedIds={expandedTreeIds}
|
|
onOpen={(node) => openTreeNode(node)}
|
|
onToggle={(node) => toggleTreeNode(node)}
|
|
renderNodeContent={(node) => renderTreeNodeContent(node)}
|
|
getNodeWrapClassName={(node) => node.kind === "list" && node.list?.id === dropTargetListId ? "is-drop-target" : undefined}
|
|
onDragOver={handleTreeDragOver}
|
|
onDragLeave={handleTreeDragLeave}
|
|
onDrop={(event, node) => void handleTreeDrop(event, node)}
|
|
/>
|
|
}
|
|
</div>
|
|
<div className="address-tree-summary">
|
|
{renderSelectedBookPanel()}
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="address-list-panel" aria-label="Contacts">
|
|
<header className="address-panel-header">
|
|
<div>
|
|
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
|
|
<p>
|
|
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
|
|
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
|
|
</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
{selectedList &&
|
|
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
|
|
}
|
|
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
|
|
</div>
|
|
</header>
|
|
<div className="address-contact-toolbar">
|
|
<input
|
|
value={query}
|
|
onChange={(event) => {
|
|
setContactPage(1);
|
|
setQuery(event.target.value);
|
|
}}
|
|
placeholder="Search contacts" />
|
|
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
|
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
|
</div>
|
|
<div className="address-contact-list">
|
|
{visibleContacts.length === 0 ?
|
|
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
|
|
<SelectionList label="Contacts" className="address-contact-selection-list">
|
|
{visibleContacts.map(renderContactRow)}
|
|
</SelectionList>
|
|
}
|
|
</div>
|
|
<DataGridPaginationBar
|
|
page={contactPage}
|
|
pageSize={contactPageSize}
|
|
totalRows={contactTotal}
|
|
pageSizeOptions={[25, 50, 100, 200]}
|
|
disabled={loading || saving || !selectedBook}
|
|
className="address-contact-pagination"
|
|
ariaLabel="Contact pagination"
|
|
onPageChange={setContactPage}
|
|
onPageSizeChange={(pageSize) => {
|
|
setContactPageSize(pageSize);
|
|
setContactPage(1);
|
|
}} />
|
|
</section>
|
|
|
|
<section className="address-detail-panel" aria-label="Contact detail">
|
|
{renderContactDetail()}
|
|
</section>
|
|
</div>
|
|
</LoadingFrame>
|
|
|
|
<Dialog
|
|
open={Boolean(bookDialog)}
|
|
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
|
|
onClose={() => setBookDialog(null)}
|
|
closeDisabled={saving}
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
|
|
</>
|
|
}>
|
|
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
|
|
<div className="form-grid two">
|
|
<FormField label="Scope">
|
|
<select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}>
|
|
<option value="user">Personal</option>
|
|
<option value="group" disabled={auth.groups.length === 0}>Group</option>
|
|
<option value="tenant">Tenant</option>
|
|
{canAdminBooks && <option value="system">System</option>}
|
|
</select>
|
|
</FormField>
|
|
{bookForm.scope_type === "group" &&
|
|
<FormField label="Group">
|
|
<select value={bookForm.group_id} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, group_id: event.target.value }))}>
|
|
{auth.groups.length === 0 && <option value="">No groups available</option>}
|
|
{auth.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
|
</select>
|
|
</FormField>
|
|
}
|
|
</div>
|
|
<FormField label="Name"><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField>
|
|
<FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(listDialog)}
|
|
title={listDialog?.mode === "edit" ? "Edit address list" : "Add address list"}
|
|
onClose={() => setListDialog(null)}
|
|
closeDisabled={saving}
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
|
|
</>
|
|
}>
|
|
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
|
|
<p className="muted">Lists group contacts inside the selected address book.</p>
|
|
<FormField label="Address book">
|
|
<input value={selectedBook?.name ?? ""} disabled readOnly />
|
|
</FormField>
|
|
<FormField label="Name">
|
|
<input value={listForm.name} onChange={(event) => setListForm((current) => ({ ...current, name: event.target.value }))} autoFocus />
|
|
</FormField>
|
|
<FormField label="Description">
|
|
<textarea value={listForm.description} onChange={(event) => setListForm((current) => ({ ...current, description: event.target.value }))} rows={3} />
|
|
</FormField>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(contactDialog)}
|
|
title={contactDialog?.mode === "edit" ? "Edit contact" : "Add contact"}
|
|
onClose={() => setContactDialog(null)}
|
|
closeDisabled={saving}
|
|
className="address-contact-dialog"
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
|
|
</>
|
|
}>
|
|
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
|
|
<div className="form-grid three">
|
|
<FormField label="Display name"><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField>
|
|
<FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField>
|
|
<FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField>
|
|
</div>
|
|
<div className="form-grid two">
|
|
<FormField label="Organization"><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField>
|
|
<FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField>
|
|
</div>
|
|
<section className="address-form-section">
|
|
<div className="address-form-section-heading">
|
|
<strong>Email addresses</strong>
|
|
<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
|
|
</div>
|
|
<div className="address-form-list">
|
|
{contactForm.emails.map((email) => (
|
|
<div className="address-form-row address-form-row-email" key={email.rowId}>
|
|
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label>
|
|
<input value={email.label} placeholder="Label" onChange={(event) => updateEmailRow(email.rowId, { label: event.target.value })} />
|
|
<input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} />
|
|
<Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="address-form-section">
|
|
<div className="address-form-section-heading">
|
|
<strong>Phone numbers</strong>
|
|
<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
|
|
</div>
|
|
<div className="address-form-list">
|
|
{contactForm.phones.map((phone) => (
|
|
<div className="address-form-row address-form-row-phone" key={phone.rowId}>
|
|
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label>
|
|
<input value={phone.label} placeholder="Label" onChange={(event) => updatePhoneRow(phone.rowId, { label: event.target.value })} />
|
|
<input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} />
|
|
<Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="address-form-section">
|
|
<div className="address-form-section-heading">
|
|
<strong>Postal addresses</strong>
|
|
<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
|
|
</div>
|
|
<div className="address-form-list">
|
|
{contactForm.postal_addresses.map((address) => (
|
|
<div className="address-form-row address-form-row-postal" key={address.rowId}>
|
|
<label className="address-primary-choice"><input type="radio" name="address-primary-postal" checked={address.is_primary} onChange={() => setPrimaryPostalAddressRow(address.rowId)} /> Primary</label>
|
|
<input value={address.label} placeholder="Label" onChange={(event) => updatePostalAddressRow(address.rowId, { label: event.target.value })} />
|
|
<input value={address.street} placeholder="Street" onChange={(event) => updatePostalAddressRow(address.rowId, { street: event.target.value })} />
|
|
<input value={address.postal_code} placeholder="Postal code" onChange={(event) => updatePostalAddressRow(address.rowId, { postal_code: event.target.value })} />
|
|
<input value={address.locality} placeholder="Locality" onChange={(event) => updatePostalAddressRow(address.rowId, { locality: event.target.value })} />
|
|
<input value={address.region} placeholder="Region" onChange={(event) => updatePostalAddressRow(address.rowId, { region: event.target.value })} />
|
|
<input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} />
|
|
<Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<FormField label="Tags"><input value={contactForm.tags} placeholder="Comma-separated tags" onChange={(event) => setContactForm((current) => ({ ...current, tags: event.target.value }))} /></FormField>
|
|
<FormField label="Note"><textarea rows={3} value={contactForm.note} onChange={(event) => setContactForm((current) => ({ ...current, note: event.target.value }))} /></FormField>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={memberDialogOpen}
|
|
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
|
onClose={() => setMemberDialogOpen(false)}
|
|
closeDisabled={saving}
|
|
className="address-member-dialog"
|
|
footerClassName="button-row compact-actions"
|
|
footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
|
|
<div className="address-member-picker">
|
|
<div className="address-contact-toolbar address-member-search">
|
|
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
|
|
</div>
|
|
<div className="address-member-candidate-list" role="list">
|
|
{memberCandidateContacts.length === 0 ?
|
|
<div className="address-empty-note">No contacts available to add.</div> :
|
|
memberCandidateContacts.map((contact) => {
|
|
const targetOptions = availableTargetOptionsForContact(contact);
|
|
const selectedTarget = selectedTargetOptionForContact(contact);
|
|
return (
|
|
<div className="address-member-candidate" key={contact.id}>
|
|
<span className="address-contact-row-main">
|
|
<strong>{contact.display_name}</strong>
|
|
<small>{primaryEmail(contact) || contact.organization || "No primary email"}</small>
|
|
</span>
|
|
<select
|
|
value={selectedTarget?.key ?? ""}
|
|
aria-label={`Target for ${contact.display_name}`}
|
|
onChange={(event) => setMemberTarget(contact.id, event.target.value)}
|
|
disabled={saving || targetOptions.length === 0}>
|
|
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
|
|
</select>
|
|
<Button
|
|
type="button"
|
|
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
|
|
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
|
|
<Plus size={15} /> Add
|
|
</Button>
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
<p className="muted small-text">Tip: drag and drop adds the first available target. Use this dialog for a specific email or postal address.</p>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={importOpen}
|
|
title="Import vCard"
|
|
onClose={() => setImportOpen(false)}
|
|
closeDisabled={saving}
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>
|
|
</>
|
|
}>
|
|
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
|
<p className="muted">Paste one or more vCard entries into the selected address book.</p>
|
|
<FormField label="vCard content">
|
|
<textarea
|
|
className="address-vcard-textarea"
|
|
value={vcardContent}
|
|
onChange={(event) => setVcardContent(event.target.value)}
|
|
rows={14}
|
|
placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"}
|
|
/>
|
|
</FormField>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={cardDavOpen}
|
|
title="Connect CardDAV"
|
|
onClose={() => setCardDavOpen(false)}
|
|
closeDisabled={saving}
|
|
className="address-sync-dialog"
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
<Button type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</Button>
|
|
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
|
|
</>
|
|
}>
|
|
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
|
|
<FormField label="Address book">
|
|
<input value={selectedBook?.name ?? ""} disabled readOnly />
|
|
</FormField>
|
|
<FormField label="CardDAV URL">
|
|
<input value={cardDavForm.collection_url} onChange={(event) => setCardDavForm((current) => ({ ...current, collection_url: event.target.value }))} autoFocus />
|
|
</FormField>
|
|
<div className="form-grid three">
|
|
<FormField label="Authentication">
|
|
<select value={cardDavForm.auth_type} onChange={(event) => setCardDavForm((current) => ({ ...current, auth_type: event.target.value as CardDavFormState["auth_type"] }))}>
|
|
<option value="none">None</option>
|
|
<option value="basic">Basic</option>
|
|
<option value="bearer">Bearer token</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Sync direction">
|
|
<select value={cardDavForm.sync_direction} onChange={(event) => setCardDavForm((current) => ({ ...current, sync_direction: event.target.value as CardDavFormState["sync_direction"] }))}>
|
|
<option value="read_only">Read-only</option>
|
|
<option value="import">One-way import</option>
|
|
<option value="two_way">Two-way marker</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Display name">
|
|
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
|
|
</FormField>
|
|
</div>
|
|
{cardDavForm.auth_type !== "none" &&
|
|
<FormField label="Reusable credential">
|
|
<select
|
|
value={cardDavForm.credential_envelope_id}
|
|
onChange={(event) => {
|
|
const credentialId = event.target.value;
|
|
const credential = cardDavCredentials.find((item) => item.id === credentialId);
|
|
const username = credential?.public_data?.username;
|
|
setCardDavForm((current) => ({
|
|
...current,
|
|
credential_envelope_id: credentialId,
|
|
username: credentialId && username ? String(username) : "",
|
|
password: "",
|
|
bearer_token: ""
|
|
}));
|
|
}}>
|
|
<option value="">Enter source-specific credentials below</option>
|
|
{cardDavCredentials.map((credential) => (
|
|
<option key={credential.id} value={credential.id}>
|
|
{credential.name}{credential.public_data.username ? ` (${String(credential.public_data.username)})` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
}
|
|
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
|
|
{cardDavForm.auth_type === "basic" &&
|
|
<div className="form-grid two">
|
|
<FormField label="Username">
|
|
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
|
|
</FormField>
|
|
{!cardDavForm.credential_envelope_id &&
|
|
<FormField label="Password">
|
|
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
|
|
</FormField>
|
|
}
|
|
</div>
|
|
}
|
|
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
|
|
<FormField label="Bearer token">
|
|
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
|
|
</FormField>
|
|
}
|
|
{cardDavDiscovery.length > 0 &&
|
|
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
|
|
{cardDavDiscovery.map((item) => (
|
|
<SelectionListItem
|
|
selected={cardDavForm.collection_url === item.collection_url}
|
|
className="address-sync-result-row"
|
|
key={item.collection_url}
|
|
onClick={() => useDiscoveredCardDavBook(item)}>
|
|
<strong>{item.display_name || item.collection_url}</strong>
|
|
<small>{item.collection_url}</small>
|
|
</SelectionListItem>
|
|
))}
|
|
</SelectionList>
|
|
}
|
|
</form>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(syncInspector)}
|
|
title={syncInspector ? `Sync: ${syncSourceLabel(syncInspector.source)}` : "Sync"}
|
|
onClose={() => setSyncInspector(null)}
|
|
closeDisabled={saving}
|
|
className="address-sync-dialog"
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
|
|
<Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
|
|
<Button type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</Button>
|
|
</>
|
|
}>
|
|
{syncInspector &&
|
|
<div className="address-sync-inspector">
|
|
<div className="address-sync-source-card">
|
|
<div>
|
|
<strong>{syncSourceLabel(syncInspector.source)}</strong>
|
|
<p>{syncInspector.source.external_address_book_ref || "No external reference."}</p>
|
|
</div>
|
|
<div className="status-row">
|
|
<StatusBadge status={syncInspector.source.connector_type} />
|
|
<StatusBadge status={syncInspector.source.status} />
|
|
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
|
|
</div>
|
|
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at, ADDRESS_DATE_TIME_OPTIONS)} · Last success: {formatDateTime(syncInspector.source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}</p>
|
|
{syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
|
|
<div className="button-row compact-actions">
|
|
<Button
|
|
type="button"
|
|
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
|
|
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
|
{syncInspector.source.enabled ? "Disable" : "Enable"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="danger"
|
|
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
|
|
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
|
Disconnect
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="address-detail-section">
|
|
<h3>Preview</h3>
|
|
<p className="muted">{planSummary(syncPlan)}</p>
|
|
{syncPlan &&
|
|
<div className="address-sync-plan-grid">
|
|
{syncPlan.items.slice(0, 100).map((item, index) => (
|
|
<div className="address-sync-plan-row" key={`${item.href ?? item.message ?? "item"}-${index}`}>
|
|
<StatusBadge status={item.action} label={syncActionLabel(item.action)} />
|
|
<span>{item.display_name || item.href || item.message || "Sync item"}</span>
|
|
{item.message && <small>{item.message}</small>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
</section>
|
|
|
|
<section className="address-detail-section">
|
|
<h3>Conflicts</h3>
|
|
{syncConflicts.length === 0 ? <p className="muted">No open conflicts.</p> :
|
|
<div className="address-sync-record-list">
|
|
{syncConflicts.map((conflict) => (
|
|
<div className="address-sync-record-row" key={conflict.id}>
|
|
<span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
|
|
<Button type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
</section>
|
|
|
|
<section className="address-detail-section">
|
|
<h3>Diagnostics</h3>
|
|
{syncDiagnostics.length === 0 ? <p className="muted">No diagnostics recorded.</p> :
|
|
<div className="address-sync-record-list">
|
|
{syncDiagnostics.slice(0, 20).map((diagnostic) => (
|
|
<div className="address-sync-record-row" key={diagnostic.id}>
|
|
<StatusBadge status={diagnostic.severity} />
|
|
<span><strong>{diagnostic.code}</strong><small>{diagnostic.message}</small></span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
</section>
|
|
|
|
<section className="address-detail-section">
|
|
<h3>Tombstones</h3>
|
|
{syncTombstones.length === 0 ? <p className="muted">No tombstones recorded.</p> :
|
|
<div className="address-sync-record-list">
|
|
{syncTombstones.slice(0, 20).map((tombstone) => (
|
|
<div className="address-sync-record-row" key={tombstone.id}>
|
|
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at, ADDRESS_DATE_TIME_OPTIONS)}</small></span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
</section>
|
|
</div>
|
|
}
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(conflictDialog)}
|
|
title="Review sync conflict"
|
|
onClose={() => { setConflictDialog(null); setConflictMergeChoices({}); }}
|
|
className="address-sync-dialog"
|
|
footerClassName="button-row compact-actions"
|
|
footer={conflictDialog && <>
|
|
<Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
|
|
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
|
Ignore
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
|
|
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
|
Keep local
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void applyMergedConflict(conflictDialog)}
|
|
disabledReason={disabledReason(
|
|
[!canWriteSync, "You need permission to resolve sync conflicts."],
|
|
[!canMergeConflict(conflictDialog), "This conflict does not contain both local and remote field payloads."],
|
|
[saving, savingReason]
|
|
)}>
|
|
Apply merged
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
|
|
disabledReason={disabledReason(
|
|
[!canWriteSync, "You need permission to resolve sync conflicts."],
|
|
[!canApplyRemoteConflict(conflictDialog), "This conflict does not contain a stored remote payload that can be applied automatically."],
|
|
[saving, savingReason]
|
|
)}>
|
|
Use remote
|
|
</Button>
|
|
</>}>
|
|
{conflictDialog &&
|
|
<div className="address-conflict-review">
|
|
<div className="address-sync-source-card">
|
|
<div>
|
|
<strong>{conflictDialog.field_path}</strong>
|
|
<p>{conflictDialog.resource_href || conflictDialog.remote_uid || conflictDialog.contact_id || "No remote identifier."}</p>
|
|
</div>
|
|
<div className="status-row">
|
|
<StatusBadge status={conflictDialog.status} />
|
|
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
|
|
</div>
|
|
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
|
|
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
|
|
</div>
|
|
<div className="address-conflict-grid">
|
|
<div className="address-conflict-grid-header">Field</div>
|
|
<div className="address-conflict-grid-header">Local</div>
|
|
<div className="address-conflict-grid-header">Remote</div>
|
|
<div className="address-conflict-grid-header">Use</div>
|
|
{conflictFieldRows(conflictDialog).map((row) => (
|
|
<div className={`address-conflict-row ${row.differs ? "has-difference" : ""}`} key={row.field}>
|
|
<strong>{row.field}</strong>
|
|
<span>{row.local}</span>
|
|
<span>{row.remote}</span>
|
|
<div>
|
|
{canMergeConflict(conflictDialog) ?
|
|
<SegmentedControl<ConflictMergeChoice>
|
|
className="address-conflict-choice"
|
|
role="group"
|
|
size="equal"
|
|
ariaLabel={`Source for ${row.field}`}
|
|
options={[{ id: "local", label: "Local" }, { id: "remote", label: "Remote" }]}
|
|
value={conflictMergeChoices[row.field] ?? "local"}
|
|
onChange={(choice) => setConflictMergeChoice(row.field, choice)}
|
|
/> :
|
|
"—"
|
|
}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(confirmState)}
|
|
title={confirmState?.kind === "book" ? "Delete address book" : confirmState?.kind === "list" ? "Delete address list" : confirmState?.kind === "sync-source" ? "Disconnect sync source" : "Delete contact"}
|
|
message={
|
|
confirmState?.kind === "book" ? `Delete "${confirmState.book.name}" and hide its contacts?` :
|
|
confirmState?.kind === "list" ? `Delete "${confirmState.list.name}" and hide its entries?` :
|
|
confirmState?.kind === "sync-source" ? `Disconnect "${confirmState.source.display_name}" from this address book? Local contacts are kept; source diagnostics, tombstones, and conflicts are removed.` :
|
|
`Delete "${confirmState?.contact.display_name}"?`
|
|
}
|
|
confirmLabel={confirmState?.kind === "sync-source" ? "Disconnect" : "Delete"}
|
|
tone="danger"
|
|
busy={saving}
|
|
onCancel={() => setConfirmState(null)}
|
|
onConfirm={() => void confirmDelete()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|