@workbench-kit/field-remap 0.0.1-prototype.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/package.json +31 -0
- package/src/domain/constants.ts +14 -0
- package/src/domain/document/fieldRemapDocument.ts +129 -0
- package/src/domain/document/mappingEdge.ts +136 -0
- package/src/domain/ingest/sourceFieldsFromPlainObject.ts +103 -0
- package/src/domain/ingest/targetSlotsFromPlainObject.ts +41 -0
- package/src/domain/mapping/convertItemEdges.ts +93 -0
- package/src/domain/mapping/dateFormat.ts +102 -0
- package/src/domain/mapping/mappingConflicts.ts +90 -0
- package/src/domain/mapping/pathUtils.ts +156 -0
- package/src/domain/mapping/resolveMappedValue.ts +96 -0
- package/src/domain/mapping/transformOptions.ts +184 -0
- package/src/domain/mapping/treeUtils.ts +32 -0
- package/src/domain/shapes/conversionDefinition.ts +95 -0
- package/src/domain/shapes/convertToShape.ts +187 -0
- package/src/domain/shapes/dataShape.ts +109 -0
- package/src/domain/types.ts +182 -0
- package/src/index.ts +137 -0
- package/src/registry/builtinTransforms.ts +243 -0
- package/src/registry/createValueTransformRegistry.ts +194 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export type DateParts = { readonly year: string; readonly month: string; readonly day: string };
|
|
2
|
+
|
|
3
|
+
const FORMAT_TOKEN_RE = /YYYY|MM|DD/g;
|
|
4
|
+
|
|
5
|
+
/** Parse a date string with a simple token format (YYYY/MM/DD only). */
|
|
6
|
+
export function parseDateParts(value: string, inputFormat: string): DateParts | undefined {
|
|
7
|
+
const format = inputFormat.trim();
|
|
8
|
+
const raw = value.trim();
|
|
9
|
+
if (!format || !raw) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let year = '';
|
|
14
|
+
let month = '';
|
|
15
|
+
let day = '';
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < format.length;) {
|
|
19
|
+
if (format.startsWith('YYYY', i)) {
|
|
20
|
+
year = raw.slice(cursor, cursor + 4);
|
|
21
|
+
if (!/^\d{4}$/.test(year)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
cursor += 4;
|
|
25
|
+
i += 4;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (format.startsWith('MM', i)) {
|
|
29
|
+
month = raw.slice(cursor, cursor + 2);
|
|
30
|
+
if (!/^\d{2}$/.test(month)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
cursor += 2;
|
|
34
|
+
i += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (format.startsWith('DD', i)) {
|
|
38
|
+
day = raw.slice(cursor, cursor + 2);
|
|
39
|
+
if (!/^\d{2}$/.test(day)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
cursor += 2;
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (raw[cursor] !== format[i]) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
cursor += 1;
|
|
50
|
+
i += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (cursor !== raw.length || !year || !month || !day) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return { year, month, day };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatDateParts(parts: DateParts, outputFormat: string): string {
|
|
60
|
+
return outputFormat.replace(FORMAT_TOKEN_RE, (token) => {
|
|
61
|
+
if (token === 'YYYY') {
|
|
62
|
+
return parts.year;
|
|
63
|
+
}
|
|
64
|
+
if (token === 'MM') {
|
|
65
|
+
return parts.month;
|
|
66
|
+
}
|
|
67
|
+
return parts.day;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function reformatDateString(
|
|
72
|
+
value: string,
|
|
73
|
+
inputFormat: string,
|
|
74
|
+
outputFormat: string,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const parts = parseDateParts(value, inputFormat);
|
|
77
|
+
if (!parts) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return formatDateParts(parts, outputFormat);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Split `YYYY-MM-DDTHH:mm:ss` / `YYYY-MM-DD HH:mm:ss` / date-only. */
|
|
84
|
+
export function splitDateTimeString(value: string): { date: string; time: string } | undefined {
|
|
85
|
+
const raw = value.trim();
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const tIndex = raw.indexOf('T');
|
|
90
|
+
const spaceIndex = raw.indexOf(' ');
|
|
91
|
+
const splitAt = tIndex >= 0 ? tIndex : spaceIndex;
|
|
92
|
+
if (splitAt < 0) {
|
|
93
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw) || /^\d{8}$/.test(raw)) {
|
|
94
|
+
return { date: raw, time: '' };
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
date: raw.slice(0, splitAt),
|
|
100
|
+
time: raw.slice(splitAt + 1),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { MappingEdge, SourceField, TargetSlot } from '../types.js';
|
|
2
|
+
import { flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
3
|
+
|
|
4
|
+
export interface MappingConflict {
|
|
5
|
+
readonly kind: 'parent-child-source' | 'parent-child-target';
|
|
6
|
+
readonly parentId: string;
|
|
7
|
+
readonly childId: string;
|
|
8
|
+
readonly parentEdgeId: string;
|
|
9
|
+
readonly childEdgeId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isAncestorId(ancestorId: string, descendantId: string): boolean {
|
|
13
|
+
return (
|
|
14
|
+
descendantId.startsWith(`${ancestorId}.`) || descendantId.startsWith(`${ancestorId}.item.`)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect edges that map both a parent object/array and one of its descendants.
|
|
20
|
+
* Hosts should warn — writing both usually overwrites or double-defines output.
|
|
21
|
+
*/
|
|
22
|
+
export function findParentChildMappingConflicts(
|
|
23
|
+
edges: readonly MappingEdge[],
|
|
24
|
+
sources: readonly SourceField[],
|
|
25
|
+
targets: readonly TargetSlot[],
|
|
26
|
+
): MappingConflict[] {
|
|
27
|
+
const sourceIds = new Set(flattenSourceFields(sources).map((field) => field.id));
|
|
28
|
+
const targetIds = new Set(flattenTargetSlots(targets).map((slot) => slot.id));
|
|
29
|
+
const conflicts: MappingConflict[] = [];
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
32
|
+
const a = edges[i]!;
|
|
33
|
+
for (let j = i + 1; j < edges.length; j += 1) {
|
|
34
|
+
const b = edges[j]!;
|
|
35
|
+
if (
|
|
36
|
+
sourceIds.has(a.sourceFieldId) &&
|
|
37
|
+
sourceIds.has(b.sourceFieldId) &&
|
|
38
|
+
isAncestorId(a.sourceFieldId, b.sourceFieldId)
|
|
39
|
+
) {
|
|
40
|
+
conflicts.push({
|
|
41
|
+
kind: 'parent-child-source',
|
|
42
|
+
parentId: a.sourceFieldId,
|
|
43
|
+
childId: b.sourceFieldId,
|
|
44
|
+
parentEdgeId: a.id,
|
|
45
|
+
childEdgeId: b.id,
|
|
46
|
+
});
|
|
47
|
+
} else if (
|
|
48
|
+
sourceIds.has(a.sourceFieldId) &&
|
|
49
|
+
sourceIds.has(b.sourceFieldId) &&
|
|
50
|
+
isAncestorId(b.sourceFieldId, a.sourceFieldId)
|
|
51
|
+
) {
|
|
52
|
+
conflicts.push({
|
|
53
|
+
kind: 'parent-child-source',
|
|
54
|
+
parentId: b.sourceFieldId,
|
|
55
|
+
childId: a.sourceFieldId,
|
|
56
|
+
parentEdgeId: b.id,
|
|
57
|
+
childEdgeId: a.id,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
targetIds.has(a.targetSlotId) &&
|
|
63
|
+
targetIds.has(b.targetSlotId) &&
|
|
64
|
+
isAncestorId(a.targetSlotId, b.targetSlotId)
|
|
65
|
+
) {
|
|
66
|
+
conflicts.push({
|
|
67
|
+
kind: 'parent-child-target',
|
|
68
|
+
parentId: a.targetSlotId,
|
|
69
|
+
childId: b.targetSlotId,
|
|
70
|
+
parentEdgeId: a.id,
|
|
71
|
+
childEdgeId: b.id,
|
|
72
|
+
});
|
|
73
|
+
} else if (
|
|
74
|
+
targetIds.has(a.targetSlotId) &&
|
|
75
|
+
targetIds.has(b.targetSlotId) &&
|
|
76
|
+
isAncestorId(b.targetSlotId, a.targetSlotId)
|
|
77
|
+
) {
|
|
78
|
+
conflicts.push({
|
|
79
|
+
kind: 'parent-child-target',
|
|
80
|
+
parentId: b.targetSlotId,
|
|
81
|
+
childId: a.targetSlotId,
|
|
82
|
+
parentEdgeId: b.id,
|
|
83
|
+
childEdgeId: a.id,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return conflicts;
|
|
90
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight path helpers for collection item projection and safe templates.
|
|
3
|
+
* Supports simple dotted paths (`name`, `meta.label`) on plain objects.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Identifier or dotted path: `city`, `a.b` (no expressions / eval). */
|
|
7
|
+
const SAFE_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
8
|
+
|
|
9
|
+
/** Placeholder matcher: `{city}`, `{a.b}` — rejects expressions / spaces. */
|
|
10
|
+
const TEMPLATE_PLACEHOLDER_RE = /\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}/g;
|
|
11
|
+
|
|
12
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isSafeObjectPath(path: string): boolean {
|
|
17
|
+
return SAFE_PATH_RE.test(path.trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fill `{path}` placeholders from a plain object using safe dotted paths only.
|
|
22
|
+
* Unknown / unsafe placeholders become empty strings. No JS eval.
|
|
23
|
+
*/
|
|
24
|
+
export function applyStringTemplate(
|
|
25
|
+
template: string,
|
|
26
|
+
record: Readonly<Record<string, unknown>> | null | undefined,
|
|
27
|
+
): string {
|
|
28
|
+
return template.replace(TEMPLATE_PLACEHOLDER_RE, (_match, path: string) => {
|
|
29
|
+
if (!record || !isSafeObjectPath(path)) {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
const resolved = readObjectPath(record, path);
|
|
33
|
+
if (resolved === null || resolved === undefined) {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
typeof resolved === 'string' ||
|
|
38
|
+
typeof resolved === 'number' ||
|
|
39
|
+
typeof resolved === 'boolean'
|
|
40
|
+
) {
|
|
41
|
+
return String(resolved);
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readObjectPath(value: unknown, path: string): unknown {
|
|
48
|
+
const parts = path
|
|
49
|
+
.split('.')
|
|
50
|
+
.map((part) => part.trim())
|
|
51
|
+
.filter((part) => part.length > 0);
|
|
52
|
+
if (parts.length === 0) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let current: unknown = value;
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
current = (current as Record<string, unknown>)[part];
|
|
62
|
+
}
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write `value` at a dotted path, creating plain-object parents as needed.
|
|
68
|
+
* Returns a new root object (does not mutate `root`).
|
|
69
|
+
*/
|
|
70
|
+
export function writeObjectPath(
|
|
71
|
+
root: Readonly<Record<string, unknown>> | null | undefined,
|
|
72
|
+
path: string,
|
|
73
|
+
value: unknown,
|
|
74
|
+
): Record<string, unknown> {
|
|
75
|
+
const parts = path
|
|
76
|
+
.split('.')
|
|
77
|
+
.map((part) => part.trim())
|
|
78
|
+
.filter((part) => part.length > 0);
|
|
79
|
+
if (parts.length === 0) {
|
|
80
|
+
return isPlainObject(root) ? { ...root } : {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result: Record<string, unknown> = isPlainObject(root) ? { ...root } : {};
|
|
84
|
+
let cursor: Record<string, unknown> = result;
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
87
|
+
const part = parts[index]!;
|
|
88
|
+
const existing = cursor[part];
|
|
89
|
+
const nextChild: Record<string, unknown> = isPlainObject(existing) ? { ...existing } : {};
|
|
90
|
+
cursor[part] = nextChild;
|
|
91
|
+
cursor = nextChild;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
cursor[parts[parts.length - 1]!] = value;
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Project each array element through `itemSourcePath`.
|
|
100
|
+
* Non-arrays are returned unchanged (callers decide whether that is valid).
|
|
101
|
+
*/
|
|
102
|
+
export function projectCollectionItems(value: unknown, itemSourcePath: string): unknown {
|
|
103
|
+
const path = itemSourcePath.trim();
|
|
104
|
+
if (!path || !Array.isArray(value)) {
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
return value.map((item) => readObjectPath(item, path));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ArrayItemProjectionOption {
|
|
111
|
+
readonly path: string;
|
|
112
|
+
readonly label: string;
|
|
113
|
+
readonly dataType?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Projection candidates for an array source:
|
|
118
|
+
* 1) explicit item-schema `children` (path / label)
|
|
119
|
+
* 2) else keys of the first object in `sampleValue`
|
|
120
|
+
*/
|
|
121
|
+
export function listArrayItemProjectionOptions(source: {
|
|
122
|
+
readonly children?: readonly {
|
|
123
|
+
readonly label: string;
|
|
124
|
+
readonly path?: string;
|
|
125
|
+
readonly dataType?: string;
|
|
126
|
+
}[];
|
|
127
|
+
readonly sampleValue?: unknown;
|
|
128
|
+
}): ArrayItemProjectionOption[] {
|
|
129
|
+
if (source.children?.length) {
|
|
130
|
+
const options: ArrayItemProjectionOption[] = [];
|
|
131
|
+
for (const child of source.children) {
|
|
132
|
+
const path = (child.path ?? child.label).trim();
|
|
133
|
+
if (!path) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
options.push({
|
|
137
|
+
path,
|
|
138
|
+
label: child.label,
|
|
139
|
+
dataType: child.dataType,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return options;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!Array.isArray(source.sampleValue) || source.sampleValue.length === 0) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const first = source.sampleValue[0];
|
|
149
|
+
if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
return Object.keys(first as Record<string, unknown>).map((key) => ({
|
|
153
|
+
path: key,
|
|
154
|
+
label: key,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MappingEdge,
|
|
3
|
+
SourceField,
|
|
4
|
+
TransformContext,
|
|
5
|
+
ValueTransformRegistry,
|
|
6
|
+
} from '../types.js';
|
|
7
|
+
import { edgeItemTransformIds, edgeTransformIds } from '../document/mappingEdge.js';
|
|
8
|
+
import { projectCollectionItems } from './pathUtils.js';
|
|
9
|
+
import { applyTransformChain } from '../../registry/createValueTransformRegistry.js';
|
|
10
|
+
import { BUILTIN_TRANSFORM_IDS } from '../../registry/builtinTransforms.js';
|
|
11
|
+
import { resolveOptionSteps } from './transformOptions.js';
|
|
12
|
+
import { flattenSourceFields } from './treeUtils.js';
|
|
13
|
+
|
|
14
|
+
export { findTargetSlot, flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
15
|
+
|
|
16
|
+
export { projectCollectionItems, readObjectPath } from './pathUtils.js';
|
|
17
|
+
|
|
18
|
+
export function findSourceField(
|
|
19
|
+
fields: readonly SourceField[],
|
|
20
|
+
fieldId: string,
|
|
21
|
+
): SourceField | undefined {
|
|
22
|
+
return flattenSourceFields(fields).find((field) => field.id === fieldId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve an edge against a live / sample source value.
|
|
27
|
+
*
|
|
28
|
+
* Apply order:
|
|
29
|
+
* 1. Optional `itemSourcePath` projection (array of objects → projected array)
|
|
30
|
+
* 2. Optional `itemTransformIds` per element (when the value is still an array)
|
|
31
|
+
* 3. `transformIds` / legacy `transformId` on the whole value (including array reduces)
|
|
32
|
+
*
|
|
33
|
+
* Per-step options (`transformOptionSteps` / `itemTransformOptionSteps`) win when
|
|
34
|
+
* present; otherwise shared `transformOptions` / `itemTransformOptions` apply to all steps.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveMappedValue(
|
|
37
|
+
edge: MappingEdge,
|
|
38
|
+
sourceValue: unknown,
|
|
39
|
+
registry: ValueTransformRegistry,
|
|
40
|
+
context: TransformContext = {},
|
|
41
|
+
): unknown {
|
|
42
|
+
let current = edge.itemSourcePath
|
|
43
|
+
? projectCollectionItems(sourceValue, edge.itemSourcePath)
|
|
44
|
+
: sourceValue;
|
|
45
|
+
|
|
46
|
+
const itemChain = edgeItemTransformIds(edge);
|
|
47
|
+
const itemSteps = resolveOptionSteps(
|
|
48
|
+
itemChain,
|
|
49
|
+
edge.itemTransformOptionSteps,
|
|
50
|
+
edge.itemTransformOptions,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (itemChain.length > 0 && Array.isArray(current)) {
|
|
54
|
+
current = current.map((item) =>
|
|
55
|
+
applyTransformChain(registry, itemChain, item, context, itemSteps),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const chain = edgeTransformIds(edge);
|
|
60
|
+
if (chain.length === 0) {
|
|
61
|
+
const identity = registry.get(BUILTIN_TRANSFORM_IDS.identity);
|
|
62
|
+
return identity ? identity.apply(current, context) : current;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const valueSteps = resolveOptionSteps(chain, edge.transformOptionSteps, edge.transformOptions);
|
|
66
|
+
return applyTransformChain(registry, chain, current, context, valueSteps);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveEdgePreview(
|
|
70
|
+
edge: MappingEdge,
|
|
71
|
+
sources: readonly SourceField[],
|
|
72
|
+
registry: ValueTransformRegistry,
|
|
73
|
+
context: TransformContext = {},
|
|
74
|
+
): unknown {
|
|
75
|
+
const field = findSourceField(sources, edge.sourceFieldId);
|
|
76
|
+
const sample =
|
|
77
|
+
context.sampleValue !== undefined ? context.sampleValue : (field?.sampleValue ?? context.now);
|
|
78
|
+
return resolveMappedValue(edge, sample, registry, {
|
|
79
|
+
...context,
|
|
80
|
+
sampleValue: sample,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolve every edge into `{ targetSlotId, value }` for live preview panels. */
|
|
85
|
+
export function resolveAllEdgePreviews(
|
|
86
|
+
edges: readonly MappingEdge[],
|
|
87
|
+
sources: readonly SourceField[],
|
|
88
|
+
registry: ValueTransformRegistry,
|
|
89
|
+
context: TransformContext = {},
|
|
90
|
+
): ReadonlyArray<{ edgeId: string; targetSlotId: string; value: unknown }> {
|
|
91
|
+
return edges.map((edge) => ({
|
|
92
|
+
edgeId: edge.id,
|
|
93
|
+
targetSlotId: edge.targetSlotId,
|
|
94
|
+
value: resolveEdgePreview(edge, sources, registry, context),
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { TransformContext, TransformOptionField, ValueTransformRegistry } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/** Merge edge-local options over host `context.options` (edge wins). */
|
|
4
|
+
export function contextWithEdgeOptions(
|
|
5
|
+
context: TransformContext,
|
|
6
|
+
edgeOptions: Readonly<Record<string, unknown>> | undefined,
|
|
7
|
+
): TransformContext {
|
|
8
|
+
if (!edgeOptions || Object.keys(edgeOptions).length === 0) {
|
|
9
|
+
return context;
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
...context,
|
|
13
|
+
options: {
|
|
14
|
+
...context.options,
|
|
15
|
+
...edgeOptions,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Collect unique `optionFields` declared by transforms in a chain (later ids win per key). */
|
|
21
|
+
export function collectOptionFields(
|
|
22
|
+
registry: ValueTransformRegistry,
|
|
23
|
+
transformIds: readonly string[],
|
|
24
|
+
): TransformOptionField[] {
|
|
25
|
+
const byKey = new Map<string, TransformOptionField>();
|
|
26
|
+
for (const id of transformIds) {
|
|
27
|
+
const fields = registry.get(id)?.optionFields;
|
|
28
|
+
if (!fields) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const field of fields) {
|
|
32
|
+
byKey.set(field.key, field);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return [...byKey.values()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Option fields for a single chain step. */
|
|
39
|
+
export function optionFieldsForStep(
|
|
40
|
+
registry: ValueTransformRegistry,
|
|
41
|
+
transformId: string | undefined,
|
|
42
|
+
): TransformOptionField[] {
|
|
43
|
+
if (!transformId) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return [...(registry.get(transformId)?.optionFields ?? [])];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Drop empty / undefined option bags when persisting edges. */
|
|
50
|
+
export function sanitizeOptionRecord(
|
|
51
|
+
options: Readonly<Record<string, unknown>> | undefined,
|
|
52
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
53
|
+
if (!options) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const next: Record<string, unknown> = {};
|
|
57
|
+
for (const [key, value] of Object.entries(options)) {
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
next[key] = value;
|
|
62
|
+
}
|
|
63
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function patchOptionRecord(
|
|
67
|
+
previous: Readonly<Record<string, unknown>> | undefined,
|
|
68
|
+
key: string,
|
|
69
|
+
value: unknown,
|
|
70
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
71
|
+
return sanitizeOptionRecord({
|
|
72
|
+
...previous,
|
|
73
|
+
[key]: value,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Align / sanitize per-step option bags to `length`.
|
|
79
|
+
* Returns `undefined` when every step is empty.
|
|
80
|
+
*/
|
|
81
|
+
export function sanitizeOptionSteps(
|
|
82
|
+
steps: readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined,
|
|
83
|
+
length: number,
|
|
84
|
+
): readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined {
|
|
85
|
+
if (length <= 0) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const next: (Readonly<Record<string, unknown>> | undefined)[] = [];
|
|
89
|
+
let any = false;
|
|
90
|
+
for (let index = 0; index < length; index += 1) {
|
|
91
|
+
const sanitized = sanitizeOptionRecord(steps?.[index]);
|
|
92
|
+
next.push(sanitized);
|
|
93
|
+
if (sanitized) {
|
|
94
|
+
any = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return any ? next : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve per-step options for a transform chain.
|
|
102
|
+
* Prefers `steps`; otherwise expands shared `transformOptions` to every step (apply-to-all).
|
|
103
|
+
*/
|
|
104
|
+
export function resolveOptionSteps(
|
|
105
|
+
transformIds: readonly string[],
|
|
106
|
+
steps: readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined,
|
|
107
|
+
shared: Readonly<Record<string, unknown>> | undefined,
|
|
108
|
+
): (Readonly<Record<string, unknown>> | undefined)[] {
|
|
109
|
+
const length = transformIds.length;
|
|
110
|
+
if (length === 0) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
if (steps && steps.length > 0) {
|
|
114
|
+
return Array.from({ length }, (_, index) => sanitizeOptionRecord(steps[index]));
|
|
115
|
+
}
|
|
116
|
+
const bag = sanitizeOptionRecord(shared);
|
|
117
|
+
if (!bag) {
|
|
118
|
+
return Array.from({ length }, () => undefined);
|
|
119
|
+
}
|
|
120
|
+
return Array.from({ length }, () => bag);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Back-compat summary: first non-empty step bag (else undefined). */
|
|
124
|
+
export function sharedOptionsFromSteps(
|
|
125
|
+
steps: readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined,
|
|
126
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
127
|
+
if (!steps) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
for (const step of steps) {
|
|
131
|
+
const sanitized = sanitizeOptionRecord(step);
|
|
132
|
+
if (sanitized) {
|
|
133
|
+
return sanitized;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Merge all step bags (later steps win) — useful for live format-sample chips. */
|
|
140
|
+
export function mergeOptionSteps(
|
|
141
|
+
steps: readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined,
|
|
142
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
143
|
+
if (!steps || steps.length === 0) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const merged: Record<string, unknown> = {};
|
|
147
|
+
for (const step of steps) {
|
|
148
|
+
if (!step) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
Object.assign(merged, step);
|
|
152
|
+
}
|
|
153
|
+
return sanitizeOptionRecord(merged);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function patchOptionStep(
|
|
157
|
+
steps: readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined,
|
|
158
|
+
length: number,
|
|
159
|
+
index: number,
|
|
160
|
+
key: string,
|
|
161
|
+
value: unknown,
|
|
162
|
+
sharedFallback?: Readonly<Record<string, unknown>>,
|
|
163
|
+
): readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined {
|
|
164
|
+
const base = resolveOptionSteps(
|
|
165
|
+
Array.from({ length }, () => ''),
|
|
166
|
+
steps,
|
|
167
|
+
sharedFallback,
|
|
168
|
+
);
|
|
169
|
+
const next = base.map((step, stepIndex) =>
|
|
170
|
+
stepIndex === index ? patchOptionRecord(step, key, value) : step,
|
|
171
|
+
);
|
|
172
|
+
return sanitizeOptionSteps(next, length);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resizeOptionSteps(
|
|
176
|
+
steps: readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined,
|
|
177
|
+
length: number,
|
|
178
|
+
): readonly (Readonly<Record<string, unknown>> | undefined)[] | undefined {
|
|
179
|
+
if (length <= 0) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const next = Array.from({ length }, (_, index) => sanitizeOptionRecord(steps?.[index]));
|
|
183
|
+
return sanitizeOptionSteps(next, length);
|
|
184
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SourceField, TargetSlot } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/** Flatten nested source fields depth-first. */
|
|
4
|
+
export function flattenSourceFields(fields: readonly SourceField[]): SourceField[] {
|
|
5
|
+
const out: SourceField[] = [];
|
|
6
|
+
for (const field of fields) {
|
|
7
|
+
out.push(field);
|
|
8
|
+
if (field.children?.length) {
|
|
9
|
+
out.push(...flattenSourceFields(field.children));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Flatten nested target slots depth-first. */
|
|
16
|
+
export function flattenTargetSlots(slots: readonly TargetSlot[]): TargetSlot[] {
|
|
17
|
+
const out: TargetSlot[] = [];
|
|
18
|
+
for (const slot of slots) {
|
|
19
|
+
out.push(slot);
|
|
20
|
+
if (slot.children?.length) {
|
|
21
|
+
out.push(...flattenTargetSlots(slot.children));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findTargetSlot(
|
|
28
|
+
slots: readonly TargetSlot[],
|
|
29
|
+
slotId: string,
|
|
30
|
+
): TargetSlot | undefined {
|
|
31
|
+
return flattenTargetSlots(slots).find((slot) => slot.id === slotId);
|
|
32
|
+
}
|