@taylorwong/ichartjs 2.0.1
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/CHANGELOG.md +69 -0
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/agent-recipes/diagrams/agent-orchestration.json +18 -0
- package/agent-recipes/diagrams/approval-process.json +16 -0
- package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
- package/agent-recipes/diagrams/workflow.json +19 -0
- package/agent-recipes/foundational-analysis.json +18 -0
- package/agent-recipes/project-management.json +47 -0
- package/agent-recipes/trend-line.json +21 -0
- package/docs/agent/README.md +62 -0
- package/docs/agent/charting-scenario.md +82 -0
- package/docs/agent/coding-agent-integration.md +88 -0
- package/docs/agent/development/2.0-release-readiness.md +65 -0
- package/docs/agent/development/iteration-2.md +9 -0
- package/docs/agent/development/iteration-3.md +142 -0
- package/docs/agent/development/iteration-4.md +261 -0
- package/docs/agent/development/iteration-5.md +43 -0
- package/docs/agent/development/iteration-6.md +130 -0
- package/docs/agent/development/iteration-7.md +117 -0
- package/docs/agent/development/iteration-8-acceptance.md +68 -0
- package/docs/agent/development/iteration-8.md +116 -0
- package/docs/agent/development/iteration-9.md +44 -0
- package/docs/agent/development/playground-plan.md +143 -0
- package/docs/agent/development/prompt-contract.md +22 -0
- package/docs/agent/development/rc-1-acceptance.md +43 -0
- package/docs/agent/development/roadmap.md +259 -0
- package/docs/agent/development-guide.md +60 -0
- package/docs/agent/diagram-scenario.md +82 -0
- package/docs/agent/editing-contract.md +71 -0
- package/docs/agent/frontend-integration.md +66 -0
- package/docs/agent/project-scenario.md +89 -0
- package/docs/agent/quickstart.md +205 -0
- package/docs/agent/runtime-contract.md +132 -0
- package/docs/agent/theme-guide.md +59 -0
- package/docs/agent/zh-CN/README.md +26 -0
- package/docs/agent/zh-CN/charting-scenario.md +62 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
- package/docs/agent/zh-CN/development-guide.md +31 -0
- package/docs/agent/zh-CN/diagram-scenario.md +37 -0
- package/docs/agent/zh-CN/editing-contract.md +27 -0
- package/docs/agent/zh-CN/frontend-integration.md +31 -0
- package/docs/agent/zh-CN/project-scenario.md +40 -0
- package/docs/agent/zh-CN/quickstart.md +84 -0
- package/docs/agent/zh-CN/runtime-contract.md +85 -0
- package/docs/agent/zh-CN/theme-guide.md +50 -0
- package/docs/manifests/capabilities.json +79 -0
- package/docs/manifests/commands.json +30 -0
- package/docs/manifests/schemas.json +13 -0
- package/examples/agent-workflow.mjs +94 -0
- package/package.json +55 -0
- package/skills/ichartjs/SKILL.md +72 -0
- package/skills/ichartjs/agents/openai.yaml +4 -0
- package/skills/ichartjs/references/agent-contract.md +39 -0
- package/skills/ichartjs/references/chart-selection.md +22 -0
- package/src/capabilities.mjs +190 -0
- package/src/charts.mjs +234 -0
- package/src/command.mjs +93 -0
- package/src/data.mjs +75 -0
- package/src/diagram-interaction.mjs +195 -0
- package/src/diagram.mjs +135 -0
- package/src/edit-controller.mjs +192 -0
- package/src/edit.mjs +369 -0
- package/src/format.mjs +19 -0
- package/src/history.mjs +14 -0
- package/src/index.mjs +562 -0
- package/src/plugin.mjs +18 -0
- package/src/project-analytics.mjs +357 -0
- package/src/project-linking.mjs +72 -0
- package/src/project.mjs +370 -0
- package/src/recipes.mjs +18 -0
- package/src/renderer.mjs +77 -0
- package/src/scale.mjs +9 -0
- package/src/scene.mjs +18 -0
- package/src/schema.mjs +142 -0
- package/src/spec.mjs +127 -0
- package/src/theme.mjs +232 -0
- package/src/transforms.mjs +34 -0
- package/src/validation.mjs +106 -0
- package/types/index.d.ts +134 -0
package/src/edit.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business and diagram edit preview/commit primitives.
|
|
3
|
+
* Validates schemas and commands before producing deterministic patches.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON, getBusinessSchema, issue, validateDataSchema } from './schema.mjs';
|
|
6
|
+
import { validateData } from './validation.mjs';
|
|
7
|
+
import { commandCapabilities, normalizeCommand, validateCommand } from './command.mjs';
|
|
8
|
+
import { validateDiagram } from './diagram.mjs';
|
|
9
|
+
|
|
10
|
+
const day = 86400000;
|
|
11
|
+
const plusDays = (value, days) => { const date = new Date(`${value}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() + days); return date.toISOString().slice(0, 10); };
|
|
12
|
+
const equality = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
13
|
+
const recordId = operation => operation.recordId ?? operation.taskId ?? operation.nodeId ?? operation.edgeId;
|
|
14
|
+
const diagramOperationTypes = new Set(['moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
|
|
15
|
+
const structureOperationTypes = new Set(['assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
|
|
16
|
+
|
|
17
|
+
export function businessModelForType(type) {
|
|
18
|
+
return { gantt: 'project-task', timeline: 'timeline-event', milestone: 'milestone', burndown: 'burndown-sample', flow: 'flow-node', swimlane: 'flow-node' }[type] || type;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function jsonValue(value) { return value === undefined ? null : copyJSON(value); }
|
|
22
|
+
function change(before, after, path, operation) { return { path, before: jsonValue(before), after: jsonValue(after), operation: operation.op }; }
|
|
23
|
+
function nextId(rows, prefix) { let index = rows.length + 1; while (rows.some(row => row?.id === `${prefix}-${index}`)) index += 1; return `${prefix}-${index}`; }
|
|
24
|
+
function uniqueId(existing, preferred) { let index = 1, value = preferred; while (existing.has(value)) value = `${preferred}-${index++}`; existing.add(value); return value; }
|
|
25
|
+
function duplicateEdge(edges, edge) { return edges.some(item => item.from === edge.from && item.to === edge.to && (item.fromPort || null) === (edge.fromPort || null) && (item.toPort || null) === (edge.toPort || null)); }
|
|
26
|
+
|
|
27
|
+
function previewDiagramEdit(command, options = {}) {
|
|
28
|
+
const nodeSchemaResult = validateDataSchema(options.nodeSchema || options.schema || getBusinessSchema('flow-node'));
|
|
29
|
+
const edgeSchemaResult = validateDataSchema(options.edgeSchema || getBusinessSchema('flow-edge'));
|
|
30
|
+
const errors = [...nodeSchemaResult.errors, ...edgeSchemaResult.errors], warnings = [], changes = [], patches = [], affected = new Set();
|
|
31
|
+
const beforeNodes = copyJSON(options.nodes || options.values || []), beforeEdges = copyJSON(options.edges || []), beforeGroups = copyJSON(options.groups || []), lanes = copyJSON(options.lanes || []);
|
|
32
|
+
if (!nodeSchemaResult.valid || !edgeSchemaResult.valid) return result({ valid: false, errors, command, changes, patches, affectedRecords: [], warnings, requiresConfirmation: false });
|
|
33
|
+
const nodes = copyJSON(beforeNodes), edges = copyJSON(beforeEdges), groups = copyJSON(beforeGroups), nodeIds = new Set(nodes.map(row => row.id)), edgeIds = new Set(edges.map(row => row.id).filter(Boolean));
|
|
34
|
+
const nodeIndex = () => new Map(nodes.map((row, rowIndex) => [row.id, { row, rowIndex }]));
|
|
35
|
+
const edgeIndex = () => new Map(edges.map((row, rowIndex) => [row.id || `edge-${rowIndex}`, { row, rowIndex }]));
|
|
36
|
+
const groupIndex = () => new Map(groups.map((row, rowIndex) => [row.id, { row, rowIndex }]));
|
|
37
|
+
const editable = (schema, field) => schema?.fields?.[field]?.editable !== false && schema?.fields?.[field]?.agentEditable !== false;
|
|
38
|
+
const setField = (collection, hit, field, value, operation, opIndex, schemaName) => {
|
|
39
|
+
const schema = schemaName === 'edges' ? edgeSchemaResult.schema : nodeSchemaResult.schema;
|
|
40
|
+
if (!editable(schema, field)) { errors.push(issue('READ_ONLY_FIELD', `command.operations.${opIndex}.changes.${field}`, `Field ${field} is not editable by an Agent.`)); return; }
|
|
41
|
+
const before = hit.row[field];
|
|
42
|
+
hit.row[field] = copyJSON(value);
|
|
43
|
+
changes.push(change(before, hit.row[field], `${schemaName}.${hit.rowIndex}.${field}`, operation));
|
|
44
|
+
patches.push({ op: 'replace', path: `/${schemaName}/${hit.rowIndex}/${field}`, value: copyJSON(value) });
|
|
45
|
+
if (hit.row.id) affected.add(hit.row.id);
|
|
46
|
+
};
|
|
47
|
+
const ensureStructureAllowed = (operation, opIndex) => {
|
|
48
|
+
if (!structureOperationTypes.has(operation.op)) return true;
|
|
49
|
+
if (options.allowStructuralChanges === true) return true;
|
|
50
|
+
errors.push(issue('STRUCTURAL_EDIT_DISABLED', `command.operations.${opIndex}`, 'Structural diagram edits require editing.allowStructuralChanges.'));
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
command.operations.forEach((operation, opIndex) => {
|
|
55
|
+
if (!diagramOperationTypes.has(operation.op)) return;
|
|
56
|
+
if (!ensureStructureAllowed(operation, opIndex)) return;
|
|
57
|
+
if (['moveNodes', 'alignNodes', 'snapNodes'].includes(operation.op)) {
|
|
58
|
+
const index = nodeIndex(), hits = operation.nodeIds.map(id => index.get(id));
|
|
59
|
+
if (hits.some(hit => !hit)) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
|
|
60
|
+
const positions = hits.map(hit => ({ hit, position: { ...(hit.row.position || { x: 0, y: 0 }) } }));
|
|
61
|
+
if (operation.op === 'moveNodes') positions.forEach(item => { item.position.x += operation.delta.x; item.position.y += operation.delta.y; });
|
|
62
|
+
if (operation.op === 'alignNodes') {
|
|
63
|
+
const sizes = positions.map(item => ({ width: Number(item.hit.row.size?.width) || 112, height: Number(item.hit.row.size?.height) || 36 }));
|
|
64
|
+
const bounds = positions.map((item, index) => ({ left: item.position.x, top: item.position.y, right: item.position.x + sizes[index].width, bottom: item.position.y + sizes[index].height, center: item.position.x + sizes[index].width / 2, middle: item.position.y + sizes[index].height / 2 }));
|
|
65
|
+
const target = operation.alignment === 'left' ? Math.min(...bounds.map(item => item.left))
|
|
66
|
+
: operation.alignment === 'right' ? Math.max(...bounds.map(item => item.right))
|
|
67
|
+
: operation.alignment === 'top' ? Math.min(...bounds.map(item => item.top))
|
|
68
|
+
: operation.alignment === 'bottom' ? Math.max(...bounds.map(item => item.bottom))
|
|
69
|
+
: operation.alignment === 'center' ? bounds.reduce((sum, item) => sum + item.center, 0) / bounds.length
|
|
70
|
+
: bounds.reduce((sum, item) => sum + item.middle, 0) / bounds.length;
|
|
71
|
+
positions.forEach((item, index) => {
|
|
72
|
+
if (operation.alignment === 'left') item.position.x = target;
|
|
73
|
+
else if (operation.alignment === 'right') item.position.x = target - sizes[index].width;
|
|
74
|
+
else if (operation.alignment === 'top') item.position.y = target;
|
|
75
|
+
else if (operation.alignment === 'bottom') item.position.y = target - sizes[index].height;
|
|
76
|
+
else if (operation.alignment === 'center') item.position.x = target - sizes[index].width / 2;
|
|
77
|
+
else item.position.y = target - sizes[index].height / 2;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (operation.op === 'snapNodes') positions.forEach(item => { const grid = Number(options.grid) || 8; item.position.x = Math.round(item.position.x / grid) * grid; item.position.y = Math.round(item.position.y / grid) * grid; });
|
|
81
|
+
positions.forEach(item => setField(nodes, item.hit, 'position', item.position, operation, opIndex, 'nodes'));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (['moveNode', 'resizeNode', 'moveNodeToLane'].includes(operation.op)) {
|
|
86
|
+
const hit = nodeIndex().get(recordId(operation));
|
|
87
|
+
if (!hit) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return; }
|
|
88
|
+
if (operation.op === 'moveNode') setField(nodes, hit, 'position', operation.position, operation, opIndex, 'nodes');
|
|
89
|
+
if (operation.op === 'resizeNode') setField(nodes, hit, 'size', operation.size, operation, opIndex, 'nodes');
|
|
90
|
+
if (operation.op === 'moveNodeToLane') setField(nodes, hit, 'laneId', operation.laneId, operation, opIndex, 'nodes');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (operation.op === 'moveGroup') {
|
|
95
|
+
if (!groupIndex().has(operation.groupId)) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
|
|
96
|
+
const hits = nodes.filter(node => node.groupId === operation.groupId).map(node => nodeIndex().get(node.id));
|
|
97
|
+
if (!hits.length) { errors.push(issue('EMPTY_GROUP', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} has no member nodes.`)); return; }
|
|
98
|
+
hits.forEach(hit => setField(nodes, hit, 'position', { x: Number(hit.row.position?.x || 0) + operation.delta.x, y: Number(hit.row.position?.y || 0) + operation.delta.y }, operation, opIndex, 'nodes'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (operation.op === 'resizeGroup') {
|
|
103
|
+
if (!groupIndex().has(operation.groupId)) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
|
|
104
|
+
const hits = nodes.filter(node => node.groupId === operation.groupId).map(node => nodeIndex().get(node.id));
|
|
105
|
+
if (!hits.length) { errors.push(issue('EMPTY_GROUP', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} has no member nodes.`)); return; }
|
|
106
|
+
const boxes = hits.map(hit => ({ x: Number(hit.row.position?.x || 0), y: Number(hit.row.position?.y || 0), width: Number(hit.row.size?.width || 112), height: Number(hit.row.size?.height || 36) }));
|
|
107
|
+
const left = Math.min(...boxes.map(box => box.x)), top = Math.min(...boxes.map(box => box.y)), right = Math.max(...boxes.map(box => box.x + box.width)), bottom = Math.max(...boxes.map(box => box.y + box.height));
|
|
108
|
+
const scaleX = operation.size.width / Math.max(1, right - left), scaleY = operation.size.height / Math.max(1, bottom - top);
|
|
109
|
+
hits.forEach((hit, index) => {
|
|
110
|
+
setField(nodes, hit, 'position', { x: left + (boxes[index].x - left) * scaleX, y: top + (boxes[index].y - top) * scaleY }, operation, opIndex, 'nodes');
|
|
111
|
+
setField(nodes, hit, 'size', { width: boxes[index].width * scaleX, height: boxes[index].height * scaleY }, operation, opIndex, 'nodes');
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (operation.op === 'assignNodesToGroup') {
|
|
117
|
+
if (operation.groupId !== null && !groupIndex().has(operation.groupId)) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
|
|
118
|
+
const index = nodeIndex(), hits = [...new Set(operation.nodeIds)].map(id => index.get(id));
|
|
119
|
+
if (hits.some(hit => !hit)) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
|
|
120
|
+
hits.forEach(hit => {
|
|
121
|
+
const before = hit.row.groupId;
|
|
122
|
+
if (operation.groupId === null) delete hit.row.groupId;
|
|
123
|
+
else hit.row.groupId = operation.groupId;
|
|
124
|
+
affected.add(hit.row.id);
|
|
125
|
+
changes.push(change(before, hit.row.groupId, `nodes.${hit.rowIndex}.groupId`, operation));
|
|
126
|
+
patches.push(operation.groupId === null ? { op: 'remove', path: `/nodes/${hit.rowIndex}/groupId` } : { op: before === undefined ? 'add' : 'replace', path: `/nodes/${hit.rowIndex}/groupId`, value: operation.groupId });
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (operation.op === 'duplicateGroup') {
|
|
132
|
+
const hit = groupIndex().get(operation.groupId);
|
|
133
|
+
if (!hit) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
|
|
134
|
+
const members = nodes.filter(node => node.groupId === operation.groupId);
|
|
135
|
+
if (!members.length) { errors.push(issue('EMPTY_GROUP', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} has no member nodes.`)); return; }
|
|
136
|
+
const groupId = uniqueId(new Set(groups.map(group => group.id)), `${operation.groupId}-copy`);
|
|
137
|
+
const group = { ...copyJSON(hit.row), id: groupId, label: `${hit.row.label || hit.row.id} copy`, collapsed: false };
|
|
138
|
+
groups.push(group);
|
|
139
|
+
affected.add(group.id);
|
|
140
|
+
changes.push(change(undefined, group, `groups.${groups.length - 1}`, operation));
|
|
141
|
+
patches.push({ op: 'add', path: `/groups/${groups.length - 1}`, value: copyJSON(group) });
|
|
142
|
+
const mapping = new Map(), existingNodeIds = new Set(nodes.map(node => node.id)), offset = operation.offset || { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 };
|
|
143
|
+
members.forEach(node => {
|
|
144
|
+
const id = uniqueId(existingNodeIds, `${node.id}-copy`);
|
|
145
|
+
mapping.set(node.id, id);
|
|
146
|
+
const copy = { ...copyJSON(node), id, groupId, position: { x: Number(node.position?.x || 0) + Number(offset.x || 0), y: Number(node.position?.y || 0) + Number(offset.y || 0) } };
|
|
147
|
+
nodes.push(copy);
|
|
148
|
+
affected.add(copy.id);
|
|
149
|
+
changes.push(change(undefined, copy, `nodes.${nodes.length - 1}`, operation));
|
|
150
|
+
patches.push({ op: 'add', path: `/nodes/${nodes.length - 1}`, value: copyJSON(copy) });
|
|
151
|
+
});
|
|
152
|
+
edges.filter(edge => mapping.has(edge.from) && mapping.has(edge.to)).forEach(edge => {
|
|
153
|
+
const copy = { ...copyJSON(edge), id: uniqueId(edgeIds, `${edge.id || 'edge'}-copy`), from: mapping.get(edge.from), to: mapping.get(edge.to) };
|
|
154
|
+
edges.push(copy);
|
|
155
|
+
affected.add(copy.id);
|
|
156
|
+
changes.push(change(undefined, copy, `edges.${edges.length - 1}`, operation));
|
|
157
|
+
patches.push({ op: 'add', path: `/edges/${edges.length - 1}`, value: copyJSON(copy) });
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (operation.op === 'deleteGroup') {
|
|
163
|
+
const hit = groupIndex().get(operation.groupId);
|
|
164
|
+
if (!hit) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
|
|
165
|
+
const memberIds = new Set(nodes.filter(node => node.groupId === operation.groupId).map(node => node.id));
|
|
166
|
+
if ((operation.policy || 'ungroup') === 'delete-members') {
|
|
167
|
+
for (let index = edges.length - 1; index >= 0; index -= 1) if (memberIds.has(edges[index].from) || memberIds.has(edges[index].to)) { const removed = edges.splice(index, 1)[0]; affected.add(removed.id || `edge-${index}`); changes.push(change(removed, undefined, `edges.${index}`, operation)); patches.push({ op: 'remove', path: `/edges/${index}` }); }
|
|
168
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) if (memberIds.has(nodes[index].id)) { const removed = nodes.splice(index, 1)[0]; affected.add(removed.id); changes.push(change(removed, undefined, `nodes.${index}`, operation)); patches.push({ op: 'remove', path: `/nodes/${index}` }); }
|
|
169
|
+
} else nodes.forEach((node, index) => { if (node.groupId === operation.groupId) { delete node.groupId; affected.add(node.id); changes.push(change(operation.groupId, undefined, `nodes.${index}.groupId`, operation)); patches.push({ op: 'remove', path: `/nodes/${index}/groupId` }); } });
|
|
170
|
+
const removed = groups.splice(hit.rowIndex, 1)[0];
|
|
171
|
+
affected.add(removed.id);
|
|
172
|
+
changes.push(change(removed, undefined, `groups.${hit.rowIndex}`, operation));
|
|
173
|
+
patches.push({ op: 'remove', path: `/groups/${hit.rowIndex}` });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (operation.op === 'updateEdge') {
|
|
178
|
+
const hit = edgeIndex().get(recordId(operation));
|
|
179
|
+
if (!hit) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return; }
|
|
180
|
+
Object.entries(operation.changes || {}).forEach(([field, value]) => setField(edges, hit, field, value, operation, opIndex, 'edges'));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (operation.op === 'addEdge') {
|
|
185
|
+
const edge = {
|
|
186
|
+
id: operation.id || nextId(edges, 'edge'),
|
|
187
|
+
from: operation.from,
|
|
188
|
+
to: operation.to,
|
|
189
|
+
...(operation.fromPort ? { fromPort: operation.fromPort } : {}),
|
|
190
|
+
...(operation.toPort ? { toPort: operation.toPort } : {}),
|
|
191
|
+
...(operation.label ? { label: operation.label } : {}),
|
|
192
|
+
...(operation.status ? { status: operation.status } : {}),
|
|
193
|
+
...(operation.routing ? { routing: operation.routing } : {})
|
|
194
|
+
};
|
|
195
|
+
if (duplicateEdge(edges, edge)) { errors.push(issue('DUPLICATE_EDGE', `command.operations.${opIndex}`, 'An equivalent edge already exists.')); return; }
|
|
196
|
+
edges.push(edge);
|
|
197
|
+
edgeIds.add(edge.id);
|
|
198
|
+
affected.add(edge.id);
|
|
199
|
+
changes.push(change(undefined, edge, `edges.${edges.length - 1}`, operation));
|
|
200
|
+
patches.push({ op: 'add', path: `/edges/${edges.length - 1}`, value: copyJSON(edge) });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (operation.op === 'toggleGroupCollapse') {
|
|
205
|
+
const hit = groupIndex().get(operation.groupId);
|
|
206
|
+
if (!hit) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
|
|
207
|
+
const next = operation.collapsed ?? !hit.row.collapsed;
|
|
208
|
+
const before = Boolean(hit.row.collapsed);
|
|
209
|
+
hit.row.collapsed = next;
|
|
210
|
+
affected.add(hit.row.id);
|
|
211
|
+
changes.push(change(before, next, `groups.${hit.rowIndex}.collapsed`, operation));
|
|
212
|
+
patches.push({ op: before === undefined ? 'add' : 'replace', path: `/groups/${hit.rowIndex}/collapsed`, value: next });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const paste = (payloadNodes = [], payloadEdges = [], offset = { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 }, operationRef = operation) => {
|
|
217
|
+
const mapping = new Map(), existing = new Set(nodes.map(node => node.id));
|
|
218
|
+
payloadNodes.forEach(node => {
|
|
219
|
+
const baseId = node.id || nextId(nodes, 'node');
|
|
220
|
+
const id = uniqueId(existing, `${baseId}-copy`);
|
|
221
|
+
mapping.set(node.id, id);
|
|
222
|
+
const copy = {
|
|
223
|
+
...copyJSON(node),
|
|
224
|
+
id,
|
|
225
|
+
position: {
|
|
226
|
+
x: Number(node.position?.x || 0) + Number(offset.x || 0),
|
|
227
|
+
y: Number(node.position?.y || 0) + Number(offset.y || 0)
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
nodes.push(copy);
|
|
231
|
+
affected.add(copy.id);
|
|
232
|
+
changes.push(change(undefined, copy, `nodes.${nodes.length - 1}`, operationRef));
|
|
233
|
+
patches.push({ op: 'add', path: `/nodes/${nodes.length - 1}`, value: copyJSON(copy) });
|
|
234
|
+
});
|
|
235
|
+
payloadEdges.filter(edge => mapping.has(edge.from) && mapping.has(edge.to)).forEach(edge => {
|
|
236
|
+
const id = uniqueId(edgeIds, `${edge.id || 'edge'}-copy`);
|
|
237
|
+
const copy = {
|
|
238
|
+
...copyJSON(edge),
|
|
239
|
+
id,
|
|
240
|
+
from: mapping.get(edge.from),
|
|
241
|
+
to: mapping.get(edge.to)
|
|
242
|
+
};
|
|
243
|
+
if (duplicateEdge(edges, copy)) return;
|
|
244
|
+
edges.push(copy);
|
|
245
|
+
affected.add(copy.id);
|
|
246
|
+
changes.push(change(undefined, copy, `edges.${edges.length - 1}`, operationRef));
|
|
247
|
+
patches.push({ op: 'add', path: `/edges/${edges.length - 1}`, value: copyJSON(copy) });
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (operation.op === 'duplicateSelection') {
|
|
252
|
+
const index = nodeIndex(), selected = [...new Set(operation.nodeIds)].map(id => index.get(id)?.row).filter(Boolean);
|
|
253
|
+
if (!selected.length) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
|
|
254
|
+
const selectedIds = new Set(selected.map(node => node.id));
|
|
255
|
+
paste(selected, edges.filter(edge => selectedIds.has(edge.from) && selectedIds.has(edge.to)), operation.offset || { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (operation.op === 'pasteSelection') {
|
|
260
|
+
paste(operation.nodes || [], operation.edges || [], operation.offset || { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const nodeValidation = validateData(nodes, nodeSchemaResult.schema, { ...options.validationOptions, references: { ...options.validationOptions?.references, swimlane: lanes.map(lane => lane.id) } });
|
|
265
|
+
const edgeValidation = validateData(edges, edgeSchemaResult.schema, { ...options.validationOptions, references: { ...options.validationOptions?.references, 'flow-node': nodeValidation.rows.map(node => node.id) } });
|
|
266
|
+
errors.push(...nodeValidation.errors, ...edgeValidation.errors);
|
|
267
|
+
warnings.push(...nodeValidation.warnings, ...edgeValidation.warnings);
|
|
268
|
+
const diagramSpec = { type: 'flow', nodes: nodeValidation.rows, edges: edgeValidation.rows, groups, lanes, ...(options.diagram ? { diagram: options.diagram } : {}) };
|
|
269
|
+
const diagramValidation = validateDiagram(diagramSpec);
|
|
270
|
+
errors.push(...diagramValidation.errors.map(error => issue(error.code, error.path, error.message, error.suggestion)));
|
|
271
|
+
|
|
272
|
+
const targets = {};
|
|
273
|
+
if (!equality(beforeNodes, nodeValidation.rows)) targets.nodes = nodeValidation.rows;
|
|
274
|
+
if (!equality(beforeEdges, edgeValidation.rows)) targets.edges = edgeValidation.rows;
|
|
275
|
+
if (!equality(beforeGroups, groups)) targets.groups = groups;
|
|
276
|
+
if (errors.length) return result({ valid: false, errors, command, changes: [], patches: [], affectedRecords: [...affected], warnings, requiresConfirmation: false, before: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups }, after: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups }, targets: {} });
|
|
277
|
+
const requiresConfirmation = options.requireConfirmation !== false || command.operations.some(operation => structureOperationTypes.has(operation.op) || operation.op === 'updateEdge');
|
|
278
|
+
return result({
|
|
279
|
+
valid: true,
|
|
280
|
+
errors: [],
|
|
281
|
+
command,
|
|
282
|
+
changes,
|
|
283
|
+
patches,
|
|
284
|
+
affectedRecords: [...affected],
|
|
285
|
+
warnings,
|
|
286
|
+
requiresConfirmation,
|
|
287
|
+
before: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups },
|
|
288
|
+
after: { nodes: nodeValidation.rows, edges: edgeValidation.rows, groups },
|
|
289
|
+
targets,
|
|
290
|
+
rows: targets.nodes || nodeValidation.rows,
|
|
291
|
+
normalized: targets.nodes || nodeValidation.rows,
|
|
292
|
+
capabilities: { ...commandCapabilities(nodeSchemaResult.schema), ...commandCapabilities(edgeSchemaResult.schema) }
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function previewBusinessEdit(command, options = {}) {
|
|
297
|
+
const commandResult = validateCommand(command), schemaResult = validateDataSchema(options.schema), errors = [...commandResult.errors, ...schemaResult.errors], warnings = [], changes = [], patches = [], affected = new Set();
|
|
298
|
+
if (!commandResult.valid || !schemaResult.valid) return result({ valid: false, errors, command, changes, patches, affectedRecords: [], warnings, requiresConfirmation: false });
|
|
299
|
+
if (command.type === 'layout-edit' && !['flow-node', 'flow-edge'].includes(schemaResult.schema.name)) errors.push(issue('COMMAND_MODEL', 'command.type', 'layout-edit only supports diagram models.'));
|
|
300
|
+
const inputRows = copyJSON(options.values || []), validation = validateData(inputRows, schemaResult.schema, options.validationOptions), rows = validation.rows;
|
|
301
|
+
errors.push(...validation.errors); warnings.push(...validation.warnings);
|
|
302
|
+
const key = schemaResult.schema.key, index = new Map(rows.map((row, rowIndex) => [row[key], { row, rowIndex }])), editable = name => schemaResult.schema.fields[name]?.editable !== false && schemaResult.schema.fields[name]?.agentEditable !== false;
|
|
303
|
+
const target = operation => index.get(recordId(operation));
|
|
304
|
+
const requireTarget = (operation, opIndex) => { const hit = target(operation); if (!hit) errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return hit; };
|
|
305
|
+
const setField = (hit, field, value, operation, opIndex) => {
|
|
306
|
+
if (!Object.hasOwn(schemaResult.schema.fields, field)) { errors.push(issue('UNKNOWN_FIELD', `command.operations.${opIndex}.changes.${field}`, 'Field is not declared in the schema.')); return; }
|
|
307
|
+
if (!editable(field)) { errors.push(issue('READ_ONLY_FIELD', `command.operations.${opIndex}.changes.${field}`, `Field ${field} is not editable by an Agent.`)); return; }
|
|
308
|
+
const before = hit.row[field]; hit.row[field] = copyJSON(value); changes.push(change(before, hit.row[field], `values.${hit.rowIndex}.${field}`, operation)); patches.push({ op: 'replace', path: `/values/${hit.rowIndex}/${field}`, value: copyJSON(value) }); affected.add(hit.row[key]);
|
|
309
|
+
};
|
|
310
|
+
command.operations.forEach((operation, opIndex) => {
|
|
311
|
+
if (['moveNodes', 'alignNodes', 'snapNodes'].includes(operation.op)) {
|
|
312
|
+
const hits = operation.nodeIds.map(id => index.get(id));
|
|
313
|
+
if (hits.some(hit => !hit)) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
|
|
314
|
+
const positions = hits.map(hit => ({ hit, position: { ...(hit.row.position || { x: 0, y: 0 }) } }));
|
|
315
|
+
if (operation.op === 'moveNodes') positions.forEach(item => { item.position.x += operation.delta.x; item.position.y += operation.delta.y; });
|
|
316
|
+
if (operation.op === 'alignNodes') {
|
|
317
|
+
const sizes = positions.map(item => ({ width: Number(item.hit.row.size?.width) || 112, height: Number(item.hit.row.size?.height) || 36 })), bounds = positions.map((item, index) => ({ left: item.position.x, top: item.position.y, right: item.position.x + sizes[index].width, bottom: item.position.y + sizes[index].height, center: item.position.x + sizes[index].width / 2, middle: item.position.y + sizes[index].height / 2 }));
|
|
318
|
+
const target = operation.alignment === 'left' ? Math.min(...bounds.map(item => item.left)) : operation.alignment === 'right' ? Math.max(...bounds.map(item => item.right)) : operation.alignment === 'top' ? Math.min(...bounds.map(item => item.top)) : operation.alignment === 'bottom' ? Math.max(...bounds.map(item => item.bottom)) : operation.alignment === 'center' ? bounds.reduce((sum, item) => sum + item.center, 0) / bounds.length : bounds.reduce((sum, item) => sum + item.middle, 0) / bounds.length;
|
|
319
|
+
positions.forEach((item, index) => { if (operation.alignment === 'left') item.position.x = target; else if (operation.alignment === 'right') item.position.x = target - sizes[index].width; else if (operation.alignment === 'top') item.position.y = target; else if (operation.alignment === 'bottom') item.position.y = target - sizes[index].height; else if (operation.alignment === 'center') item.position.x = target - sizes[index].width / 2; else item.position.y = target - sizes[index].height / 2; });
|
|
320
|
+
}
|
|
321
|
+
if (operation.op === 'snapNodes') positions.forEach(item => { const grid = Number(options.grid) || 8; item.position.x = Math.round(item.position.x / grid) * grid; item.position.y = Math.round(item.position.y / grid) * grid; });
|
|
322
|
+
positions.forEach(item => setField(item.hit, 'position', item.position, operation, opIndex));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const hit = requireTarget(operation, opIndex); if (!hit) return;
|
|
326
|
+
if (operation.op === 'updateField') setField(hit, operation.field, operation.value, operation, opIndex);
|
|
327
|
+
if (['updateRecord', 'updateTask', 'updateMilestone'].includes(operation.op)) Object.entries(operation.changes || {}).forEach(([field, value]) => setField(hit, field, value, operation, opIndex));
|
|
328
|
+
if (operation.op === 'updateProgress') { setField(hit, 'progress', operation.progress, operation, opIndex); if (operation.status !== undefined) setField(hit, 'status', operation.status, operation, opIndex); }
|
|
329
|
+
if (operation.op === 'shiftTask') {
|
|
330
|
+
if (!equality(hit.row.status, 'done') || operation.allowCompleted) { setField(hit, 'start', plusDays(hit.row.start, operation.days), operation, opIndex); setField(hit, 'end', plusDays(hit.row.end, operation.days), operation, opIndex); }
|
|
331
|
+
else warnings.push(issue('COMPLETED_TASK', `command.operations.${opIndex}`, 'Completed task schedule change requires explicit allowCompleted.'));
|
|
332
|
+
if (operation.shiftDependents) rows.filter(row => (row.dependencies || []).includes(hit.row[key])).forEach(row => { const dependent = index.get(row[key]); if (dependent) { const days = operation.days; setField(dependent, 'start', plusDays(dependent.row.start, days), operation, opIndex); setField(dependent, 'end', plusDays(dependent.row.end, days), operation, opIndex); } });
|
|
333
|
+
}
|
|
334
|
+
if (['addDependency', 'removeDependency'].includes(operation.op)) {
|
|
335
|
+
const current = Array.isArray(hit.row.dependencies) ? [...hit.row.dependencies] : [], next = operation.op === 'addDependency' ? [...new Set([...current, operation.dependencyId])] : current.filter(id => id !== operation.dependencyId); setField(hit, 'dependencies', next, operation, opIndex);
|
|
336
|
+
}
|
|
337
|
+
if (operation.op === 'moveNode') setField(hit, 'position', operation.position, operation, opIndex);
|
|
338
|
+
if (operation.op === 'resizeNode') setField(hit, 'size', operation.size, operation, opIndex);
|
|
339
|
+
if (operation.op === 'moveNodeToLane') setField(hit, 'laneId', operation.laneId, operation, opIndex);
|
|
340
|
+
if (operation.op === 'updateEdge') Object.entries(operation.changes || {}).forEach(([field, value]) => setField(hit, field, value, operation, opIndex));
|
|
341
|
+
});
|
|
342
|
+
const finalValidation = validateData(rows, schemaResult.schema, options.validationOptions); errors.push(...finalValidation.errors); warnings.push(...finalValidation.warnings);
|
|
343
|
+
if (errors.length) return result({ valid: false, errors, command, changes: [], patches: [], affectedRecords: [...affected], warnings, requiresConfirmation: false, before: inputRows, after: inputRows });
|
|
344
|
+
const requiresConfirmation = options.requireConfirmation !== false || command.operations.some(operation => ['addRecord', 'removeRecord', 'addDependency', 'removeDependency', 'updateEdge'].includes(operation.op));
|
|
345
|
+
return result({ valid: true, errors: [], command, changes, patches, affectedRecords: [...affected], warnings, requiresConfirmation, before: inputRows, after: rows, normalized: finalValidation.rows, capabilities: commandCapabilities(schemaResult.schema), rows: finalValidation.rows, targets: { values: finalValidation.rows } });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function previewEdit(input, options = {}) {
|
|
349
|
+
let command;
|
|
350
|
+
try { command = normalizeCommand(input); } catch (error) { return { valid: false, errors: [issue('INVALID_COMMAND', 'command', error.message)], changes: [], patches: [], affectedRecords: [], warnings: [], requiresConfirmation: false }; }
|
|
351
|
+
const commandResult = validateCommand(command);
|
|
352
|
+
if (!commandResult.valid) return result({ valid: false, errors: commandResult.errors, command, changes: [], patches: [], affectedRecords: [], warnings: [], requiresConfirmation: false });
|
|
353
|
+
if (command.operations.some(operation => diagramOperationTypes.has(operation.op))) return previewDiagramEdit(command, options);
|
|
354
|
+
return previewBusinessEdit(command, options);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function result(value) { return { ...value, id: value.id || `preview-${JSON.stringify(value.command || {}).length}-${(value.changes || []).length}`, revision: value.revision ?? null, assumptions: ['Only declared editable fields may change.', 'Preview operates on a copied local dataset.', 'External persistence and authorization remain the host application responsibility.'] }; }
|
|
358
|
+
|
|
359
|
+
export function validateEdit(input, options = {}) { const preview = previewEdit(input, options); return { valid: preview.valid, errors: preview.errors, warnings: preview.warnings, affectedRecords: preview.affectedRecords, requiresConfirmation: preview.requiresConfirmation }; }
|
|
360
|
+
|
|
361
|
+
export function getEditCapabilities(schema) { return commandCapabilities(validateDataSchema(schema).schema || {}); }
|
|
362
|
+
|
|
363
|
+
export function commitPreview(preview, options = {}) {
|
|
364
|
+
if (!preview?.valid) return { valid: false, errors: [issue('INVALID_PREVIEW', 'preview', 'Only a valid preview can be committed.')], warnings: [] };
|
|
365
|
+
if (preview.requiresConfirmation && options.confirmed !== true && !options.approval) return { valid: false, errors: [issue('CONFIRMATION_REQUIRED', 'approval', 'This edit requires explicit host confirmation.')], warnings: preview.warnings || [] };
|
|
366
|
+
if (options.previewId !== undefined && options.previewId !== preview.id) return { valid: false, errors: [issue('PREVIEW_MISMATCH', 'previewId', 'Approval does not match this preview.')], warnings: [] };
|
|
367
|
+
if (options.expectedRevision !== undefined && options.expectedRevision !== preview.revision) return { valid: false, errors: [issue('STALE_PREVIEW', 'expectedRevision', 'The preview was created from a stale data revision.')], warnings: [] };
|
|
368
|
+
return { valid: true, rows: copyJSON(preview.normalized || preview.after), targets: copyJSON(preview.targets || { values: preview.normalized || preview.after }), changes: copyJSON(preview.changes), patches: copyJSON(preview.patches), affectedRecords: copyJSON(preview.affectedRecords), warnings: copyJSON(preview.warnings || []), command: copyJSON(preview.command), audit: { actor: options.actor || 'host', timestamp: options.timestamp || new Date().toISOString(), reason: preview.command?.reason || options.reason || 'business edit', source: options.source || 'agent', previewId: preview.id } };
|
|
369
|
+
}
|
package/src/format.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-safe value formatting shared by renderers and Agent descriptions.
|
|
3
|
+
*/
|
|
4
|
+
export function formatValue(value, format, locale = 'en-US') {
|
|
5
|
+
if (value == null || value === '') return '';
|
|
6
|
+
if (!format) return typeof value === 'number' ? String(Number(value.toFixed(6))) : String(value);
|
|
7
|
+
const options = typeof format === 'string' ? { style: format } : format;
|
|
8
|
+
const prefix = options.prefix || '', suffix = options.suffix || '';
|
|
9
|
+
if (options.style === 'date') {
|
|
10
|
+
const date = new Date(value);
|
|
11
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
12
|
+
return `${prefix}${new Intl.DateTimeFormat(options.locale || locale, options.options || { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(date)}${suffix}`;
|
|
13
|
+
}
|
|
14
|
+
const numeric = Number(value);
|
|
15
|
+
if (!Number.isFinite(numeric)) return String(value);
|
|
16
|
+
if (options.style === 'percent') return `${prefix}${new Intl.NumberFormat(options.locale || locale, { style: 'percent', maximumFractionDigits: options.maximumFractionDigits ?? 1 }).format(options.ratio === false ? numeric / 100 : numeric)}${suffix}`;
|
|
17
|
+
if (options.style === 'currency') return `${prefix}${new Intl.NumberFormat(options.locale || locale, { style: 'currency', currency: options.currency || 'USD', maximumFractionDigits: options.maximumFractionDigits ?? 2 }).format(numeric)}${suffix}`;
|
|
18
|
+
return `${prefix}${new Intl.NumberFormat(options.locale || locale, { maximumFractionDigits: options.maximumFractionDigits ?? 2, minimumFractionDigits: options.minimumFractionDigits }).format(numeric)}${suffix}`;
|
|
19
|
+
}
|
package/src/history.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded undo/redo history for committed local chart edits.
|
|
3
|
+
* Stores immutable before/after snapshots supplied by the edit controller.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON } from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
export class EditHistory {
|
|
8
|
+
constructor(limit = 100) { this.limit = limit; this.undoStack = []; this.redoStack = []; }
|
|
9
|
+
push(entry) { this.undoStack.push(copyJSON(entry)); if (this.undoStack.length > this.limit) this.undoStack.shift(); this.redoStack = []; }
|
|
10
|
+
undo() { const entry = this.undoStack.pop(); if (!entry) return null; this.redoStack.push(copyJSON(entry)); return copyJSON(entry); }
|
|
11
|
+
redo() { const entry = this.redoStack.pop(); if (!entry) return null; this.undoStack.push(copyJSON(entry)); return copyJSON(entry); }
|
|
12
|
+
clear() { this.undoStack = []; this.redoStack = []; }
|
|
13
|
+
state() { return { undo: this.undoStack.length, redo: this.redoStack.length }; }
|
|
14
|
+
}
|