Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 23318c709a
commit ab2343aa88
22 changed files with 2176 additions and 197 deletions

View 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());
}