Release v0.1.3
This commit is contained in:
460
webui/src/features/campaigns/utils/bulkImport.ts
Normal file
460
webui/src/features/campaigns/utils/bulkImport.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
type ImportRecord = Record<string, unknown>;
|
||||
|
||||
export type ImportFieldDefinition = {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
can_override?: boolean;
|
||||
};
|
||||
|
||||
export type ImportAttachmentBasePath = {
|
||||
id?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type RecipientImportMode = "append" | "replace";
|
||||
|
||||
export type CsvDelimiter = "auto" | "," | ";" | "\t";
|
||||
|
||||
export type CsvParseOptions = {
|
||||
delimiter: CsvDelimiter;
|
||||
headerRows: number;
|
||||
quoted: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
newFieldName?: string;
|
||||
};
|
||||
|
||||
export type RecipientImportTable = {
|
||||
delimiter: "," | ";" | "\t";
|
||||
headers: string[];
|
||||
headerRows: string[][];
|
||||
rows: string[][];
|
||||
dataRows: string[][];
|
||||
firstDataRowNumber: number;
|
||||
};
|
||||
|
||||
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 RecipientImportPreviewOptions = {
|
||||
existingFields: ImportFieldDefinition[];
|
||||
existingEntries?: Record<string, unknown>[];
|
||||
valueSeparators?: string;
|
||||
};
|
||||
|
||||
export type MaterializeImportOptions = {
|
||||
mode: RecipientImportMode;
|
||||
attachmentBasePath?: ImportAttachmentBasePath | 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);
|
||||
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,
|
||||
headers,
|
||||
headerRows,
|
||||
rows,
|
||||
dataRows,
|
||||
firstDataRowNumber: normalizedHeaderRows + 1
|
||||
};
|
||||
}
|
||||
|
||||
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 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("Missing To address");
|
||||
|
||||
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 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 };
|
||||
delete nextEntries.source;
|
||||
delete nextEntries.mapping;
|
||||
|
||||
return {
|
||||
...draft,
|
||||
fields: mergeFieldDefinitions(draft.fields, preview.fieldNamesToCreate),
|
||||
entries: nextEntries
|
||||
};
|
||||
}
|
||||
|
||||
export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview): boolean {
|
||||
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
|
||||
}
|
||||
|
||||
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 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 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 escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
|
||||
if (!escaped) return [value.trim()].filter(Boolean);
|
||||
return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
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 humanizeFieldName(value: string): string {
|
||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user