feat: add selective vCard batch workflows

This commit is contained in:
2026-08-20 17:24:42 +02:00
parent 147f34c5c1
commit 42fe262376
14 changed files with 2116 additions and 158 deletions
+112 -1
View File
@@ -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) &&