Files
govoplan-campaign/webui/src/features/campaigns/utils/templatePlaceholders.ts

257 lines
10 KiB
TypeScript

import { asArray, asRecord } from "./campaignView";
import { getBool } from "./draftEditor";
import { addressesFromValue, type MailboxAddress } from "../../../utils/emailAddresses";
export type TemplateNamespace = "global" | "local";
export const RECIPIENT_ADDRESS_FIELD_IDS = ["from", "to", "reply_to", "cc", "bcc"] as const;
export function recipientAddressTemplateFieldOptions(): Array<{ name: string; label: string }> {
return RECIPIENT_ADDRESS_FIELD_IDS.flatMap((name) => [
{ name, label: name },
{ name: `all_${name}`, label: `all_${name}` }
]);
}
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);
}
export type TemplatePlaceholder = {
raw: string;
namespace: string;
name: string;
validNamespace: boolean;
display: string;
};
export type UndefinedPlaceholder = TemplatePlaceholder & {
reason: "missing-field" | "invalid-namespace";
};
export function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
const placeholders = new Map<string, TemplatePlaceholder>();
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
for (const pattern of patterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
const raw = match[1].trim();
if (!raw || placeholders.has(raw)) continue;
placeholders.set(raw, parseTemplatePlaceholder(raw));
}
}
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
}
export function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
const cleaned = normalizeTemplatePlaceholderKey(raw);
const separator = cleaned.indexOf(":");
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
const validNamespace = namespace === "global" || namespace === "local";
return {
raw,
namespace,
name,
validNamespace,
display: validNamespace ? `${namespace}:${name}` : raw
};
}
export function normalizeTemplatePlaceholderKey(raw: string): string {
return raw.trim()
.replace(/^fields\./, "local:")
.replace(/^local\./, "local:")
.replace(/^global\./, "global:")
.replace(/^local::/, "local:")
.replace(/^global::/, "global:");
}
export function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const key = item.raw;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function buildUndefinedPlaceholders(
placeholders: TemplatePlaceholder[],
availableNames: Set<string>,
availableByNamespace?: Partial<Record<TemplateNamespace, Set<string>>>
): UndefinedPlaceholder[] {
return uniquePlaceholders(placeholders
.filter((field) => {
if (!field.validNamespace) return true;
const namesForNamespace = availableByNamespace?.[field.namespace as TemplateNamespace];
if ((namesForNamespace ?? availableNames).has(field.name)) return false;
return !(field.namespace === "local" && isRecipientAddressPlaceholderName(field.name));
})
.map((field): UndefinedPlaceholder => ({
...field,
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
})));
}
export function buildTemplatePreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
const context: Record<string, string> = {};
const globalValues = asRecord(draft?.global_values);
const entryFields = asRecord(entry.fields);
const overridePolicy = fieldOverridePolicy(draft);
for (const field of asArray(draft?.fields).map(asRecord)) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
addPreviewContextValue(context, name, "global", "");
addPreviewContextValue(context, name, "local", "");
}
for (const [key, value] of Object.entries(globalValues)) {
addPreviewContextValue(context, key, "global", value);
addPreviewContextValue(context, key, "local", value);
}
for (const [key, value] of Object.entries(entryFields)) {
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
addPreviewContextValue(context, key, "local", value);
}
}
if (entry.name) addPreviewContextValue(context, "name", "local", entry.name);
if (entry.email) addPreviewContextValue(context, "email", "local", entry.email);
addRecipientAddressPreviewValues(context, effectivePreviewAddresses(draft, entry));
return context;
}
function effectivePreviewAddresses(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, MailboxAddress[]> {
const recipients = asRecord(draft?.recipients);
const defaults = asRecord(asRecord(draft?.entries).defaults);
const result: Record<string, MailboxAddress[]> = {};
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
const globalAddresses = addressesFromValue(recipients[field]);
const localAddresses = addressesFromValue(entry[field]);
const allowIndividual = getBool(recipients, `allow_individual_${field}`, field === "to");
const defaultMerge = field !== "from";
const merge = mergeFlag(entry, defaults, field, defaultMerge);
if (!allowIndividual) {
result[field] = dedupeAddressOrder(globalAddresses);
} else if (localAddresses.length === 0) {
result[field] = dedupeAddressOrder(globalAddresses);
} else {
result[field] = dedupeAddressOrder(merge ? [...globalAddresses, ...localAddresses] : localAddresses);
}
}
return result;
}
function dedupeAddressOrder(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Set<string>();
return addresses.filter((address) => {
const key = address.email.trim().toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function mergeFlag(entry: Record<string, unknown>, defaults: Record<string, unknown>, field: string, fallback: boolean): boolean {
const mergeKey = `merge_${field}`;
const legacyKey = `combine_${field}`;
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
return fallback;
}
function addRecipientAddressPreviewValues(context: Record<string, string>, addresses: Record<string, MailboxAddress[]>) {
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
const values = addresses[field] ?? [];
const formatted = values.map(formatPreviewAddress);
const emails = values.map((address) => address.email);
const names = values.map((address) => address.name ?? "");
addPreviewContextValue(context, field, "local", formatted[0] ?? "");
addPreviewContextValue(context, `${field}.email`, "local", emails[0] ?? "");
addPreviewContextValue(context, `${field}.name`, "local", names[0] ?? "");
addPreviewContextValue(context, `all_${field}`, "local", formatted.join(", "));
addPreviewContextValue(context, `all_${field}.email`, "local", emails.join(", "));
addPreviewContextValue(context, `all_${field}.name`, "local", names.filter(Boolean).join(", "));
values.forEach((address, index) => {
const oneBased = index + 1;
addPreviewContextValue(context, `${field}[${oneBased}]`, "local", formatted[index]);
addPreviewContextValue(context, `${field}[${oneBased}].email`, "local", address.email);
addPreviewContextValue(context, `${field}[${oneBased}].name`, "local", address.name ?? "");
addPreviewContextValue(context, `${field}.${index}.email`, "local", address.email);
addPreviewContextValue(context, `${field}.${index}.name`, "local", address.name ?? "");
});
}
}
function formatPreviewAddress(address: MailboxAddress): string {
return address.name ? `${address.name} <${address.email}>` : address.email;
}
export function renderTemplatePreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
if (!text) return "";
return text
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
}
export function valueToPreview(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
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"), "");
}
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)) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
policy.set(name, getBool(field, "can_override", true));
}
return policy;
}
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
if (!policy.has(name)) return true;
return policy.get(name) !== false;
}
function addPreviewContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
const text = valueToPreview(value);
context[key] = text;
context[`${namespace}:${key}`] = text;
context[`${namespace}::${key}`] = text;
}
function hasPreviewOverrideValue(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim() !== "";
return true;
}
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
const key = normalizeTemplatePlaceholderKey(raw);
const value = context[key];
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}