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());
|
||||
}
|
||||
143
webui/src/features/campaigns/utils/fileLinking.ts
Normal file
143
webui/src/features/campaigns/utils/fileLinking.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { listFiles, resolveFilePatterns, shareFilesWithCampaign, type ManagedFile } from "../../../api/files";
|
||||
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
|
||||
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
|
||||
|
||||
export type RenderedImportPattern = {
|
||||
key: string;
|
||||
rowNumber: number;
|
||||
sourcePattern: string;
|
||||
renderedPattern: string;
|
||||
};
|
||||
|
||||
export type ImportFileLinkResolution = {
|
||||
basePath: AttachmentBasePath;
|
||||
patterns: RenderedImportPattern[];
|
||||
matchedPatterns: { pattern: RenderedImportPattern; matches: ManagedFile[] }[];
|
||||
unmatchedPatterns: RenderedImportPattern[];
|
||||
files: ManagedFile[];
|
||||
linkedFiles: ManagedFile[];
|
||||
linkableFiles: ManagedFile[];
|
||||
};
|
||||
|
||||
export async function resolveImportedAttachmentLinks(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
basePath: AttachmentBasePath,
|
||||
preview: RecipientImportPreview
|
||||
): Promise<ImportFileLinkResolution> {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
if (!parsedSource) {
|
||||
throw new Error("The selected attachment source is not connected to a managed file space.");
|
||||
}
|
||||
|
||||
const patterns = renderedImportPatterns(preview);
|
||||
if (patterns.length === 0) {
|
||||
return { basePath, patterns, matchedPatterns: [], unmatchedPatterns: [], files: [], linkedFiles: [], linkableFiles: [] };
|
||||
}
|
||||
|
||||
const root = normalizedPathPrefix(basePath.path);
|
||||
const [resolved, visibleFiles] = await Promise.all([
|
||||
resolveFilePatterns(settings, {
|
||||
patterns: patterns.map((item) => item.renderedPattern),
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined,
|
||||
include_unmatched: false
|
||||
}),
|
||||
listFiles(settings, {
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined
|
||||
})
|
||||
]);
|
||||
|
||||
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
|
||||
const fileById = new Map<string, ManagedFile>();
|
||||
const matchedPatterns = patterns.map((pattern, index) => {
|
||||
const matches = (resolved.patterns[index]?.matches ?? [])
|
||||
.map((file) => visibleById.get(file.id) ?? file);
|
||||
matches.forEach((file) => fileById.set(file.id, file));
|
||||
return { pattern, matches };
|
||||
});
|
||||
const files = [...fileById.values()].sort((left, right) => left.display_path.localeCompare(right.display_path));
|
||||
const linkedFiles = files.filter((file) => isSharedWithCampaign(file, campaignId));
|
||||
const linkableFiles = files.filter((file) => !isSharedWithCampaign(file, campaignId));
|
||||
|
||||
return {
|
||||
basePath,
|
||||
patterns,
|
||||
matchedPatterns,
|
||||
unmatchedPatterns: matchedPatterns.filter((item) => item.matches.length === 0).map((item) => item.pattern),
|
||||
files,
|
||||
linkedFiles,
|
||||
linkableFiles
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkLinkFilesToCampaign(settings: ApiSettings, campaignId: string, files: ManagedFile[]): Promise<number> {
|
||||
const uniqueIds = [...new Set(files.map((file) => file.id).filter(Boolean))];
|
||||
if (uniqueIds.length === 0) return 0;
|
||||
const response = await shareFilesWithCampaign(settings, uniqueIds, campaignId);
|
||||
return response.shared_count;
|
||||
}
|
||||
|
||||
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
|
||||
const byKey = new Map<string, RenderedImportPattern>();
|
||||
preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.forEach((row) => {
|
||||
row.patterns.forEach((sourcePattern) => {
|
||||
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
|
||||
if (!renderedPattern) return;
|
||||
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
|
||||
});
|
||||
});
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
export function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
|
||||
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
|
||||
}
|
||||
|
||||
function renderPatternForImportedRow(pattern: string, row: ImportedRecipientRow): string {
|
||||
const values = valuesForImportedRow(row);
|
||||
const replace = (rawKey: string, original: string) => {
|
||||
const key = normalizeTemplateKey(rawKey);
|
||||
const value = values.get(key);
|
||||
return value === undefined ? original : value;
|
||||
};
|
||||
const dollarRendered = pattern.replace(/\\?\$\{([^}]+)\}/g, (match, key) => match.startsWith("\\") ? match.slice(1) : replace(key, match));
|
||||
return dollarRendered.replace(/\\?\{\{\s*([^}]+?)\s*\}\}/g, (match, key) => match.startsWith("\\") ? match.slice(1) : replace(key, match));
|
||||
}
|
||||
|
||||
function valuesForImportedRow(row: ImportedRecipientRow): Map<string, string> {
|
||||
const values = new Map<string, string>();
|
||||
const setValue = (key: string, value: string) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) return;
|
||||
values.set(trimmedKey, value);
|
||||
values.set(`local:${trimmedKey}`, value);
|
||||
values.set(`local::${trimmedKey}`, value);
|
||||
};
|
||||
setValue("id", row.id);
|
||||
setValue("name", row.name);
|
||||
setValue("email", row.email);
|
||||
Object.entries(row.fields).forEach(([key, value]) => setValue(key, String(value ?? "")));
|
||||
return values;
|
||||
}
|
||||
|
||||
function normalizeTemplateKey(value: string): string {
|
||||
const key = value.trim();
|
||||
if (key.startsWith("local::") || key.startsWith("global::")) return key;
|
||||
if (key.startsWith("local:")) return `local::${key.slice("local:".length)}`;
|
||||
if (key.startsWith("global:")) return `global::${key.slice("global:".length)}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
function normalizedPathPrefix(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "." || trimmed === "/") return "";
|
||||
return trimmed.replace(/^[\/]+|[\/]+$/g, "");
|
||||
}
|
||||
@@ -215,6 +215,12 @@ export function removePlaceholderFromText(text: string, raw: string): string {
|
||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
||||
}
|
||||
|
||||
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
|
||||
if (!text) return text;
|
||||
const escaped = escapeRegExp(raw.trim());
|
||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), replacement);
|
||||
}
|
||||
|
||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||
const policy = new Map<string, boolean>();
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
|
||||
Reference in New Issue
Block a user