/// /** * Custom Field Module - Generic property/value pairs for structured data * * A "catch-all" module for capturing properties that don't fit existing typed modules. * Supports multiple value types: text, number, date, boolean, url. * * Key features: * - Multi-instance: Users can add many custom fields * - Array extraction: LLM extracts multiple fields at once * - Always available: Schema included in extraction even without existing instances */ import { computed, type Default, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; import type { ModuleMetadata } from "./container-protocol.ts"; // ===== Types ===== export type CustomFieldValueType = | "text" | "number" | "date" | "boolean" | "url"; export const VALUE_TYPE_OPTIONS = [ { value: "text", label: "Text" }, { value: "number", label: "Number" }, { value: "date", label: "Date" }, { value: "boolean", label: "Yes/No" }, { value: "url", label: "URL" }, ]; // ===== Self-Describing Metadata ===== export const MODULE_METADATA: ModuleMetadata = { type: "custom-field", label: "Custom Field", icon: "\u{1F4CB}", // clipboard emoji allowMultiple: true, // Special extraction flags: // - alwaysExtract: Include in extraction schema even with no instances // - extractionMode: "array" means each array item creates a separate module instance alwaysExtract: true, extractionMode: "array", schema: { customFields: { type: "array", items: { type: "object", properties: { fieldName: { type: "string", description: "The property name (e.g., 'Employee ID', 'SKU')", }, fieldValue: { type: "string", description: "The property value", }, fieldType: { type: "string", enum: ["text", "number", "date", "boolean", "url"], description: "Value type - use 'number' for IDs/quantities, 'date' for dates, 'boolean' for yes/no, 'url' for links, 'text' for everything else", }, }, required: ["fieldName", "fieldValue"], }, description: "Custom properties that don't fit other structured modules. Only extract clearly structured data like IDs, codes, explicit measurements. Do NOT extract vague preferences, opinions, or conversational text.", }, }, fieldMapping: ["customFields"], }; // ===== Input/Output Types ===== export interface CustomFieldModuleInput { /** Field name (e.g., "Employee ID", "SKU") */ name: Default; /** Field value (stored as string, parsed by UI) */ value: Default; /** Value type determines input UI */ valueType: Default; } // Output interface with unknown for UI properties to prevent OOM (CT-1148) interface CustomFieldModuleOutput { [NAME]: unknown; [UI]: unknown; name: string; value: string; valueType: CustomFieldValueType; } // ===== Handlers ===== // Handler to toggle boolean value const toggleBoolean = handler< unknown, { value: Writable } >((_event, { value }) => { const current = (value.get() || "").toLowerCase(); const isTrue = current === "true" || current === "yes" || current === "1"; value.set(isTrue ? "false" : "true"); }); // ===== The Pattern ===== export const CustomFieldModule = pattern< CustomFieldModuleInput, CustomFieldModuleOutput >(({ name, value, valueType }) => { // Format display value based on type const displayValue = computed(() => { const v = String(value || ""); const t = String(valueType) as CustomFieldValueType; if (!v) return "(empty)"; switch (t) { case "boolean": { const lower = v.toLowerCase(); return lower === "true" || lower === "yes" || lower === "1" ? "Yes" : "No"; } case "url": { try { const url = new URL(v.startsWith("http") ? v : `https://${v}`); return url.hostname; } catch { return v; } } default: return v; } }); // Check value type for conditional rendering const isText = computed(() => String(valueType) === "text"); const isNumber = computed(() => String(valueType) === "number"); const isDate = computed(() => String(valueType) === "date"); const isBoolean = computed(() => String(valueType) === "boolean"); const isUrl = computed(() => String(valueType) === "url"); // Fallback for invalid valueType - treat as text const isFallback = computed(() => { const t = String(valueType); return !["text", "number", "date", "boolean", "url"].includes(t); }); // For boolean type, compute the checked state const isChecked = computed(() => { const v = String(value || "").toLowerCase(); return v === "true" || v === "yes" || v === "1"; }); // Display name for checkbox label const displayName = computed(() => { const n = String(name || ""); return n || "Value"; }); // Sanitize URL to only allow http/https protocols (prevent javascript:/data: XSS) const safeUrl = computed(() => { const v = String(value || "").trim(); if (!v) return ""; // Add https if no protocol specified const urlWithProtocol = v.startsWith("http://") || v.startsWith("https://") ? v : `https://${v}`; // Only allow http/https protocols try { const url = new URL(urlWithProtocol); if (url.protocol === "http:" || url.protocol === "https:") { return urlWithProtocol; } return ""; // Invalid protocol } catch { return ""; // Invalid URL } }); return { [NAME]: computed(() => { const n = String(name || ""); const dv = displayValue; if (!n) return `${MODULE_METADATA.icon} Custom Field`; return `${MODULE_METADATA.icon} ${n}: ${dv}`; }), [UI]: ( {/* Field name + type selector row */} {/* Value input - type-specific */} {/* Text input */} {ifElse( isText, , null, )} {/* Number input */} {ifElse( isNumber, , null, )} {/* Date input */} {ifElse(isDate, , null)} {/* Boolean checkbox */} {ifElse( isBoolean,
{displayName}
, null, )} {/* URL input with preview */} {ifElse( isUrl, {ifElse( computed(() => !!safeUrl), Open link ↗ , null, )} , null, )} {/* Fallback for invalid valueType */} {ifElse( isFallback, , null, )}
), name, value, valueType, }; }); export default CustomFieldModule;