Files
govoplan-campaign/webui/src/features/campaigns/utils/bulkImport.ts
2026-07-14 13:22:10 +02:00

879 lines
32 KiB
TypeScript

type ImportRecord = Record<string, unknown>;
type XlsxWorkbookSheet = {
sheet: string;
data: unknown[][];
};
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
export type ImportFieldDefinition = {
name: string;
label?: string;
type?: string;
required?: boolean;
can_override?: boolean;
};
export type ImportAttachmentBasePath = {
id?: string;
name?: string;
path?: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type RecipientImportMode = "append" | "replace";
export type CsvDelimiter = "auto" | "," | ";" | "\t";
export type CsvParseOptions = {
delimiter: CsvDelimiter;
headerRows: number;
quoted: boolean;
};
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientColumnMapping = {
columnIndex: number;
kind: RecipientColumnKind;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportTable = {
delimiter: "," | ";" | "\t";
headers: string[];
headerRows: string[][];
rows: string[][];
dataRows: string[][];
firstDataRowNumber: number;
};
export type RecipientImportSpreadsheetSheet = {
name: string;
rows: string[][];
};
export type ImportedAddress = {
email: string;
name?: string;
};
export type ImportedRecipientRow = {
rowNumber: number;
id: string;
name: string;
email: string;
active: boolean;
addresses: {
from: ImportedAddress[];
to: ImportedAddress[];
cc: ImportedAddress[];
bcc: ImportedAddress[];
reply_to: ImportedAddress[];
};
fields: Record<string, string>;
patterns: string[];
issues: string[];
};
export type RecipientImportPreview = {
table: RecipientImportTable;
rows: ImportedRecipientRow[];
fieldNamesToCreate: string[];
validCount: number;
invalidCount: number;
patternCount: number;
};
export type RecipientImportColumnProvenance = {
column_index: number;
header: string;
kind: RecipientColumnKind;
field_name?: string | null;
new_field_name?: string | null;
};
export type RecipientImportProvenance = {
id: string;
imported_at: string;
mode: RecipientImportMode;
source_type: RecipientImportSourceType;
source_id?: string | null;
source_label?: string | null;
source_revision?: string | null;
source_provenance?: Record<string, unknown>;
filename?: string | null;
sheet_name?: string | null;
encoding?: string | null;
delimiter?: string | null;
header_rows: number;
quoted?: boolean | null;
value_separators?: string | null;
rows_total: number;
valid_rows: number;
invalid_rows: number;
imported_rows: number;
field_names_created: string[];
attachment_patterns: number;
mapping: RecipientImportColumnProvenance[];
};
export type RecipientImportPreviewOptions = {
existingFields: ImportFieldDefinition[];
existingEntries?: Record<string, unknown>[];
valueSeparators?: string;
};
export type RecipientMappingProfile = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
columnCount: number;
headers: string[];
normalizedHeaders: string[];
orderedHeaderFingerprint: string;
unorderedHeaderFingerprint: string;
delimiter: "," | ";" | "\t";
headerRows: number;
quoted: boolean;
valueSeparators: string;
mappings: RecipientColumnMapping[];
};
export type RecipientMappingProfileMatchMode = "ordered" | "unordered" | "similar";
export type RecipientMappingProfileMatch = {
profile: RecipientMappingProfile;
mode: RecipientMappingProfileMatchMode;
score: number;
matchedHeaders: number;
missingHeaders: string[];
extraHeaders: string[];
};
export type RecipientMappingProfileInput = {
id?: string;
name: string;
table: RecipientImportTable;
mappings: RecipientColumnMapping[];
parseOptions: Pick<CsvParseOptions, "headerRows" | "quoted">;
valueSeparators: string;
createdAt?: string;
updatedAt?: string;
};
export type MaterializeImportOptions = {
mode: RecipientImportMode;
attachmentBasePath?: ImportAttachmentBasePath | null;
provenance?: RecipientImportProvenance | null;
};
const EMAIL_COLUMNS = new Set(["email", "e_mail", "mail", "to", "to_email", "recipient", "recipient_email"]);
const NAME_COLUMNS = new Set(["name", "full_name", "recipient_name", "to_name"]);
const ID_COLUMNS = new Set(["id", "entry_id", "recipient_id"]);
const ACTIVE_COLUMNS = new Set(["active", "enabled", "send", "include"]);
export function buildImportTable(text: string, options: CsvParseOptions): RecipientImportTable {
const delimiter = options.delimiter === "auto" ? detectDelimiter(text, options.quoted) : options.delimiter;
const rows = parseDelimitedText(text, delimiter, options.quoted);
return buildImportTableFromRows(rows, { headerRows: options.headerRows, delimiter });
}
export function buildImportTableFromRows(
sourceRows: string[][],
options: {headerRows: number;delimiter?: "," | ";" | "\t";})
: RecipientImportTable {
const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? "")));
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
const headerRows = rows.slice(0, normalizedHeaderRows);
const dataRows = rows.slice(normalizedHeaderRows);
const columnCount = Math.max(0, ...rows.map((row) => row.length));
const headers = Array.from({ length: columnCount }, (_value, columnIndex) => headerForColumn(headerRows, columnIndex));
return {
delimiter: options.delimiter ?? ",",
headers,
headerRows,
rows,
dataRows,
firstDataRowNumber: normalizedHeaderRows + 1
};
}
export async function xlsxSheetsFromArrayBuffer(buffer: ArrayBuffer): Promise<RecipientImportSpreadsheetSheet[]> {
const readXlsxFile = await loadXlsxWorkbookReader();
const workbook = await readXlsxFile(buffer);
return workbook.map((sheet) => ({
name: sheet.sheet,
rows: sheet.data.map((row) => row.map((cell) => cellToImportText(cell)))
}));
}
async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
const module = await import("read-excel-file/browser");
return module.default as XlsxWorkbookReader;
}
const module = await import("read-excel-file/universal");
return module.default as XlsxWorkbookReader;
}
export function defaultColumnMappings(headers: string[], existingFields: ImportFieldDefinition[]): RecipientColumnMapping[] {
const existingFieldNames = new Set(existingFields.map((field) => field.name));
return headers.map((header, columnIndex) => {
const key = normalizeColumnKey(header);
if (ID_COLUMNS.has(key)) return { columnIndex, kind: "id" };
if (ACTIVE_COLUMNS.has(key)) return { columnIndex, kind: "active" };
if (NAME_COLUMNS.has(key)) return { columnIndex, kind: "name" };
if (key === "from" || key === "from_email") return { columnIndex, kind: "from" };
if (key === "cc" || key === "cc_email") return { columnIndex, kind: "cc" };
if (key === "bcc" || key === "bcc_email") return { columnIndex, kind: "bcc" };
if (key === "reply_to" || key === "reply_to_email") return { columnIndex, kind: "reply_to" };
if (EMAIL_COLUMNS.has(key)) return { columnIndex, kind: "to" };
if (isPatternColumn(key)) return { columnIndex, kind: "attachment_pattern" };
const explicitFieldName = explicitFieldNameFromHeader(header);
if (explicitFieldName) return existingFieldNames.has(explicitFieldName) ? { columnIndex, kind: "field", fieldName: explicitFieldName } : { columnIndex, kind: "new_field", newFieldName: explicitFieldName };
const sanitized = sanitizeFieldName(header);
if (existingFieldNames.has(sanitized)) return { columnIndex, kind: "field", fieldName: sanitized };
return sanitized ? { columnIndex, kind: "new_field", newFieldName: sanitized } : { columnIndex, kind: "ignore" };
});
}
export function createRecipientMappingProfile(input: RecipientMappingProfileInput): RecipientMappingProfile {
const fingerprints = recipientImportHeaderFingerprints(input.table.headers);
const updatedAt = input.updatedAt ?? new Date().toISOString();
const createdAt = input.createdAt ?? updatedAt;
const id = input.id ?? `recipient-import-${fingerprints.orderedHeaderFingerprint}-${Date.now().toString(36)}`;
return {
id,
name: input.name.trim() || "Recipient import mapping",
createdAt,
updatedAt,
columnCount: input.table.headers.length,
headers: input.table.headers.slice(),
normalizedHeaders: fingerprints.normalizedHeaders,
orderedHeaderFingerprint: fingerprints.orderedHeaderFingerprint,
unorderedHeaderFingerprint: fingerprints.unorderedHeaderFingerprint,
delimiter: input.table.delimiter,
headerRows: Math.max(0, Math.floor(input.parseOptions.headerRows || 0)),
quoted: input.parseOptions.quoted,
valueSeparators: input.valueSeparators,
mappings: normalizeMappingList(input.mappings, input.table.headers.length)
};
}
export function recipientImportHeaderFingerprints(headers: string[]): Pick<RecipientMappingProfile, "normalizedHeaders" | "orderedHeaderFingerprint" | "unorderedHeaderFingerprint"> {
const normalizedHeaders = headers.map(normalizeImportHeader);
return {
normalizedHeaders,
orderedHeaderFingerprint: fingerprintHeaderList(normalizedHeaders),
unorderedHeaderFingerprint: fingerprintHeaderList(normalizedHeaders.slice().sort())
};
}
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles.
map((profile): RecipientMappingProfileMatch | null => {
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
if (mode === "similar" && score < 0.45) return null;
return {
profile,
mode,
score,
matchedHeaders: diff.matchedHeaders,
missingHeaders: diff.missingHeaders,
extraHeaders: diff.extraHeaders
};
}).
filter((match): match is RecipientMappingProfileMatch => match !== null).
sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
});
}
export function applyRecipientMappingProfile(
table: RecipientImportTable,
profile: RecipientMappingProfile,
existingFields: ImportFieldDefinition[])
: RecipientColumnMapping[] {
const defaults = defaultColumnMappings(table.headers, existingFields);
const fingerprints = recipientImportHeaderFingerprints(table.headers);
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint && table.headers.length === profileMappings.length;
if (exactOrdered) {
return table.headers.map((header, columnIndex) => normalizeProfileMapping(profileMappings[columnIndex], columnIndex, header, existingFields, defaults[columnIndex]));
}
const profileIndexesByHeader = new Map<string, number[]>();
profile.normalizedHeaders.forEach((header, index) => {
const indexes = profileIndexesByHeader.get(header) ?? [];
indexes.push(index);
profileIndexesByHeader.set(header, indexes);
});
return table.headers.map((header, columnIndex) => {
const normalizedHeader = normalizeImportHeader(header);
const profileIndex = profileIndexesByHeader.get(normalizedHeader)?.shift();
if (profileIndex === undefined) return defaults[columnIndex] ?? { columnIndex, kind: "ignore" };
return normalizeProfileMapping(profileMappings[profileIndex], columnIndex, header, existingFields, defaults[columnIndex]);
});
}
export function buildRecipientImportPreview(
table: RecipientImportTable,
mappings: RecipientColumnMapping[],
options: RecipientImportPreviewOptions)
: RecipientImportPreview {
const existingFields = options.existingFields;
const existingFieldNames = new Set(existingFields.map((field) => field.name));
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
const usedIds = new Set(existingIds);
const fieldNamesToCreate = new Set<string>();
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
const valueSeparators = options.valueSeparators || ",;|";
const rows = table.dataRows.
map((cells, index): ImportedRecipientRow | null => {
if (cells.every((cell) => !cell.trim())) return null;
const rowNumber = table.firstDataRowNumber + index;
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
const fields: Record<string, string> = {};
const patterns: string[] = [];
const issues: string[] = [];
let preferredId = "";
let name = "";
let active = true;
cells.forEach((rawValue, columnIndex) => {
const value = String(rawValue ?? "").trim();
if (!value) return;
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
switch (mapping.kind) {
case "id":
preferredId = value;
break;
case "active":
active = parseOptionalBoolean(value, true);
break;
case "name":
name = value;
break;
case "from":
case "to":
case "cc":
case "bcc":
case "reply_to":
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
break;
case "field":{
const fieldName = mapping.fieldName?.trim();
if (fieldName) fields[fieldName] = value;
break;
}
case "new_field":{
const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`);
if (fieldName) {
fields[fieldName] = value;
if (!existingFieldNames.has(fieldName)) fieldNamesToCreate.add(fieldName);
}
break;
}
case "attachment_pattern":
patterns.push(...splitCell(value, valueSeparators));
break;
case "ignore":
default:
break;
}
});
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
const email = primaryAddress?.email ?? "";
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
usedIds.add(id);
for (const [key, list] of Object.entries(addresses)) {
for (const address of list) {
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
}
}
if (addresses.to.length === 0) issues.push("i18n:govoplan-campaign.missing_to_address.d4ff53b4");
return {
rowNumber,
id,
name: name || primaryAddress?.name || "",
email,
active,
addresses,
fields,
patterns,
issues: [...new Set(issues)]
};
}).
filter((row): row is ImportedRecipientRow => row !== null);
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: rows.reduce((count, row) => count + row.patterns.length, 0)
};
}
export function materializeRecipientImport(
draft: Record<string, unknown>,
preview: RecipientImportPreview,
options: MaterializeImportOptions)
: Record<string, unknown> {
const currentEntries = asRecord(draft.entries);
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
const previousImports = asArray(currentEntries.imports).map(asRecord);
const importedEntries = preview.rows.
filter((row) => row.issues.length === 0).
map((row) => importedRowToEntry(row, options.attachmentBasePath));
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
delete nextEntries.source;
delete nextEntries.mapping;
return {
...draft,
fields: mergeFieldDefinitions(draft.fields, preview.fieldNamesToCreate),
entries: nextEntries
};
}
export function materializeRecipientImportWithAttachmentDefaults(
draft: Record<string, unknown>,
preview: RecipientImportPreview,
options: MaterializeImportOptions)
: Record<string, unknown> {
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeImportAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: ImportAttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = options.attachmentBasePath ?? nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { ...options, attachmentBasePath });
if (!needsAttachmentSource) return importedDraft;
return {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
};
}
export function createRecipientImportProvenance(input: {
preview: RecipientImportPreview;
mappings: RecipientColumnMapping[];
mode: RecipientImportMode;
sourceType: RecipientImportSourceType;
filename?: string | null;
sheetName?: string | null;
encoding?: string | null;
delimiter?: string | null;
headerRows: number;
quoted?: boolean | null;
valueSeparators?: string | null;
}): RecipientImportProvenance {
const now = new Date().toISOString();
const mappingsByColumn = new Map(input.mappings.map((mapping) => [mapping.columnIndex, mapping]));
return {
id: `recipient-import-${stableHash(`${now}\u001f${input.filename ?? ""}\u001f${input.sheetName ?? ""}\u001f${input.preview.rows.length}`)}`,
imported_at: now,
mode: input.mode,
source_type: input.sourceType,
filename: input.filename?.trim() || null,
sheet_name: input.sheetName?.trim() || null,
encoding: input.encoding?.trim() || null,
delimiter: input.delimiter?.trim() || null,
header_rows: Math.max(0, Math.floor(input.headerRows || 0)),
quoted: input.quoted ?? null,
value_separators: input.valueSeparators ?? null,
rows_total: input.preview.rows.length,
valid_rows: input.preview.validCount,
invalid_rows: input.preview.invalidCount,
imported_rows: input.preview.validCount,
field_names_created: input.preview.fieldNamesToCreate.slice(),
attachment_patterns: input.preview.patternCount,
mapping: input.preview.table.headers.map((header, columnIndex) => {
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
return {
column_index: columnIndex,
header,
kind: mapping.kind,
...(mapping.fieldName ? { field_name: mapping.fieldName } : {}),
...(mapping.newFieldName ? { new_field_name: mapping.newFieldName } : {})
};
})
};
}
export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview): boolean {
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
}
function normalizeImportAttachmentBasePaths(value: unknown, attachments: ImportRecord, fallbackAllowIndividual = false): ImportAttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual"),
unsent_warning: getBool(basePath, "unsent_warning")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
unsent_warning: false
}];
}
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
return {
id: row.id,
active: row.active,
name: row.name,
email: row.email,
from: row.addresses.from.slice(0, 1),
to: row.addresses.to,
cc: row.addresses.cc,
bcc: row.addresses.bcc,
reply_to: row.addresses.reply_to,
merge_to: false,
merge_cc: true,
merge_bcc: true,
merge_reply_to: true,
fields: row.fields,
attachments: row.patterns.map((pattern, index) => ({
id: `attachment-import-${row.rowNumber}-${index + 1}`,
label: row.patterns.length === 1 ? "Imported attachment pattern" : `Imported attachment pattern ${index + 1}`,
type: "pattern",
base_path_id: attachmentBasePath?.id || undefined,
base_dir: attachmentBasePath?.path || ".",
file_filter: pattern,
include_subdirs: false,
required: true,
zip: { archive_id: "inherit" }
})),
combine_attachments: true
};
}
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
const existing = asArray(fieldsValue).map(asRecord);
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
const additions = fieldNamesToCreate.
filter((name) => !existingNames.has(name)).
map((name) => ({
name,
label: humanizeFieldName(name),
type: "string",
required: false,
can_override: true
}));
return [...existing, ...additions];
}
export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", quoted = true): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (quoted && inQuotes) {
if (char === '"' && next === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (quoted && char === '"') {
inQuotes = true;
} else if (char === delimiter) {
row.push(cell);
cell = "";
} else if (char === "\n") {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (char !== "\r") {
cell += char;
}
}
row.push(cell);
if (row.length > 1 || row[0] !== "" || text.endsWith(delimiter)) rows.push(row);
return rows;
}
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
return candidates.
map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 })).
sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
}
function headerForColumn(headerRows: string[][], columnIndex: number): string {
const parts = headerRows.map((row) => String(row[columnIndex] ?? "").trim()).filter(Boolean);
return parts.length ? parts.join(" / ") : `Column ${columnIndex + 1}`;
}
function cellToImportText(value: unknown): string {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
return String(value);
}
function explicitFieldNameFromHeader(header: string): string {
const explicit = /^(?:field|fields)[.:](.+)$/i.exec(header.trim());
return explicit ? sanitizeFieldName(explicit[1]) : "";
}
function normalizeColumnKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function normalizeImportHeader(value: string): string {
return normalizeColumnKey(value).replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
}
function fingerprintHeaderList(headers: string[]): string {
return stableHash(headers.join("\u001f"));
}
function stableHash(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): {matchedHeaders: number;missingHeaders: string[];extraHeaders: string[];} {
const currentCounts = countHeaders(currentHeaders);
const profileCounts = countHeaders(profileHeaders);
let matchedHeaders = 0;
const missingHeaders: string[] = [];
const extraHeaders: string[] = [];
for (const [header, profileCount] of profileCounts) {
const currentCount = currentCounts.get(header) ?? 0;
matchedHeaders += Math.min(currentCount, profileCount);
for (let index = currentCount; index < profileCount; index += 1) missingHeaders.push(header);
}
for (const [header, currentCount] of currentCounts) {
const profileCount = profileCounts.get(header) ?? 0;
for (let index = profileCount; index < currentCount; index += 1) extraHeaders.push(header);
}
return { matchedHeaders, missingHeaders, extraHeaders };
}
function countHeaders(headers: string[]): Map<string, number> {
const counts = new Map<string, number>();
headers.forEach((header) => counts.set(header, (counts.get(header) ?? 0) + 1));
return counts;
}
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
const currentCounts = countHeaders(currentHeaders);
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length).
filter((mapping) => mapping.kind !== "ignore").
map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "").
filter((header) => header && !currentCounts.has(header));
}
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
const byColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
return Array.from({ length: Math.max(0, columnCount) }, (_value, columnIndex) => normalizeMappingShape(byColumn.get(columnIndex), columnIndex));
}
function normalizeMappingShape(mapping: RecipientColumnMapping | undefined, columnIndex: number): RecipientColumnMapping {
if (!mapping) return { columnIndex, kind: "ignore" };
const kind = mapping.kind;
if (kind === "field") return { columnIndex, kind, fieldName: mapping.fieldName ?? "" };
if (kind === "new_field") return { columnIndex, kind, newFieldName: mapping.newFieldName ?? "" };
return { columnIndex, kind };
}
function normalizeProfileMapping(
mapping: RecipientColumnMapping | undefined,
columnIndex: number,
header: string,
existingFields: ImportFieldDefinition[],
fallback: RecipientColumnMapping | undefined)
: RecipientColumnMapping {
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
if (!mapping) return fallbackMapping;
const existingFieldNames = new Set(existingFields.map((field) => field.name));
if (mapping.kind === "field") {
const fieldName = sanitizeFieldName(mapping.fieldName ?? "");
if (!fieldName) return fallbackMapping;
return existingFieldNames.has(fieldName) ? { columnIndex, kind: "field", fieldName } : { columnIndex, kind: "new_field", newFieldName: fieldName };
}
if (mapping.kind === "new_field") {
const fieldName = sanitizeFieldName(mapping.newFieldName || header);
if (!fieldName) return fallbackMapping;
return existingFieldNames.has(fieldName) ? { columnIndex, kind: "field", fieldName } : { columnIndex, kind: "new_field", newFieldName: fieldName };
}
return { columnIndex, kind: mapping.kind };
}
function isPatternColumn(key: string): boolean {
return key === "pattern" ||
key === "patterns" ||
key === "file" ||
key === "files" ||
key === "file_pattern" ||
key === "file_patterns" ||
key === "attachment" ||
key === "attachments" ||
key === "attachment_pattern" ||
key === "attachment_patterns" ||
/^pattern_\d+$/.test(key) ||
/^file_\d+$/.test(key) ||
/^attachment_\d+$/.test(key);
}
function parseAddressCell(value: string, separators: string): ImportedAddress[] {
return splitCell(value, separators).
map((item) => parseAddress(item)).
filter((address) => Boolean(address.email));
}
function parseAddress(value: string): ImportedAddress {
const trimmed = value.trim();
const match = /^(.*?)<([^<>]+)>$/.exec(trimmed);
if (match) {
const name = match[1].trim().replace(/^"|"$/g, "");
return { email: match[2].trim(), ...(name ? { name } : {}) };
}
return { email: trimmed };
}
function splitCell(value: string, separators: string): string[] {
const separatorSet = new Set(separators.split(""));
if (separatorSet.size === 0) return [value.trim()].filter(Boolean);
const items: string[] = [];
let current = "";
for (const char of value) {
if (separatorSet.has(char)) {
const trimmed = current.trim();
if (trimmed) items.push(trimmed);
current = "";
continue;
}
current += char;
}
const trimmed = current.trim();
if (trimmed) items.push(trimmed);
return items;
}
function parseOptionalBoolean(value: string, fallback: boolean): boolean {
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (["1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"].includes(normalized)) return true;
if (["0", "false", "no", "n", "nein", "inactive", "inaktiv"].includes(normalized)) return false;
return fallback;
}
function idFromEmail(email: string): string {
const localPart = email.split("@", 1)[0] || "";
return sanitizeFieldName(localPart).toLowerCase() || "";
}
function uniqueId(preferred: string, usedIds: Set<string>): string {
const base = sanitizeFieldName(preferred).toLowerCase() || "recipient";
if (!usedIds.has(base)) return base;
let index = 2;
let candidate = `${base}-${index}`;
while (usedIds.has(candidate)) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
}
function sanitizeFieldName(value: string): string {
return value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function isRecord(value: unknown): value is ImportRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function asRecord(value: unknown): ImportRecord {
return isRecord(value) ? value : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function getText(record: ImportRecord, key: string, fallback = ""): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
function getBool(record: ImportRecord, key: string, fallback = false): boolean {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
if (typeof value === "number") return value !== 0;
return fallback;
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}