879 lines
32 KiB
TypeScript
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());
|
|
}
|