/** * Attribute value types for Capture (CE-WP-0012). * * Interim consumer catalog aligned with * `citation-evidence/wiki/AttributeValueTypes-proposal.md` (accepted defaults). * Long-term owner: InfoTechCanon AttributeValueType catalog (ITC-WP-0013). */ /** Implemented MVP types + legacy alias `textarea`. */ export type AttributeValueType = | "text" | "text-long" | "textarea" // legacy alias of text-long | "date" | "time" | "datetime" | "amount" | "one-of" | "some-of"; /** Reserved in the proposal; not rendered yet. */ export type ReservedAttributeValueType = | "boolean" | "integer" | "number" | "uri" | "identifier"; export interface AttributeOption { readonly id: string; readonly label: string; } export interface FormFieldSchema { readonly type: AttributeValueType; readonly id: string; readonly label: string; /** CodeList options for one-of / some-of. */ readonly options?: readonly AttributeOption[]; /** Default ISO 4217 code for amount attributes. */ readonly defaultCurrency?: string; } export interface FormSchema { readonly id: string; readonly title: string; readonly fields: readonly FormFieldSchema[]; } export const IMPLEMENTED_FIELD_TYPES: readonly { value: AttributeValueType; label: string; }[] = [ { value: "text", label: "Text" }, { value: "text-long", label: "Long text" }, { value: "date", label: "Date" }, { value: "time", label: "Time" }, { value: "datetime", label: "Date & time" }, { value: "amount", label: "Amount" }, { value: "one-of", label: "One of" }, { value: "some-of", label: "Some of" }, ]; const KNOWN_TYPES = new Set([ ...IMPLEMENTED_FIELD_TYPES.map((t) => t.value), "textarea", ]); export function isAttributeValueType(value: unknown): value is AttributeValueType { return typeof value === "string" && KNOWN_TYPES.has(value); } /** Normalize legacy wire ids. */ export function normalizeAttributeType(type: AttributeValueType): AttributeValueType { return type === "textarea" ? "text-long" : type; } export function displayTypeLabel(type: AttributeValueType): string { const n = normalizeAttributeType(type); return IMPLEMENTED_FIELD_TYPES.find((t) => t.value === n)?.label ?? n; } export function typeNeedsOptions(type: AttributeValueType): boolean { const n = normalizeAttributeType(type); return n === "one-of" || n === "some-of"; } export interface AmountValue { readonly value: string; readonly currency: string; } export const DEFAULT_CURRENCY = "EUR"; export function parseAmountValue(raw: string | undefined): AmountValue { if (!raw || raw.trim().length === 0) { return { value: "", currency: DEFAULT_CURRENCY }; } try { const parsed = JSON.parse(raw) as unknown; if (typeof parsed === "object" && parsed !== null) { const o = parsed as Record; return { value: typeof o.value === "string" ? o.value : String(o.value ?? ""), currency: typeof o.currency === "string" && o.currency.length > 0 ? o.currency : DEFAULT_CURRENCY, }; } } catch { // plain "1500 EUR" or bare number const m = raw.trim().match(/^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]{3})?$/); if (m) { return { value: m[1] ?? "", currency: (m[2] ?? DEFAULT_CURRENCY).toUpperCase(), }; } } return { value: raw, currency: DEFAULT_CURRENCY }; } export function serializeAmountValue(amount: AmountValue): string { return JSON.stringify({ value: amount.value, currency: amount.currency || DEFAULT_CURRENCY, }); } export function parseSomeOfValue(raw: string | undefined): readonly string[] { if (!raw || raw.trim().length === 0) return []; try { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) { return parsed.filter((x): x is string => typeof x === "string"); } } catch { // single id without JSON return [raw]; } return []; } export function serializeSomeOfValue(ids: readonly string[]): string { return JSON.stringify([...ids]); } /** slug-ish option id from label */ export function optionIdFromLabel(label: string, used: ReadonlySet): string { let base = label .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (!base) base = "opt"; let id = base; let n = 2; while (used.has(id)) { id = `${base}-${n}`; n += 1; } return id; } /** * Parse options editor text: one option per line. * `id|label` or bare `label` (id auto-derived). */ export function parseOptionsText(text: string): readonly AttributeOption[] { const used = new Set(); const out: AttributeOption[] = []; for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const pipe = trimmed.indexOf("|"); let id: string; let label: string; if (pipe >= 0) { id = trimmed.slice(0, pipe).trim(); label = trimmed.slice(pipe + 1).trim(); if (!id) id = optionIdFromLabel(label, used); } else { label = trimmed; id = optionIdFromLabel(label, used); } if (!label) continue; used.add(id); out.push({ id, label }); } return out; } export function formatOptionsText(options: readonly AttributeOption[] | undefined): string { if (!options || options.length === 0) return ""; return options.map((o) => `${o.id}|${o.label}`).join("\n"); } export function normalizeFormField(field: FormFieldSchema): FormFieldSchema { const type = normalizeAttributeType(field.type); const base: FormFieldSchema = { id: field.id, label: field.label, type, }; if (typeNeedsOptions(type) && field.options) { return { ...base, options: field.options }; } if (type === "amount" && field.defaultCurrency) { return { ...base, defaultCurrency: field.defaultCurrency }; } return base; } export function normalizeFormSchema(schema: FormSchema): FormSchema { return { ...schema, fields: schema.fields.map(normalizeFormField), }; }