export type TemplatedTextPart = { text: string; isDynamicInput: boolean }; export type UiConditionRule = | { field: string; equals: string } | { field: string; in: string[] } | { field: string; present: boolean }; export function toStringOrNull(value: unknown): string | null { if (typeof value === 'string' && value.trim().length > 0) return value; return null; } export function flattenPrimitiveValues( source: Record, prefix = '' ): Array<{ path: string; value: unknown }> { const entries: Array<{ path: string; value: unknown }> = []; for (const [key, value] of Object.entries(source ?? {})) { const path = prefix ? `${prefix}.${key}` : key; if (key.startsWith('__')) continue; if (value != null && typeof value === 'object' && !Array.isArray(value)) { entries.push(...flattenPrimitiveValues(value as Record, path)); } else { entries.push({ path, value }); } } return entries; } export function valueToDisplayString(value: unknown): string { if (value == null) return '-'; if (typeof value === 'string') return value.trim().length ? value : '-'; if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '-'; try { return JSON.stringify(value); } catch { return String(value); } } export function pathToLabel(path: string): string { const lastSegment = path.split('.').at(-1) ?? path; return lastSegment .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[_-]/g, ' ') .replace(/^./, (c) => c.toUpperCase()); } export function readUiLabel(value: unknown): string | null { if (typeof value !== 'string') return null; const normalized = value.trim(); return normalized.length ? normalized : null; } export function readUiDescription(value: unknown): string | null { if (typeof value !== 'string') return null; const normalized = value.trim(); return normalized.length ? normalized : null; } export function schemaFieldLabel(path: string, schema: Record | null | undefined): string { return readUiLabel(schema?.['x-ui-label']) ?? pathToLabel(path); } export function schemaFieldDescription(schema: Record | null | undefined): string | null { return readUiDescription(schema?.['x-ui-description']) ?? (typeof schema?.['x-ui-tip'] === 'string' ? String(schema['x-ui-tip']).trim() || null : null); } export function parentPath(path: string): string | null { const index = path.lastIndexOf('.'); if (index <= 0) return null; return path.slice(0, index); } export function resolveSchemaRef(node: Record, root: Record) { if (!node || typeof node !== 'object') return node; const ref = node['$ref']; if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; const path = ref.slice(2).split('/'); let current: any = root; for (const segment of path) { current = current?.[segment]; if (current == null) return node; } if (!current || typeof current !== 'object') return node; // Keep local wrapper metadata such as x-ui-* when a schema node decorates a $ref. return { ...current, ...node }; } export function resolveSchemaPath(root: Record | null | undefined, path: string): Record | null { if (!root || !path.trim()) return root ?? null; let current: Record | null = root; for (const segment of path.split('.')) { if (!current) return null; const resolved = resolveSchemaRef(current, root); if (!resolved || typeof resolved !== 'object') return null; if (/^\d+$/.test(segment)) { const items = resolved.items; if (!items || typeof items !== 'object') return null; current = resolveSchemaRef(items as Record, root) as Record | null; continue; } const properties = resolved.properties as Record | undefined; if (!properties || !properties[segment]) return null; current = resolveSchemaRef(properties[segment] as Record, root) as Record | null; } return current; } export function readUiConditionRule(value: unknown): UiConditionRule | null { if (!value || typeof value !== 'object') return null; const field = (value as Record)['field']; const equals = (value as Record)['equals']; const includes = (value as Record)['in']; const present = (value as Record)['present']; if (typeof field !== 'string' || field.trim().length === 0) return null; if (typeof equals === 'string') { return { field: field.trim(), equals }; } if (Array.isArray(includes)) { const values = includes.filter((item): item is string => typeof item === 'string'); if (!values.length) return null; return { field: field.trim(), in: values }; } if (typeof present === 'boolean') { return { field: field.trim(), present }; } return null; } export function readUiGroup(value: unknown): string | null { if (typeof value !== 'string') return null; const normalized = value.trim(); return normalized.length ? normalized : null; } export function getValueByPath(source: Record | null | undefined, path: string): unknown { return path.split('.').reduce((acc, key) => { if (acc == null || typeof acc !== 'object') return undefined; return (acc as Record)[key]; }, source ?? {}); } export function validateUniqueByConstraint( items: unknown[], nextItem: Record, uniqueBy: string | null | undefined, currentIndex: number | null, isMissingValue: (value: unknown) => boolean, areValuesEqual: (left: unknown, right: unknown) => boolean ): { path: string; value: unknown } | null { if (!uniqueBy) return null; const uniqueValue = getValueByPath(nextItem, uniqueBy); if (isMissingValue(uniqueValue)) return null; const duplicateIndex = items.findIndex((item, index) => { if (currentIndex != null && index === currentIndex) return false; const candidateValue = item && typeof item === 'object' && !Array.isArray(item) ? getValueByPath(item as Record, uniqueBy) : undefined; return areValuesEqual(candidateValue, uniqueValue); }); if (duplicateIndex < 0) return null; return { path: uniqueBy, value: uniqueValue }; } export function shouldSkipSchemaField(key: string, schema: Record | null | undefined): boolean { if (key.startsWith('__')) return true; if (key !== 'type') return false; const enumValues = Array.isArray(schema?.['enum']) ? schema['enum'].filter((value: unknown): value is string => typeof value === 'string') : []; const defaultValue = typeof schema?.['default'] === 'string' ? String(schema['default']) : null; return enumValues.length === 1 && defaultValue != null && enumValues[0] === defaultValue; } export function evaluateUiConditionRule( rule: UiConditionRule | null | undefined, config: Record | null | undefined, resolveFieldSchema?: (path: string) => Record | null ): boolean { if (!rule) return true; const actualValue = getValueByPath(config, rule.field); const schema = resolveFieldSchema?.(rule.field); const schemaType = schema?.['type']; const type = typeof schemaType === 'string' ? schemaType : null; const expectedValues = 'in' in rule ? rule.in : null; const expectedValue = 'equals' in rule ? rule.equals : null; const expectedPresence = 'present' in rule ? rule.present : null; if (expectedPresence != null) { const isPresent = isMeaningfullyPresent(actualValue); return isPresent === expectedPresence; } if (type === 'boolean' || typeof actualValue === 'boolean') { if (expectedValues) { return expectedValues.some((value) => actualValue === parseBooleanCondition(value)); } return expectedValue != null && actualValue === parseBooleanCondition(expectedValue); } if (type === 'number' || type === 'integer' || typeof actualValue === 'number') { if (expectedValues) { return expectedValues .map((value) => Number(value)) .filter((value) => Number.isFinite(value)) .some((value) => actualValue === value); } const expected = Number(expectedValue); return Number.isFinite(expected) && actualValue === expected; } if (expectedValues) { return expectedValues.includes(String(actualValue ?? '')); } return expectedValue != null && String(actualValue ?? '') === expectedValue; } function parseBooleanCondition(value: string): boolean { return value.trim().toLowerCase() === 'true'; } function isMeaningfullyPresent(value: unknown): boolean { if (value == null) return false; if (typeof value === 'string') return value.trim().length > 0; if (Array.isArray(value)) return value.some((item) => isMeaningfullyPresent(item)); if (typeof value === 'object') { return Object.values(value as Record).some((item) => isMeaningfullyPresent(item)); } return true; } export function splitTemplatedTextParts(text: string | null): TemplatedTextPart[] { if (!text) return []; const parts: TemplatedTextPart[] = []; const re = /\$\{\{[^}]+\}\}/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = re.exec(text)) !== null) { if (match.index > lastIndex) { parts.push({ text: text.slice(lastIndex, match.index), isDynamicInput: false }); } parts.push({ text: match[0], isDynamicInput: true }); lastIndex = re.lastIndex; } if (lastIndex < text.length) { parts.push({ text: text.slice(lastIndex), isDynamicInput: false }); } return parts; }