Harden campaign import handling
This commit is contained in:
@@ -749,9 +749,22 @@ function parseAddress(value: string): ImportedAddress {
|
||||
}
|
||||
|
||||
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);
|
||||
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 {
|
||||
|
||||
@@ -21,6 +21,10 @@ export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function isSafeObjectPathSegment(segment: string): boolean {
|
||||
return segment !== "__proto__" && segment !== "prototype" && segment !== "constructor";
|
||||
}
|
||||
|
||||
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return version?.raw_json ?? version?.campaign_json ?? {};
|
||||
}
|
||||
@@ -179,10 +183,11 @@ export function getString(record: Record<string, unknown>, key: string, fallback
|
||||
}
|
||||
|
||||
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
||||
if (!path.every(isSafeObjectPathSegment)) return fallback;
|
||||
let current: unknown = record;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) return fallback;
|
||||
current = current[part];
|
||||
current = Object.getOwnPropertyDescriptor(current, part)?.value;
|
||||
}
|
||||
if (typeof current === "string" && current.trim()) return current;
|
||||
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
|
||||
import { asRecord, getCampaignJson, isRecord, isSafeObjectPathSegment } from "./campaignView";
|
||||
|
||||
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
@@ -46,19 +46,32 @@ export function updateNested(
|
||||
path: string[],
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
if (!path.length || !path.every(isSafeObjectPathSegment)) return cloneJson(draft);
|
||||
const next = cloneJson(draft);
|
||||
let current: Record<string, unknown> = next;
|
||||
path.forEach((segment, index) => {
|
||||
for (const [index, segment] of path.entries()) {
|
||||
if (index === path.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
Object.defineProperty(current, segment, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value,
|
||||
writable: true
|
||||
});
|
||||
break;
|
||||
}
|
||||
const existing = current[segment];
|
||||
const existing = Object.getOwnPropertyDescriptor(current, segment)?.value;
|
||||
if (!isRecord(existing)) {
|
||||
current[segment] = {};
|
||||
Object.defineProperty(current, segment, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: {},
|
||||
writable: true
|
||||
});
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
});
|
||||
const child = Object.getOwnPropertyDescriptor(current, segment)?.value;
|
||||
if (!isRecord(child)) return next;
|
||||
current = child;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,48 @@ export function recipientAddressTemplateFieldOptions(): Array<{ name: string; la
|
||||
}
|
||||
|
||||
export function isRecipientAddressPlaceholderName(name: string): boolean {
|
||||
const field = "(?:from|to|reply_to|cc|bcc)";
|
||||
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|
||||
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|
||||
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
|
||||
const normalized = name.trim();
|
||||
const allPrefix = "all_";
|
||||
const fieldName = recipientAddressFieldFromPlaceholder(normalized);
|
||||
if (!fieldName) return false;
|
||||
if (normalized === fieldName || normalized === `${allPrefix}${fieldName}`) return true;
|
||||
if (["email", "name"].some((suffix) => normalized === `${fieldName}.${suffix}` || normalized === `${allPrefix}${fieldName}.${suffix}`)) return true;
|
||||
if (isIndexedRecipientAddressPlaceholder(normalized, fieldName)) return true;
|
||||
return isDottedRecipientAddressPlaceholder(normalized, fieldName);
|
||||
}
|
||||
|
||||
function recipientAddressFieldFromPlaceholder(value: string): string | null {
|
||||
const candidates = [...RECIPIENT_ADDRESS_FIELD_IDS].sort((left, right) => right.length - left.length);
|
||||
for (const field of candidates) {
|
||||
if (value === field || value.startsWith(`${field}.`) || value.startsWith(`${field}[`)) return field;
|
||||
const allField = `all_${field}`;
|
||||
if (value === allField || value.startsWith(`${allField}.`)) return field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: string): boolean {
|
||||
return value.length > 0 && value[0] !== "0" && [...value].every((char) => char >= "0" && char <= "9");
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: string): boolean {
|
||||
return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9");
|
||||
}
|
||||
|
||||
function isIndexedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
|
||||
if (!value.startsWith(`${fieldName}[`)) return false;
|
||||
const closeIndex = value.indexOf("]", fieldName.length + 1);
|
||||
if (closeIndex < 0) return false;
|
||||
const index = value.slice(fieldName.length + 1, closeIndex);
|
||||
const suffix = value.slice(closeIndex + 1);
|
||||
return isPositiveInteger(index) && (suffix === "" || suffix === ".email" || suffix === ".name");
|
||||
}
|
||||
|
||||
function isDottedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
|
||||
const prefix = `${fieldName}.`;
|
||||
if (!value.startsWith(prefix)) return false;
|
||||
const [index, suffix, extra] = value.slice(prefix.length).split(".");
|
||||
return extra === undefined && isNonNegativeInteger(index) && ["email", "name", "type"].includes(suffix);
|
||||
}
|
||||
|
||||
export type TemplatePlaceholder = {
|
||||
@@ -211,14 +249,20 @@ export function valueToPreview(value: unknown): string {
|
||||
|
||||
export function removePlaceholderFromText(text: string, raw: string): string {
|
||||
if (!text) return text;
|
||||
const escaped = escapeRegExp(raw.trim());
|
||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
||||
return replaceMatchingPlaceholders(text, raw, "");
|
||||
}
|
||||
|
||||
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);
|
||||
return replaceMatchingPlaceholders(text, raw, replacement);
|
||||
}
|
||||
|
||||
function replaceMatchingPlaceholders(text: string, raw: string, replacement: string): string {
|
||||
const target = raw.trim();
|
||||
if (!target) return text;
|
||||
return text.replace(/\{\{\s*([^}]+?)\s*\}\}|\$\{\s*([^}]+?)\s*\}/g, (match, braceRaw: string | undefined, dollarRaw: string | undefined) =>
|
||||
(braceRaw ?? dollarRaw ?? "").trim() === target ? replacement : match
|
||||
);
|
||||
}
|
||||
|
||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||
@@ -255,7 +299,3 @@ function previewValueFor(raw: string, context: Record<string, string>, ignoreEmp
|
||||
if (value !== undefined) return value;
|
||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user