feat: add selective vCard batch workflows
This commit is contained in:
+112
-1
@@ -354,6 +354,60 @@ export type VCardImportResult = {
|
||||
issues: Array<{ index: number; message: string; severity: "warning" | "error"; field?: string | null; line?: number | null }>;
|
||||
};
|
||||
|
||||
export type VCardBatchPlanItem = {
|
||||
source_key: string;
|
||||
source_filename: string;
|
||||
card_index: number;
|
||||
action: "create" | "update" | "ignore" | "unchanged" | "conflict";
|
||||
allowed_actions: Array<"create" | "update" | "ignore">;
|
||||
contact_id?: string | null;
|
||||
display_name?: string | null;
|
||||
changed_fields: string[];
|
||||
duplicate_suggestions: Array<{ contact_id: string; display_name: string; reasons: string[] }>;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export type VCardBatchRun = {
|
||||
id: string;
|
||||
address_book_id: string;
|
||||
status: string;
|
||||
input_hash: string;
|
||||
plan_hash: string;
|
||||
parser_version: string;
|
||||
execution_mode: "bounded_sync" | "persisted_batch";
|
||||
file_count: number;
|
||||
card_count: number;
|
||||
statistics: Record<string, number | string>;
|
||||
diagnostics: Array<{
|
||||
severity: "info" | "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
source_filename?: string | null;
|
||||
card_index?: number | null;
|
||||
field?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
}>;
|
||||
plan: VCardBatchPlanItem[];
|
||||
progress: { total: number; completed: number; created: number; updated: number; ignored: number; failed: number };
|
||||
can_apply: boolean;
|
||||
can_cancel: boolean;
|
||||
commit_hash?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
applied_at?: string | null;
|
||||
};
|
||||
|
||||
export type VCardExportResult = {
|
||||
filename: string;
|
||||
media_type: string;
|
||||
scope: "address_book" | "address_list" | "contacts";
|
||||
version: "3.0" | "4.0";
|
||||
ordering: string;
|
||||
contact_count: number;
|
||||
content_hash: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AddressSyncSource = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
@@ -538,7 +592,7 @@ export type AddressImportDiagnostic = {
|
||||
export type AddressImportRun = {
|
||||
id: string;
|
||||
address_book_id: string;
|
||||
profile_id: string;
|
||||
profile_id: string | null;
|
||||
source_filename: string;
|
||||
source_format: string;
|
||||
input_hash: string;
|
||||
@@ -1057,6 +1111,47 @@ export function importAddressBookVcards(settings: ApiSettings, addressBookId: st
|
||||
});
|
||||
}
|
||||
|
||||
export function previewVCardBatch(
|
||||
settings: ApiSettings,
|
||||
addressBookId: string,
|
||||
payload: {
|
||||
files: Array<{ filename: string; content_base64: string }>;
|
||||
duplicate_card_policy?: "reject" | "first" | "last";
|
||||
existing_contact_policy?: "update" | "ignore" | "reject";
|
||||
}
|
||||
): Promise<VCardBatchRun> {
|
||||
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcard-batches/preview`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getVCardBatch(settings: ApiSettings, runId: string): Promise<VCardBatchRun> {
|
||||
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
export function applyVCardBatch(
|
||||
settings: ApiSettings,
|
||||
run: VCardBatchRun,
|
||||
selections: Array<{ source_key: string; action: "create" | "update" | "ignore" }>
|
||||
): Promise<VCardBatchRun> {
|
||||
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(run.id)}/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_plan_hash: run.plan_hash, selections })
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelVCardBatch(settings: ApiSettings, run: VCardBatchRun, reason: string): Promise<VCardBatchRun> {
|
||||
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(run.id)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason })
|
||||
});
|
||||
}
|
||||
|
||||
export function exportVCardBatchDiagnostics(settings: ApiSettings, runId: string): Promise<string> {
|
||||
return apiFetch<string>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(runId)}/diagnostics`);
|
||||
}
|
||||
|
||||
export async function listAddressImportProfiles(settings: ApiSettings): Promise<AddressImportProfile[]> {
|
||||
const response = await apiFetch<AddressImportProfileListResponse>(settings, "/api/v1/addresses/import-profiles");
|
||||
return response.profiles;
|
||||
@@ -1123,6 +1218,22 @@ export function exportAddressBookVcards(settings: ApiSettings, addressBookId: st
|
||||
return apiFetch<string>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`);
|
||||
}
|
||||
|
||||
export function exportScopedVcards(
|
||||
settings: ApiSettings,
|
||||
addressBookId: string,
|
||||
payload: {
|
||||
scope: "address_book" | "address_list" | "contacts";
|
||||
address_list_id?: string | null;
|
||||
contact_ids?: string[];
|
||||
version?: "3.0" | "4.0";
|
||||
}
|
||||
): Promise<VCardExportResult> {
|
||||
return apiFetch<VCardExportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function exportContactVcard(settings: ApiSettings, contactId: string): Promise<string> {
|
||||
return apiFetch<string>(settings, `/api/v1/addresses/contacts/${contactId}/vcard`);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ import {
|
||||
createContact,
|
||||
createContactChannelRule,
|
||||
createContactQualityDecision,
|
||||
applyAddressImport,
|
||||
applyVCardBatch,
|
||||
cancelVCardBatch,
|
||||
deleteAddressBook,
|
||||
deleteAddressList,
|
||||
deleteAddressListEntry,
|
||||
@@ -50,11 +53,9 @@ import {
|
||||
discoverCardDavAddressBooks,
|
||||
discoverLdapBaseDns,
|
||||
endContactChannelRule,
|
||||
exportAddressBookVcards,
|
||||
exportContactVcard,
|
||||
exportScopedVcards,
|
||||
getAddressImportRun,
|
||||
importAddressBookVcards,
|
||||
applyAddressImport,
|
||||
getVCardBatch,
|
||||
getAddressQualitySummary,
|
||||
listAddressBooks,
|
||||
listAddressImportProfiles,
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
listContactProvenance,
|
||||
previewAddressSyncSource,
|
||||
previewAddressImport,
|
||||
previewVCardBatch,
|
||||
rollbackAddressImport,
|
||||
mergeContacts,
|
||||
recoverContactMerge,
|
||||
@@ -109,7 +111,8 @@ import {
|
||||
type ContactFieldProvenance,
|
||||
type ContactMergeRecord,
|
||||
type ContactPointQualityDecision,
|
||||
type ContactPointQualityState
|
||||
type ContactPointQualityState,
|
||||
type VCardBatchRun
|
||||
} from "../../api/addresses";
|
||||
import {
|
||||
ADDRESS_FIELDS_DOCUMENTATION,
|
||||
@@ -905,10 +908,6 @@ function contactFormHasIdentity(form: ContactFormState): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1003,7 +1002,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const [dropTargetListId, setDropTargetListId] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("vcard");
|
||||
const [vcardContent, setVcardContent] = useState("");
|
||||
const [vcardFiles, setVcardFiles] = useState<File[]>([]);
|
||||
const [vcardRun, setVcardRun] = useState<VCardBatchRun | null>(null);
|
||||
const [vcardSelections, setVcardSelections] = useState<Record<string, "create" | "update" | "ignore">>({});
|
||||
const [vcardDuplicatePolicy, setVcardDuplicatePolicy] = useState<"reject" | "first" | "last">("reject");
|
||||
const [vcardExistingPolicy, setVcardExistingPolicy] = useState<"update" | "ignore" | "reject">("update");
|
||||
const [vcardExportVersion, setVcardExportVersion] = useState<"3.0" | "4.0">("4.0");
|
||||
const [importProfiles, setImportProfiles] = useState<AddressImportProfile[]>([]);
|
||||
const [selectedImportProfileId, setSelectedImportProfileId] = useState("");
|
||||
const [creatingImportProfile, setCreatingImportProfile] = useState(false);
|
||||
@@ -1305,10 +1309,16 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
]
|
||||
);
|
||||
const dialogCancelReason = disabledReason([saving, savingReason]);
|
||||
const vcardImportReason = disabledReason(
|
||||
const vcardPreviewReason = disabledReason(
|
||||
[saving, savingReason],
|
||||
[!selectedBook, "Select an address book before importing vCards."],
|
||||
[!vcardContent.trim(), "Paste vCard content before importing."]
|
||||
[vcardFiles.length === 0, "Select one or more .vcf files before previewing."]
|
||||
);
|
||||
const vcardApplyReason = disabledReason(
|
||||
[saving, savingReason],
|
||||
[!vcardRun, "Preview the vCard files before applying."],
|
||||
[!vcardRun?.can_apply, "This vCard batch is no longer pending."],
|
||||
[Object.keys(vcardSelections).length === 0, "Select an action for at least one card."]
|
||||
);
|
||||
const importProfileSaveReason = disabledReason(
|
||||
[saving, savingReason],
|
||||
@@ -2288,8 +2298,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const content = await exportAddressBookVcards(settings, selectedBook.id);
|
||||
downloadText(safeFilename(selectedBook.name), content);
|
||||
const result = await exportScopedVcards(settings, selectedBook.id, selectedList
|
||||
? { scope: "address_list", address_list_id: selectedList.id, version: vcardExportVersion }
|
||||
: { scope: "address_book", version: vcardExportVersion });
|
||||
downloadText(result.filename, result.content);
|
||||
setNotice(`Exported ${result.contact_count} contact${result.contact_count === 1 ? "" : "s"} as vCard ${result.version}.`);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -2302,8 +2315,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const content = await exportContactVcard(settings, contact.id);
|
||||
downloadText(safeFilename(contact.display_name), content);
|
||||
const result = await exportScopedVcards(settings, contact.address_book_id, {
|
||||
scope: "contacts",
|
||||
contact_ids: [contact.id],
|
||||
version: vcardExportVersion
|
||||
});
|
||||
downloadText(result.filename, result.content);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -2311,18 +2328,58 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVcardImport(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function previewSelectedVcards() {
|
||||
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"})` : ""}.`);
|
||||
const files = await Promise.all(vcardFiles.map(async (file) => ({
|
||||
filename: file.name,
|
||||
content_base64: await fileAsBase64(file)
|
||||
})));
|
||||
const run = await previewVCardBatch(settings, selectedBook.id, {
|
||||
files,
|
||||
duplicate_card_policy: vcardDuplicatePolicy,
|
||||
existing_contact_policy: vcardExistingPolicy
|
||||
});
|
||||
setVcardRun(run);
|
||||
setVcardSelections(Object.fromEntries(run.plan.map((item) => {
|
||||
const action = item.allowed_actions.includes(item.action as "create" | "update" | "ignore")
|
||||
? item.action as "create" | "update" | "ignore"
|
||||
: "ignore";
|
||||
return [item.source_key, action];
|
||||
})));
|
||||
setNotice(`Previewed ${run.card_count} vCard${run.card_count === 1 ? "" : "s"} without changing contacts.`);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadVcardRun() {
|
||||
if (!vcardRun) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
setVcardRun(await getVCardBatch(settings, vcardRun.id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySelectedVcards() {
|
||||
if (!selectedBook || !vcardRun) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const run = await applyVCardBatch(settings, vcardRun, Object.entries(vcardSelections).map(([source_key, action]) => ({ source_key, action })));
|
||||
setVcardRun(run);
|
||||
setNotice(`Applied vCard batch: ${run.progress.created} created, ${run.progress.updated} updated, ${run.progress.ignored} ignored.`);
|
||||
await refreshBooks();
|
||||
await refreshContacts(selectedBook.id, query);
|
||||
} catch (err) {
|
||||
@@ -2332,9 +2389,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSelectedVcardBatch() {
|
||||
if (!vcardRun) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const run = await cancelVCardBatch(settings, vcardRun, "Cancelled by operator before commit.");
|
||||
setVcardRun(run);
|
||||
setNotice("Cancelled the pending vCard batch without changing contacts.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openImportDialog() {
|
||||
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
||||
setImportMode("vcard");
|
||||
setVcardFiles([]);
|
||||
setVcardRun(null);
|
||||
setVcardSelections({});
|
||||
setImportRun(null);
|
||||
setImportRunUnavailable("");
|
||||
setImportFile(null);
|
||||
@@ -2346,6 +2421,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
|
||||
function closeImportDialog() {
|
||||
setImportOpen(false);
|
||||
setVcardFiles([]);
|
||||
setVcardRun(null);
|
||||
setVcardSelections({});
|
||||
setImportRun(null);
|
||||
setImportRunUnavailable("");
|
||||
setImportRollbackOpen(false);
|
||||
@@ -2882,7 +2960,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<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 contacts" aria-label="Import contacts" onClick={openImportDialog} 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>
|
||||
<select aria-label="vCard export version" value={vcardExportVersion} onChange={(event) => setVcardExportVersion(event.target.value as "3.0" | "4.0")}>
|
||||
<option value="4.0">vCard 4.0</option>
|
||||
<option value="3.0">vCard 3.0</option>
|
||||
</select>
|
||||
<Button type="button" title={`Export selected ${selectedList ? "address list" : "address book"} as vCard ${vcardExportVersion}`} aria-label={`Export selected ${selectedList ? "address list" : "address book"} as vCard ${vcardExportVersion}`} 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="Connect LDAP or Active Directory" aria-label="Connect LDAP or Active Directory" onClick={openLdapDialog} disabledReason={connectLdapReason}><Network 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>
|
||||
@@ -3804,7 +3886,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<>
|
||||
<Button type="button" onClick={closeImportDialog} disabledReason={dialogCancelReason}>Close</Button>
|
||||
{importMode === "vcard" &&
|
||||
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
|
||||
<Button type="button" onClick={() => void previewSelectedVcards()} disabledReason={vcardPreviewReason}><Search size={16} /> Preview</Button>}
|
||||
{importMode === "vcard" && vcardRun?.can_apply &&
|
||||
<Button type="button" variant="primary" onClick={() => void applySelectedVcards()} disabledReason={vcardApplyReason}><Upload size={16} /> Apply selected</Button>}
|
||||
{importMode === "vcard" && vcardRun?.can_cancel &&
|
||||
<Button type="button" variant="danger" onClick={() => void cancelSelectedVcardBatch()} disabledReason={savingReason}><X size={16} /> Cancel batch</Button>}
|
||||
{importMode === "tabular" && creatingImportProfile &&
|
||||
<Button type="button" variant="primary" onClick={() => void saveImportProfile()} disabledReason={importProfileSaveReason}><Save size={16} /> {editingImportProfileId ? "Save new version" : "Save mapping"}</Button>}
|
||||
{importMode === "tabular" && !creatingImportProfile &&
|
||||
@@ -3825,18 +3911,74 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
|
||||
/>
|
||||
{importMode === "vcard" &&
|
||||
<DialogForm 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"}
|
||||
<div className="address-import-workspace">
|
||||
<p className="muted">Select one or more .vcf files. Preview parses and validates them without changing contacts; only the reviewed actions are committed.</p>
|
||||
<FormField label="vCard files">
|
||||
<input
|
||||
type="file"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
setVcardFiles(Array.from(event.target.files ?? []));
|
||||
setVcardRun(null);
|
||||
setVcardSelections({});
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</DialogForm>}
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Duplicate cards in upload">
|
||||
<select value={vcardDuplicatePolicy} onChange={(event) => { setVcardDuplicatePolicy(event.target.value as typeof vcardDuplicatePolicy); setVcardRun(null); }}>
|
||||
<option value="reject">Require manual rejection</option>
|
||||
<option value="first">Use first occurrence</option>
|
||||
<option value="last">Use last occurrence</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Existing contacts">
|
||||
<select value={vcardExistingPolicy} onChange={(event) => { setVcardExistingPolicy(event.target.value as typeof vcardExistingPolicy); setVcardRun(null); }}>
|
||||
<option value="update">Propose update</option>
|
||||
<option value="ignore">Propose ignore</option>
|
||||
<option value="reject">Require rejection</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
{vcardRun &&
|
||||
<div className="address-import-preview">
|
||||
<div className="address-import-run-state">
|
||||
<div>
|
||||
<strong>Persisted vCard batch</strong>
|
||||
<span className="muted block">{vcardRun.id} · {vcardRun.parser_version} · {vcardRun.execution_mode.replace("_", " ")}</span>
|
||||
</div>
|
||||
<StatusBadge status={vcardRun.status} />
|
||||
<Button type="button" onClick={() => void reloadVcardRun()} disabledReason={savingReason}><RefreshCw size={15} /> Reload run</Button>
|
||||
</div>
|
||||
<div className="address-sync-plan-grid">
|
||||
{(["create", "update", "ignore", "unchanged", "conflict", "errors"] as const).map((key) =>
|
||||
<div key={key}><strong>{vcardRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
|
||||
</div>
|
||||
{vcardRun.diagnostics.map((diagnostic, index) =>
|
||||
<DismissibleAlert key={`${diagnostic.code}-${diagnostic.source_filename ?? index}-${diagnostic.card_index ?? index}`} tone={diagnostic.severity === "error" ? "danger" : diagnostic.severity === "warning" ? "warning" : "info"}>
|
||||
{diagnostic.source_filename ? `${diagnostic.source_filename}${diagnostic.card_index ? ` card ${diagnostic.card_index}` : ""}: ` : ""}{diagnostic.message}
|
||||
</DismissibleAlert>)}
|
||||
<div className="address-sync-result-list">
|
||||
{vcardRun.plan.map((item) =>
|
||||
<div className="address-sync-plan-row" key={item.source_key}>
|
||||
<StatusBadge status={item.action} />
|
||||
<span>
|
||||
<strong>{item.display_name || `Card ${item.card_index}`} · {item.source_filename}</strong>
|
||||
<small>{item.changed_fields.join(", ") || item.message || "No field changes"}</small>
|
||||
{item.duplicate_suggestions.length > 0 && <small>Possible match: {item.duplicate_suggestions.map((candidate) => candidate.display_name).join(", ")}</small>}
|
||||
</span>
|
||||
<select
|
||||
aria-label={`Import action for ${item.display_name || `card ${item.card_index}`}`}
|
||||
value={vcardSelections[item.source_key] ?? "ignore"}
|
||||
disabled={!vcardRun.can_apply || item.allowed_actions.length < 2}
|
||||
onChange={(event) => setVcardSelections((current) => ({ ...current, [item.source_key]: event.target.value as "create" | "update" | "ignore" }))}>
|
||||
{item.allowed_actions.map((action) => <option value={action} key={action}>{action}</option>)}
|
||||
</select>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>}
|
||||
{importMode === "tabular" &&
|
||||
<div className="address-import-workspace">
|
||||
{(requestedImportRunId || importRun) &&
|
||||
|
||||
Reference in New Issue
Block a user