@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.
Files changed (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. 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, "&amp;")
288
+ .replace(/</g, "&lt;")
289
+ .replace(/>/g, "&gt;")
290
+ .replace(/"/g, "&quot;")
291
+ .replace(/'/g, "&apos;");
292
+ }
293
+
294
+ function xmlDecode(text: string): string {
295
+ return text
296
+ .replace(/&lt;/g, "<")
297
+ .replace(/&gt;/g, ">")
298
+ .replace(/&quot;/g, '"')
299
+ .replace(/&apos;/g, "'")
300
+ .replace(/&amp;/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
+ }