@beyondwork/docx-react-component 1.0.33 → 1.0.35

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.33",
4
+ "version": "1.0.35",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -130,7 +130,21 @@
130
130
  "prosemirror-view": "^1.41.7",
131
131
  "react": "^19.2.0",
132
132
  "react-dom": "^19.2.0",
133
- "tailwindcss": "^4.2.2"
133
+ "tailwindcss": "^4.2.2",
134
+ "yjs": "^13.6.0",
135
+ "y-prosemirror": "^1.2.0",
136
+ "y-protocols": "^1.0.0"
137
+ },
138
+ "peerDependenciesMeta": {
139
+ "yjs": {
140
+ "optional": true
141
+ },
142
+ "y-prosemirror": {
143
+ "optional": true
144
+ },
145
+ "y-protocols": {
146
+ "optional": true
147
+ }
134
148
  },
135
149
  "devDependencies": {
136
150
  "@chllming/wave-orchestration": "^0.9.15",
@@ -148,7 +162,10 @@
148
162
  "react": "19.2.4",
149
163
  "react-dom": "19.2.4",
150
164
  "tsup": "^8.3.0",
151
- "tsx": "^4.21.0"
165
+ "tsx": "^4.21.0",
166
+ "y-prosemirror": "^1.3.7",
167
+ "y-protocols": "^1.0.7",
168
+ "yjs": "^13.6.30"
152
169
  },
