Release v0.1.4
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createRecipientImportMappingProfile,
|
||||
listRecipientImportMappingProfiles,
|
||||
updateRecipientImportMappingProfile,
|
||||
type RecipientImportMappingProfilePayload
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FileDropZone } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
@@ -20,20 +27,33 @@ import { getBool } from "./utils/draftEditor";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
|
||||
import {
|
||||
applyRecipientMappingProfile,
|
||||
buildImportTable,
|
||||
buildImportTableFromRows,
|
||||
buildRecipientImportPreview,
|
||||
createRecipientImportProvenance,
|
||||
createRecipientMappingProfile as buildRecipientMappingProfile,
|
||||
defaultColumnMappings,
|
||||
importedRowsNeedAttachmentSource,
|
||||
matchRecipientMappingProfiles,
|
||||
materializeRecipientImport,
|
||||
recipientImportHeaderFingerprints,
|
||||
xlsxSheetsFromArrayBuffer,
|
||||
type CsvDelimiter,
|
||||
type ImportedAddress,
|
||||
type RecipientColumnKind,
|
||||
type RecipientColumnMapping,
|
||||
type RecipientImportMode,
|
||||
type RecipientImportPreview
|
||||
type RecipientImportPreview,
|
||||
type RecipientImportProvenance,
|
||||
type RecipientImportSourceType,
|
||||
type RecipientImportSpreadsheetSheet,
|
||||
type RecipientMappingProfile,
|
||||
type RecipientMappingProfileMatch
|
||||
} from "./utils/bulkImport";
|
||||
import {
|
||||
bulkLinkFilesToCampaign,
|
||||
type ImportFileLinkCapability,
|
||||
renderedImportPatterns,
|
||||
resolveImportedAttachmentLinks,
|
||||
type ImportFileLinkResolution
|
||||
@@ -178,7 +198,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
|
||||
}
|
||||
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
||||
if (locked || !draft) return;
|
||||
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||
const currentAttachments = asRecord(draft.attachments);
|
||||
@@ -194,7 +214,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
||||
}
|
||||
|
||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
|
||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
|
||||
const nextDraft = needsAttachmentSource
|
||||
? {
|
||||
...importedDraft,
|
||||
@@ -359,12 +379,12 @@ type RecipientImportDialogProps = {
|
||||
existingFields: ReturnType<typeof getDraftFields>;
|
||||
defaultAttachmentBasePath: AttachmentBasePath | null;
|
||||
onCancel: () => void;
|
||||
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode) => void;
|
||||
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
|
||||
};
|
||||
|
||||
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
|
||||
|
||||
const recipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
|
||||
const allRecipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
|
||||
{ id: "upload", label: "Upload" },
|
||||
{ id: "parse", label: "Parse" },
|
||||
{ id: "map", label: "Map" },
|
||||
@@ -387,28 +407,74 @@ const recipientColumnKindOptions: Array<{ value: RecipientColumnKind; label: str
|
||||
{ value: "attachment_pattern", label: "Attachment pattern" }
|
||||
];
|
||||
|
||||
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
|
||||
|
||||
const RECIPIENT_IMPORT_ENCODINGS = [
|
||||
{ value: "utf-8", label: "UTF-8" },
|
||||
{ value: "windows-1252", label: "Windows-1252" },
|
||||
{ value: "iso-8859-1", label: "ISO-8859-1" },
|
||||
{ value: "utf-16le", label: "UTF-16 LE" },
|
||||
{ value: "utf-16be", label: "UTF-16 BE" }
|
||||
];
|
||||
|
||||
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
|
||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
|
||||
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
|
||||
return {
|
||||
listFiles: filesFileExplorer.listFiles,
|
||||
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
|
||||
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
|
||||
};
|
||||
}, [filesFileExplorer]);
|
||||
const recipientImportSteps = useMemo(
|
||||
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
|
||||
[fileLinkCapability]
|
||||
);
|
||||
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
|
||||
const [filename, setFilename] = useState("");
|
||||
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
|
||||
const [encoding, setEncoding] = useState("utf-8");
|
||||
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
|
||||
const [selectedSheetName, setSelectedSheetName] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
|
||||
const [headerRows, setHeaderRows] = useState(1);
|
||||
const [quoted, setQuoted] = useState(true);
|
||||
const [valueSeparators, setValueSeparators] = useState(",;|");
|
||||
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
|
||||
const [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
|
||||
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
|
||||
const [mappingProfileError, setMappingProfileError] = useState("");
|
||||
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
|
||||
const [fileError, setFileError] = useState("");
|
||||
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
|
||||
const [fileLinkResolving, setFileLinkResolving] = useState(false);
|
||||
const [fileLinking, setFileLinking] = useState(false);
|
||||
const [fileLinkError, setFileLinkError] = useState("");
|
||||
const [fileLinkNotice, setFileLinkNotice] = useState("");
|
||||
const hasContent = csvText.trim().length > 0;
|
||||
const selectedSheet = useMemo(
|
||||
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
|
||||
[selectedSheetName, workbookSheets]
|
||||
);
|
||||
const hasContent = sourceType === "xlsx"
|
||||
? Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim())))
|
||||
: csvText.trim().length > 0;
|
||||
const table = useMemo(
|
||||
() => hasContent ? buildImportTable(csvText, { delimiter, headerRows, quoted }) : null,
|
||||
[csvText, delimiter, hasContent, headerRows, quoted]
|
||||
() => {
|
||||
if (!hasContent) return null;
|
||||
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
|
||||
return buildImportTable(csvText, { delimiter, headerRows, quoted });
|
||||
},
|
||||
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
|
||||
);
|
||||
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
|
||||
const mappingProfileMatches = useMemo(
|
||||
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
|
||||
[mappingProfiles, table]
|
||||
);
|
||||
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
|
||||
const preview = useMemo(
|
||||
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
|
||||
@@ -417,33 +483,93 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
|
||||
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
|
||||
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
|
||||
const isLastStep = activeStepIndex === recipientImportSteps.length - 1;
|
||||
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
|
||||
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
|
||||
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setMappingProfileError("");
|
||||
void listRecipientImportMappingProfiles(settings)
|
||||
.then((profiles) => {
|
||||
if (!cancelled) setMappingProfiles(profiles);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileBuffer || sourceType !== "csv") return;
|
||||
try {
|
||||
setCsvText(decodeImportText(fileBuffer, encoding));
|
||||
setFileError("");
|
||||
} catch (err) {
|
||||
setCsvText("");
|
||||
setFileError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [encoding, fileBuffer, sourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
setMappingManuallyChanged(false);
|
||||
}, [tableHeaderKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!table) {
|
||||
setMappings([]);
|
||||
setMappingProfileNotice("");
|
||||
return;
|
||||
}
|
||||
if (mappingManuallyChanged) return;
|
||||
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
|
||||
if (automaticMatch) {
|
||||
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
|
||||
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
|
||||
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
|
||||
return;
|
||||
}
|
||||
setMappings(defaultColumnMappings(table.headers, existingFields));
|
||||
}, [existingFields, tableHeaderKey]);
|
||||
setMappingProfileNotice("");
|
||||
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasContent) setActiveStep("upload");
|
||||
}, [hasContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep === "files") void refreshFileLinks();
|
||||
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
|
||||
}, [activeStep, fileLinkCapability]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, renderedPatterns.length]);
|
||||
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
|
||||
|
||||
async function readFile(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setFilename(file.name);
|
||||
setFileError("");
|
||||
setWorkbookSheets([]);
|
||||
setSelectedSheetName("");
|
||||
try {
|
||||
setCsvText(await file.text());
|
||||
const buffer = await file.arrayBuffer();
|
||||
if (isXlsxFile(file)) {
|
||||
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
|
||||
if (sheets.length === 0) {
|
||||
throw new Error("Workbook contains no readable sheets.");
|
||||
}
|
||||
setSourceType("xlsx");
|
||||
setFileBuffer(null);
|
||||
setCsvText("");
|
||||
setWorkbookSheets(sheets);
|
||||
setSelectedSheetName(sheets[0]?.name ?? "");
|
||||
} else {
|
||||
setSourceType("csv");
|
||||
setFileBuffer(buffer);
|
||||
setCsvText(decodeImportText(buffer, encoding));
|
||||
}
|
||||
setActiveStep("parse");
|
||||
} catch (err) {
|
||||
setFileError(err instanceof Error ? err.message : String(err));
|
||||
@@ -454,6 +580,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
if (stepId === "upload") return true;
|
||||
if (stepId === "parse") return hasContent;
|
||||
if (stepId === "map") return parseReady;
|
||||
if (stepId === "files" && !fileLinkCapability) return false;
|
||||
return parseReady && mappings.length > 0;
|
||||
}
|
||||
|
||||
@@ -475,6 +602,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
|
||||
async function refreshFileLinks() {
|
||||
setFileLinkNotice("");
|
||||
if (!fileLinkCapability) {
|
||||
setFileLinkResolution(null);
|
||||
setFileLinkError("");
|
||||
return;
|
||||
}
|
||||
if (!preview || preview.patternCount === 0) {
|
||||
setFileLinkResolution(null);
|
||||
setFileLinkError("");
|
||||
@@ -488,7 +620,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
setFileLinkResolving(true);
|
||||
setFileLinkError("");
|
||||
try {
|
||||
setFileLinkResolution(await resolveImportedAttachmentLinks(settings, campaignId, defaultAttachmentBasePath, preview));
|
||||
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
|
||||
} catch (err) {
|
||||
setFileLinkResolution(null);
|
||||
setFileLinkError(err instanceof Error ? err.message : String(err));
|
||||
@@ -498,12 +630,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
}
|
||||
|
||||
async function linkImportedFiles() {
|
||||
if (!fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
|
||||
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
|
||||
setFileLinking(true);
|
||||
setFileLinkError("");
|
||||
setFileLinkNotice("");
|
||||
try {
|
||||
const linkedCount = await bulkLinkFilesToCampaign(settings, campaignId, fileLinkResolution.linkableFiles);
|
||||
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
|
||||
await refreshFileLinks();
|
||||
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
|
||||
} catch (err) {
|
||||
@@ -513,9 +645,60 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCurrentMappingProfile() {
|
||||
if (!table || mappings.length === 0) return;
|
||||
const tableSnapshot = table;
|
||||
const mappingsSnapshot = mappings;
|
||||
const fallbackProfiles = mappingProfiles;
|
||||
const filenameSnapshot = filename;
|
||||
const parseOptions = { headerRows, quoted };
|
||||
const valueSeparatorsSnapshot = valueSeparators;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
|
||||
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
|
||||
const draftProfile = buildRecipientMappingProfile({
|
||||
id: reusableProfile?.id,
|
||||
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
|
||||
table: tableSnapshot,
|
||||
mappings: mappingsSnapshot,
|
||||
parseOptions,
|
||||
valueSeparators: valueSeparatorsSnapshot,
|
||||
createdAt: reusableProfile?.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
const payload = mappingProfilePayload(draftProfile);
|
||||
await (reusableProfile
|
||||
? updateRecipientImportMappingProfile(settings, reusableProfile.id, payload)
|
||||
: createRecipientImportMappingProfile(settings, payload));
|
||||
} catch {
|
||||
// Import acceptance must not be blocked by passive profile learning.
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function confirmRecipientImport(nextPreview: RecipientImportPreview) {
|
||||
rememberCurrentMappingProfile();
|
||||
const provenance = createRecipientImportProvenance({
|
||||
preview: nextPreview,
|
||||
mappings,
|
||||
mode,
|
||||
sourceType,
|
||||
filename,
|
||||
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
|
||||
encoding: sourceType === "xlsx" ? null : encoding,
|
||||
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
|
||||
headerRows,
|
||||
quoted: sourceType === "xlsx" ? null : quoted,
|
||||
valueSeparators
|
||||
});
|
||||
onImport(nextPreview, mode, provenance);
|
||||
}
|
||||
|
||||
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
|
||||
const columnCount = table?.headers.length ?? 0;
|
||||
setMappingManuallyChanged(true);
|
||||
setMappings((current) => {
|
||||
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
|
||||
byColumn.set(nextMapping.columnIndex, nextMapping);
|
||||
@@ -537,11 +720,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<FormField label="Recipient file">
|
||||
<FileDropZone
|
||||
accept=".csv,.tsv,text/csv,text/tab-separated-values,text/plain"
|
||||
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
multiple={false}
|
||||
label="Drop recipient file here"
|
||||
actionLabel="or click to select CSV"
|
||||
note={filename || "CSV, TSV or text"}
|
||||
actionLabel="or click to select file"
|
||||
note={filename || "CSV, TSV, text or XLSX"}
|
||||
onFiles={(files) => readFile(files[0])}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -553,21 +736,38 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
</FormField>
|
||||
</div>
|
||||
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
|
||||
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
|
||||
<textarea
|
||||
className="json-editor recipient-import-textarea"
|
||||
value={csvText}
|
||||
onChange={(event) => {
|
||||
setCsvText(event.target.value);
|
||||
if (!event.target.value.trim()) setFilename("");
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
{sourceType === "xlsx" ? (
|
||||
<DismissibleAlert tone="info" compact dismissible={false}>
|
||||
Workbook loaded: {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
|
||||
</DismissibleAlert>
|
||||
) : (
|
||||
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
|
||||
<textarea
|
||||
className="json-editor recipient-import-textarea"
|
||||
value={csvText}
|
||||
onChange={(event) => {
|
||||
setFileBuffer(null);
|
||||
setSourceType(filename ? "csv" : "text");
|
||||
setCsvText(event.target.value);
|
||||
if (!event.target.value.trim()) setFilename("");
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
) : activeStep === "parse" ? (
|
||||
<>
|
||||
<div className="campaign-header-grid">
|
||||
{sourceType === "xlsx" && (
|
||||
<FormField label="Sheet">
|
||||
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
|
||||
{workbookSheets.map((sheet) => (
|
||||
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Header rows">
|
||||
<input
|
||||
type="number"
|
||||
@@ -577,22 +777,33 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Separator">
|
||||
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
|
||||
<option value="auto">Auto</option>
|
||||
<option value=",">Comma</option>
|
||||
<option value=";">Semicolon</option>
|
||||
<option value={"\t"}>Tab</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle recipient-import-toggles">
|
||||
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
|
||||
</div>
|
||||
{sourceType !== "xlsx" && (
|
||||
<>
|
||||
<FormField label="Encoding">
|
||||
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
|
||||
{RECIPIENT_IMPORT_ENCODINGS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Separator">
|
||||
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
|
||||
<option value="auto">Auto</option>
|
||||
<option value=",">Comma</option>
|
||||
<option value=";">Semicolon</option>
|
||||
<option value={"\t"}>Tab</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle recipient-import-toggles">
|
||||
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{table && (
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>Separator</dt><dd>{formatDelimiter(table.delimiter)}</dd></div>
|
||||
<div><dt>{sourceType === "xlsx" ? "Sheet" : "Separator"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "Workbook" : formatDelimiter(table.delimiter)}</dd></div>
|
||||
<div><dt>Header rows</dt><dd>{table.headerRows.length}</dd></div>
|
||||
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
|
||||
<div><dt>Columns</dt><dd>{table.headers.length}</dd></div>
|
||||
@@ -603,9 +814,17 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
</>
|
||||
) : activeStep === "map" ? (
|
||||
<>
|
||||
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
|
||||
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
|
||||
<div className="campaign-header-grid recipient-import-map-controls">
|
||||
<FormField label="Multi-value separators">
|
||||
<input value={valueSeparators} onChange={(event) => setValueSeparators(event.target.value)} />
|
||||
<input
|
||||
value={valueSeparators}
|
||||
onChange={(event) => {
|
||||
setMappingManuallyChanged(true);
|
||||
setValueSeparators(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
||||
@@ -729,10 +948,10 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>Cancel</Button>
|
||||
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>Back</Button>}
|
||||
{activeStep !== "files" ? (
|
||||
{!isLastStep ? (
|
||||
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>Next</Button>
|
||||
) : (
|
||||
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && onImport(preview, mode)}>Import valid rows</Button>
|
||||
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>Import valid rows</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -895,6 +1114,65 @@ function formatDelimiter(delimiter: "," | ";" | "\t"): string {
|
||||
return "Comma";
|
||||
}
|
||||
|
||||
function isXlsxFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase();
|
||||
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
}
|
||||
|
||||
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
|
||||
try {
|
||||
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
|
||||
} catch (err) {
|
||||
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
|
||||
return {
|
||||
name: profile.name,
|
||||
columnCount: profile.columnCount,
|
||||
headers: profile.headers,
|
||||
normalizedHeaders: profile.normalizedHeaders,
|
||||
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
|
||||
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
|
||||
delimiter: profile.delimiter,
|
||||
headerRows: profile.headerRows,
|
||||
quoted: profile.quoted,
|
||||
valueSeparators: profile.valueSeparators,
|
||||
mappings: profile.mappings
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMappingProfileName(filename: string, table: { headers: string[] }): string {
|
||||
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
|
||||
if (trimmedFilename) return trimmedFilename;
|
||||
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
|
||||
return headerLabel ? `Mapping: ${headerLabel}` : "Recipient import mapping";
|
||||
}
|
||||
|
||||
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
|
||||
if (match.mode === "ordered") return "exact";
|
||||
if (match.mode === "unordered") return "same headers";
|
||||
return `${Math.round(match.score * 100)}%`;
|
||||
}
|
||||
|
||||
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
|
||||
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
|
||||
}
|
||||
|
||||
function findReusableMappingProfile(table: { headers: string[] }, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint)
|
||||
?? profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint)
|
||||
?? null;
|
||||
}
|
||||
|
||||
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
|
||||
if (match.mode === "ordered") return `Using mapping from previous import: ${match.profile.name}.`;
|
||||
if (match.mode === "unordered") return `Using mapping from previous import: ${match.profile.name}; matched by header names.`;
|
||||
return `Using mapping derived from previous import: ${match.profile.name} (${formatMappingProfileMatch(match)} match).`;
|
||||
}
|
||||
|
||||
function sampleColumnValue(rows: string[][], columnIndex: number): string {
|
||||
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user