/** * @factory/database-engine/views — BUTTON config helpers (TASK-0087, pure * half). Button config: {name ≤ 50, icon (emoji, default ▶️), color, * actions[] (1–5, ordered)}. Actions: set_property / create_page / * update_page / archive_page / duplicate_page / send_webhook / * trigger_automation. `resolveTemplate` expands `{Token}` placeholders from * a record's values (title + property names, case-insensitive) for * create_page/update_page/send_webhook payloads. */ export const MAX_BUTTON_ACTIONS = 5; export type ButtonActionType = | "set_property" | "create_page" | "update_page" | "archive_page" | "duplicate_page" | "send_webhook" | "trigger_automation"; export const BUTTON_ACTION_TYPES: readonly ButtonActionType[] = [ "set_property", "create_page", "update_page", "archive_page", "duplicate_page", "send_webhook", "trigger_automation", ]; export interface ButtonConfig { name: string; icon?: string; color?: string; actions: unknown[]; } export interface ButtonValidationError { field: string; message: string; } /** Validate a button config (the API 400s map to these field errors). */ export function validateButtonConfig(config: unknown): ButtonValidationError[] { const errors: ButtonValidationError[] = []; if (config === null || typeof config !== "object" || Array.isArray(config)) { return [{ field: "config", message: "button config must be an object" }]; } const c = config as Record; if (typeof c["name"] !== "string" || c["name"].length < 1 || c["name"].length > 50) { errors.push({ field: "name", message: "name must be 1–50 characters" }); } if (c["icon"] !== undefined && typeof c["icon"] !== "string") { errors.push({ field: "icon", message: "icon must be an emoji string" }); } if (c["color"] !== undefined && typeof c["color"] !== "string") { errors.push({ field: "color", message: "color must be a token string" }); } if ( !Array.isArray(c["actions"]) || c["actions"].length < 1 || c["actions"].length > MAX_BUTTON_ACTIONS ) { errors.push({ field: "actions", message: `actions must be an array of 1–${MAX_BUTTON_ACTIONS}`, }); return errors; } c["actions"].forEach((a, i) => { const p = `actions[${i}]`; if (a === null || typeof a !== "object" || Array.isArray(a)) { errors.push({ field: p, message: "action must be an object" }); return; } const o = a as Record; const type = o["type"]; if (typeof type !== "string" || !BUTTON_ACTION_TYPES.includes(type as ButtonActionType)) { errors.push({ field: `${p}.type`, message: `action type must be one of ${BUTTON_ACTION_TYPES.join(", ")}`, }); return; } switch (type) { case "set_property": if (typeof o["propertyId"] !== "string") errors.push({ field: `${p}.propertyId`, message: "required" }); if (o["value"] === undefined && o["setTo"] === undefined) errors.push({ field: `${p}.value`, message: "value or setTo required" }); break; case "create_page": if (o["databaseId"] !== undefined && typeof o["databaseId"] !== "string") errors.push({ field: `${p}.databaseId`, message: "must be a string" }); break; case "update_page": if (typeof o["patch"] !== "object" || o["patch"] === null) errors.push({ field: `${p}.patch`, message: "required" }); break; case "archive_page": case "duplicate_page": if ( o["target"] !== "self" && (typeof o["target"] !== "string" || o["target"].length === 0) ) { errors.push({ field: `${p}.target`, message: "'self' or a page id" }); } break; case "send_webhook": if (typeof o["webhookId"] !== "string" || o["webhookId"].length === 0) errors.push({ field: `${p}.webhookId`, message: "required" }); if (o["payloadTemplate"] !== undefined && typeof o["payloadTemplate"] !== "string") { errors.push({ field: `${p}.payloadTemplate`, message: "must be a JSON string" }); } break; case "trigger_automation": if (typeof o["automationId"] !== "string" || o["automationId"].length === 0) errors.push({ field: `${p}.automationId`, message: "required" }); break; } }); return errors; } /** * Expand `{Token}` placeholders from a record's values. Tokens match the * record title or property names case-insensitively (`{Title}` is the title). * Unknown tokens expand to the empty string (never an error at run time). */ export function resolveTemplate( template: string, title: string | undefined, values: Record, ): string { const lookup = new Map(); if (title !== undefined) lookup.set("title", title); for (const [key, v] of Object.entries(values)) lookup.set(key.toLowerCase(), v.display); return template.replace( /\{([A-Za-z0-9_]+)\}/g, (_m, name: string) => lookup.get(name.toLowerCase()) ?? "", ); } /** A one-line human description of an action (the confirm popover). */ export function describeAction( action: Record, propertyNames: Map, ): string { const type = action["type"]; switch (type) { case "set_property": { const name = propertyNames.get(String(action["propertyId"])) ?? "property"; if (action["setTo"] === "empty") return `Clear ${name}`; return `Set ${name}`; } case "create_page": return action["databaseId"] ? "Create a record" : "Create a page"; case "update_page": return "Update page"; case "archive_page": return action["target"] === "self" ? "Archive this record" : "Archive page"; case "duplicate_page": return action["target"] === "self" ? "Duplicate this record" : "Duplicate page"; case "send_webhook": return "Send webhook"; case "trigger_automation": return "Trigger automation"; default: return String(type ?? "action"); } }