evidence-binder/src/attribute-types.ts
tegwick 8cf7bc1128
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Add attribute value types: time, datetime, amount, one-of, some-of.
Implement CE-WP-0012 interim catalog (accepted defaults): widgets, options
editor, amount JSON encoding, textarea→text-long normalize.
2026-07-30 21:05:43 +02:00

220 lines
5.9 KiB
TypeScript

/**
* 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<string>([
...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<string, unknown>;
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>): 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<string>();
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),
};
}