workspec 1.0.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 +28 -0
- package/bin/workspec.js +289 -0
- package/index.js +10 -0
- package/package.json +30 -0
- package/v2.0.schema.json +775 -0
- package/workspec-migrate-v1-to-v2.js +714 -0
- package/workspec-validator.js +1812 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// WorkSpec v1.0 → v2.0 Migration Utilities
|
|
2
|
+
// Universal Automation Wiki
|
|
3
|
+
|
|
4
|
+
(function() {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const WORKSPEC_V2_SCHEMA_URL = 'https://universalautomation.wiki/workspec/v2.0.schema.json';
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = Object.freeze({
|
|
10
|
+
currency: 'USD',
|
|
11
|
+
locale: 'en-US',
|
|
12
|
+
timezone: 'UTC',
|
|
13
|
+
time_unit: 'minutes'
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const TYPE_ALIASES = Object.freeze({
|
|
17
|
+
// People
|
|
18
|
+
person: 'actor',
|
|
19
|
+
human: 'actor',
|
|
20
|
+
worker: 'actor',
|
|
21
|
+
employee: 'actor',
|
|
22
|
+
staff: 'actor',
|
|
23
|
+
operator: 'actor',
|
|
24
|
+
|
|
25
|
+
// Equipment
|
|
26
|
+
tool: 'equipment',
|
|
27
|
+
machine: 'equipment',
|
|
28
|
+
device: 'equipment',
|
|
29
|
+
robot: 'equipment',
|
|
30
|
+
|
|
31
|
+
// Materials / goods
|
|
32
|
+
material: 'resource',
|
|
33
|
+
ingredient: 'resource',
|
|
34
|
+
consumable: 'resource',
|
|
35
|
+
input: 'resource',
|
|
36
|
+
|
|
37
|
+
output: 'product',
|
|
38
|
+
item: 'product',
|
|
39
|
+
good: 'product'
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cloneJson(value) {
|
|
47
|
+
return JSON.parse(JSON.stringify(value));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeTimeUnit(rawValue, fallback) {
|
|
51
|
+
const defaultUnit = fallback || DEFAULTS.time_unit;
|
|
52
|
+
if (typeof rawValue !== 'string' || !rawValue.trim()) return defaultUnit;
|
|
53
|
+
|
|
54
|
+
const normalized = rawValue.trim().toLowerCase();
|
|
55
|
+
if (['second', 'seconds', 'sec', 'secs', 's'].includes(normalized)) return 'seconds';
|
|
56
|
+
if (['minute', 'minutes', 'min', 'mins', 'm'].includes(normalized)) return 'minutes';
|
|
57
|
+
if (['hour', 'hours', 'hr', 'hrs', 'h'].includes(normalized)) return 'hours';
|
|
58
|
+
|
|
59
|
+
return defaultUnit;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureString(value) {
|
|
63
|
+
return (typeof value === 'string') ? value : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeTrim(value) {
|
|
67
|
+
return ensureString(value).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toSnakeCase(rawValue) {
|
|
71
|
+
if (typeof rawValue !== 'string') return '';
|
|
72
|
+
|
|
73
|
+
const value = rawValue
|
|
74
|
+
.normalize('NFKD')
|
|
75
|
+
.replace(/[\u0300-\u036F]/g, '');
|
|
76
|
+
|
|
77
|
+
return value
|
|
78
|
+
.trim()
|
|
79
|
+
.replace(/['’]/g, '')
|
|
80
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
81
|
+
.replace(/_{2,}/g, '_')
|
|
82
|
+
.replace(/^_+|_+$/g, '')
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isEmojiCapable() {
|
|
87
|
+
try {
|
|
88
|
+
// Unicode property escapes are required for robust emoji detection.
|
|
89
|
+
// Browsers without support will throw here.
|
|
90
|
+
void new RegExp('\\p{Extended_Pictographic}', 'u');
|
|
91
|
+
return true;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const EMOJI_REGEX = isEmojiCapable()
|
|
98
|
+
? /(\p{Extended_Pictographic}(?:\uFE0F|\u200D\p{Extended_Pictographic})*)/gu
|
|
99
|
+
: null;
|
|
100
|
+
|
|
101
|
+
function extractEmojiClusters(text) {
|
|
102
|
+
if (!EMOJI_REGEX || typeof text !== 'string') return [];
|
|
103
|
+
const matches = text.match(EMOJI_REGEX);
|
|
104
|
+
return Array.isArray(matches) ? matches : [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function removeEmoji(text) {
|
|
108
|
+
if (!EMOJI_REGEX || typeof text !== 'string') return text;
|
|
109
|
+
return text.replace(EMOJI_REGEX, ' ').replace(/\s{2,}/g, ' ').trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizePlainId(rawId, prefixIfNeeded) {
|
|
113
|
+
const snake = toSnakeCase(removeEmoji(ensureString(rawId)));
|
|
114
|
+
|
|
115
|
+
let candidate = snake;
|
|
116
|
+
if (!candidate) {
|
|
117
|
+
candidate = safeTrim(prefixIfNeeded) ? `${toSnakeCase(prefixIfNeeded)}_${Date.now()}` : `id_${Date.now()}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!/^[a-z]/.test(candidate)) {
|
|
121
|
+
candidate = `${toSnakeCase(prefixIfNeeded || 'id')}_${candidate}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return candidate.slice(0, 250);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeObjectId(rawId, fallbackType) {
|
|
128
|
+
const id = ensureString(rawId);
|
|
129
|
+
if (!id) return normalizePlainId('', fallbackType || 'object');
|
|
130
|
+
|
|
131
|
+
const colonIndex = id.indexOf(':');
|
|
132
|
+
if (colonIndex === -1) {
|
|
133
|
+
return normalizePlainId(id, fallbackType || 'object');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const left = id.slice(0, colonIndex);
|
|
137
|
+
const right = id.slice(colonIndex + 1);
|
|
138
|
+
const normalizedLeft = normalizePlainId(left, 'type');
|
|
139
|
+
const normalizedRight = normalizePlainId(right, fallbackType || normalizedLeft || 'object');
|
|
140
|
+
return `${normalizedLeft}:${normalizedRight}`.slice(0, 250);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function ensureUniqueId(desiredId, usedIds) {
|
|
144
|
+
let candidate = desiredId;
|
|
145
|
+
if (!usedIds.has(candidate)) {
|
|
146
|
+
usedIds.add(candidate);
|
|
147
|
+
return candidate;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let counter = 2;
|
|
151
|
+
while (usedIds.has(`${candidate}_${counter}`)) {
|
|
152
|
+
counter += 1;
|
|
153
|
+
}
|
|
154
|
+
const unique = `${candidate}_${counter}`;
|
|
155
|
+
usedIds.add(unique);
|
|
156
|
+
return unique;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function canonicalizeObjectType(rawType) {
|
|
160
|
+
const type = safeTrim(rawType).toLowerCase();
|
|
161
|
+
if (!type) return 'object';
|
|
162
|
+
return TYPE_ALIASES[type] || type;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function coerceMeta(simulation, options) {
|
|
166
|
+
const meta = isPlainObject(simulation.meta) ? simulation.meta : {};
|
|
167
|
+
|
|
168
|
+
const articleTitle = safeTrim(meta.article_title);
|
|
169
|
+
if (!safeTrim(meta.title) && articleTitle) {
|
|
170
|
+
meta.title = articleTitle;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!safeTrim(meta.title)) {
|
|
174
|
+
const fromSimName = safeTrim(simulation.name);
|
|
175
|
+
const fromFallback = safeTrim(options.fallbackMetaTitle);
|
|
176
|
+
meta.title = fromSimName || fromFallback || 'Migrated Simulation';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!safeTrim(meta.description)) {
|
|
180
|
+
const fromSimDescription = safeTrim(simulation.description);
|
|
181
|
+
const fromFallback = safeTrim(options.fallbackMetaDescription);
|
|
182
|
+
meta.description = fromSimDescription || fromFallback || 'Migrated from WorkSpec v1.0 to v2.0.';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!safeTrim(meta.domain)) {
|
|
186
|
+
const fromSimDomain = safeTrim(simulation.domain);
|
|
187
|
+
const fromFallback = safeTrim(options.fallbackMetaDomain);
|
|
188
|
+
meta.domain = fromSimDomain || fromFallback || 'General';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (meta.article_title !== undefined) {
|
|
192
|
+
delete meta.article_title;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
simulation.meta = meta;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function coerceConfig(simulation, options) {
|
|
199
|
+
const existingConfig = isPlainObject(simulation.config) ? simulation.config : {};
|
|
200
|
+
const config = { ...existingConfig };
|
|
201
|
+
|
|
202
|
+
config.time_unit = normalizeTimeUnit(
|
|
203
|
+
config.time_unit || simulation.time_unit || simulation.simulation_config?.time_unit,
|
|
204
|
+
options.defaultTimeUnit || DEFAULTS.time_unit
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const dayTypeConfig = (() => {
|
|
208
|
+
if (!isPlainObject(simulation.day_types)) return null;
|
|
209
|
+
const dayTypeKeys = Object.keys(simulation.day_types);
|
|
210
|
+
for (const key of dayTypeKeys) {
|
|
211
|
+
const dayType = simulation.day_types[key];
|
|
212
|
+
const dtConfig = dayType?.config;
|
|
213
|
+
if (isPlainObject(dtConfig) && (dtConfig.start_time || dtConfig.end_time)) return dtConfig;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
})();
|
|
217
|
+
|
|
218
|
+
config.start_time = config.start_time
|
|
219
|
+
|| existingConfig.start_time
|
|
220
|
+
|| simulation.start_time
|
|
221
|
+
|| dayTypeConfig?.start_time
|
|
222
|
+
|| '00:00';
|
|
223
|
+
config.end_time = config.end_time
|
|
224
|
+
|| existingConfig.end_time
|
|
225
|
+
|| simulation.end_time
|
|
226
|
+
|| dayTypeConfig?.end_time
|
|
227
|
+
|| '23:59';
|
|
228
|
+
|
|
229
|
+
config.currency = safeTrim(config.currency) || safeTrim(options.defaultCurrency) || DEFAULTS.currency;
|
|
230
|
+
config.locale = safeTrim(config.locale) || safeTrim(options.defaultLocale) || DEFAULTS.locale;
|
|
231
|
+
|
|
232
|
+
if (!safeTrim(config.timezone) && safeTrim(options.defaultTimezone)) {
|
|
233
|
+
config.timezone = options.defaultTimezone;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
simulation.config = config;
|
|
237
|
+
|
|
238
|
+
// Prefer v2 config; remove obvious legacy duplicates if present
|
|
239
|
+
if (simulation.time_unit !== undefined) delete simulation.time_unit;
|
|
240
|
+
if (simulation.start_time !== undefined) delete simulation.start_time;
|
|
241
|
+
if (simulation.end_time !== undefined) delete simulation.end_time;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function moveFlatStructureToWorldProcess(simulation) {
|
|
245
|
+
if (!isPlainObject(simulation.world)) simulation.world = {};
|
|
246
|
+
if (!isPlainObject(simulation.process)) simulation.process = {};
|
|
247
|
+
|
|
248
|
+
if (!simulation.world.layout && isPlainObject(simulation.layout)) {
|
|
249
|
+
simulation.world.layout = simulation.layout;
|
|
250
|
+
}
|
|
251
|
+
if (!Array.isArray(simulation.world.objects) && Array.isArray(simulation.objects)) {
|
|
252
|
+
simulation.world.objects = simulation.objects;
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(simulation.process.tasks) && Array.isArray(simulation.tasks)) {
|
|
255
|
+
simulation.process.tasks = simulation.tasks;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!isPlainObject(simulation.process.recipes) && isPlainObject(simulation.recipes)) {
|
|
259
|
+
simulation.process.recipes = simulation.recipes;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Ensure required arrays exist
|
|
263
|
+
if (!Array.isArray(simulation.world.objects)) simulation.world.objects = [];
|
|
264
|
+
if (!Array.isArray(simulation.process.tasks)) simulation.process.tasks = [];
|
|
265
|
+
|
|
266
|
+
// Remove legacy flat keys once moved
|
|
267
|
+
if (simulation.layout !== undefined) delete simulation.layout;
|
|
268
|
+
if (simulation.objects !== undefined) delete simulation.objects;
|
|
269
|
+
if (simulation.tasks !== undefined) delete simulation.tasks;
|
|
270
|
+
if (simulation.recipes !== undefined) delete simulation.recipes;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeObjects(simulation) {
|
|
274
|
+
const objects = Array.isArray(simulation.world?.objects) ? simulation.world.objects : [];
|
|
275
|
+
|
|
276
|
+
const usedIds = new Set();
|
|
277
|
+
const idMap = new Map();
|
|
278
|
+
|
|
279
|
+
for (const obj of objects) {
|
|
280
|
+
if (!isPlainObject(obj)) continue;
|
|
281
|
+
const oldId = ensureString(obj.id);
|
|
282
|
+
const oldType = ensureString(obj.type);
|
|
283
|
+
|
|
284
|
+
obj.type = canonicalizeObjectType(oldType);
|
|
285
|
+
|
|
286
|
+
if (!safeTrim(obj.emoji) && safeTrim(obj.properties?.emoji)) {
|
|
287
|
+
obj.emoji = safeTrim(obj.properties.emoji);
|
|
288
|
+
delete obj.properties.emoji;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!safeTrim(obj.location) && safeTrim(obj.properties?.location)) {
|
|
292
|
+
obj.location = safeTrim(obj.properties.location);
|
|
293
|
+
delete obj.properties.location;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!safeTrim(obj.name)) {
|
|
297
|
+
obj.name = safeTrim(obj.properties?.role) || safeTrim(obj.properties?.name) || oldId || 'Object';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!isPlainObject(obj.properties)) obj.properties = {};
|
|
301
|
+
|
|
302
|
+
const normalizedId = ensureUniqueId(normalizeObjectId(oldId, obj.type), usedIds);
|
|
303
|
+
if (oldId && normalizedId !== oldId) {
|
|
304
|
+
idMap.set(oldId, normalizedId);
|
|
305
|
+
}
|
|
306
|
+
obj.id = normalizedId;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return idMap;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function normalizeLocations(simulation) {
|
|
313
|
+
const locations = simulation.world?.layout?.locations;
|
|
314
|
+
if (!Array.isArray(locations)) return new Map();
|
|
315
|
+
|
|
316
|
+
const usedIds = new Set();
|
|
317
|
+
const idMap = new Map();
|
|
318
|
+
|
|
319
|
+
for (const loc of locations) {
|
|
320
|
+
if (!isPlainObject(loc)) continue;
|
|
321
|
+
const oldId = ensureString(loc.id);
|
|
322
|
+
const normalizedId = ensureUniqueId(normalizePlainId(oldId, 'location'), usedIds);
|
|
323
|
+
if (oldId && normalizedId !== oldId) {
|
|
324
|
+
idMap.set(oldId, normalizedId);
|
|
325
|
+
}
|
|
326
|
+
loc.id = normalizedId;
|
|
327
|
+
if (!safeTrim(loc.name)) loc.name = oldId || normalizedId;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return idMap;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function normalizeTaskIds(simulation) {
|
|
334
|
+
const tasks = Array.isArray(simulation.process?.tasks) ? simulation.process.tasks : [];
|
|
335
|
+
|
|
336
|
+
const usedIds = new Set();
|
|
337
|
+
const idMap = new Map();
|
|
338
|
+
|
|
339
|
+
for (const task of tasks) {
|
|
340
|
+
if (!isPlainObject(task)) continue;
|
|
341
|
+
const oldId = ensureString(task.id);
|
|
342
|
+
const emojis = extractEmojiClusters(oldId);
|
|
343
|
+
if (!safeTrim(task.emoji) && emojis.length > 0) {
|
|
344
|
+
// Heuristic: prefer the last emoji cluster (often the "main" one after separators).
|
|
345
|
+
task.emoji = emojis[emojis.length - 1];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const cleaned = removeEmoji(oldId);
|
|
349
|
+
const normalizedId = ensureUniqueId(normalizePlainId(cleaned, 'task'), usedIds);
|
|
350
|
+
if (oldId && normalizedId !== oldId) {
|
|
351
|
+
idMap.set(oldId, normalizedId);
|
|
352
|
+
}
|
|
353
|
+
task.id = normalizedId;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return idMap;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function migrateInteraction(interaction, objectIdMap) {
|
|
360
|
+
if (!isPlainObject(interaction)) return interaction;
|
|
361
|
+
|
|
362
|
+
if (interaction.object_id !== undefined && interaction.target_id === undefined) {
|
|
363
|
+
interaction.target_id = interaction.object_id;
|
|
364
|
+
delete interaction.object_id;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (interaction.revert_after !== undefined && interaction.temporary === undefined) {
|
|
368
|
+
interaction.temporary = Boolean(interaction.revert_after);
|
|
369
|
+
delete interaction.revert_after;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (safeTrim(interaction.target_id) && objectIdMap.has(interaction.target_id)) {
|
|
373
|
+
interaction.target_id = objectIdMap.get(interaction.target_id);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (interaction.action === 'create' && isPlainObject(interaction.object)) {
|
|
377
|
+
const created = interaction.object;
|
|
378
|
+
created.type = canonicalizeObjectType(created.type);
|
|
379
|
+
|
|
380
|
+
if (!safeTrim(created.emoji) && safeTrim(created.properties?.emoji)) {
|
|
381
|
+
created.emoji = safeTrim(created.properties.emoji);
|
|
382
|
+
delete created.properties.emoji;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!safeTrim(created.location) && safeTrim(created.properties?.location)) {
|
|
386
|
+
created.location = safeTrim(created.properties.location);
|
|
387
|
+
delete created.properties.location;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!safeTrim(created.name)) {
|
|
391
|
+
created.name = safeTrim(created.properties?.role) || safeTrim(created.properties?.name) || created.id || 'Object';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (safeTrim(created.id) && objectIdMap.has(created.id)) {
|
|
395
|
+
created.id = objectIdMap.get(created.id);
|
|
396
|
+
} else if (safeTrim(created.id)) {
|
|
397
|
+
created.id = normalizeObjectId(created.id, created.type);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return interaction;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function addDeltaInteraction(targetId, delta, description) {
|
|
405
|
+
if (!safeTrim(targetId) || typeof delta !== 'number' || !Number.isFinite(delta) || delta === 0) return null;
|
|
406
|
+
const interaction = {
|
|
407
|
+
target_id: targetId,
|
|
408
|
+
property_changes: {
|
|
409
|
+
quantity: { delta }
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
if (safeTrim(description)) interaction.description = description;
|
|
413
|
+
return interaction;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function migrateTask(task, maps) {
|
|
417
|
+
const { objectIdMap, locationIdMap, taskIdMap } = maps;
|
|
418
|
+
if (!isPlainObject(task)) return;
|
|
419
|
+
|
|
420
|
+
if (!safeTrim(task.actor_id) && safeTrim(task.assigned_to)) {
|
|
421
|
+
task.actor_id = task.assigned_to;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!safeTrim(task.location) && safeTrim(task.location_id)) {
|
|
425
|
+
task.location = task.location_id;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (safeTrim(task.actor_id) && objectIdMap.has(task.actor_id)) {
|
|
429
|
+
task.actor_id = objectIdMap.get(task.actor_id);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (safeTrim(task.location) && locationIdMap.has(task.location)) {
|
|
433
|
+
task.location = locationIdMap.get(task.location);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (task.location_id !== undefined && locationIdMap.has(task.location_id)) {
|
|
437
|
+
task.location_id = locationIdMap.get(task.location_id);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// depends_on mapping (supports array, or {all/any})
|
|
441
|
+
if (Array.isArray(task.depends_on)) {
|
|
442
|
+
task.depends_on = task.depends_on.map((dep) => taskIdMap.get(dep) || dep);
|
|
443
|
+
} else if (isPlainObject(task.depends_on)) {
|
|
444
|
+
if (Array.isArray(task.depends_on.all)) {
|
|
445
|
+
task.depends_on.all = task.depends_on.all.map((dep) => taskIdMap.get(dep) || dep);
|
|
446
|
+
}
|
|
447
|
+
if (Array.isArray(task.depends_on.any)) {
|
|
448
|
+
task.depends_on.any = task.depends_on.any.map((dep) => taskIdMap.get(dep) || dep);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const interactions = Array.isArray(task.interactions) ? task.interactions : [];
|
|
453
|
+
|
|
454
|
+
// consumes/produces -> delta interactions
|
|
455
|
+
const consumes = isPlainObject(task.consumes) ? task.consumes : null;
|
|
456
|
+
if (consumes) {
|
|
457
|
+
for (const [rawId, amount] of Object.entries(consumes)) {
|
|
458
|
+
const numeric = Number(amount);
|
|
459
|
+
if (!Number.isFinite(numeric) || numeric === 0) continue;
|
|
460
|
+
const target = objectIdMap.get(rawId) || rawId;
|
|
461
|
+
const created = addDeltaInteraction(target, -numeric, 'Migrated from task.consumes');
|
|
462
|
+
if (created) interactions.push(created);
|
|
463
|
+
}
|
|
464
|
+
delete task.consumes;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const produces = isPlainObject(task.produces) ? task.produces : null;
|
|
468
|
+
if (produces) {
|
|
469
|
+
for (const [rawId, amount] of Object.entries(produces)) {
|
|
470
|
+
const numeric = Number(amount);
|
|
471
|
+
if (!Number.isFinite(numeric) || numeric === 0) continue;
|
|
472
|
+
const target = objectIdMap.get(rawId) || rawId;
|
|
473
|
+
const created = addDeltaInteraction(target, numeric, 'Migrated from task.produces');
|
|
474
|
+
if (created) interactions.push(created);
|
|
475
|
+
}
|
|
476
|
+
delete task.produces;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// equipment_interactions / equipment_state_changes -> interactions with from/to
|
|
480
|
+
const equipmentStateChanges = isPlainObject(task.equipment_state_changes) ? task.equipment_state_changes : null;
|
|
481
|
+
if (equipmentStateChanges) {
|
|
482
|
+
for (const [rawEquipId, stateChange] of Object.entries(equipmentStateChanges)) {
|
|
483
|
+
if (!isPlainObject(stateChange)) continue;
|
|
484
|
+
const target = objectIdMap.get(rawEquipId) || rawEquipId;
|
|
485
|
+
interactions.push({
|
|
486
|
+
target_id: target,
|
|
487
|
+
property_changes: {
|
|
488
|
+
state: { from: stateChange.from, to: stateChange.to }
|
|
489
|
+
},
|
|
490
|
+
description: 'Migrated from task.equipment_state_changes'
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
delete task.equipment_state_changes;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const equipmentInteractions = Array.isArray(task.equipment_interactions) ? task.equipment_interactions : null;
|
|
497
|
+
if (equipmentInteractions) {
|
|
498
|
+
for (const entry of equipmentInteractions) {
|
|
499
|
+
if (!isPlainObject(entry)) continue;
|
|
500
|
+
const rawTarget = entry.equipment_id || entry.object_id || entry.target_id || entry.id;
|
|
501
|
+
const target = objectIdMap.get(rawTarget) || rawTarget;
|
|
502
|
+
const from = entry.from ?? entry.state_from;
|
|
503
|
+
const to = entry.to ?? entry.state_to;
|
|
504
|
+
if (!safeTrim(target) || (from === undefined && to === undefined)) continue;
|
|
505
|
+
interactions.push({
|
|
506
|
+
target_id: target,
|
|
507
|
+
property_changes: {
|
|
508
|
+
state: { from, to }
|
|
509
|
+
},
|
|
510
|
+
temporary: entry.temporary !== undefined ? Boolean(entry.temporary) : (entry.revert_after !== undefined ? Boolean(entry.revert_after) : undefined),
|
|
511
|
+
description: 'Migrated from task.equipment_interactions'
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
delete task.equipment_interactions;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Normalize existing interactions object_id -> target_id, revert_after -> temporary
|
|
518
|
+
for (const interaction of interactions) {
|
|
519
|
+
migrateInteraction(interaction, objectIdMap);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (interactions.length > 0) {
|
|
523
|
+
task.interactions = interactions;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function migrateTasks(simulation, objectIdMap, locationIdMap) {
|
|
528
|
+
const tasks = Array.isArray(simulation.process?.tasks) ? simulation.process.tasks : [];
|
|
529
|
+
const taskIdMap = normalizeTaskIds(simulation);
|
|
530
|
+
|
|
531
|
+
for (const task of tasks) {
|
|
532
|
+
migrateTask(task, { objectIdMap, locationIdMap, taskIdMap });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return taskIdMap;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function migrateDayTypes(simulation, objectIdMap) {
|
|
539
|
+
if (!isPlainObject(simulation.day_types)) return;
|
|
540
|
+
|
|
541
|
+
for (const dayType of Object.values(simulation.day_types)) {
|
|
542
|
+
if (!isPlainObject(dayType)) continue;
|
|
543
|
+
|
|
544
|
+
// Keep legacy day type structure, but normalize interactions to v2 naming where safe.
|
|
545
|
+
const tasks = Array.isArray(dayType.tasks) ? dayType.tasks : null;
|
|
546
|
+
if (tasks) {
|
|
547
|
+
const taskIdMap = new Map();
|
|
548
|
+
for (const task of tasks) {
|
|
549
|
+
if (!isPlainObject(task)) continue;
|
|
550
|
+
const oldId = ensureString(task.id);
|
|
551
|
+
const emojis = extractEmojiClusters(oldId);
|
|
552
|
+
if (!safeTrim(task.emoji) && emojis.length > 0) {
|
|
553
|
+
task.emoji = emojis[emojis.length - 1];
|
|
554
|
+
}
|
|
555
|
+
const cleaned = removeEmoji(oldId);
|
|
556
|
+
const normalizedId = normalizePlainId(cleaned, 'task');
|
|
557
|
+
if (oldId && normalizedId !== oldId) taskIdMap.set(oldId, normalizedId);
|
|
558
|
+
task.id = normalizedId;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const task of tasks) {
|
|
562
|
+
migrateTask(task, {
|
|
563
|
+
objectIdMap,
|
|
564
|
+
locationIdMap: new Map(),
|
|
565
|
+
taskIdMap
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function migrateLegacyEntityArrays(simulation) {
|
|
573
|
+
const alreadyHasObjects = Array.isArray(simulation?.world?.objects) || Array.isArray(simulation?.objects);
|
|
574
|
+
if (alreadyHasObjects) return;
|
|
575
|
+
|
|
576
|
+
const hasLegacyEntityArrays = Array.isArray(simulation.actors)
|
|
577
|
+
|| Array.isArray(simulation.resources)
|
|
578
|
+
|| Array.isArray(simulation.equipment)
|
|
579
|
+
|| Array.isArray(simulation.products);
|
|
580
|
+
|
|
581
|
+
if (!hasLegacyEntityArrays) return;
|
|
582
|
+
if (!isPlainObject(simulation.world)) simulation.world = {};
|
|
583
|
+
if (!Array.isArray(simulation.world.objects)) simulation.world.objects = [];
|
|
584
|
+
|
|
585
|
+
const objects = simulation.world.objects;
|
|
586
|
+
|
|
587
|
+
const pushIfValid = (obj) => {
|
|
588
|
+
if (!isPlainObject(obj)) return;
|
|
589
|
+
if (!safeTrim(obj.id) || !safeTrim(obj.type) || !safeTrim(obj.name)) return;
|
|
590
|
+
objects.push(obj);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
if (Array.isArray(simulation.actors)) {
|
|
594
|
+
for (const actor of simulation.actors) {
|
|
595
|
+
if (!isPlainObject(actor)) continue;
|
|
596
|
+
const id = ensureString(actor.id);
|
|
597
|
+
pushIfValid({
|
|
598
|
+
id,
|
|
599
|
+
type: 'actor',
|
|
600
|
+
name: safeTrim(actor.role) || id,
|
|
601
|
+
properties: { ...actor }
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
delete simulation.actors;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (Array.isArray(simulation.equipment)) {
|
|
608
|
+
for (const equipment of simulation.equipment) {
|
|
609
|
+
if (!isPlainObject(equipment)) continue;
|
|
610
|
+
const id = ensureString(equipment.id);
|
|
611
|
+
pushIfValid({
|
|
612
|
+
id,
|
|
613
|
+
type: 'equipment',
|
|
614
|
+
name: safeTrim(equipment.name) || id,
|
|
615
|
+
properties: { ...equipment, state: equipment.initial_state || equipment.state }
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
delete simulation.equipment;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (Array.isArray(simulation.resources)) {
|
|
622
|
+
for (const resource of simulation.resources) {
|
|
623
|
+
if (!isPlainObject(resource)) continue;
|
|
624
|
+
const id = ensureString(resource.id);
|
|
625
|
+
pushIfValid({
|
|
626
|
+
id,
|
|
627
|
+
type: 'resource',
|
|
628
|
+
name: safeTrim(resource.name) || id,
|
|
629
|
+
properties: {
|
|
630
|
+
...resource,
|
|
631
|
+
quantity: resource.starting_stock ?? resource.quantity
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
delete simulation.resources;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (Array.isArray(simulation.products)) {
|
|
639
|
+
for (const product of simulation.products) {
|
|
640
|
+
if (!isPlainObject(product)) continue;
|
|
641
|
+
const id = ensureString(product.id);
|
|
642
|
+
pushIfValid({
|
|
643
|
+
id,
|
|
644
|
+
type: 'product',
|
|
645
|
+
name: safeTrim(product.name) || id,
|
|
646
|
+
properties: { ...product }
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
delete simulation.products;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function migrateWorkSpecDocumentV1ToV2(inputDocument, options = {}) {
|
|
654
|
+
const opts = {
|
|
655
|
+
addSchema: options.addSchema !== false,
|
|
656
|
+
defaultCurrency: options.defaultCurrency || DEFAULTS.currency,
|
|
657
|
+
defaultLocale: options.defaultLocale || DEFAULTS.locale,
|
|
658
|
+
defaultTimezone: options.defaultTimezone || DEFAULTS.timezone,
|
|
659
|
+
defaultTimeUnit: options.defaultTimeUnit || DEFAULTS.time_unit,
|
|
660
|
+
fallbackMetaTitle: options.fallbackMetaTitle,
|
|
661
|
+
fallbackMetaDescription: options.fallbackMetaDescription,
|
|
662
|
+
fallbackMetaDomain: options.fallbackMetaDomain
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const root = (isPlainObject(inputDocument) && isPlainObject(inputDocument.simulation))
|
|
666
|
+
? cloneJson(inputDocument)
|
|
667
|
+
: { simulation: isPlainObject(inputDocument) ? cloneJson(inputDocument) : {} };
|
|
668
|
+
|
|
669
|
+
const simulation = root.simulation;
|
|
670
|
+
|
|
671
|
+
// Always declare v2
|
|
672
|
+
simulation.schema_version = '2.0';
|
|
673
|
+
|
|
674
|
+
// Convert truly legacy entity arrays (actors/resources/equipment/products) into world.objects
|
|
675
|
+
migrateLegacyEntityArrays(simulation);
|
|
676
|
+
|
|
677
|
+
// Ensure v2 structure exists
|
|
678
|
+
coerceMeta(simulation, opts);
|
|
679
|
+
coerceConfig(simulation, opts);
|
|
680
|
+
moveFlatStructureToWorldProcess(simulation);
|
|
681
|
+
|
|
682
|
+
// ID + shape normalization
|
|
683
|
+
const objectIdMap = normalizeObjects(simulation);
|
|
684
|
+
const locationIdMap = normalizeLocations(simulation);
|
|
685
|
+
migrateTasks(simulation, objectIdMap, locationIdMap);
|
|
686
|
+
|
|
687
|
+
// Multi-period: migrate day_type tasks interactions best-effort
|
|
688
|
+
migrateDayTypes(simulation, objectIdMap);
|
|
689
|
+
|
|
690
|
+
// Apply object id remaps in other common locations
|
|
691
|
+
if (safeTrim(simulation.process?.recipes)) {
|
|
692
|
+
// noop: recipes is expected to be an object, handled elsewhere
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (opts.addSchema && !safeTrim(root.$schema)) {
|
|
696
|
+
root.$schema = WORKSPEC_V2_SCHEMA_URL;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return root;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const api = {
|
|
703
|
+
WORKSPEC_V2_SCHEMA_URL,
|
|
704
|
+
migrate: migrateWorkSpecDocumentV1ToV2
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
if (typeof window !== 'undefined') {
|
|
708
|
+
window.WorkSpecMigration = api;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
712
|
+
module.exports = api;
|
|
713
|
+
}
|
|
714
|
+
})();
|