humainflow-web/src/app/shared/nodes/node-utility.ts

299 lines
9.7 KiB
TypeScript

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<string, any>,
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<string, any>, 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<string, any> | null | undefined): string {
return readUiLabel(schema?.['x-ui-label']) ?? pathToLabel(path);
}
export function schemaFieldDescription(schema: Record<string, any> | 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<string, any>, root: Record<string, any>) {
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<string, any> | null | undefined, path: string): Record<string, any> | null {
if (!root || !path.trim()) return root ?? null;
let current: Record<string, any> | 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<string, any>, root) as Record<string, any> | null;
continue;
}
const properties = resolved.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return null;
current = resolveSchemaRef(properties[segment] as Record<string, any>, root) as Record<string, any> | null;
}
return current;
}
export function readUiConditionRule(value: unknown): UiConditionRule | null {
if (!value || typeof value !== 'object') return null;
const field = (value as Record<string, unknown>)['field'];
const equals = (value as Record<string, unknown>)['equals'];
const includes = (value as Record<string, unknown>)['in'];
const present = (value as Record<string, unknown>)['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<string, any> | null | undefined, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[key];
}, source ?? {});
}
export function validateUniqueByConstraint(
items: unknown[],
nextItem: Record<string, unknown>,
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<string, unknown>, uniqueBy)
: undefined;
return areValuesEqual(candidateValue, uniqueValue);
});
if (duplicateIndex < 0) return null;
return {
path: uniqueBy,
value: uniqueValue
};
}
export function shouldSkipSchemaField(key: string, schema: Record<string, any> | 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<string, any> | null | undefined,
resolveFieldSchema?: (path: string) => Record<string, any> | 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<string, unknown>).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;
}