@beyondwork/docx-react-component 1.0.41 → 1.0.42
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 +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical serialization of the `bw:workflowPayload` used as the signing
|
|
3
|
+
* surface for `bw:signature`. Called `bw-canon/1`.
|
|
4
|
+
*
|
|
5
|
+
* Rules (see bw-collab-schema-additions.md §bw:signature):
|
|
6
|
+
* 1. Remove any top-level `bw:signature` element from the tree before
|
|
7
|
+
* serializing.
|
|
8
|
+
* 2. Sort attributes lexicographically by qualified name.
|
|
9
|
+
* 3. Normalize attribute-value whitespace: collapse runs of `\s` to a
|
|
10
|
+
* single space, then trim.
|
|
11
|
+
* 4. Sort children of order-insensitive parents by a keyed tuple
|
|
12
|
+
* (see `SORT_KEYED_CHILDREN`). Children of any parent not listed
|
|
13
|
+
* retain document order.
|
|
14
|
+
* 5. Collapse empty elements to self-closing form.
|
|
15
|
+
* 6. Emit UTF-8 bytes, LF line endings (none in practice — output is
|
|
16
|
+
* single-line), no XML declaration, no BOM.
|
|
17
|
+
*
|
|
18
|
+
* This canonicalizer is intentionally minimal — it parses only the
|
|
19
|
+
* element / attribute / text / CDATA subset the bw schema uses. It does
|
|
20
|
+
* NOT implement C14N; the contract is self-consistent (writer and
|
|
21
|
+
* reader use this same routine) and self-referential (the signature
|
|
22
|
+
* element is excluded from its own input).
|
|
23
|
+
*/
|
|
24
|
+
export function canonicalizePayload(xml: string): Uint8Array {
|
|
25
|
+
const tree = parseXml(xml);
|
|
26
|
+
const filtered = stripSignature(tree);
|
|
27
|
+
const out = serialize(filtered);
|
|
28
|
+
return new TextEncoder().encode(out);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ----- AST ------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface XmlElement {
|
|
34
|
+
kind: "element";
|
|
35
|
+
name: string;
|
|
36
|
+
attributes: Record<string, string>;
|
|
37
|
+
children: XmlNode[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface XmlText {
|
|
41
|
+
kind: "text";
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type XmlNode = XmlElement | XmlText;
|
|
46
|
+
|
|
47
|
+
// ----- Parse ----------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function parseXml(src: string): XmlElement {
|
|
50
|
+
let i = 0;
|
|
51
|
+
skipProlog();
|
|
52
|
+
|
|
53
|
+
const root = readElement();
|
|
54
|
+
if (!root) throw new Error("canonicalize: no root element");
|
|
55
|
+
return root;
|
|
56
|
+
|
|
57
|
+
function skipProlog(): void {
|
|
58
|
+
while (i < src.length) {
|
|
59
|
+
if (src.startsWith("<?", i)) {
|
|
60
|
+
const end = src.indexOf("?>", i);
|
|
61
|
+
if (end < 0) throw new Error("canonicalize: unterminated prolog");
|
|
62
|
+
i = end + 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (src.startsWith("<!--", i)) {
|
|
66
|
+
const end = src.indexOf("-->", i);
|
|
67
|
+
if (end < 0) throw new Error("canonicalize: unterminated comment");
|
|
68
|
+
i = end + 3;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/\s/.test(src[i]!)) {
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readElement(): XmlElement | null {
|
|
80
|
+
// Skip whitespace + comments between siblings.
|
|
81
|
+
while (i < src.length) {
|
|
82
|
+
if (src.startsWith("<!--", i)) {
|
|
83
|
+
const end = src.indexOf("-->", i);
|
|
84
|
+
if (end < 0) throw new Error("canonicalize: unterminated comment");
|
|
85
|
+
i = end + 3;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (/\s/.test(src[i]!)) {
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (src[i] !== "<") return null;
|
|
95
|
+
if (src.startsWith("</", i)) return null;
|
|
96
|
+
i += 1;
|
|
97
|
+
|
|
98
|
+
const nameStart = i;
|
|
99
|
+
while (i < src.length && !/[\s/>]/.test(src[i]!)) i += 1;
|
|
100
|
+
const name = src.slice(nameStart, i);
|
|
101
|
+
|
|
102
|
+
const attrs: Record<string, string> = {};
|
|
103
|
+
while (i < src.length) {
|
|
104
|
+
while (i < src.length && /\s/.test(src[i]!)) i += 1;
|
|
105
|
+
if (src[i] === "/" || src[i] === ">") break;
|
|
106
|
+
const aNameStart = i;
|
|
107
|
+
while (i < src.length && src[i] !== "=" && !/\s/.test(src[i]!)) i += 1;
|
|
108
|
+
const attrName = src.slice(aNameStart, i);
|
|
109
|
+
while (i < src.length && /\s/.test(src[i]!)) i += 1;
|
|
110
|
+
if (src[i] !== "=") {
|
|
111
|
+
attrs[attrName] = "";
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
i += 1;
|
|
115
|
+
while (i < src.length && /\s/.test(src[i]!)) i += 1;
|
|
116
|
+
const quote = src[i];
|
|
117
|
+
if (quote !== '"' && quote !== "'") {
|
|
118
|
+
throw new Error(`canonicalize: unquoted attr at ${i}`);
|
|
119
|
+
}
|
|
120
|
+
i += 1;
|
|
121
|
+
const vStart = i;
|
|
122
|
+
while (i < src.length && src[i] !== quote) i += 1;
|
|
123
|
+
attrs[attrName] = xmlDecode(src.slice(vStart, i));
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (src[i] === "/") {
|
|
128
|
+
i += 1;
|
|
129
|
+
if (src[i] !== ">") throw new Error("canonicalize: bad self-close");
|
|
130
|
+
i += 1;
|
|
131
|
+
return { kind: "element", name, attributes: attrs, children: [] };
|
|
132
|
+
}
|
|
133
|
+
if (src[i] !== ">") throw new Error("canonicalize: expected >");
|
|
134
|
+
i += 1;
|
|
135
|
+
|
|
136
|
+
const children: XmlNode[] = [];
|
|
137
|
+
while (i < src.length) {
|
|
138
|
+
if (src.startsWith("</", i)) {
|
|
139
|
+
i += 2;
|
|
140
|
+
const endNameStart = i;
|
|
141
|
+
while (i < src.length && src[i] !== ">") i += 1;
|
|
142
|
+
const endName = src.slice(endNameStart, i).trim();
|
|
143
|
+
if (endName !== name) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`canonicalize: mismatched close: opened <${name}> got </${endName}>`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
i += 1;
|
|
149
|
+
return { kind: "element", name, attributes: attrs, children };
|
|
150
|
+
}
|
|
151
|
+
if (src.startsWith("<!--", i)) {
|
|
152
|
+
const end = src.indexOf("-->", i);
|
|
153
|
+
if (end < 0) throw new Error("canonicalize: unterminated comment");
|
|
154
|
+
i = end + 3;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (src.startsWith("<![CDATA[", i)) {
|
|
158
|
+
const end = src.indexOf("]]>", i);
|
|
159
|
+
if (end < 0) throw new Error("canonicalize: unterminated CDATA");
|
|
160
|
+
children.push({ kind: "text", text: src.slice(i + 9, end) });
|
|
161
|
+
i = end + 3;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (src[i] === "<") {
|
|
165
|
+
const child = readElement();
|
|
166
|
+
if (child) children.push(child);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const textStart = i;
|
|
170
|
+
while (i < src.length && src[i] !== "<") i += 1;
|
|
171
|
+
const raw = src.slice(textStart, i);
|
|
172
|
+
if (raw.length > 0) {
|
|
173
|
+
children.push({ kind: "text", text: xmlDecode(raw) });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`canonicalize: unterminated <${name}>`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ----- Transform ------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function stripSignature(el: XmlElement): XmlElement {
|
|
183
|
+
return {
|
|
184
|
+
...el,
|
|
185
|
+
children: el.children.filter(
|
|
186
|
+
(c) => !(c.kind === "element" && localName(c.name) === "signature"),
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const SORT_KEYED_CHILDREN: Record<string, (el: XmlElement) => string> = {
|
|
192
|
+
participants: (el) => el.attributes["userId"] ?? "",
|
|
193
|
+
votes: (el) => el.attributes["authorId"] ?? "",
|
|
194
|
+
requiredApprovers: (el) => el.attributes["id"] ?? "",
|
|
195
|
+
reactions: (el) =>
|
|
196
|
+
`${el.attributes["emoji"] ?? ""}\u0000${el.attributes["authorId"] ?? ""}`,
|
|
197
|
+
mentions: (el) =>
|
|
198
|
+
`${el.attributes["entryId"] ?? ""}\u0000${el.attributes["offsetInBody"] ?? ""}`,
|
|
199
|
+
counterProposals: (el) => el.attributes["id"] ?? "",
|
|
200
|
+
strippedComments: (el) => el.attributes["commentId"] ?? "",
|
|
201
|
+
strippedParticipants: (el) => el.attributes["id"] ?? "",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const CONTAINER_WITH_SORTED_ELEMENTS: Record<string, true> = {
|
|
205
|
+
commentNegotiation: true, // children keyed by commentId
|
|
206
|
+
commentPresentation: true, // children keyed by commentId
|
|
207
|
+
attachments: true, // children keyed by id
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const CONTAINER_CHILD_KEY: Record<string, (el: XmlElement) => string> = {
|
|
211
|
+
commentNegotiation: (el) => el.attributes["commentId"] ?? "",
|
|
212
|
+
commentPresentation: (el) => el.attributes["commentId"] ?? "",
|
|
213
|
+
attachments: (el) => el.attributes["id"] ?? "",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function sortChildren(el: XmlElement): XmlElement {
|
|
217
|
+
const parentLocal = localName(el.name);
|
|
218
|
+
const keyFn =
|
|
219
|
+
SORT_KEYED_CHILDREN[parentLocal] ??
|
|
220
|
+
(CONTAINER_WITH_SORTED_ELEMENTS[parentLocal]
|
|
221
|
+
? CONTAINER_CHILD_KEY[parentLocal]
|
|
222
|
+
: undefined);
|
|
223
|
+
|
|
224
|
+
const recursed = el.children.map((child) =>
|
|
225
|
+
child.kind === "element" ? sortChildren(child) : child,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (!keyFn) {
|
|
229
|
+
return { ...el, children: recursed };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const elements = recursed.filter(
|
|
233
|
+
(c): c is XmlElement => c.kind === "element",
|
|
234
|
+
);
|
|
235
|
+
const others = recursed.filter((c) => c.kind !== "element");
|
|
236
|
+
const sorted = [...elements].sort((a, b) => {
|
|
237
|
+
const ka = keyFn(a);
|
|
238
|
+
const kb = keyFn(b);
|
|
239
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
240
|
+
});
|
|
241
|
+
return { ...el, children: [...others, ...sorted] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ----- Serialize ------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
function serialize(el: XmlElement): string {
|
|
247
|
+
const tree = sortChildren(el);
|
|
248
|
+
return emit(tree);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function emit(node: XmlNode): string {
|
|
252
|
+
if (node.kind === "text") return xmlEncode(node.text);
|
|
253
|
+
const attrPairs = Object.entries(node.attributes)
|
|
254
|
+
.map(([k, v]) => [k, normalizeWhitespace(v)] as const)
|
|
255
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
256
|
+
const attrStr = attrPairs
|
|
257
|
+
.map(([k, v]) => ` ${k}="${xmlEncode(v)}"`)
|
|
258
|
+
.join("");
|
|
259
|
+
|
|
260
|
+
const content = node.children
|
|
261
|
+
.map(emit)
|
|
262
|
+
.filter((s) => s.length > 0 || preservesSpace())
|
|
263
|
+
.join("");
|
|
264
|
+
if (content.length === 0) {
|
|
265
|
+
return `<${node.name}${attrStr}/>`;
|
|
266
|
+
}
|
|
267
|
+
return `<${node.name}${attrStr}>${content}</${node.name}>`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function preservesSpace(): boolean {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ----- Helpers --------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function localName(qname: string): string {
|
|
277
|
+
const colon = qname.indexOf(":");
|
|
278
|
+
return colon < 0 ? qname : qname.slice(colon + 1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeWhitespace(value: string): string {
|
|
282
|
+
return value.replace(/\s+/g, " ").trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function xmlEncode(text: string): string {
|
|
286
|
+
return text
|
|
287
|
+
.replace(/&/g, "&")
|
|
288
|
+
.replace(/</g, "<")
|
|
289
|
+
.replace(/>/g, ">")
|
|
290
|
+
.replace(/"/g, """)
|
|
291
|
+
.replace(/'/g, "'");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function xmlDecode(text: string): string {
|
|
295
|
+
return text
|
|
296
|
+
.replace(/</g, "<")
|
|
297
|
+
.replace(/>/g, ">")
|
|
298
|
+
.replace(/"/g, '"')
|
|
299
|
+
.replace(/'/g, "'")
|
|
300
|
+
.replace(/&/g, "&");
|
|
301
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommentNegotiationActionType,
|
|
3
|
+
CommentNegotiationEntry,
|
|
4
|
+
CommentNegotiationSnapshot,
|
|
5
|
+
CommentNegotiationState,
|
|
6
|
+
NegotiationCounterProposal,
|
|
7
|
+
NegotiationHistoryRow,
|
|
8
|
+
NegotiationVote,
|
|
9
|
+
} from "../../api/comment-negotiation-types.ts";
|
|
10
|
+
import {
|
|
11
|
+
attrNumber,
|
|
12
|
+
childrenOf,
|
|
13
|
+
firstChild,
|
|
14
|
+
parseBwXml,
|
|
15
|
+
renderElement,
|
|
16
|
+
renderText,
|
|
17
|
+
stripNs,
|
|
18
|
+
textOf,
|
|
19
|
+
} from "./bw-xml.ts";
|
|
20
|
+
|
|
21
|
+
const NS_URI = "urn:beyondwork:workflow-payload:1";
|
|
22
|
+
|
|
23
|
+
const STATE_VOCAB = new Set<CommentNegotiationState>([
|
|
24
|
+
"proposed",
|
|
25
|
+
"negotiating",
|
|
26
|
+
"accepted",
|
|
27
|
+
"rejected",
|
|
28
|
+
"resolved",
|
|
29
|
+
]);
|
|
30
|
+
const VERDICT_VOCAB = new Set<NegotiationVote["verdict"]>([
|
|
31
|
+
"approve",
|
|
32
|
+
"reject",
|
|
33
|
+
"abstain",
|
|
34
|
+
]);
|
|
35
|
+
const ACTION_VOCAB = new Set<CommentNegotiationActionType>([
|
|
36
|
+
"propose-change",
|
|
37
|
+
"counter-propose",
|
|
38
|
+
"vote",
|
|
39
|
+
"accept",
|
|
40
|
+
"reject",
|
|
41
|
+
"lock",
|
|
42
|
+
"reopen",
|
|
43
|
+
]);
|
|
44
|
+
const EDIT_KIND_VOCAB = new Set<"replace" | "insert" | "delete">([
|
|
45
|
+
"replace",
|
|
46
|
+
"insert",
|
|
47
|
+
"delete",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export function buildCommentNegotiationXml(
|
|
51
|
+
snap: CommentNegotiationSnapshot,
|
|
52
|
+
): string {
|
|
53
|
+
const threads = snap.entries.map(buildThread).join("");
|
|
54
|
+
return renderElement(
|
|
55
|
+
"bw:commentNegotiation",
|
|
56
|
+
{
|
|
57
|
+
"xmlns:bw": NS_URI,
|
|
58
|
+
schemaVersion: String(snap.schemaVersion),
|
|
59
|
+
},
|
|
60
|
+
[threads],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildThread(entry: CommentNegotiationEntry): string {
|
|
65
|
+
const approvers = entry.requiredApprovers.length
|
|
66
|
+
? renderElement("bw:requiredApprovers", {}, [
|
|
67
|
+
entry.requiredApprovers
|
|
68
|
+
.map((id) => renderElement("bw:userRef", { id }))
|
|
69
|
+
.join(""),
|
|
70
|
+
])
|
|
71
|
+
: "";
|
|
72
|
+
|
|
73
|
+
const votes = entry.votes.length
|
|
74
|
+
? renderElement("bw:votes", {}, [
|
|
75
|
+
entry.votes
|
|
76
|
+
.map((v) =>
|
|
77
|
+
renderElement("bw:vote", {
|
|
78
|
+
authorId: v.authorId,
|
|
79
|
+
verdict: v.verdict,
|
|
80
|
+
castAt: v.castAt,
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
.join(""),
|
|
84
|
+
])
|
|
85
|
+
: "";
|
|
86
|
+
|
|
87
|
+
const proposals = entry.counterProposals.length
|
|
88
|
+
? renderElement("bw:counterProposals", {}, [
|
|
89
|
+
entry.counterProposals.map(buildCounterProposal).join(""),
|
|
90
|
+
])
|
|
91
|
+
: "";
|
|
92
|
+
|
|
93
|
+
const history = entry.history.length
|
|
94
|
+
? renderElement("bw:history", {}, [
|
|
95
|
+
entry.history
|
|
96
|
+
.map((h) =>
|
|
97
|
+
renderElement("bw:transition", {
|
|
98
|
+
from: h.from,
|
|
99
|
+
to: h.to,
|
|
100
|
+
actorId: h.actorId,
|
|
101
|
+
at: h.at,
|
|
102
|
+
action: h.action,
|
|
103
|
+
reasonCode: h.reasonCode,
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
.join(""),
|
|
107
|
+
])
|
|
108
|
+
: "";
|
|
109
|
+
|
|
110
|
+
return renderElement(
|
|
111
|
+
"bw:thread",
|
|
112
|
+
{
|
|
113
|
+
commentId: entry.commentId,
|
|
114
|
+
state: entry.state,
|
|
115
|
+
lockedAt: entry.lockedAt,
|
|
116
|
+
lockedBy: entry.lockedBy,
|
|
117
|
+
acceptedProposalId: entry.acceptedProposalId,
|
|
118
|
+
},
|
|
119
|
+
[approvers, votes, proposals, history],
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildCounterProposal(p: NegotiationCounterProposal): string {
|
|
124
|
+
const bodyEl = renderElement("bw:body", {}, [renderText(p.body)]);
|
|
125
|
+
const edit = p.proposedRangeEdit
|
|
126
|
+
? renderElement(
|
|
127
|
+
"bw:proposedRangeEdit",
|
|
128
|
+
{
|
|
129
|
+
kind: p.proposedRangeEdit.kind,
|
|
130
|
+
start: String(p.proposedRangeEdit.start),
|
|
131
|
+
end: String(p.proposedRangeEdit.end),
|
|
132
|
+
},
|
|
133
|
+
[p.proposedRangeEdit.text ? renderText(p.proposedRangeEdit.text) : ""],
|
|
134
|
+
)
|
|
135
|
+
: "";
|
|
136
|
+
return renderElement(
|
|
137
|
+
"bw:counterProposal",
|
|
138
|
+
{
|
|
139
|
+
id: p.id,
|
|
140
|
+
authorId: p.authorId,
|
|
141
|
+
createdAt: p.createdAt,
|
|
142
|
+
supersededBy: p.supersededBy,
|
|
143
|
+
},
|
|
144
|
+
[bodyEl, edit],
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseCommentNegotiationXml(
|
|
149
|
+
xml: string,
|
|
150
|
+
): CommentNegotiationSnapshot {
|
|
151
|
+
const root = parseBwXml(xml);
|
|
152
|
+
if (stripNs(root.name) !== "commentNegotiation") {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`parseCommentNegotiationXml: expected <bw:commentNegotiation>, got <${root.name}>`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const schemaVersion =
|
|
158
|
+
attrNumber(root.attributes["schemaVersion"]) ?? 1;
|
|
159
|
+
if (schemaVersion !== 1) {
|
|
160
|
+
// Unknown major — preserve-only; return an empty snapshot so the runtime
|
|
161
|
+
// still works. The full tree is preserved by the workflow-payload emitter.
|
|
162
|
+
return { schemaVersion: 1, entries: [] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const entries: CommentNegotiationEntry[] = [];
|
|
166
|
+
for (const thread of childrenOf(root, "thread")) {
|
|
167
|
+
const state = thread.attributes["state"] as CommentNegotiationState;
|
|
168
|
+
if (!STATE_VOCAB.has(state)) continue;
|
|
169
|
+
|
|
170
|
+
const entry: CommentNegotiationEntry = {
|
|
171
|
+
commentId: thread.attributes["commentId"] ?? "",
|
|
172
|
+
state,
|
|
173
|
+
requiredApprovers: parseApprovers(thread),
|
|
174
|
+
votes: parseVotes(thread),
|
|
175
|
+
counterProposals: parseProposals(thread),
|
|
176
|
+
history: parseHistory(thread),
|
|
177
|
+
};
|
|
178
|
+
if (thread.attributes["lockedAt"] !== undefined) {
|
|
179
|
+
entry.lockedAt = thread.attributes["lockedAt"];
|
|
180
|
+
}
|
|
181
|
+
if (thread.attributes["lockedBy"] !== undefined) {
|
|
182
|
+
entry.lockedBy = thread.attributes["lockedBy"];
|
|
183
|
+
}
|
|
184
|
+
if (thread.attributes["acceptedProposalId"] !== undefined) {
|
|
185
|
+
entry.acceptedProposalId = thread.attributes["acceptedProposalId"];
|
|
186
|
+
}
|
|
187
|
+
entries.push(entry);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { schemaVersion: 1, entries };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseApprovers(
|
|
194
|
+
thread: ReturnType<typeof parseBwXml>,
|
|
195
|
+
): string[] {
|
|
196
|
+
const container = firstChild(thread, "requiredApprovers");
|
|
197
|
+
if (!container) return [];
|
|
198
|
+
return childrenOf(container, "userRef")
|
|
199
|
+
.map((el) => el.attributes["id"])
|
|
200
|
+
.filter((id): id is string => id !== undefined && id !== "");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseVotes(thread: ReturnType<typeof parseBwXml>): NegotiationVote[] {
|
|
204
|
+
const container = firstChild(thread, "votes");
|
|
205
|
+
if (!container) return [];
|
|
206
|
+
const out: NegotiationVote[] = [];
|
|
207
|
+
for (const el of childrenOf(container, "vote")) {
|
|
208
|
+
const verdict = el.attributes["verdict"] as NegotiationVote["verdict"];
|
|
209
|
+
if (!VERDICT_VOCAB.has(verdict)) continue;
|
|
210
|
+
const authorId = el.attributes["authorId"];
|
|
211
|
+
const castAt = el.attributes["castAt"];
|
|
212
|
+
if (!authorId || !castAt) continue;
|
|
213
|
+
out.push({ authorId, verdict, castAt });
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseProposals(
|
|
219
|
+
thread: ReturnType<typeof parseBwXml>,
|
|
220
|
+
): NegotiationCounterProposal[] {
|
|
221
|
+
const container = firstChild(thread, "counterProposals");
|
|
222
|
+
if (!container) return [];
|
|
223
|
+
const out: NegotiationCounterProposal[] = [];
|
|
224
|
+
for (const el of childrenOf(container, "counterProposal")) {
|
|
225
|
+
const id = el.attributes["id"];
|
|
226
|
+
const authorId = el.attributes["authorId"];
|
|
227
|
+
const createdAt = el.attributes["createdAt"];
|
|
228
|
+
if (!id || !authorId || !createdAt) continue;
|
|
229
|
+
const bodyEl = firstChild(el, "body");
|
|
230
|
+
const editEl = firstChild(el, "proposedRangeEdit");
|
|
231
|
+
const proposal: NegotiationCounterProposal = {
|
|
232
|
+
id,
|
|
233
|
+
authorId,
|
|
234
|
+
createdAt,
|
|
235
|
+
body: bodyEl ? textOf(bodyEl) : "",
|
|
236
|
+
};
|
|
237
|
+
if (el.attributes["supersededBy"] !== undefined) {
|
|
238
|
+
proposal.supersededBy = el.attributes["supersededBy"];
|
|
239
|
+
}
|
|
240
|
+
if (editEl) {
|
|
241
|
+
const kind = editEl.attributes["kind"] as "replace" | "insert" | "delete";
|
|
242
|
+
const start = attrNumber(editEl.attributes["start"]);
|
|
243
|
+
const end = attrNumber(editEl.attributes["end"]);
|
|
244
|
+
if (EDIT_KIND_VOCAB.has(kind) && start !== undefined && end !== undefined) {
|
|
245
|
+
const edit: NegotiationCounterProposal["proposedRangeEdit"] = {
|
|
246
|
+
kind,
|
|
247
|
+
start,
|
|
248
|
+
end,
|
|
249
|
+
};
|
|
250
|
+
const text = textOf(editEl);
|
|
251
|
+
if (text.length > 0) edit.text = text;
|
|
252
|
+
proposal.proposedRangeEdit = edit;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
out.push(proposal);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseHistory(
|
|
261
|
+
thread: ReturnType<typeof parseBwXml>,
|
|
262
|
+
): NegotiationHistoryRow[] {
|
|
263
|
+
const container = firstChild(thread, "history");
|
|
264
|
+
if (!container) return [];
|
|
265
|
+
const out: NegotiationHistoryRow[] = [];
|
|
266
|
+
for (const el of childrenOf(container, "transition")) {
|
|
267
|
+
const from = el.attributes["from"] as CommentNegotiationState;
|
|
268
|
+
const to = el.attributes["to"] as CommentNegotiationState;
|
|
269
|
+
const action = el.attributes["action"] as CommentNegotiationActionType;
|
|
270
|
+
const actorId = el.attributes["actorId"];
|
|
271
|
+
const at = el.attributes["at"];
|
|
272
|
+
if (
|
|
273
|
+
!STATE_VOCAB.has(from) ||
|
|
274
|
+
!STATE_VOCAB.has(to) ||
|
|
275
|
+
!ACTION_VOCAB.has(action) ||
|
|
276
|
+
!actorId ||
|
|
277
|
+
!at
|
|
278
|
+
) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const row: NegotiationHistoryRow = { from, to, actorId, at, action };
|
|
282
|
+
if (el.attributes["reasonCode"] !== undefined) {
|
|
283
|
+
row.reasonCode = el.attributes["reasonCode"];
|
|
284
|
+
}
|
|
285
|
+
out.push(row);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|