@beyondwork/docx-react-component 1.0.31 → 1.0.33

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.
@@ -4,9 +4,13 @@ import type {
4
4
  OpcRelationship,
5
5
  } from "./part-manifest.ts";
6
6
  import type {
7
+ EditorAnchorProjection,
8
+ WorkflowOverlay,
7
9
  WorkflowMetadataDefinition,
8
10
  WorkflowMetadataEntry,
9
11
  WorkflowMetadataSnapshot,
12
+ WorkflowScope,
13
+ WorkflowWorkItem,
10
14
  } from "../../api/public-types.ts";
11
15
 
12
16
  export const WORKFLOW_PAYLOAD_PART_PATH = "/customXml/item1.xml";
@@ -17,6 +21,8 @@ export const WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE =
17
21
  "application/vnd.openxmlformats-officedocument.customXmlProperties+xml";
18
22
  export const WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE =
19
23
  "application/vnd.openxmlformats-officedocument.custom-properties+xml";
24
+ export const WORKFLOW_PAYLOAD_RELATIONSHIP_TYPE =
25
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
20
26
  export const WORKFLOW_PAYLOAD_ITEM_PROPS_RELATIONSHIP_TYPE =
21
27
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps";
22
28
  export const WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE =
@@ -30,6 +36,11 @@ type EmbeddedWorkflowPayloadDescriptor = {
30
36
  itemPropsPartPath: string;
31
37
  };
32
38
 
