Release v0.1.4

This commit is contained in:
2026-07-02 14:59:52 +02:00
parent ab2343aa88
commit fb6fb67d45
27 changed files with 1437 additions and 1785 deletions

View File

@@ -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) ?? "";
}