@open-agreements/open-agreements 0.3.1 → 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.
- package/content/templates/closing-checklist/template.docx +0 -0
- package/dist/core/checklist/docx-import.d.ts +50 -0
- package/dist/core/checklist/docx-import.d.ts.map +1 -0
- package/dist/core/checklist/docx-import.js +613 -0
- package/dist/core/checklist/docx-import.js.map +1 -0
- package/dist/core/checklist/docx-table-helpers.d.ts +33 -0
- package/dist/core/checklist/docx-table-helpers.d.ts.map +1 -0
- package/dist/core/checklist/docx-table-helpers.js +154 -0
- package/dist/core/checklist/docx-table-helpers.js.map +1 -0
- package/dist/core/checklist/format-checklist-docx.d.ts.map +1 -1
- package/dist/core/checklist/format-checklist-docx.js +37 -88
- package/dist/core/checklist/format-checklist-docx.js.map +1 -1
- package/dist/core/checklist/index.d.ts +15 -12
- package/dist/core/checklist/index.d.ts.map +1 -1
- package/dist/core/checklist/index.js +48 -30
- package/dist/core/checklist/index.js.map +1 -1
- package/dist/core/checklist/status-labels.d.ts +6 -0
- package/dist/core/checklist/status-labels.d.ts.map +1 -1
- package/dist/core/checklist/status-labels.js +8 -0
- package/dist/core/checklist/status-labels.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
|
Binary file
|
|
@@ -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
|