@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  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/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -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
+ }
@@ -0,0 +1,311 @@
1
+ import type {
2
+ CommentAttachment,
3
+ CommentAudience,
4
+ CommentBody,
5
+ CommentLabel,
6
+ CommentMention,
7
+ CommentPresentation,
8
+ CommentPresentationReply,
9
+ CommentPresentationSnapshot,
10
+ CommentReaction,
11
+ } from "../../api/comment-presentation-types.ts";
12
+ import {
13
+ attrNumber,
14
+ childrenOf,
15
+ firstChild,
16
+ parseBwXml,
17
+ renderCdata,
18
+ renderElement,
19
+ renderText,
20
+ stripNs,
21
+ textOf,
22
+ } from "./bw-xml.ts";
23
+
24
+ const NS_URI = "urn:beyondwork:workflow-payload:1";
25
+
26
+ const AUDIENCE_VOCAB = new Set<CommentAudience>([
27
+ "internal",
28
+ "external",
29
+ "shared",
30
+ ]);
31
+ const ATTACHMENT_KIND_VOCAB = new Set<CommentAttachment["kind"]>([
32
+ "image",
33
+ "file",
34
+ "link",
35
+ ]);
36
+
37
+ export function buildCommentPresentationXml(
38
+ snap: CommentPresentationSnapshot,
39
+ ): string {
40
+ const entries = snap.entries.map(buildComment).join("");
41
+ return renderElement(
42
+ "bw:commentPresentation",
43
+ {
44
+ "xmlns:bw": NS_URI,
45
+ schemaVersion: String(snap.schemaVersion),
46
+ },
47
+ [entries],
48
+ );
49
+ }
50
+
51
+ function buildComment(entry: CommentPresentation): string {
52
+ const body = buildBody("bw:body", entry.body);
53
+ const replies = entry.replies.length
54
+ ? renderElement("bw:replies", {}, [
55
+ entry.replies.map(buildReply).join(""),
56
+ ])
57
+ : "";
58
+ const mentions = entry.mentions.length
59
+ ? renderElement("bw:mentions", {}, [
60
+ entry.mentions
61
+ .map((m) =>
62
+ renderElement("bw:mention", {
63
+ userId: m.userId,
64
+ displayName: m.displayName,
65
+ offsetInBody: String(m.offsetInBody),
66
+ entryId: m.entryId,
67
+ }),
68
+ )
69
+ .join(""),
70
+ ])
71
+ : "";
72
+ const attachments = entry.attachments.length
73
+ ? renderElement("bw:attachments", {}, [
74
+ entry.attachments.map(buildAttachment).join(""),
75
+ ])
76
+ : "";
77
+ const reactions = entry.reactions.length
78
+ ? renderElement("bw:reactions", {}, [
79
+ entry.reactions
80
+ .map((r) =>
81
+ renderElement("bw:reaction", {
82
+ emoji: r.emoji,
83
+ authorId: r.authorId,
84
+ reactedAt: r.reactedAt,
85
+ }),
86
+ )
87
+ .join(""),
88
+ ])
89
+ : "";
90
+ const labels = entry.labels.length
91
+ ? renderElement("bw:labels", {}, [
92
+ entry.labels
93
+ .map((l) =>
94
+ renderElement("bw:label", { key: l.key, color: l.color }, [
95
+ renderText(l.text),
96
+ ]),
97
+ )
98
+ .join(""),
99
+ ])
100
+ : "";
101
+
102
+ return renderElement(
103
+ "bw:comment",
104
+ {
105
+ commentId: entry.commentId,
106
+ audience: entry.audience,
107
+ },
108
+ [body, replies, mentions, attachments, reactions, labels],
109
+ );
110
+ }
111
+
112
+ function buildReply(reply: CommentPresentationReply): string {
113
+ return renderElement("bw:reply", { entryId: reply.entryId }, [
114
+ buildBody("bw:body", reply.body),
115
+ ]);
116
+ }
117
+
118
+ function buildBody(elementName: string, body: CommentBody): string {
119
+ const needsCdata = /[<>&]/.test(body.text);
120
+ return renderElement(
121
+ elementName,
122
+ {
123
+ format: body.format,
124
+ digest: body.digest,
125
+ sanitized: body.sanitized ? "true" : undefined,
126
+ },
127
+ [needsCdata ? renderCdata(body.text) : renderText(body.text)],
128
+ );
129
+ }
130
+
131
+ function buildAttachment(a: CommentAttachment): string {
132
+ return renderElement("bw:attachment", {
133
+ id: a.id,
134
+ kind: a.kind,
135
+ displayName: a.displayName,
136
+ mimeType: a.mimeType,
137
+ relationshipId: a.relationshipId,
138
+ href: a.href,
139
+ byteLength: a.byteLength !== undefined ? String(a.byteLength) : undefined,
140
+ width: a.width !== undefined ? String(a.width) : undefined,
141
+ height: a.height !== undefined ? String(a.height) : undefined,
142
+ });
143
+ }
144
+
145
+ export function parseCommentPresentationXml(
146
+ xml: string,
147
+ ): CommentPresentationSnapshot {
148
+ const root = parseBwXml(xml);
149
+ if (stripNs(root.name) !== "commentPresentation") {
150
+ throw new Error(
151
+ `parseCommentPresentationXml: expected <bw:commentPresentation>, got <${root.name}>`,
152
+ );
153
+ }
154
+ const schemaVersion = attrNumber(root.attributes["schemaVersion"]) ?? 1;
155
+ if (schemaVersion !== 1) {
156
+ return { schemaVersion: 1, entries: [] };
157
+ }
158
+
159
+ const entries: CommentPresentation[] = [];
160
+ for (const commentEl of childrenOf(root, "comment")) {
161
+ const commentId = commentEl.attributes["commentId"] ?? "";
162
+ if (!commentId) continue;
163
+ const audienceAttr = commentEl.attributes["audience"] as CommentAudience;
164
+ const audience: CommentAudience = AUDIENCE_VOCAB.has(audienceAttr)
165
+ ? audienceAttr
166
+ : "internal"; // fail-closed per schema
167
+ const bodyEl = firstChild(commentEl, "body");
168
+ const body: CommentBody = bodyEl ? parseBody(bodyEl) : emptyBody();
169
+
170
+ entries.push({
171
+ commentId,
172
+ audience,
173
+ body,
174
+ replies: parseReplies(commentEl),
175
+ mentions: parseMentions(commentEl),
176
+ attachments: parseAttachments(commentEl),
177
+ reactions: parseReactions(commentEl),
178
+ labels: parseLabels(commentEl),
179
+ });
180
+ }
181
+
182
+ return { schemaVersion: 1, entries };
183
+ }
184
+
185
+ function parseBody(el: ReturnType<typeof parseBwXml>): CommentBody {
186
+ const body: CommentBody = {
187
+ format: "markdown",
188
+ text: textOf(el),
189
+ digest: el.attributes["digest"] ?? "",
190
+ };
191
+ if (el.attributes["sanitized"] === "true") body.sanitized = true;
192
+ return body;
193
+ }
194
+
195
+ function emptyBody(): CommentBody {
196
+ return {
197
+ format: "markdown",
198
+ text: "",
199
+ digest: "",
200
+ };
201
+ }
202
+
203
+ function parseReplies(
204
+ commentEl: ReturnType<typeof parseBwXml>,
205
+ ): CommentPresentationReply[] {
206
+ const container = firstChild(commentEl, "replies");
207
+ if (!container) return [];
208
+ const out: CommentPresentationReply[] = [];
209
+ for (const el of childrenOf(container, "reply")) {
210
+ const entryId = el.attributes["entryId"];
211
+ if (!entryId) continue;
212
+ const bodyEl = firstChild(el, "body");
213
+ out.push({
214
+ entryId,
215
+ body: bodyEl ? parseBody(bodyEl) : emptyBody(),
216
+ });
217
+ }
218
+ return out;
219
+ }
220
+
221
+ function parseMentions(
222
+ commentEl: ReturnType<typeof parseBwXml>,
223
+ ): CommentMention[] {
224
+ const container = firstChild(commentEl, "mentions");
225
+ if (!container) return [];
226
+ const out: CommentMention[] = [];
227
+ for (const el of childrenOf(container, "mention")) {
228
+ const userId = el.attributes["userId"];
229
+ const displayName = el.attributes["displayName"];
230
+ const offsetInBody = attrNumber(el.attributes["offsetInBody"]);
231
+ if (!userId || !displayName || offsetInBody === undefined) continue;
232
+ const mention: CommentMention = { userId, displayName, offsetInBody };
233
+ if (el.attributes["entryId"] !== undefined) {
234
+ mention.entryId = el.attributes["entryId"];
235
+ }
236
+ out.push(mention);
237
+ }
238
+ return out;
239
+ }
240
+
241
+ function parseAttachments(
242
+ commentEl: ReturnType<typeof parseBwXml>,
243
+ ): CommentAttachment[] {
244
+ const container = firstChild(commentEl, "attachments");
245
+ if (!container) return [];
246
+ const out: CommentAttachment[] = [];
247
+ for (const el of childrenOf(container, "attachment")) {
248
+ const id = el.attributes["id"];
249
+ const kind = el.attributes["kind"] as CommentAttachment["kind"];
250
+ const displayName = el.attributes["displayName"];
251
+ if (!id || !displayName || !ATTACHMENT_KIND_VOCAB.has(kind)) continue;
252
+ const a: CommentAttachment = { id, kind, displayName };
253
+ if (el.attributes["mimeType"] !== undefined) {
254
+ a.mimeType = el.attributes["mimeType"];
255
+ }
256
+ if (el.attributes["relationshipId"] !== undefined) {
257
+ a.relationshipId = el.attributes["relationshipId"];
258
+ }
259
+ if (el.attributes["href"] !== undefined && isAllowedHref(el.attributes["href"])) {
260
+ a.href = el.attributes["href"];
261
+ }
262
+ const byteLength = attrNumber(el.attributes["byteLength"]);
263
+ if (byteLength !== undefined) a.byteLength = byteLength;
264
+ const width = attrNumber(el.attributes["width"]);
265
+ if (width !== undefined) a.width = width;
266
+ const height = attrNumber(el.attributes["height"]);
267
+ if (height !== undefined) a.height = height;
268
+ out.push(a);
269
+ }
270
+ return out;
271
+ }
272
+
273
+ function isAllowedHref(href: string): boolean {
274
+ return (
275
+ href.startsWith("http://") ||
276
+ href.startsWith("https://") ||
277
+ href.startsWith("mailto:")
278
+ );
279
+ }
280
+
281
+ function parseReactions(
282
+ commentEl: ReturnType<typeof parseBwXml>,
283
+ ): CommentReaction[] {
284
+ const container = firstChild(commentEl, "reactions");
285
+ if (!container) return [];
286
+ const out: CommentReaction[] = [];
287
+ for (const el of childrenOf(container, "reaction")) {
288
+ const emoji = el.attributes["emoji"];
289
+ const authorId = el.attributes["authorId"];
290
+ const reactedAt = el.attributes["reactedAt"];
291
+ if (!emoji || !authorId || !reactedAt) continue;
292
+ out.push({ emoji, authorId, reactedAt });
293
+ }
294
+ return out;
295
+ }
296
+
297
+ function parseLabels(
298
+ commentEl: ReturnType<typeof parseBwXml>,
299
+ ): CommentLabel[] {
300
+ const container = firstChild(commentEl, "labels");
301
+ if (!container) return [];
302
+ const out: CommentLabel[] = [];
303
+ for (const el of childrenOf(container, "label")) {
304
+ const key = el.attributes["key"];
305
+ if (!key) continue;
306
+ const label: CommentLabel = { key, text: textOf(el) };
307
+ if (el.attributes["color"] !== undefined) label.color = el.attributes["color"];
308
+ out.push(label);
309
+ }
310
+ return out;
311
+ }
@@ -0,0 +1,102 @@
1
+ import type { ExternalCustody } from "../../api/external-custody-types.ts";
2
+ import {
3
+ attrNumber,
4
+ childrenOf,
5
+ parseBwXml,
6
+ renderElement,
7
+ stripNs,
8
+ } from "./bw-xml.ts";
9
+
10
+ const NS_URI = "urn:beyondwork:workflow-payload:1";
11
+
12
+ export function buildExternalCustodyXml(custody: ExternalCustody): string {
13
+ const stripped = renderElement("bw:strippedComments", {}, [
14
+ custody.strippedCommentIds
15
+ .map((id) => renderElement("bw:commentRef", { commentId: id }))
16
+ .join(""),
17
+ ]);
18
+ const participants = custody.strippedParticipantIds.length
19
+ ? renderElement("bw:strippedParticipants", {}, [
20
+ custody.strippedParticipantIds
21
+ .map((id) => renderElement("bw:userRef", { id }))
22
+ .join(""),
23
+ ])
24
+ : "";
25
+ return renderElement(
26
+ "bw:externalCustody",
27
+ {
28
+ "xmlns:bw": NS_URI,
29
+ schemaVersion: String(custody.schemaVersion),
30
+ custodyId: custody.custodyId,
31
+ originDocumentId: custody.originDocumentId,
32
+ originPayloadId: custody.originPayloadId,
33
+ originContentHash: custody.originContentHash,
34
+ sentAt: custody.sentAt,
35
+ sentBy: custody.sentBy,
36
+ recipient: custody.recipient,
37
+ archiveRef: custody.archiveRef,
38
+ },
39
+ [stripped, participants],
40
+ );
41
+ }
42
+
43
+ export function parseExternalCustodyXml(xml: string): ExternalCustody {
44
+ const root = parseBwXml(xml);
45
+ if (stripNs(root.name) !== "externalCustody") {
46
+ throw new Error(
47
+ `parseExternalCustodyXml: expected <bw:externalCustody>, got <${root.name}>`,
48
+ );
49
+ }
50
+ const schemaVersion = attrNumber(root.attributes["schemaVersion"]) ?? 1;
51
+ if (schemaVersion !== 1) {
52
+ throw new Error(
53
+ `parseExternalCustodyXml: unsupported schemaVersion=${schemaVersion}`,
54
+ );
55
+ }
56
+
57
+ const required = [
58
+ "custodyId",
59
+ "originDocumentId",
60
+ "originPayloadId",
61
+ "originContentHash",
62
+ "sentAt",
63
+ "sentBy",
64
+ "recipient",
65
+ "archiveRef",
66
+ ] as const;
67
+ for (const key of required) {
68
+ if (!root.attributes[key]) {
69
+ throw new Error(
70
+ `parseExternalCustodyXml: missing required attribute ${key}`,
71
+ );
72
+ }
73
+ }
74
+
75
+ const strippedContainer = childrenOf(root, "strippedComments")[0];
76
+ const strippedCommentIds = strippedContainer
77
+ ? childrenOf(strippedContainer, "commentRef")
78
+ .map((el) => el.attributes["commentId"])
79
+ .filter((id): id is string => Boolean(id))
80
+ : [];
81
+
82
+ const participantsContainer = childrenOf(root, "strippedParticipants")[0];
83
+ const strippedParticipantIds = participantsContainer
84
+ ? childrenOf(participantsContainer, "userRef")
85
+ .map((el) => el.attributes["id"])
86
+ .filter((id): id is string => Boolean(id))
87
+ : [];
88
+
89
+ return {
90
+ schemaVersion: 1,
91
+ custodyId: root.attributes["custodyId"]!,
92
+ originDocumentId: root.attributes["originDocumentId"]!,
93
+ originPayloadId: root.attributes["originPayloadId"]!,
94
+ originContentHash: root.attributes["originContentHash"]!,
95
+ sentAt: root.attributes["sentAt"]!,
96
+ sentBy: root.attributes["sentBy"]!,
97
+ recipient: root.attributes["recipient"]!,
98
+ archiveRef: root.attributes["archiveRef"]!,
99
+ strippedCommentIds,
100
+ strippedParticipantIds,
101
+ };
102
+ }