|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
import { MetricGrid } from "@govoplan/core-webui";
|
|
|
|
|
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
|
|
|
|
import { useSearchParams } from "react-router";
|
|
|
|
|
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
|
|
|
|
ApiError,
|
|
|
|
|
ActionBlockerHint,
|
|
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
|
|
endContactChannelRule,
|
|
|
|
|
exportAddressBookVcards,
|
|
|
|
|
exportContactVcard,
|
|
|
|
|
getAddressImportRun,
|
|
|
|
|
importAddressBookVcards,
|
|
|
|
|
applyAddressImport,
|
|
|
|
|
getAddressQualitySummary,
|
|
|
|
@@ -71,6 +73,7 @@ import {
|
|
|
|
|
listContactProvenance,
|
|
|
|
|
previewAddressSyncSource,
|
|
|
|
|
previewAddressImport,
|
|
|
|
|
rollbackAddressImport,
|
|
|
|
|
mergeContacts,
|
|
|
|
|
recoverContactMerge,
|
|
|
|
|
restoreAddressBook,
|
|
|
|
@@ -114,6 +117,12 @@ import {
|
|
|
|
|
ADDRESSES_DOCUMENTATION,
|
|
|
|
|
ADDRESSES_I18N
|
|
|
|
|
} from "./interfacePatterns";
|
|
|
|
|
import {
|
|
|
|
|
importRunIdFromSearch,
|
|
|
|
|
importRunLifecycle,
|
|
|
|
|
unavailableImportRunMessage,
|
|
|
|
|
withImportRunSearch
|
|
|
|
|
} from "./importRunState";
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
settings: ApiSettings;
|
|
|
|
@@ -922,6 +931,8 @@ function formKey(value: unknown): string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
|
const requestedImportRunId = importRunIdFromSearch(searchParams);
|
|
|
|
|
const [books, setBooks] = useState<AddressBook[]>([]);
|
|
|
|
|
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
|
|
|
|
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
|
|
|
@@ -980,6 +991,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
|
|
|
|
|
const [importFile, setImportFile] = useState<File | null>(null);
|
|
|
|
|
const [importRun, setImportRun] = useState<AddressImportRun | null>(null);
|
|
|
|
|
const [importRunLoading, setImportRunLoading] = useState(false);
|
|
|
|
|
const [importRunUnavailable, setImportRunUnavailable] = useState("");
|
|
|
|
|
const [importRollbackOpen, setImportRollbackOpen] = useState(false);
|
|
|
|
|
const [importRollbackReason, setImportRollbackReason] = useState("");
|
|
|
|
|
const [cardDavOpen, setCardDavOpen] = useState(false);
|
|
|
|
|
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
|
|
|
|
|
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
|
|
|
|
@@ -1077,13 +1092,41 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
if (!active) return;
|
|
|
|
|
setImportProfiles(profiles);
|
|
|
|
|
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
|
|
|
|
|
setCreatingImportProfile(profiles.length === 0);
|
|
|
|
|
setCreatingImportProfile(profiles.length === 0 && !requestedImportRunId);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (active) setError(errorMessage(err));
|
|
|
|
|
});
|
|
|
|
|
return () => { active = false; };
|
|
|
|
|
}, [importMode, importOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
}, [importMode, importOpen, requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!requestedImportRunId) return;
|
|
|
|
|
let active = true;
|
|
|
|
|
setImportOpen(true);
|
|
|
|
|
setImportMode("tabular");
|
|
|
|
|
setCreatingImportProfile(false);
|
|
|
|
|
setImportRunLoading(true);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
getAddressImportRun(settings, requestedImportRunId)
|
|
|
|
|
.then((run) => {
|
|
|
|
|
if (!active) return;
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
setSelectedBookId(run.address_book_id);
|
|
|
|
|
setSelectedImportProfileId(run.profile_id);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (!active) return;
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable(
|
|
|
|
|
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (active) setImportRunLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => { active = false; };
|
|
|
|
|
}, [requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (auth.groups_loaded || !onAuthChange) return;
|
|
|
|
@@ -1095,6 +1138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}, [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 importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
|
|
|
|
|
const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
|
|
|
|
|
const selectedBookSyncSources = useMemo(
|
|
|
|
|
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
|
|
|
|
@@ -1261,7 +1305,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
[saving, savingReason],
|
|
|
|
|
[!importRun, "Preview the import first."],
|
|
|
|
|
[!importRun?.can_apply, "Resolve all import diagnostics before applying."],
|
|
|
|
|
[importRun?.status !== "previewed", "This import plan is no longer pending."]
|
|
|
|
|
[!importLifecycle?.canApply, "This import plan is no longer pending."]
|
|
|
|
|
);
|
|
|
|
|
const tabularRollbackReason = disabledReason(
|
|
|
|
|
[saving, savingReason],
|
|
|
|
|
[!importRun, "Load an applied import run before rolling it back."],
|
|
|
|
|
[!importLifecycle?.canRollback, "Only an applied import run can be rolled back."],
|
|
|
|
|
[importRollbackReason.trim().length < 3, "Record why this import is being rolled back."]
|
|
|
|
|
);
|
|
|
|
|
const connectLdapReason = disabledReason(
|
|
|
|
|
[!selectedBook, "Select an address book before connecting LDAP."],
|
|
|
|
@@ -2263,8 +2313,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openImportDialog() {
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
|
|
|
|
setImportMode("vcard");
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setImportFile(null);
|
|
|
|
|
setCreatingImportProfile(false);
|
|
|
|
|
setEditingImportProfileId("");
|
|
|
|
@@ -2272,6 +2324,46 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setImportOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeImportDialog() {
|
|
|
|
|
setImportOpen(false);
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setImportRollbackOpen(false);
|
|
|
|
|
setImportRollbackReason("");
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function retainImportRun(run: AddressImportRun) {
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, run.id), { replace: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearRetainedImportRun() {
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function reloadImportRun() {
|
|
|
|
|
if (!importRun?.id && !requestedImportRunId) return;
|
|
|
|
|
setImportRunLoading(true);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
try {
|
|
|
|
|
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setSelectedBookId(run.address_book_id);
|
|
|
|
|
setSelectedImportProfileId(run.profile_id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable(
|
|
|
|
|
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setImportRunLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveImportProfile() {
|
|
|
|
|
if (!selectedBook) return;
|
|
|
|
|
setSaving(true);
|
|
|
|
@@ -2315,7 +2407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setSelectedImportProfileId(profile.id);
|
|
|
|
|
setCreatingImportProfile(false);
|
|
|
|
|
setEditingImportProfileId("");
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
clearRetainedImportRun();
|
|
|
|
|
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorMessage(err));
|
|
|
|
@@ -2328,7 +2420,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setEditingImportProfileId("");
|
|
|
|
|
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
|
|
|
|
|
setCreatingImportProfile(true);
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
clearRetainedImportRun();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function editSelectedImportProfile() {
|
|
|
|
@@ -2352,7 +2444,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
field_mappings: { ...config.field_mappings }
|
|
|
|
|
});
|
|
|
|
|
setCreatingImportProfile(true);
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
clearRetainedImportRun();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelImportProfileEditor() {
|
|
|
|
@@ -2383,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
filename: importFile.name,
|
|
|
|
|
content_base64: content
|
|
|
|
|
});
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorMessage(err));
|
|
|
|
@@ -2399,7 +2491,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setNotice("");
|
|
|
|
|
try {
|
|
|
|
|
const run = await applyAddressImport(settings, importRun);
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
|
|
|
|
|
await refreshBooks();
|
|
|
|
|
await refreshContacts(selectedBook.id, query);
|
|
|
|
@@ -2410,6 +2502,26 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rollbackTabularImport() {
|
|
|
|
|
if (!importRun) return;
|
|
|
|
|
setSaving(true);
|
|
|
|
|
setError("");
|
|
|
|
|
setNotice("");
|
|
|
|
|
try {
|
|
|
|
|
const run = await rollbackAddressImport(settings, importRun, importRollbackReason.trim());
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setImportRollbackOpen(false);
|
|
|
|
|
setImportRollbackReason("");
|
|
|
|
|
setNotice("Rolled back the persisted import run.");
|
|
|
|
|
await refreshBooks();
|
|
|
|
|
await refreshContacts(run.address_book_id, query);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorMessage(err));
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCardDavDialog() {
|
|
|
|
|
setCardDavForm(EMPTY_CARDDAV_FORM);
|
|
|
|
|
setCardDavDiscovery([]);
|
|
|
|
@@ -3662,13 +3774,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Dialog
|
|
|
|
|
open={importOpen}
|
|
|
|
|
title="Import contacts"
|
|
|
|
|
onClose={() => setImportOpen(false)}
|
|
|
|
|
onClose={closeImportDialog}
|
|
|
|
|
closeDisabled={saving}
|
|
|
|
|
className="address-import-dialog"
|
|
|
|
|
footerClassName="button-row compact-actions"
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
|
|
|
|
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
|
|
|
<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>}
|
|
|
|
|
{importMode === "tabular" && creatingImportProfile &&
|
|
|
|
@@ -3677,6 +3789,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="button" onClick={() => void previewTabularImport()} disabledReason={tabularPreviewReason}><Search size={16} /> Preview</Button>}
|
|
|
|
|
{importMode === "tabular" && !creatingImportProfile && importRun &&
|
|
|
|
|
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
|
|
|
|
|
{importMode === "tabular" && importLifecycle?.canRollback &&
|
|
|
|
|
<Button type="button" variant="danger" onClick={() => setImportRollbackOpen(true)} disabledReason={savingReason}><RotateCcw size={16} /> Roll back</Button>}
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<DialogSection className="address-dialog-form">
|
|
|
|
@@ -3686,7 +3800,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
ariaLabel="Contact import format"
|
|
|
|
|
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
|
|
|
|
|
value={importMode}
|
|
|
|
|
onChange={(mode) => { setImportMode(mode); setImportRun(null); }}
|
|
|
|
|
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
|
|
|
|
|
/>
|
|
|
|
|
{importMode === "vcard" &&
|
|
|
|
|
<DialogForm id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
|
|
|
@@ -3703,12 +3817,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</DialogForm>}
|
|
|
|
|
{importMode === "tabular" &&
|
|
|
|
|
<div className="address-import-workspace">
|
|
|
|
|
{(requestedImportRunId || importRun) &&
|
|
|
|
|
<div className="address-import-run-state">
|
|
|
|
|
<div>
|
|
|
|
|
<strong>Persisted import run</strong>
|
|
|
|
|
<span className="muted block">{importRun?.id ?? requestedImportRunId}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{importRun && importLifecycle &&
|
|
|
|
|
<>
|
|
|
|
|
<StatusBadge status={importLifecycle.tone} label={importLifecycle.label} />
|
|
|
|
|
<p className="muted">{importLifecycle.guidance}</p>
|
|
|
|
|
</>}
|
|
|
|
|
<Button type="button" onClick={() => void reloadImportRun()} disabledReason={importRunLoading ? "The import run is loading." : undefined}>
|
|
|
|
|
<RefreshCw size={15} /> {importRunLoading ? "Loading…" : "Reload run"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>}
|
|
|
|
|
{importRunUnavailable &&
|
|
|
|
|
<DismissibleAlert tone="warning" resetKey={importRunUnavailable}>{importRunUnavailable}</DismissibleAlert>}
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Mapping profile">
|
|
|
|
|
<select
|
|
|
|
|
value={selectedImportProfileId}
|
|
|
|
|
disabled={creatingImportProfile}
|
|
|
|
|
onChange={(event) => { setSelectedImportProfileId(event.target.value); setImportRun(null); }}>
|
|
|
|
|
onChange={(event) => { setSelectedImportProfileId(event.target.value); clearRetainedImportRun(); }}>
|
|
|
|
|
<option value="">Select a saved mapping</option>
|
|
|
|
|
{importProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.source_format.toUpperCase()} · v{profile.version}</option>)}
|
|
|
|
|
</select>
|
|
|
|
@@ -3784,11 +3915,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<input
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }}
|
|
|
|
|
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
{importRun &&
|
|
|
|
|
<div className="address-import-preview">
|
|
|
|
|
<p className="muted small-text">
|
|
|
|
|
{importRun.source_filename} · plan {importRun.plan_hash.slice(0, 12)}… · updated {formatDateTime(importRun.updated_at)}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="address-sync-plan-grid">
|
|
|
|
|
{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
|
|
|
|
|
<div key={key}><strong>{importRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
|
|
|
|
@@ -3812,6 +3946,34 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</DialogSection>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|
open={importRollbackOpen}
|
|
|
|
|
title="Roll back contact import"
|
|
|
|
|
onClose={() => setImportRollbackOpen(false)}
|
|
|
|
|
closeDisabled={saving}
|
|
|
|
|
footerClassName="button-row compact-actions"
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
|
|
|
|
<Button type="button" onClick={() => setImportRollbackOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
|
|
|
<Button type="button" variant="danger" onClick={() => void rollbackTabularImport()} disabledReason={tabularRollbackReason}><RotateCcw size={16} /> Roll back import</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<DialogSection className="address-dialog-form">
|
|
|
|
|
<p className="muted">
|
|
|
|
|
Rollback is accepted only while every affected contact still matches the evidence recorded for plan {importRun?.plan_hash.slice(0, 12) ?? "-"}….
|
|
|
|
|
</p>
|
|
|
|
|
<FormField label="Reason">
|
|
|
|
|
<textarea
|
|
|
|
|
rows={3}
|
|
|
|
|
value={importRollbackReason}
|
|
|
|
|
onChange={(event) => setImportRollbackReason(event.target.value)}
|
|
|
|
|
placeholder="Why should this import be rolled back?"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
</DialogSection>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|
open={cardDavOpen}
|
|
|
|
|
title="Connect CardDAV"
|
|
|
|
|