153
170
  "scripts": {
154
171
  "build": "tsup",
@@ -1,13 +1,8 @@
1
1
  import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
2
- import type {
3
- FieldFamily as FieldFamilyType,
4
- FieldRefreshStatus as FieldRefreshStatusType,
5
- SupportedFieldFamily as SupportedFieldFamilyType,
6
- } from "../model/canonical-document.ts";
7
2
 
8
- export type FieldFamily = FieldFamilyType;
9
- export type FieldRefreshStatus = FieldRefreshStatusType;
10
- export type SupportedFieldFamily = SupportedFieldFamilyType;
3
+ export type FieldFamily = import("../model/canonical-document.ts").FieldFamily;
4
+ export type FieldRefreshStatus = import("../model/canonical-document.ts").FieldRefreshStatus;
5
+ export type SupportedFieldFamily = import("../model/canonical-document.ts").SupportedFieldFamily;
11
6
 
12
7
  export type ExternalDocumentSource =
13
8
  | {
@@ -1150,6 +1145,7 @@ export interface WorkflowCandidateRange {
1150
1145
 
1151
1146
  export interface WorkflowScope {
1152
1147
  scopeId: string;
1148
+ version?: number;
1153
1149
  mode: WorkflowScopeMode;
1154
1150
  anchor: EditorAnchorProjection;
1155
1151
  storyTarget?: EditorStoryTarget;
@@ -1157,6 +1153,13 @@ export interface WorkflowScope {
1157
1153
  label?: string;
1158
1154
  domain?: "legal" | "commercial" | "finance" | "other";
1159
1155
  metadataRefs?: string[];
1156
+ metadata?: WorkflowScopeMetadataField[];
1157
+ }
1158
+
1159
+ export interface WorkflowScopeMetadataField {
1160
+ key: string;
1161
+ valueType?: "string" | "number" | "boolean" | "json";
1162
+ value?: string | number | boolean | Record<string, unknown>;
1160
1163
  }
1161
1164
 
1162
1165
  export interface WorkflowWorkItem {
@@ -1930,6 +1933,8 @@ export interface WordReviewEditorChromeOptions {
1930
1933
  export interface WordReviewEditorProps {
1931
1934
  documentId: string;
1932
1935
  currentUser: EditorUser;
1936
+ ydoc?: import('yjs').Doc;
1937
+ awareness?: import("y-protocols/awareness").Awareness;
1933
1938
  initialDocx?: Uint8Array | ArrayBuffer;
1934
1939
  initialSessionState?: EditorSessionState;
1935
1940
  initialSnapshot?: PersistedEditorSnapshot;
@@ -2,6 +2,10 @@ import type {
2
2
  EditorSessionState,
3
3
  PersistedEditorSnapshot,
4
4
  } from "./public-types.ts";
5
+ import {
6
+ assertPersistedEditorSnapshot,
7
+ validatePersistedEditorSnapshot,
8
+ } from "../model/snapshot.ts";
5
9
 
6
10
  export const EDITOR_SESSION_STATE_VERSION = "editor-session-state/1" as const;
7
11
 
@@ -62,3 +66,23 @@ export function persistedSnapshotFromEditorSessionState(
62
66
  workflowMetadata: sessionState.workflowMetadata,
63
67
  });
64
68
  }
69
+
70
+ export function validateEditorSessionState(
71
+ sessionState: EditorSessionState,
72
+ ): ReturnType<typeof validatePersistedEditorSnapshot> {
73
+ return validatePersistedEditorSnapshot(
74
+ persistedSnapshotFromEditorSessionState(sessionState, {
75
+ savedAt: sessionState.updatedAt,
76
+ }),
77
+ );
78
+ }
79
+
80
+ export function assertEditorSessionState(
81
+ sessionState: EditorSessionState,
82
+ ): void {
83
+ assertPersistedEditorSnapshot(
84
+ persistedSnapshotFromEditorSessionState(sessionState, {
85
+ savedAt: sessionState.updatedAt,
86
+ }),
87
+ );
88
+ }
@@ -1365,6 +1365,59 @@ function applySuggestingInsert(
1365
1365
  result.mapping,
1366
1366
  );
1367
1367
 
1368
+ // Extend an existing adjacent insertion revision if the cursor is right at its end.
1369
+ // After remapping, a prior insertion revision's `to` stays at `insertedFrom` (assoc.end = -1),
1370
+ // so consecutive keystrokes coalesce into one revision instead of one per character.
1371
+ const existingInsertion = findAdjacentInsertionRevision(
1372
+ reviewState.document.review.revisions,
1373
+ insertedFrom,
1374
+ authorId,
1375
+ );
1376
+
1377
+ if (existingInsertion && existingInsertion.anchor.kind === "range") {
1378
+ const updatedRevision: CanonicalRevisionRecord = {
1379
+ ...existingInsertion,
1380
+ anchor: {
1381
+ kind: "range",
1382
+ range: { from: existingInsertion.anchor.range.from, to: insertedTo },
1383
+ assoc: { start: 1, end: -1 },
1384
+ },
1385
+ };
1386
+
1387
+ const finalDocument: CanonicalDocumentEnvelope = {
1388
+ ...reviewState.document,
1389
+ review: {
1390
+ ...reviewState.document.review,
1391
+ revisions: {
1392
+ ...reviewState.document.review.revisions,
1393
+ [updatedRevision.changeId]: updatedRevision,
1394
+ },
1395
+ },
1396
+ };
1397
+
1398
+ return createTransaction(
1399
+ {
1400
+ ...state,
1401
+ document: finalDocument,
1402
+ selection: result.selection,
1403
+ warnings: reviewState.warnings,
1404
+ runtime: {
1405
+ ...state.runtime,
1406
+ activeCommentId: reviewState.activeCommentId,
1407
+ },
1408
+ },
1409
+ {
1410
+ historyBoundary: "push",
1411
+ markDirty: true,
1412
+ mapping: result.mapping,
1413
+ effects: {
1414
+ ...reviewState.effects,
1415
+ revisionAuthored: { changeId: updatedRevision.changeId, kind: "insertion" },
1416
+ },
1417
+ },
1418
+ );
1419
+ }
1420
+
1368
1421
  // Create the revision with pre-mapping positions — it refers to content
1369
1422
  // that was just inserted, so its anchors are already correct in the new document
1370
1423
  const revision = createAuthoredRevision(
@@ -1850,6 +1903,26 @@ function applySuggestingParagraphSplit(
1850
1903
  );
1851
1904
  }
1852
1905
 
1906
+ function findAdjacentInsertionRevision(
1907
+ revisions: Record<string, CanonicalRevisionRecord>,
1908
+ cursorPos: number,
1909
+ authorId: string,
1910
+ ): CanonicalRevisionRecord | undefined {
1911
+ for (const revision of Object.values(revisions)) {
1912
+ if (
1913
+ revision.kind === "insertion" &&
1914
+ revision.status === "open" &&
1915
+ revision.metadata?.source === "runtime" &&
1916
+ revision.anchor.kind === "range" &&
1917
+ revision.anchor.range.to === cursorPos &&
1918
+ revision.authorId === authorId
1919
+ ) {
1920
+ return revision;
1921
+ }
1922
+ }
1923
+ return undefined;
1924
+ }
1925
+
1853
1926
  function findOverlappingAuthoredDeletion(
1854
1927
  revisions: Record<string, CanonicalRevisionRecord>,
1855
1928
  from: number,
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { WordReviewEditor } from "./ui/WordReviewEditor.tsx";
2
2
  export {
3
3
  createEditorSessionState,
4
+ assertEditorSessionState,
4
5
  editorSessionStateFromPersistedSnapshot,
5
6
  persistedSnapshotFromEditorSessionState,
7
+ validateEditorSessionState,
6
8
  EDITOR_SESSION_STATE_VERSION,
7
9
  } from "./api/session-state.ts";
8
10
  export type {
@@ -9,6 +9,11 @@ import type {
9
9
  PersistedEditorSnapshot,
10
10
  ProtectionRange,
11
11
  ProtectionSnapshot,
12
+ WorkflowMetadataSnapshot,
13
+ WorkflowOverlay,
14
+ WorkflowScope,
15
+ WorkflowScopeMetadataField,
16
+ WorkflowWorkItem,
12
17
  } from "../api/public-types.ts";
13
18
  import { editorSessionStateFromPersistedSnapshot } from "../api/session-state.ts";
14
19
  import type {
@@ -655,6 +660,12 @@ export function loadDocxEditorSession(
655
660
  : undefined;
656
661
 
657
662
  const timestamp = new Date().toISOString();
663
+ const translatedWorkflowState = translateClmCommentsToWorkflow({
664
+ comments: normalizedComments.threads,
665
+ workflowOverlay: embeddedWorkflowOverlay,
666
+ workflowMetadata: embeddedWorkflowMetadata,
667
+ timestamp,
668
+ });
658
669
  const document = createImportedCanonicalDocument({
659
670
  documentId: options.documentId,
660
671
  timestamp,
@@ -703,7 +714,7 @@ export function loadDocxEditorSession(
703
714
  errors: [],
704
715
  },
705
716
  review: {
706
- comments: toRuntimeCommentRecords(normalizedComments.threads),
717
+ comments: toRuntimeCommentRecords(translatedWorkflowState.comments),
707
718
  revisions: toRuntimeRevisionRecords([
708
719
  ...normalizedRevisions.revisions,
709
720
  ...importedStoryRevisions,
@@ -722,8 +733,8 @@ export function loadDocxEditorSession(
722
733
  compatibility: toPublicCompatibilityReport(compatibility),
723
734
  protectionSnapshot: importedProtectionSnapshot,
724
735
  sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
725
- workflowOverlay: embeddedWorkflowOverlay,
726
- workflowMetadata: embeddedWorkflowMetadata,
736
+ workflowOverlay: translatedWorkflowState.workflowOverlay,
737
+ workflowMetadata: translatedWorkflowState.workflowMetadata,
727
738
  });
728
739
  const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
729
740
  if (snapshotIssues.length > 0) {
@@ -1940,6 +1951,252 @@ function normalizeImportedCommentThreads(
1940
1951
  };
1941
1952
  }
1942
1953
 
1954
+ function translateClmCommentsToWorkflow(input: {
1955
+ comments: readonly CommentThread[];
1956
+ workflowOverlay?: WorkflowOverlay;
1957
+ workflowMetadata?: WorkflowMetadataSnapshot;
1958
+ timestamp: string;
1959
+ }): {
1960
+ comments: CommentThread[];
1961
+ workflowOverlay?: WorkflowOverlay;
1962
+ workflowMetadata?: WorkflowMetadataSnapshot;
1963
+ } {
1964
+ const nextComments = input.comments.map((thread) => structuredClone(thread));
1965
+ let nextOverlay = input.workflowOverlay ? structuredClone(input.workflowOverlay) : undefined;
1966
+ let overlayChanged = false;
1967
+
1968
+ for (const [index, thread] of nextComments.entries()) {
1969
+ const directive = parseClmCommentDirective(thread);
1970
+ if (!directive || thread.anchor.kind !== "range") {
1971
+ continue;
1972
+ }
1973
+
1974
+ if (!nextOverlay) {
1975
+ nextOverlay = {
1976
+ overlayVersion: "workflow-overlay/1",
1977
+ scopes: [],
1978
+ };
1979
+ }
1980
+
1981
+ const existingScope = findExistingClmScope(nextOverlay.scopes, directive);
1982
+ if (!existingScope) {
1983
+ const version = getNextClmScopeVersion(nextOverlay.scopes, thread.anchor);
1984
+ const scopeId = `clm-scope-${thread.commentId}-v${version}`;
1985
+ const workItem = directive.tag === "TASK"
1986
+ ? createClmWorkflowWorkItem(thread, directive.description, scopeId)
1987
+ : undefined;
1988
+ const scope = createClmWorkflowScope(thread, directive, version, scopeId, workItem?.workItemId);
1989
+
1990
+ nextOverlay.scopes = [...nextOverlay.scopes, scope];
1991
+ if (workItem) {
1992
+ nextOverlay.workItems = [...(nextOverlay.workItems ?? []), workItem];
1993
+ if (!nextOverlay.activeWorkItemId) {
1994
+ nextOverlay.activeWorkItemId = workItem.workItemId;
1995
+ }
1996
+ }
1997
+ overlayChanged = true;
1998
+ }
1999
+
2000
+ nextComments[index] = resolveImportedCommentThreadOnTranslation(thread, input.timestamp);
2001
+ }
2002
+
2003
+ return {
2004
+ comments: nextComments,
2005
+ workflowOverlay: overlayChanged || input.workflowOverlay ? nextOverlay : undefined,
2006
+ workflowMetadata: input.workflowMetadata,
2007
+ };
2008
+ }
2009
+
2010
+ function parseClmCommentDirective(
2011
+ thread: CommentThread,
2012
+ ): { tag: "TASK" | "READ" | "COMMENT" | "EDIT"; description: string; sourceCommentId: string; sourceCommentDurableId?: string; sourceCommentParaId?: string; mode: WorkflowScope["mode"] } | undefined {
2013
+ if (thread.metadata?.rootParaId === undefined && thread.entries[0]?.metadata?.parentParaId) {
2014
+ return undefined;
2015
+ }
2016
+ const firstMeaningfulLine = thread.entries
2017
+ .flatMap((entry) => entry.body.split(/\r?\n/u))
2018
+ .map((line) => line.trim())
2019
+ .find((line) => line.length > 0);
2020
+ if (!firstMeaningfulLine) {
2021
+ return undefined;
2022
+ }
2023
+ const match = /^CLM:([A-Z]+):(.*)$/u.exec(firstMeaningfulLine);
2024
+ if (!match) {
2025
+ return undefined;
2026
+ }
2027
+ const tag = match[1] as "TASK" | "READ" | "COMMENT" | "EDIT";
2028
+ const description = (match[2] ?? "").trim();
2029
+ const mode = getClmWorkflowScopeMode(tag);
2030
+ if (!mode || description.length === 0) {
2031
+ return undefined;
2032
+ }
2033
+ return {
2034
+ tag,
2035
+ description,
2036
+ sourceCommentId: thread.metadata?.rootOoxmlCommentId ?? thread.commentId,
2037
+ sourceCommentDurableId: thread.entries[0]?.metadata?.durableId,
2038
+ sourceCommentParaId: thread.metadata?.rootParaId ?? thread.entries[0]?.metadata?.paraId,
2039
+ mode,
2040
+ };
2041
+ }
2042
+
2043
+ function getClmWorkflowScopeMode(tag: string): WorkflowScope["mode"] | undefined {
2044
+ switch (tag) {
2045
+ case "TASK":
2046
+ return "suggest";
2047
+ case "READ":
2048
+ return "view";
2049
+ case "COMMENT":
2050
+ return "comment";
2051
+ case "EDIT":
2052
+ return "edit";
2053
+ default:
2054
+ return undefined;
2055
+ }
2056
+ }
2057
+
2058
+ function createClmWorkflowWorkItem(
2059
+ thread: CommentThread,
2060
+ description: string,
2061
+ scopeId: string,
2062
+ ): WorkflowWorkItem {
2063
+ return {
2064
+ workItemId: `clm-task-${thread.commentId}`,
2065
+ title: description,
2066
+ description,
2067
+ status: "pending",
2068
+ scopeIds: [scopeId],
2069
+ };
2070
+ }
2071
+
2072
+ function createClmWorkflowScope(
2073
+ thread: CommentThread,
2074
+ directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
2075
+ version: number,
2076
+ scopeId: string,
2077
+ workItemId?: string,
2078
+ ): WorkflowScope {
2079
+ if (workItemId) {
2080
+ return {
2081
+ scopeId,
2082
+ version,
2083
+ mode: directive.mode,
2084
+ anchor: toPublicAnchorProjection(thread.anchor),
2085
+ storyTarget: { kind: "main" },
2086
+ workItemId,
2087
+ label: directive.description,
2088
+ metadata: createClmScopeMetadata(directive),
2089
+ };
2090
+ }
2091
+ return {
2092
+ scopeId,
2093
+ version,
2094
+ mode: directive.mode,
2095
+ anchor: toPublicAnchorProjection(thread.anchor),
2096
+ storyTarget: { kind: "main" },
2097
+ label: directive.description,
2098
+ metadata: createClmScopeMetadata(directive),
2099
+ };
2100
+ }
2101
+
2102
+ function createClmScopeMetadata(
2103
+ directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
2104
+ ): WorkflowScopeMetadataField[] {
2105
+ return [
2106
+ {
2107
+ key: "workblock.clm.tag",
2108
+ valueType: "string",
2109
+ value: directive.tag,
2110
+ },
2111
+ {
2112
+ key: "workblock.clm.description",
2113
+ valueType: "string",
2114
+ value: directive.description,
2115
+ },
2116
+ {
2117
+ key: "workblock.sourceCommentId",
2118
+ valueType: "string",
2119
+ value: directive.sourceCommentId,
2120
+ },
2121
+ ...(directive.sourceCommentDurableId
2122
+ ? [{
2123
+ key: "workblock.sourceCommentDurableId",
2124
+ valueType: "string" as const,
2125
+ value: directive.sourceCommentDurableId,
2126
+ }]
2127
+ : []),
2128
+ ...(directive.sourceCommentParaId
2129
+ ? [{
2130
+ key: "workblock.sourceCommentParaId",
2131
+ valueType: "string" as const,
2132
+ value: directive.sourceCommentParaId,
2133
+ }]
2134
+ : []),
2135
+ ];
2136
+ }
2137
+
2138
+ function findExistingClmScope(
2139
+ scopes: readonly WorkflowScope[],
2140
+ directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
2141
+ ): WorkflowScope | undefined {
2142
+ return scopes.find((scope) => {
2143
+ const sourceCommentId = getWorkflowScopeMetadataValue(scope.metadata, "workblock.sourceCommentId");
2144
+ const sourceCommentDurableId = getWorkflowScopeMetadataValue(
2145
+ scope.metadata,
2146
+ "workblock.sourceCommentDurableId",
2147
+ );
2148
+ return (
2149
+ sourceCommentId === directive.sourceCommentId ||
2150
+ (directive.sourceCommentDurableId !== undefined &&
2151
+ sourceCommentDurableId === directive.sourceCommentDurableId)
2152
+ );
2153
+ });
2154
+ }
2155
+
2156
+ function getWorkflowScopeMetadataValue(
2157
+ metadata: WorkflowScope["metadata"] | undefined,
2158
+ key: string,
2159
+ ): string | undefined {
2160
+ const field = metadata?.find((entry) => entry.key === key);
2161
+ return typeof field?.value === "string" ? field.value : undefined;
2162
+ }
2163
+
2164
+ function getNextClmScopeVersion(
2165
+ scopes: readonly WorkflowScope[],
2166
+ anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
2167
+ ): number {
2168
+ const anchorRange = {
2169
+ from: anchor.range.from,
2170
+ to: anchor.range.to,
2171
+ };
2172
+ const overlappingVersions = scopes.flatMap((scope) => {
2173
+ if (scope.anchor.kind !== "range") {
2174
+ return [];
2175
+ }
2176
+ return rangesIntersect(scope.anchor, anchorRange) && typeof scope.version === "number"
2177
+ ? [scope.version]
2178
+ : [];
2179
+ });
2180
+ return overlappingVersions.length > 0 ? Math.max(...overlappingVersions) + 1 : 1;
2181
+ }
2182
+
2183
+ function resolveImportedCommentThreadOnTranslation(
2184
+ thread: CommentThread,
2185
+ timestamp: string,
2186
+ ): CommentThread {
2187
+ if (thread.status === "resolved") {
2188
+ return thread;
2189
+ }
2190
+ return {
2191
+ ...thread,
2192
+ status: thread.status === "detached" ? "detached" : "resolved",
2193
+ resolution: thread.resolution ?? {
2194
+ resolvedAt: timestamp,
2195
+ resolvedBy: thread.entries[thread.entries.length - 1]?.authorId ?? thread.createdBy,
2196
+ },
2197
+ };
2198
+ }
2199
+
1943
2200
  function resolveDefinitionRootCommentId(
1944
2201
  definition: ImportedCommentDefinition,
1945
2202
  definitions: readonly ImportedCommentDefinition[],
@@ -371,19 +371,46 @@ function buildWorkblockExtensionXml(workflowOverlay: WorkflowOverlay | undefined
371
371
  }
372
372
 
373
373
  function buildWorkflowScopeXml(scope: WorkflowScope): string {
374
+ const scopeMetadataXml = buildWorkflowScopeMetadataXml(scope.metadata);
374
375
  return [
375
376
  `<bw:scope`,
376
377
  ` id="${escapeXml(scope.scopeId)}"`,
378
+ typeof scope.version === "number" ? ` version="${String(scope.version)}"` : "",
377
379
  ` mode="${escapeXml(scope.mode)}"`,
378
380
  scope.workItemId ? ` workItemRef="${escapeXml(scope.workItemId)}"` : "",
379
381
  scope.label ? ` label="${escapeXml(scope.label)}"` : "",
380
382
  scope.domain ? ` domain="${escapeXml(scope.domain)}"` : "",
381
383
  `>`,
382
384
  indentLines(buildWorkflowAnchorXml(scope.anchor, scope.storyTarget), 2),
385
+ scopeMetadataXml ? indentLines(scopeMetadataXml, 2) : "",
383
386
  `</bw:scope>`,
384
387
  ].join("");
385
388
  }
386
389
 
390
+ function buildWorkflowScopeMetadataXml(scopeMetadata: WorkflowScope["metadata"]): string {
391
+ if (!scopeMetadata || scopeMetadata.length === 0) {
392
+ return "";
393
+ }
394
+ return [
395
+ `<bw:scopeMetadata>`,
396
+ indentLines(
397
+ scopeMetadata
398
+ .map((field) => {
399
+ const serialized = serializeWorkflowScopeMetadataValue(field.valueType, field.value);
400
+ return [
401
+ `<bw:field`,
402
+ ` key="${escapeXml(field.key)}"`,
403
+ serialized.valueType ? ` type="${escapeXml(serialized.valueType)}"` : "",
404
+ `>${escapeXml(serialized.text)}</bw:field>`,
405
+ ].join("");
406
+ })
407
+ .join("\n"),
408
+ 2,
409
+ ),
410
+ `</bw:scopeMetadata>`,
411
+ ].join("\n");
412
+ }
413
+
387
414
  function buildWorkflowAnchorXml(
388
415
  anchor: EditorAnchorProjection,
389
416
  storyTarget: WorkflowScope["storyTarget"],
@@ -563,15 +590,110 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
563
590
  : createDefaultRangeAnchor(0, 0);
564
591
  return {
565
592
  scopeId: attributes.id,
593
+ version: attributes.version !== undefined ? Number(attributes.version) : undefined,
566
594
  mode: attributes.mode as WorkflowScope["mode"],
567
595
  anchor,
568
596
  storyTarget: anchorMatch ? parseOverlayStoryTarget(parseAttributes(anchorMatch[1] ?? "")) : { kind: "main" },
569
597
  workItemId: attributes.workItemRef,
570
598
  label: attributes.label,
571
599
  domain: attributes.domain as WorkflowScope["domain"],
600
+ metadata: parseWorkflowScopeMetadata(body),
601
+ };
602
+ }
603
+
604
+ function parseWorkflowScopeMetadata(body: string): WorkflowScope["metadata"] | undefined {
605
+ const metadataMatch = body.match(/<bw:scopeMetadata\b[^>]*>([\s\S]*?)<\/bw:scopeMetadata>/u);
606
+ if (!metadataMatch) {
607
+ return undefined;
608
+ }
609
+ const fields = [...(metadataMatch[1] ?? "").matchAll(/<bw:field\b([^>]*)>([\s\S]*?)<\/bw:field>/gu)]
610
+ .map((match) => parseWorkflowScopeMetadataField(match[1] ?? "", match[2] ?? ""))
611
+ .filter((field): field is NonNullable<WorkflowScope["metadata"]>[number] => field !== null);
612
+ return fields.length > 0 ? fields : undefined;
613
+ }
614
+
615
+ function parseWorkflowScopeMetadataField(
616
+ attributesSource: string,
617
+ body: string,
618
+ ): NonNullable<WorkflowScope["metadata"]>[number] | null {
619
+ const attributes = parseAttributes(attributesSource);
620
+ if (!attributes.key) {
621
+ return null;
622
+ }
623
+ const valueType = attributes.type as NonNullable<WorkflowScope["metadata"]>[number]["valueType"] | undefined;
624
+ return {
625
+ key: attributes.key,
626
+ ...(valueType ? { valueType } : {}),
627
+ value: parseWorkflowScopeMetadataValue(valueType, body),
628
+ };
629
+ }
630
+
631
+ function serializeWorkflowScopeMetadataValue(
632
+ valueType: NonNullable<WorkflowScope["metadata"]>[number]["valueType"],
633
+ value: NonNullable<WorkflowScope["metadata"]>[number]["value"],
634
+ ): { valueType?: "string" | "number" | "boolean" | "json"; text: string } {
635
+ const resolvedValueType =
636
+ valueType ??
637
+ (typeof value === "number"
638
+ ? "number"
639
+ : typeof value === "boolean"
640
+ ? "boolean"
641
+ : typeof value === "string"
642
+ ? "string"
643
+ : value && typeof value === "object"
644
+ ? "json"
645
+ : undefined);
646
+
647
+ if (resolvedValueType === "json") {
648
+ return {
649
+ valueType: "json",
650
+ text: JSON.stringify(value ?? {}),
651
+ };
652
+ }
653
+ if (resolvedValueType === "number") {
654
+ return {
655
+ valueType: "number",
656
+ text: typeof value === "number" ? String(value) : "0",
657
+ };
658
+ }
659
+ if (resolvedValueType === "boolean") {
660
+ return {
661
+ valueType: "boolean",
662
+ text: String(value === true),
663
+ };
664
+ }
665
+ return {
666
+ valueType: resolvedValueType,
667
+ text: typeof value === "string" ? value : value === undefined ? "" : String(value),
572
668
  };
573
669
  }
574
670
 
671
+ function parseWorkflowScopeMetadataValue(
672
+ valueType: NonNullable<WorkflowScope["metadata"]>[number]["valueType"],
673
+ payloadText: string,
674
+ ): NonNullable<WorkflowScope["metadata"]>[number]["value"] {
675
+ const decodedText = decodeXmlEntities(payloadText);
676
+ if (valueType === "json") {
677
+ try {
678
+ const parsed = JSON.parse(decodedText) as unknown;
679
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
680
+ return parsed as Record<string, unknown>;
681
+ }
682
+ return { value: parsed };
683
+ } catch {
684
+ return { value: decodedText };
685
+ }
686
+ }
687
+ if (valueType === "number") {
688
+ const numericValue = Number(decodedText);
689
+ return Number.isFinite(numericValue) ? numericValue : 0;
690
+ }
691
+ if (valueType === "boolean") {
692
+ return decodedText === "true" || decodedText === "1";
693
+ }
694
+ return decodedText;
695
+ }
696
+
575
697
  function parseWorkflowWorkItem(attributesSource: string, body: string): WorkflowWorkItem | null {
576
698
  const attributes = parseAttributes(attributesSource);
577
699
  if (!attributes.id || !attributes.title) {