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.
@@ -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
+ })();