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[] {
|
function splitCell(value: string, separators: string): string[] {
|
||||||
const escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
|
const separatorSet = new Set(separators.split(""));
|
||||||
if (!escaped) return [value.trim()].filter(Boolean);
|
if (separatorSet.size === 0) return [value.trim()].filter(Boolean);
|
||||||
return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.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 {
|
function parseOptionalBoolean(value: string, fallback: boolean): boolean {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export function asArray(value: unknown): unknown[] {
|
|||||||
return Array.isArray(value) ? value : [];
|
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> {
|
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||||
return version?.raw_json ?? version?.campaign_json ?? {};
|
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 {
|
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
||||||
|
if (!path.every(isSafeObjectPathSegment)) return fallback;
|
||||||
let current: unknown = record;
|
let current: unknown = record;
|
||||||
for (const part of path) {
|
for (const part of path) {
|
||||||
if (!isRecord(current)) return fallback;
|
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 === "string" && current.trim()) return current;
|
||||||
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
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>;
|
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
|
||||||
@@ -46,19 +46,32 @@ export function updateNested(
|
|||||||
path: string[],
|
path: string[],
|
||||||
value: unknown
|
value: unknown
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
if (!path.length || !path.every(isSafeObjectPathSegment)) return cloneJson(draft);
|
||||||
const next = cloneJson(draft);
|
const next = cloneJson(draft);
|
||||||
let current: Record<string, unknown> = next;
|
let current: Record<string, unknown> = next;
|
||||||
path.forEach((segment, index) => {
|
for (const [index, segment] of path.entries()) {
|
||||||
if (index === path.length - 1) {
|
if (index === path.length - 1) {
|
||||||
current[segment] = value;
|
Object.defineProperty(current, segment, {
|
||||||
return;
|
configurable: true,
|
||||||
}
|
enumerable: true,
|
||||||
const existing = current[segment];
|
value,
|
||||||
if (!isRecord(existing)) {
|
writable: true
|
||||||
current[segment] = {};
|
|
||||||
}
|
|
||||||
current = current[segment] as Record<string, unknown>;
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const existing = Object.getOwnPropertyDescriptor(current, segment)?.value;
|
||||||
|
if (!isRecord(existing)) {
|
||||||
|
Object.defineProperty(current, segment, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: {},
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const child = Object.getOwnPropertyDescriptor(current, segment)?.value;
|
||||||
|
if (!isRecord(child)) return next;
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,48 @@ export function recipientAddressTemplateFieldOptions(): Array<{ name: string; la
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isRecipientAddressPlaceholderName(name: string): boolean {
|
export function isRecipientAddressPlaceholderName(name: string): boolean {
|
||||||
const field = "(?:from|to|reply_to|cc|bcc)";
|
const normalized = name.trim();
|
||||||
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|
const allPrefix = "all_";
|
||||||
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|
const fieldName = recipientAddressFieldFromPlaceholder(normalized);
|
||||||
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
|
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 = {
|
export type TemplatePlaceholder = {
|
||||||
@@ -211,14 +249,20 @@ export function valueToPreview(value: unknown): string {
|
|||||||
|
|
||||||
export function removePlaceholderFromText(text: string, raw: string): string {
|
export function removePlaceholderFromText(text: string, raw: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
const escaped = escapeRegExp(raw.trim());
|
return replaceMatchingPlaceholders(text, raw, "");
|
||||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
|
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
const escaped = escapeRegExp(raw.trim());
|
return replaceMatchingPlaceholders(text, raw, replacement);
|
||||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), 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> {
|
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;
|
if (value !== undefined) return value;
|
||||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user