@open-agreements/open-agreements 0.3.0 → 0.4.0

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Checklist DOCX import/diff module.
3
+ *
4
+ * Parses a rendered closing-checklist DOCX and produces RFC 6902 patch
5
+ * operations representing human edits to the document.
6
+ *
7
+ * Processes 5-column Documents, 5-column Action Items, and 5-column Issues tables.
8
+ */
9
+ import type { ClosingChecklist } from './schemas.js';
10
+ import type { ChecklistPatchEnvelope } from './patch-schemas.js';
11
+ export interface DocxImportOptions {
12
+ docxBuffer: Buffer;
13
+ canonicalChecklist: ClosingChecklist;
14
+ checklistId: string;
15
+ currentRevision: number;
16
+ }
17
+ export type DocxImportWarningCode = 'SUB_ROW_CHANGED' | 'ROW_REORDERED' | 'UNSUPPORTED_EDIT' | 'UNKNOWN_ID' | 'DUPLICATE_ID' | 'NEW_ROW_ADDED' | 'DISPLAY_ROW_SKIPPED';
18
+ export interface DocxImportWarning {
19
+ code: DocxImportWarningCode;
20
+ table: 'documents' | 'action_items' | 'issues';
21
+ message: string;
22
+ rowIndex?: number;
23
+ column?: string;
24
+ }
25
+ export interface DocxImportSummary {
26
+ documentsProcessed: number;
27
+ actionItemsProcessed: number;
28
+ issuesProcessed: number;
29
+ operationsGenerated: number;
30
+ warningsGenerated: number;
31
+ }
32
+ export interface DocxImportResult {
33
+ ok: true;
34
+ patch: ChecklistPatchEnvelope | null;
35
+ warnings: DocxImportWarning[];
36
+ summary: DocxImportSummary;
37
+ }
38
+ export type DocxImportErrorCode = 'TABLE_NOT_FOUND' | 'MERGED_CELLS' | 'PARSE_FAILED' | 'VERSION_MISMATCH' | 'ID_COLUMN_MISSING';
39
+ export interface DocxImportFailure {
40
+ ok: false;
41
+ error_code: DocxImportErrorCode;
42
+ message: string;
43
+ }
44
+ export type DocxImportOutcome = DocxImportResult | DocxImportFailure;
45
+ /**
46
+ * Import a rendered closing-checklist DOCX and produce a patch envelope
47
+ * representing the differences from the canonical checklist state.
48
+ */
49
+ export declare function importChecklistFromDocx(options: DocxImportOptions): DocxImportOutcome;
50
+ //# sourceMappingURL=docx-import.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docx-import.d.ts","sourceRoot":"","sources":["../../../src/core/checklist/docx-import.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAUH,OAAO,KAAK,EAAE,gBAAgB,EAAwD,MAAM,cAAc,CAAC;AAC3G,OAAO,KAAK,EAA2B,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAc1F,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,gBAAgB,CAAC;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,qBAAqB,GAC7B,iBAAiB,GACjB,eAAe,GACf,kBAAkB,GAClB,YAAY,GACZ,cAAc,GACd,eAAe,GACf,qBAAqB,CAAC;AAE1B,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,KAAK,EAAE,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,IAAI,CAAC;IACT,KAAK,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACrC,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,OAAO,EAAE,iBAAiB,CAAC;CAC5B;AAED,MAAM,MAAM,mBAAmB,GAC3B,iBAAiB,GACjB,cAAc,GACd,cAAc,GACd,kBAAkB,GAClB,mBAAmB,CAAC;AAExB,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,KAAK,CAAC;IACV,UAAU,EAAE,mBAAmB,CAAC;IAChC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,iBAAiB,GAAG,gBAAgB,GAAG,iBAAiB,CAAC;AAgjBrE;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAiHrF"}
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Checklist DOCX import/diff module.
3
+ *
4
+ * Parses a rendered closing-checklist DOCX and produces RFC 6902 patch
5
+ * operations representing human edits to the document.
6
+ *
7
+ * Processes 5-column Documents, 5-column Action Items, and 5-column Issues tables.
8
+ */
9
+ import AdmZip from 'adm-zip';
10
+ import { DOMParser } from '@xmldom/xmldom';
11
+ import { extractTables, } from '@usejunior/docx-core';
12
+ import { reverseHumanStatus } from './status-labels.js';
13
+ import { DOCUMENTS_HEADERS, ACTION_ITEMS_HEADERS, ISSUES_HEADERS, classifyRow, extractRenderVersion, } from './docx-table-helpers.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Parsing helpers
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Parse the merged Responsible column: "Entity (Individual)" format.
19
+ * Uses the canonical shape to disambiguate single-token text.
20
+ *
21
+ * Known limitation: when canonical has both org and individual, any
22
+ * parenthesized text is split — e.g. "Acme (Delaware)" would be parsed as
23
+ * org="Acme", individual="Delaware". This is inherent to the format; the
24
+ * canonical shape is the best available disambiguation signal.
25
+ */
26
+ function parseResponsibleColumn(text, canonical) {
27
+ const trimmed = text.trim();
28
+ if (!trimmed)
29
+ return { organization: null, individual_name: null };
30
+ // Only attempt "Entity (Individual)" split when canonical has BOTH fields
31
+ const hasBoth = canonical?.organization && canonical?.individual_name;
32
+ if (hasBoth) {
33
+ const parenIdx = trimmed.lastIndexOf('(');
34
+ if (parenIdx > 0 && trimmed.endsWith(')')) {
35
+ const org = trimmed.slice(0, parenIdx).trim();
36
+ const individual = trimmed.slice(parenIdx + 1, -1).trim();
37
+ return { organization: org || null, individual_name: individual || null };
38
+ }
39
+ }
40
+ // No parens or single-field canonical — use canonical shape as tiebreaker
41
+ if (canonical) {
42
+ if (canonical.organization && !canonical.individual_name) {
43
+ return { organization: trimmed, individual_name: null };
44
+ }
45
+ if (canonical.individual_name && !canonical.organization) {
46
+ return { organization: null, individual_name: trimmed };
47
+ }
48
+ }
49
+ // Default (no canonical or canonical has both but no parens): treat as org
50
+ return { organization: trimmed, individual_name: null };
51
+ }
52
+ function toArray(collection) {
53
+ return Array.isArray(collection) ? collection : Object.values(collection);
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Table extraction from DOCX buffer
57
+ // ---------------------------------------------------------------------------
58
+ function parseDocxXml(buffer) {
59
+ const zip = new AdmZip(buffer);
60
+ const entry = zip.getEntry('word/document.xml');
61
+ if (!entry)
62
+ throw new Error('word/document.xml not found in DOCX');
63
+ const xmlStr = entry.getData().toString('utf-8');
64
+ return new DOMParser().parseFromString(xmlStr, 'text/xml');
65
+ }
66
+ function findExtractedTable(result, targetHeaders) {
67
+ return result.tables.find((t) => {
68
+ if (t.headers.length !== targetHeaders.length)
69
+ return false;
70
+ return t.headers.every((h, i) => h === targetHeaders[i]);
71
+ }) ?? null;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Documents table import
75
+ // ---------------------------------------------------------------------------
76
+ function importDocumentsTable(table, checklist, ops, warnings) {
77
+ const entries = toArray(checklist.checklist_entries);
78
+ const documents = toArray(checklist.documents);
79
+ const entriesById = new Map(entries.map((e) => [e.entry_id, e]));
80
+ const documentsById = new Map(documents.map((d) => [d.document_id, d]));
81
+ const seenIds = new Set();
82
+ let processed = 0;
83
+ for (let i = 0; i < table.rows.length; i++) {
84
+ const row = table.rows[i];
85
+ const entryId = row['ID']?.trim() ?? '';
86
+ const titleText = row['Title']?.trim() ?? '';
87
+ // Skip stage headings and sub-rows
88
+ const rowType = classifyRow(entryId, titleText);
89
+ if (rowType === 'stage_heading')
90
+ continue;
91
+ // Sub-rows: detect changes and emit warnings
92
+ if (rowType !== 'main_entry' && rowType !== 'unknown') {
93
+ // Sub-rows are read-only
94
+ if (titleText || row['Status']?.trim()) {
95
+ warnings.push({
96
+ code: 'SUB_ROW_CHANGED',
97
+ table: 'documents',
98
+ message: `Sub-row at index ${i} (${rowType}) is read-only`,
99
+ rowIndex: i,
100
+ });
101
+ }
102
+ continue;
103
+ }
104
+ // Empty-ID unknown rows: display-only, skip with info warning
105
+ if (!entryId && rowType === 'unknown') {
106
+ warnings.push({
107
+ code: 'DISPLAY_ROW_SKIPPED',
108
+ table: 'documents',
109
+ message: `Row ${i} has empty ID — display-only row skipped`,
110
+ rowIndex: i,
111
+ });
112
+ continue;
113
+ }
114
+ // ID integrity policy
115
+ if (!entryId) {
116
+ warnings.push({
117
+ code: 'NEW_ROW_ADDED',
118
+ table: 'documents',
119
+ message: `Row ${i} has empty ID — new row addition not supported for documents table`,
120
+ rowIndex: i,
121
+ });
122
+ continue;
123
+ }
124
+ if (seenIds.has(entryId)) {
125
+ warnings.push({
126
+ code: 'DUPLICATE_ID',
127
+ table: 'documents',
128
+ message: `Duplicate ID "${entryId}" at row ${i} — skipping`,
129
+ rowIndex: i,
130
+ });
131
+ continue;
132
+ }
133
+ seenIds.add(entryId);
134
+ const entry = entriesById.get(entryId);
135
+ if (!entry) {
136
+ warnings.push({
137
+ code: 'UNKNOWN_ID',
138
+ table: 'documents',
139
+ message: `Unknown entry ID "${entryId}" at row ${i} — skipping`,
140
+ rowIndex: i,
141
+ });
142
+ continue;
143
+ }
144
+ processed++;
145
+ // Compare and generate replace ops
146
+ // Title (strip NBSP indent for comparison)
147
+ const cleanTitle = titleText.replace(/^\u00A0+/, '').trim();
148
+ if (cleanTitle && cleanTitle !== entry.title) {
149
+ ops.push({
150
+ op: 'replace',
151
+ path: `/checklist_entries/${entryId}/title`,
152
+ value: cleanTitle,
153
+ rationale: 'DOCX import: title changed',
154
+ });
155
+ }
156
+ // Status
157
+ const statusText = row['Status']?.trim() ?? '';
158
+ if (statusText) {
159
+ const enumStatus = reverseHumanStatus(statusText);
160
+ if (enumStatus && enumStatus !== entry.status) {
161
+ ops.push({
162
+ op: 'replace',
163
+ path: `/checklist_entries/${entryId}/status`,
164
+ value: enumStatus,
165
+ rationale: 'DOCX import: status changed',
166
+ });
167
+ }
168
+ }
169
+ // Link → ChecklistDocument.primary_link
170
+ const linkText = row['Link']?.trim() ?? '';
171
+ const doc = entry.document_id ? documentsById.get(entry.document_id) : null;
172
+ if (doc) {
173
+ const currentLink = doc.primary_link ?? '';
174
+ if (linkText !== currentLink) {
175
+ ops.push({
176
+ op: linkText ? 'replace' : 'remove',
177
+ path: `/documents/${doc.document_id}/primary_link`,
178
+ ...(linkText ? { value: linkText } : {}),
179
+ rationale: 'DOCX import: link changed',
180
+ });
181
+ }
182
+ }
183
+ // Responsible → merged "Entity (Individual)" column
184
+ const responsibleText = row['Responsible']?.trim() ?? '';
185
+ const parsed = parseResponsibleColumn(responsibleText, entry.responsible_party);
186
+ const currentOrg = entry.responsible_party?.organization ?? '';
187
+ const currentIndividual = entry.responsible_party?.individual_name ?? '';
188
+ if ((parsed.organization ?? '') !== currentOrg || (parsed.individual_name ?? '') !== currentIndividual) {
189
+ if (!entry.responsible_party && (parsed.organization || parsed.individual_name)) {
190
+ // Create new responsible_party (never touch role)
191
+ ops.push({
192
+ op: 'replace',
193
+ path: `/checklist_entries/${entryId}/responsible_party`,
194
+ value: {
195
+ ...(parsed.organization ? { organization: parsed.organization } : {}),
196
+ ...(parsed.individual_name ? { individual_name: parsed.individual_name } : {}),
197
+ },
198
+ rationale: 'DOCX import: responsible party added',
199
+ });
200
+ }
201
+ else if (entry.responsible_party) {
202
+ // Clearing entire responsible_party
203
+ if (!parsed.organization && !parsed.individual_name && !responsibleText) {
204
+ ops.push({
205
+ op: 'remove',
206
+ path: `/checklist_entries/${entryId}/responsible_party`,
207
+ rationale: 'DOCX import: responsible party cleared',
208
+ });
209
+ }
210
+ else {
211
+ // Update individual fields (never touch role)
212
+ if ((parsed.organization ?? '') !== currentOrg) {
213
+ if (parsed.organization) {
214
+ ops.push({
215
+ op: 'replace',
216
+ path: `/checklist_entries/${entryId}/responsible_party/organization`,
217
+ value: parsed.organization,
218
+ rationale: 'DOCX import: organization changed',
219
+ });
220
+ }
221
+ else {
222
+ ops.push({
223
+ op: 'remove',
224
+ path: `/checklist_entries/${entryId}/responsible_party/organization`,
225
+ rationale: 'DOCX import: organization cleared',
226
+ });
227
+ }
228
+ }
229
+ if ((parsed.individual_name ?? '') !== currentIndividual) {
230
+ if (parsed.individual_name) {
231
+ ops.push({
232
+ op: 'replace',
233
+ path: `/checklist_entries/${entryId}/responsible_party/individual_name`,
234
+ value: parsed.individual_name,
235
+ rationale: 'DOCX import: individual_name changed',
236
+ });
237
+ }
238
+ else {
239
+ ops.push({
240
+ op: 'remove',
241
+ path: `/checklist_entries/${entryId}/responsible_party/individual_name`,
242
+ rationale: 'DOCX import: individual_name cleared',
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ return processed;
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // Action Items table import
254
+ // ---------------------------------------------------------------------------
255
+ function importActionItemsTable(table, checklist, ops, warnings) {
256
+ const actions = toArray(checklist.action_items);
257
+ const actionsById = new Map(actions.map((a) => [a.action_id, a]));
258
+ const seenIds = new Set();
259
+ let processed = 0;
260
+ for (let i = 0; i < table.rows.length; i++) {
261
+ const row = table.rows[i];
262
+ const actionId = row['ID']?.trim() ?? '';
263
+ // Empty ID = new row
264
+ if (!actionId) {
265
+ const description = row['Description']?.trim() ?? '';
266
+ if (description) {
267
+ const newId = `A-${Date.now()}-${i}`;
268
+ const statusText = row['Status']?.trim() ?? '';
269
+ const enumStatus = reverseHumanStatus(statusText) ?? 'NOT_STARTED';
270
+ ops.push({
271
+ op: 'add',
272
+ path: `/action_items/${newId}`,
273
+ value: {
274
+ action_id: newId,
275
+ description,
276
+ status: enumStatus,
277
+ assigned_to: row['Assigned To']?.trim() ? { individual_name: row['Assigned To']?.trim() } : undefined,
278
+ due_date: row['Due Date']?.trim() || undefined,
279
+ related_document_ids: [],
280
+ citations: [],
281
+ },
282
+ rationale: 'DOCX import: new action item added',
283
+ });
284
+ warnings.push({
285
+ code: 'NEW_ROW_ADDED',
286
+ table: 'action_items',
287
+ message: `New action item "${description}" added with generated ID "${newId}"`,
288
+ rowIndex: i,
289
+ });
290
+ }
291
+ continue;
292
+ }
293
+ if (seenIds.has(actionId)) {
294
+ warnings.push({
295
+ code: 'DUPLICATE_ID',
296
+ table: 'action_items',
297
+ message: `Duplicate ID "${actionId}" at row ${i} — skipping`,
298
+ rowIndex: i,
299
+ });
300
+ continue;
301
+ }
302
+ seenIds.add(actionId);
303
+ const action = actionsById.get(actionId);
304
+ if (!action) {
305
+ warnings.push({
306
+ code: 'UNKNOWN_ID',
307
+ table: 'action_items',
308
+ message: `Unknown action ID "${actionId}" at row ${i} — skipping`,
309
+ rowIndex: i,
310
+ });
311
+ continue;
312
+ }
313
+ processed++;
314
+ // Description
315
+ const desc = row['Description']?.trim() ?? '';
316
+ if (desc && desc !== action.description) {
317
+ ops.push({
318
+ op: 'replace',
319
+ path: `/action_items/${actionId}/description`,
320
+ value: desc,
321
+ rationale: 'DOCX import: action description changed',
322
+ });
323
+ }
324
+ // Status
325
+ const statusText = row['Status']?.trim() ?? '';
326
+ if (statusText) {
327
+ const enumStatus = reverseHumanStatus(statusText);
328
+ if (enumStatus && enumStatus !== action.status) {
329
+ ops.push({
330
+ op: 'replace',
331
+ path: `/action_items/${actionId}/status`,
332
+ value: enumStatus,
333
+ rationale: 'DOCX import: action status changed',
334
+ });
335
+ }
336
+ }
337
+ // Assigned To
338
+ const assignedTo = row['Assigned To']?.trim() ?? '';
339
+ const currentAssigned = action.assigned_to?.individual_name ?? '';
340
+ if (assignedTo !== currentAssigned) {
341
+ if (!action.assigned_to && assignedTo) {
342
+ ops.push({
343
+ op: 'replace',
344
+ path: `/action_items/${actionId}/assigned_to`,
345
+ value: { individual_name: assignedTo },
346
+ rationale: 'DOCX import: action assigned_to changed',
347
+ });
348
+ }
349
+ else if (action.assigned_to) {
350
+ ops.push({
351
+ op: 'replace',
352
+ path: `/action_items/${actionId}/assigned_to/individual_name`,
353
+ value: assignedTo || undefined,
354
+ rationale: 'DOCX import: action assigned_to changed',
355
+ });
356
+ }
357
+ }
358
+ // Due Date
359
+ const dueDate = row['Due Date']?.trim() ?? '';
360
+ const currentDue = action.due_date ?? '';
361
+ if (dueDate !== currentDue) {
362
+ ops.push({
363
+ op: dueDate ? 'replace' : 'remove',
364
+ path: `/action_items/${actionId}/due_date`,
365
+ ...(dueDate ? { value: dueDate } : {}),
366
+ rationale: 'DOCX import: action due_date changed',
367
+ });
368
+ }
369
+ }
370
+ // Check for removed rows
371
+ for (const action of actions) {
372
+ if (!seenIds.has(action.action_id)) {
373
+ ops.push({
374
+ op: 'remove',
375
+ path: `/action_items/${action.action_id}`,
376
+ rationale: 'DOCX import: action item removed from table',
377
+ });
378
+ }
379
+ }
380
+ return processed;
381
+ }
382
+ // ---------------------------------------------------------------------------
383
+ // Issues table import
384
+ // ---------------------------------------------------------------------------
385
+ function importIssuesTable(table, checklist, ops, warnings) {
386
+ const issues = toArray(checklist.issues);
387
+ const issuesById = new Map(issues.map((iss) => [iss.issue_id, iss]));
388
+ const seenIds = new Set();
389
+ let processed = 0;
390
+ for (let i = 0; i < table.rows.length; i++) {
391
+ const row = table.rows[i];
392
+ const issueId = row['ID']?.trim() ?? '';
393
+ // Empty ID = new row
394
+ if (!issueId) {
395
+ const title = row['Title']?.trim() ?? '';
396
+ if (title) {
397
+ const newId = `I-${Date.now()}-${i}`;
398
+ const statusText = row['Status']?.trim() ?? '';
399
+ const enumStatus = reverseHumanStatus(statusText) ?? 'OPEN';
400
+ ops.push({
401
+ op: 'add',
402
+ path: `/issues/${newId}`,
403
+ value: {
404
+ issue_id: newId,
405
+ title,
406
+ status: enumStatus,
407
+ summary: row['Summary']?.trim() || undefined,
408
+ related_document_ids: [],
409
+ citations: [],
410
+ },
411
+ rationale: 'DOCX import: new issue added',
412
+ });
413
+ warnings.push({
414
+ code: 'NEW_ROW_ADDED',
415
+ table: 'issues',
416
+ message: `New issue "${title}" added with generated ID "${newId}"`,
417
+ rowIndex: i,
418
+ });
419
+ }
420
+ continue;
421
+ }
422
+ if (seenIds.has(issueId)) {
423
+ warnings.push({
424
+ code: 'DUPLICATE_ID',
425
+ table: 'issues',
426
+ message: `Duplicate ID "${issueId}" at row ${i} — skipping`,
427
+ rowIndex: i,
428
+ });
429
+ continue;
430
+ }
431
+ seenIds.add(issueId);
432
+ const issue = issuesById.get(issueId);
433
+ if (!issue) {
434
+ warnings.push({
435
+ code: 'UNKNOWN_ID',
436
+ table: 'issues',
437
+ message: `Unknown issue ID "${issueId}" at row ${i} — skipping`,
438
+ rowIndex: i,
439
+ });
440
+ continue;
441
+ }
442
+ processed++;
443
+ // Title
444
+ const title = row['Title']?.trim() ?? '';
445
+ if (title && title !== issue.title) {
446
+ ops.push({
447
+ op: 'replace',
448
+ path: `/issues/${issueId}/title`,
449
+ value: title,
450
+ rationale: 'DOCX import: issue title changed',
451
+ });
452
+ }
453
+ // Status
454
+ const statusText = row['Status']?.trim() ?? '';
455
+ if (statusText) {
456
+ const enumStatus = reverseHumanStatus(statusText);
457
+ if (enumStatus && enumStatus !== issue.status) {
458
+ ops.push({
459
+ op: 'replace',
460
+ path: `/issues/${issueId}/status`,
461
+ value: enumStatus,
462
+ rationale: 'DOCX import: issue status changed',
463
+ });
464
+ }
465
+ }
466
+ // Summary
467
+ const summary = row['Summary']?.trim() ?? '';
468
+ const currentSummary = issue.summary ?? '';
469
+ if (summary !== currentSummary) {
470
+ ops.push({
471
+ op: summary ? 'replace' : 'remove',
472
+ path: `/issues/${issueId}/summary`,
473
+ ...(summary ? { value: summary } : {}),
474
+ rationale: 'DOCX import: issue summary changed',
475
+ });
476
+ }
477
+ // Citation (latest citation text)
478
+ const citationText = row['Citation']?.trim() ?? '';
479
+ const currentCitations = issue.citations ?? [];
480
+ const currentLatestCitation = currentCitations.length > 0
481
+ ? (currentCitations[currentCitations.length - 1].text ?? currentCitations[currentCitations.length - 1].ref ?? '')
482
+ : '';
483
+ if (citationText && citationText !== currentLatestCitation) {
484
+ // Add a new citation
485
+ ops.push({
486
+ op: 'add',
487
+ path: `/issues/${issueId}/citations/-`,
488
+ value: { text: citationText },
489
+ rationale: 'DOCX import: issue citation updated',
490
+ });
491
+ }
492
+ }
493
+ // Check for removed rows
494
+ for (const issue of issues) {
495
+ if (!seenIds.has(issue.issue_id)) {
496
+ ops.push({
497
+ op: 'remove',
498
+ path: `/issues/${issue.issue_id}`,
499
+ rationale: 'DOCX import: issue removed from table',
500
+ });
501
+ }
502
+ }
503
+ return processed;
504
+ }
505
+ // ---------------------------------------------------------------------------
506
+ // Main import function
507
+ // ---------------------------------------------------------------------------
508
+ /**
509
+ * Import a rendered closing-checklist DOCX and produce a patch envelope
510
+ * representing the differences from the canonical checklist state.
511
+ */
512
+ export function importChecklistFromDocx(options) {
513
+ const { docxBuffer, canonicalChecklist, checklistId, currentRevision } = options;
514
+ // Parse DOCX XML
515
+ let xmlDoc;
516
+ try {
517
+ xmlDoc = parseDocxXml(docxBuffer);
518
+ }
519
+ catch (err) {
520
+ return {
521
+ ok: false,
522
+ error_code: 'PARSE_FAILED',
523
+ message: `Failed to parse DOCX: ${err instanceof Error ? err.message : String(err)}`,
524
+ };
525
+ }
526
+ // Check render version marker
527
+ const renderVersion = extractRenderVersion(xmlDoc);
528
+ if (renderVersion !== 'closing-checklist') {
529
+ return {
530
+ ok: false,
531
+ error_code: 'VERSION_MISMATCH',
532
+ message: 'DOCX is missing render version marker (closing-checklist). Was this rendered with the current template?',
533
+ };
534
+ }
535
+ // Use docx-core extractTables on the raw DOM
536
+ // We need to use the xmldom Document as a DOM Document
537
+ const extractResult = extractTables(xmlDoc, {
538
+ rejectMergedCells: true,
539
+ headerFilter: [DOCUMENTS_HEADERS, ACTION_ITEMS_HEADERS, ISSUES_HEADERS],
540
+ });
541
+ // Check for merged cell errors
542
+ if (extractResult.mergedCellDiagnostics.length > 0) {
543
+ return {
544
+ ok: false,
545
+ error_code: 'MERGED_CELLS',
546
+ message: `DOCX contains merged cells which cannot be safely parsed (${extractResult.mergedCellDiagnostics.length} merged cells detected)`,
547
+ };
548
+ }
549
+ const docsTable = findExtractedTable(extractResult, DOCUMENTS_HEADERS);
550
+ const actionsTable = findExtractedTable(extractResult, ACTION_ITEMS_HEADERS);
551
+ const issuesTable = findExtractedTable(extractResult, ISSUES_HEADERS);
552
+ if (!docsTable && !actionsTable && !issuesTable) {
553
+ return {
554
+ ok: false,
555
+ error_code: 'TABLE_NOT_FOUND',
556
+ message: 'No recognized checklist tables found in DOCX. Expected 5-column headers (ID | Title | Link | Status | Responsible).',
557
+ };
558
+ }
559
+ const ops = [];
560
+ const warnings = [];
561
+ // Filter out control rows from extracted tables
562
+ // (docx-core extractTables gives us raw data; control rows have {FOR / {END-FOR text)
563
+ function filterControlRows(table) {
564
+ const filteredRows = table.rows.filter((row) => {
565
+ const firstCell = Object.values(row)[0] ?? '';
566
+ if (firstCell.trim().startsWith('{FOR') || firstCell.trim().startsWith('{END-FOR')) {
567
+ return false;
568
+ }
569
+ return true;
570
+ });
571
+ return { ...table, rows: filteredRows };
572
+ }
573
+ let documentsProcessed = 0;
574
+ let actionItemsProcessed = 0;
575
+ let issuesProcessed = 0;
576
+ if (docsTable) {
577
+ const filtered = filterControlRows(docsTable);
578
+ documentsProcessed = importDocumentsTable(filtered, canonicalChecklist, ops, warnings);
579
+ }
580
+ if (actionsTable) {
581
+ const filtered = filterControlRows(actionsTable);
582
+ actionItemsProcessed = importActionItemsTable(filtered, canonicalChecklist, ops, warnings);
583
+ }
584
+ if (issuesTable) {
585
+ const filtered = filterControlRows(issuesTable);
586
+ issuesProcessed = importIssuesTable(filtered, canonicalChecklist, ops, warnings);
587
+ }
588
+ // Build patch envelope
589
+ const patch = ops.length > 0
590
+ ? {
591
+ patch_id: `docx-import-${checklistId}-${Date.now()}`,
592
+ mode: 'APPLY',
593
+ expected_revision: currentRevision,
594
+ operations: ops,
595
+ source_event: {
596
+ provider: 'docx-import',
597
+ },
598
+ }
599
+ : null;
600
+ return {
601
+ ok: true,
602
+ patch,
603
+ warnings,
604
+ summary: {
605
+ documentsProcessed,
606
+ actionItemsProcessed,
607
+ issuesProcessed,
608
+ operationsGenerated: ops.length,
609
+ warningsGenerated: warnings.length,
610
+ },
611
+ };
612
+ }
613
+ //# sourceMappingURL=docx-import.js.map