@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 +20 -3
- package/src/api/public-types.ts +13 -8
- package/src/api/session-state.ts +24 -0
- package/src/core/commands/index.ts +73 -0
- package/src/index.ts +2 -0
- package/src/io/docx-session.ts +260 -3
- package/src/io/ooxml/workflow-payload.ts +122 -0
- package/src/model/snapshot.ts +58 -0
- package/src/runtime/collab-review-sync.ts +254 -0
- package/src/runtime/document-runtime.ts +4 -3
- package/src/runtime/surface-projection.ts +8 -0
- package/src/ui/WordReviewEditor.tsx +11 -0
- package/src/ui/editor-surface-controller.tsx +2 -0
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +40 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +27 -33
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +8 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +55 -24
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.
|
|
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",
|
package/src/api/public-types.ts
CHANGED
|
@@ -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 =
|
|
9
|
-
export type FieldRefreshStatus =
|
|
10
|
-
export type SupportedFieldFamily =
|
|
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;
|
package/src/api/session-state.ts
CHANGED
|
@@ -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 {
|
package/src/io/docx-session.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
726
|
-
workflowMetadata:
|
|
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) {
|