39
+ export interface WorkflowPayloadEnvelope {
40
+ workflowMetadata: WorkflowMetadataSnapshot;
41
+ workflowOverlay?: WorkflowOverlay;
42
+ }
43
+
33
44
  export function getDocumentBackedWorkflowMetadata(
34
45
  snapshot: WorkflowMetadataSnapshot | undefined,
35
46
  ): WorkflowMetadataSnapshot {
@@ -51,6 +62,7 @@ export function getDocumentBackedWorkflowMetadata(
51
62
  export function buildWorkflowPayloadParts(input: {
52
63
  sourcePackage: OpcPackage;
53
64
  workflowMetadata: WorkflowMetadataSnapshot | undefined;
65
+ workflowOverlay?: WorkflowOverlay;
54
66
  documentId: string;
55
67
  createdAt: string;
56
68
  updatedAt: string;
@@ -65,7 +77,20 @@ export function buildWorkflowPayloadParts(input: {
65
77
  descriptor: EmbeddedWorkflowPayloadDescriptor;
66
78
  } | null {
67
79
  const workflowMetadata = getDocumentBackedWorkflowMetadata(input.workflowMetadata);
68
- if (workflowMetadata.definitions.length === 0 && workflowMetadata.entries.length === 0) {
80
+ const existingPayloadPartPath = resolvePayloadPartPath(input.sourcePackage);
81
+ const existingPayloadEnvelope = existingPayloadPartPath
82
+ ? parseWorkflowPayloadEnvelopeFromPackage(input.sourcePackage)
83
+ : undefined;
84
+ const sourceHasRuntimeOwnedPayloadContent = Boolean(
85
+ existingPayloadEnvelope?.workflowOverlay ||
86
+ (existingPayloadEnvelope?.workflowMetadata.definitions.length ?? 0) > 0,
87
+ );
88
+ if (
89
+ workflowMetadata.definitions.length === 0 &&
90
+ workflowMetadata.entries.length === 0 &&
91
+ !input.workflowOverlay &&
92
+ !sourceHasRuntimeOwnedPayloadContent
93
+ ) {
69
94
  return null;
70
95
  }
71
96
 
@@ -76,6 +101,8 @@ export function buildWorkflowPayloadParts(input: {
76
101
  updatedAt: input.updatedAt,
77
102
  producerVersion: input.producerVersion,
78
103
  workflowMetadata,
104
+ workflowOverlay: input.workflowOverlay,
105
+ preservedExtensionsXml: getPreservedExtensionsXml(input.sourcePackage, descriptor.payloadPartPath),
79
106
  });
80
107
  const itemPropsXml = buildItemPropsXml(descriptor.itemId);
81
108
  const customPropertiesXml = buildCustomPropertiesXml(descriptor, workflowMetadata, input.sourcePackage);
@@ -101,6 +128,12 @@ export function buildWorkflowPayloadParts(input: {
101
128
  export function parseWorkflowPayloadFromPackage(
102
129
  sourcePackage: OpcPackage,
103
130
  ): WorkflowMetadataSnapshot | undefined {
131
+ return parseWorkflowPayloadEnvelopeFromPackage(sourcePackage)?.workflowMetadata;
132
+ }
133
+
134
+ export function parseWorkflowPayloadEnvelopeFromPackage(
135
+ sourcePackage: OpcPackage,
136
+ ): WorkflowPayloadEnvelope | undefined {
104
137
  const payloadPartPath = resolvePayloadPartPath(sourcePackage);
105
138
  if (!payloadPartPath) {
106
139
  return undefined;
@@ -120,6 +153,7 @@ export function parseWorkflowPayloadFromPackage(
120
153
  if (!attributes.key) {
121
154
  continue;
122
155
  }
156
+
123
157
  if (attributes.key.startsWith("definition.")) {
124
158
  const metadataId = attributes.key.replace(/^definition\./, "");
125
159
  const kind = attributes.ns ?? "tag-category";
@@ -133,7 +167,7 @@ export function parseWorkflowPayloadFromPackage(
133
167
  label,
134
168
  ...(attributes.source ? { color: attributes.source } : {}),
135
169
  ...(attributes.status ? { icon: attributes.status } : {}),
136
- persistence: "document-metadata",
170
+ persistence: "document-metadata" as const,
137
171
  });
138
172
  continue;
139
173
  }
@@ -153,10 +187,10 @@ export function parseWorkflowPayloadFromPackage(
153
187
  entryId,
154
188
  metadataId,
155
189
  anchor: {
156
- kind: "range",
190
+ kind: "range" as const,
157
191
  from: 0,
158
192
  to: 0,
159
- assoc: { start: -1, end: 1 },
193
+ assoc: { start: -1 as const, end: 1 as const },
160
194
  },
161
195
  storyTarget: parseWorkflowStoryTarget(attributes),
162
196
  value: parseWorkflowMetadataValue(payloadText, attributes.valueType),
@@ -168,8 +202,11 @@ export function parseWorkflowPayloadFromPackage(
168
202
  }
169
203
 
170
204
  return {
171
- definitions,
172
- entries,
205
+ workflowMetadata: {
206
+ definitions,
207
+ entries,
208
+ },
209
+ workflowOverlay: parseWorkflowOverlay(xml),
173
210
  };
174
211
  }
175
212
 
@@ -179,6 +216,8 @@ function buildPayloadXml(input: {
179
216
  updatedAt: string;
180
217
  producerVersion: string;
181
218
  workflowMetadata: WorkflowMetadataSnapshot;
219
+ workflowOverlay?: WorkflowOverlay;
220
+ preservedExtensionsXml: string;
182
221
  }): string {
183
222
  const definitionEntriesXml = input.workflowMetadata.definitions
184
223
  .map((definition) => [
@@ -212,6 +251,11 @@ function buildPayloadXml(input: {
212
251
  .filter((value) => value.length > 0)
213
252
  .join("\n");
214
253
 
254
+ const workblockXml = buildWorkblockExtensionXml(input.workflowOverlay);
255
+ const extensionsXml = [workblockXml, input.preservedExtensionsXml]
256
+ .filter((value) => value.trim().length > 0)
257
+ .join("\n");
258
+
215
259
  return [
216
260
  `<?xml version="1.0" encoding="UTF-8"?>`,
217
261
  `<bw:workflowPayload xmlns:bw="urn:beyondwork:workflow-payload:1" version="1.0" payloadId="${escapeXml(input.descriptor.payloadId)}" itemId="${escapeXml(input.descriptor.itemId)}" documentId="${escapeXml(input.descriptor.documentId)}" createdAt="${escapeXml(input.createdAt)}" updatedAt="${escapeXml(input.updatedAt)}">`,
@@ -223,7 +267,9 @@ function buildPayloadXml(input: {
223
267
  definitionEntriesXml ? indentLines(definitionEntriesXml, 4) : "",
224
268
  metadataEntriesXml ? indentLines(metadataEntriesXml, 4) : "",
225
269
  ` </bw:metadata>`,
226
- ` <bw:extensions />`,
270
+ extensionsXml
271
+ ? ` <bw:extensions>\n${indentLines(extensionsXml, 4)}\n </bw:extensions>`
272
+ : ` <bw:extensions />`,
227
273
  `</bw:workflowPayload>`,
228
274
  ].filter((line) => line.length > 0).join("\n");
229
275
  }
@@ -283,6 +329,129 @@ function buildCustomProperty(pid: number, name: string, value: string): string {
283
329
  return ` <property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="${pid}" name="${escapeXml(name)}"><vt:lpwstr>${escapeXml(value)}</vt:lpwstr></property>`;
284
330
  }
285
331
 
332
+ function buildWorkblockExtensionXml(workflowOverlay: WorkflowOverlay | undefined): string {
333
+ if (!workflowOverlay) {
334
+ return "";
335
+ }
336
+ const workItemsXml = (workflowOverlay.workItems ?? [])
337
+ .map((workItem) => [
338
+ `<bw:workItem`,
339
+ ` id="${escapeXml(workItem.workItemId)}"`,
340
+ ` title="${escapeXml(workItem.title)}"`,
341
+ workItem.status ? ` status="${escapeXml(workItem.status)}"` : "",
342
+ workItem.domain ? ` domain="${escapeXml(workItem.domain)}"` : "",
343
+ `>`,
344
+ workItem.scopeIds.map((scopeId) => ` <bw:scopeRef ref="${escapeXml(scopeId)}" />`).join("\n"),
345
+ `</bw:workItem>`,
346
+ ].join(""))
347
+ .join("\n");
348
+ const scopesXml = workflowOverlay.scopes
349
+ .map((scope) => buildWorkflowScopeXml(scope))
350
+ .join("\n");
351
+
352
+ return [
353
+ `<bw:workblock>`,
354
+ [
355
+ `<bw:workflowOverlay`,
356
+ ` overlayVersion="${escapeXml(workflowOverlay.overlayVersion)}"`,
357
+ workflowOverlay.activeWorkItemId
358
+ ? ` activeWorkItemId="${escapeXml(workflowOverlay.activeWorkItemId)}"`
359
+ : "",
360
+ `>`,
361
+ ].join(""),
362
+ ` <bw:workItems>`,
363
+ workItemsXml ? indentLines(workItemsXml, 4) : "",
364
+ ` </bw:workItems>`,
365
+ ` <bw:scopes>`,
366
+ scopesXml ? indentLines(scopesXml, 4) : "",
367
+ ` </bw:scopes>`,
368
+ `</bw:workflowOverlay>`,
369
+ `</bw:workblock>`,
370
+ ].filter((line) => line.length > 0).join("\n");
371
+ }
372
+
373
+ function buildWorkflowScopeXml(scope: WorkflowScope): string {
374
+ return [
375
+ `<bw:scope`,
376
+ ` id="${escapeXml(scope.scopeId)}"`,
377
+ ` mode="${escapeXml(scope.mode)}"`,
378
+ scope.workItemId ? ` workItemRef="${escapeXml(scope.workItemId)}"` : "",
379
+ scope.label ? ` label="${escapeXml(scope.label)}"` : "",
380
+ scope.domain ? ` domain="${escapeXml(scope.domain)}"` : "",
381
+ `>`,
382
+ indentLines(buildWorkflowAnchorXml(scope.anchor, scope.storyTarget), 2),
383
+ `</bw:scope>`,
384
+ ].join("");
385
+ }
386
+
387
+ function buildWorkflowAnchorXml(
388
+ anchor: EditorAnchorProjection,
389
+ storyTarget: WorkflowScope["storyTarget"],
390
+ ): string {
391
+ const resolvedStoryTarget = storyTarget ?? { kind: "main" as const };
392
+ const range = anchor.kind === "range"
393
+ ? { from: anchor.from, to: anchor.to }
394
+ : anchor.kind === "node"
395
+ ? { from: anchor.at, to: anchor.at }
396
+ : { from: anchor.lastKnownRange.from, to: anchor.lastKnownRange.to };
397
+
398
+ return [
399
+ `<bw:anchor${serializeOverlayStoryTargetAttributes(resolvedStoryTarget)} targetKind="text">`,
400
+ ` <bw:selector type="position-range" start="${String(range.from)}" end="${String(range.to)}" />`,
401
+ ` <bw:selector type="binding-hash" alg="sha256" value="${escapeXml(createAnchorBindingHash(resolvedStoryTarget, range.from, range.to))}" />`,
402
+ `</bw:anchor>`,
403
+ ].join("\n");
404
+ }
405
+
406
+ function serializeOverlayStoryTargetAttributes(target: WorkflowScope["storyTarget"]): string {
407
+ const storyTarget = target ?? { kind: "main" as const };
408
+ switch (storyTarget.kind) {
409
+ case "main":
410
+ return ` story="main"`;
411
+ case "header":
412
+ case "footer":
413
+ return [
414
+ ` story="${escapeXml(storyTarget.kind)}"`,
415
+ ` relationshipId="${escapeXml(storyTarget.relationshipId)}"`,
416
+ ` variant="${escapeXml(storyTarget.variant)}"`,
417
+ typeof storyTarget.sectionIndex === "number"
418
+ ? ` sectionIndex="${String(storyTarget.sectionIndex)}"`
419
+ : "",
420
+ ].join("");
421
+ case "footnote":
422
+ case "endnote":
423
+ return [
424
+ ` story="${escapeXml(storyTarget.kind)}"`,
425
+ ` noteId="${escapeXml(storyTarget.noteId)}"`,
426
+ ].join("");
427
+ }
428
+ }
429
+
430
+ function createAnchorBindingHash(
431
+ storyTarget: NonNullable<WorkflowScope["storyTarget"]>,
432
+ from: number,
433
+ to: number,
434
+ ): string {
435
+ return `${serializeStoryTargetKey(storyTarget)}:${from}:${to}`;
436
+ }
437
+
438
+ function getPreservedExtensionsXml(sourcePackage: OpcPackage, payloadPartPath: string): string {
439
+ const payloadPart = sourcePackage.parts.get(payloadPartPath);
440
+ if (!payloadPart) {
441
+ return "";
442
+ }
443
+ const xml = decodeUtf8(payloadPart.bytes);
444
+ const match = xml.match(/<bw:extensions\b[^>]*>([\s\S]*?)<\/bw:extensions>/u);
445
+ if (!match) {
446
+ return "";
447
+ }
448
+ return stripWorkblockExtension(match[1] ?? "").trim();
449
+ }
450
+
451
+ function stripWorkblockExtension(extensionsInnerXml: string): string {
452
+ return extensionsInnerXml.replace(/<bw:workblock\b[\s\S]*?<\/bw:workblock>/gu, "").trim();
453
+ }
454
+
286
455
  function serializeWorkflowMetadataValue(
287
456
  value: WorkflowMetadataEntry["value"],
288
457
  ): { type: "json"; text: string } {
@@ -361,6 +530,140 @@ function serializeWorkflowStoryTarget(target: WorkflowMetadataEntry["storyTarget
361
530
  }
362
531
  }
363
532
 
533
+ function parseWorkflowOverlay(xml: string): WorkflowOverlay | undefined {
534
+ const overlayMatch = xml.match(/<bw:workflowOverlay\b([^>]*)>([\s\S]*?)<\/bw:workflowOverlay>/u);
535
+ if (!overlayMatch) {
536
+ return undefined;
537
+ }
538
+ const overlayAttributes = parseAttributes(overlayMatch[1] ?? "");
539
+ const overlayBody = overlayMatch[2] ?? "";
540
+ const scopes = [...overlayBody.matchAll(/<bw:scope\b([^>]*)>([\s\S]*?)<\/bw:scope>/gu)]
541
+ .map((match) => parseWorkflowScope(match[1] ?? "", match[2] ?? ""))
542
+ .filter((scope): scope is WorkflowScope => scope !== null);
543
+ const workItems = [...overlayBody.matchAll(/<bw:workItem\b([^>]*)>([\s\S]*?)<\/bw:workItem>/gu)]
544
+ .map((match) => parseWorkflowWorkItem(match[1] ?? "", match[2] ?? ""))
545
+ .filter((item): item is WorkflowWorkItem => item !== null);
546
+
547
+ return {
548
+ overlayVersion: (overlayAttributes.overlayVersion as WorkflowOverlay["overlayVersion"]) ?? "workflow-overlay/1",
549
+ activeWorkItemId: overlayAttributes.activeWorkItemId ?? null,
550
+ scopes,
551
+ ...(workItems.length > 0 ? { workItems } : {}),
552
+ };
553
+ }
554
+
555
+ function parseWorkflowScope(attributesSource: string, body: string): WorkflowScope | null {
556
+ const attributes = parseAttributes(attributesSource);
557
+ if (!attributes.id || !attributes.mode) {
558
+ return null;
559
+ }
560
+ const anchorMatch = body.match(/<bw:anchor\b([^>]*)>([\s\S]*?)<\/bw:anchor>/u);
561
+ const anchor = anchorMatch
562
+ ? parseWorkflowOverlayAnchor(anchorMatch[1] ?? "", anchorMatch[2] ?? "")
563
+ : createDefaultRangeAnchor(0, 0);
564
+ return {
565
+ scopeId: attributes.id,
566
+ mode: attributes.mode as WorkflowScope["mode"],
567
+ anchor,
568
+ storyTarget: anchorMatch ? parseOverlayStoryTarget(parseAttributes(anchorMatch[1] ?? "")) : { kind: "main" },
569
+ workItemId: attributes.workItemRef,
570
+ label: attributes.label,
571
+ domain: attributes.domain as WorkflowScope["domain"],
572
+ };
573
+ }
574
+
575
+ function parseWorkflowWorkItem(attributesSource: string, body: string): WorkflowWorkItem | null {
576
+ const attributes = parseAttributes(attributesSource);
577
+ if (!attributes.id || !attributes.title) {
578
+ return null;
579
+ }
580
+ const scopeIds = [...body.matchAll(/<bw:scopeRef\b([^>]*)\/>/gu)]
581
+ .map((match) => parseAttributes(match[1] ?? "").ref ?? "")
582
+ .filter((value) => value.length > 0);
583
+ return {
584
+ workItemId: attributes.id,
585
+ title: attributes.title,
586
+ status: attributes.status as WorkflowWorkItem["status"],
587
+ domain: attributes.domain as WorkflowWorkItem["domain"],
588
+ scopeIds,
589
+ };
590
+ }
591
+
592
+ function parseWorkflowOverlayAnchor(
593
+ attributesSource: string,
594
+ body: string,
595
+ ): EditorAnchorProjection {
596
+ const selectorMatches = [...body.matchAll(/<bw:selector\b([^>]*)\/>/gu)];
597
+ const positionSelector = selectorMatches
598
+ .map((match) => parseAttributes(match[1] ?? ""))
599
+ .find((attributes) => attributes.type === "position-range");
600
+ if (positionSelector?.start !== undefined && positionSelector.end !== undefined) {
601
+ return createDefaultRangeAnchor(Number(positionSelector.start), Number(positionSelector.end));
602
+ }
603
+ const anchorAttributes = parseAttributes(attributesSource);
604
+ if (anchorAttributes.targetKind === "node" && anchorAttributes.at !== undefined) {
605
+ return {
606
+ kind: "node",
607
+ at: Number(anchorAttributes.at),
608
+ assoc: 1,
609
+ };
610
+ }
611
+ return createDefaultRangeAnchor(0, 0);
612
+ }
613
+
614
+ function parseOverlayStoryTarget(attributes: Record<string, string>): WorkflowScope["storyTarget"] {
615
+ switch (attributes.story) {
616
+ case "header":
617
+ case "footer":
618
+ if (!attributes.relationshipId || !attributes.variant) {
619
+ return { kind: "main" };
620
+ }
621
+ return {
622
+ kind: attributes.story,
623
+ relationshipId: attributes.relationshipId,
624
+ variant: attributes.variant as "default" | "first" | "even",
625
+ sectionIndex: attributes.sectionIndex !== undefined ? Number(attributes.sectionIndex) : undefined,
626
+ };
627
+ case "footnote":
628
+ case "endnote":
629
+ if (!attributes.noteId) {
630
+ return { kind: "main" };
631
+ }
632
+ return {
633
+ kind: attributes.story,
634
+ noteId: attributes.noteId,
635
+ };
636
+ case "main":
637
+ default:
638
+ return { kind: "main" };
639
+ }
640
+ }
641
+
642
+ function createDefaultRangeAnchor(from: number, to: number): EditorAnchorProjection {
643
+ return {
644
+ kind: "range",
645
+ from,
646
+ to,
647
+ assoc: {
648
+ start: -1,
649
+ end: 1,
650
+ },
651
+ };
652
+ }
653
+
654
+ function serializeStoryTargetKey(storyTarget: NonNullable<WorkflowScope["storyTarget"]>): string {
655
+ switch (storyTarget.kind) {
656
+ case "main":
657
+ return "main";
658
+ case "header":
659
+ case "footer":
660
+ return `${storyTarget.kind}:${storyTarget.relationshipId}:${storyTarget.variant}:${storyTarget.sectionIndex ?? "none"}`;
661
+ case "footnote":
662
+ case "endnote":
663
+ return `${storyTarget.kind}:${storyTarget.noteId}`;
664
+ }
665
+ }
666
+
364
667
  function parseWorkflowStoryTarget(
365
668
  attributes: Record<string, string>,
366
669
  ): WorkflowMetadataEntry["storyTarget"] {
@@ -423,7 +726,7 @@ function resolveDescriptor(sourcePackage: OpcPackage, documentId: string): Embed
423
726
  };
424
727
  }
425
728
 
426
- function resolvePayloadPartPath(sourcePackage: OpcPackage): string | null {
729
+ export function resolvePayloadPartPath(sourcePackage: OpcPackage): string | null {
427
730
  const mirroredItemId = readCustomPropertyValue(
428
731
  sourcePackage,
429
732
  "BwWorkflowPayloadItemId",
@@ -122,6 +122,33 @@ export interface PersistedWorkflowMetadataSnapshot {
122
122
  entries: PersistedWorkflowMetadataEntry[];
123
123
  }
124
124
 
125
+ export interface PersistedWorkflowScope {
126
+ scopeId: string;
127
+ mode: "edit" | "suggest" | "comment" | "view";
128
+ anchor: Record<string, unknown>;
129
+ storyTarget?: Record<string, unknown>;
130
+ workItemId?: string;
131
+ label?: string;
132
+ domain?: "legal" | "commercial" | "finance" | "other";
133
+ }
134
+
135
+ export interface PersistedWorkflowWorkItem {
136
+ workItemId: string;
137
+ title: string;
138
+ description?: string;
139
+ domain?: "legal" | "commercial" | "finance" | "other";
140
+ status?: "pending" | "active" | "done" | "blocked";
141
+ scopeIds: string[];
142
+ }
143
+
144
+ export interface PersistedWorkflowOverlay {
145
+ overlayVersion: "workflow-overlay/1";
146
+ candidates?: Array<Record<string, unknown>>;
147
+ scopes: PersistedWorkflowScope[];
148
+ workItems?: PersistedWorkflowWorkItem[];
149
+ activeWorkItemId?: string | null;
150
+ }
151
+
125
152
  export interface PersistedEditorSnapshot {
126
153
  snapshotVersion: PersistedEditorSnapshotVersion;
127
154
  schemaVersion: typeof CDS_SCHEMA_VERSION;
@@ -136,6 +163,7 @@ export interface PersistedEditorSnapshot {
136
163
  warningLog: EditorWarning[];
137
164
  protectionSnapshot?: ProtectionSnapshotRecord;
138
165
  sourcePackage?: PersistedSourcePackage;
166
+ workflowOverlay?: PersistedWorkflowOverlay;
139
167
  workflowMetadata?: PersistedWorkflowMetadataSnapshot;
140
168
  }
141
169
 
@@ -153,7 +181,12 @@ const SNAPSHOT_REQUIRED_TOP_LEVEL_KEYS = [
153
181
  "warningLog",
154
182
  ] as const;
155
183
 
156
- const SNAPSHOT_OPTIONAL_TOP_LEVEL_KEYS = ["sourcePackage", "protectionSnapshot", "workflowMetadata"] as const;
184
+ const SNAPSHOT_OPTIONAL_TOP_LEVEL_KEYS = [
185
+ "sourcePackage",
186
+ "protectionSnapshot",
187
+ "workflowOverlay",
188
+ "workflowMetadata",
189
+ ] as const;
157
190
 
158
191
  export interface ProtectionRangeRecord {
159
192
  rangeId: string;
@@ -308,6 +341,9 @@ export function validatePersistedEditorSnapshot(
308
341
  if (record.protectionSnapshot !== undefined) {
309
342
  validateProtectionSnapshot(record.protectionSnapshot, "$.protectionSnapshot", issues);
310
343
  }
344
+ if (record.workflowOverlay !== undefined) {
345
+ validateWorkflowOverlay(record.workflowOverlay, "$.workflowOverlay", issues);
346
+ }
311
347
  if (record.workflowMetadata !== undefined) {
312
348
  validateWorkflowMetadataSnapshot(record.workflowMetadata, "$.workflowMetadata", issues);
313
349
  }
@@ -642,6 +678,82 @@ function validateProtectionSnapshot(
642
678
  }
643
679
  }
644
680
 
681
+ function validateWorkflowOverlay(
682
+ value: unknown,
683
+ path: string,
684
+ issues: ModelValidationIssue[],
685
+ ): void {
686
+ const record = asPlainObject(value, path, issues);
687
+ if (!record) {
688
+ return;
689
+ }
690
+ expectExactString(record.overlayVersion, "workflow-overlay/1", `${path}.overlayVersion`, issues);
691
+ if (!Array.isArray(record.scopes)) {
692
+ issues.push({ path: `${path}.scopes`, message: "scopes must be an array." });
693
+ } else {
694
+ record.scopes.forEach((scope, index) => validateWorkflowScope(scope, `${path}.scopes[${index}]`, issues));
695
+ }
696
+ if (record.workItems !== undefined) {
697
+ if (!Array.isArray(record.workItems)) {
698
+ issues.push({ path: `${path}.workItems`, message: "workItems must be an array." });
699
+ } else {
700
+ record.workItems.forEach((workItem, index) =>
701
+ validateWorkflowWorkItem(workItem, `${path}.workItems[${index}]`, issues),
702
+ );
703
+ }
704
+ }
705
+ if (record.activeWorkItemId !== undefined && record.activeWorkItemId !== null) {
706
+ expectString(record.activeWorkItemId, `${path}.activeWorkItemId`, issues);
707
+ }
708
+ }
709
+
710
+ function validateWorkflowScope(
711
+ value: unknown,
712
+ path: string,
713
+ issues: ModelValidationIssue[],
714
+ ): void {
715
+ const record = asPlainObject(value, path, issues);
716
+ if (!record) {
717
+ return;
718
+ }
719
+ expectString(record.scopeId, `${path}.scopeId`, issues);
720
+ if (
721
+ record.mode !== "edit" &&
722
+ record.mode !== "suggest" &&
723
+ record.mode !== "comment" &&
724
+ record.mode !== "view"
725
+ ) {
726
+ issues.push({ path: `${path}.mode`, message: "mode must be edit, suggest, comment, or view." });
727
+ }
728
+ asPlainObject(record.anchor, `${path}.anchor`, issues);
729
+ if (record.workItemId !== undefined) {
730
+ expectString(record.workItemId, `${path}.workItemId`, issues);
731
+ }
732
+ if (record.label !== undefined) {
733
+ expectString(record.label, `${path}.label`, issues);
734
+ }
735
+ }
736
+
737
+ function validateWorkflowWorkItem(
738
+ value: unknown,
739
+ path: string,
740
+ issues: ModelValidationIssue[],
741
+ ): void {
742
+ const record = asPlainObject(value, path, issues);
743
+ if (!record) {
744
+ return;
745
+ }
746
+ expectString(record.workItemId, `${path}.workItemId`, issues);
747
+ expectString(record.title, `${path}.title`, issues);
748
+ if (!Array.isArray(record.scopeIds)) {
749
+ issues.push({ path: `${path}.scopeIds`, message: "scopeIds must be an array." });
750
+ } else {
751
+ record.scopeIds.forEach((scopeId, index) => {
752
+ expectString(scopeId, `${path}.scopeIds[${index}]`, issues);
753
+ });
754
+ }
755
+ }
756
+
645
757
  function validateWorkflowMetadataSnapshot(
646
758
  value: unknown,
647
759
  path: string,