type ImportRecord = Record; type XlsxWorkbookSheet = { sheet: string; data: unknown[][]; }; type XlsxWorkbookReader = (input: ArrayBuffer) => Promise; 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; 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; 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[]; 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; 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 { 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 { 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 { 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(); 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(); 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 = {}; 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, preview: RecipientImportPreview, options: MaterializeImportOptions) : Record { 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, preview: RecipientImportPreview, options: MaterializeImportOptions) : Record { 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 { 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[] { 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 { const counts = new Map(); 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 { 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()); }