docrev 0.11.1 → 0.11.3

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 (144) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +207 -197
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +433 -433
  5. package/bin/rev.js +11 -11
  6. package/bin/rev.ts +145 -145
  7. package/completions/rev.bash +127 -127
  8. package/completions/rev.ps1 +210 -210
  9. package/completions/rev.zsh +207 -207
  10. package/dist/lib/annotations.d.ts.map +1 -1
  11. package/dist/lib/annotations.js +28 -0
  12. package/dist/lib/annotations.js.map +1 -1
  13. package/dist/lib/build.d.ts +28 -0
  14. package/dist/lib/build.d.ts.map +1 -1
  15. package/dist/lib/build.js +81 -4
  16. package/dist/lib/build.js.map +1 -1
  17. package/dist/lib/commands/comments.d.ts.map +1 -1
  18. package/dist/lib/commands/comments.js +58 -28
  19. package/dist/lib/commands/comments.js.map +1 -1
  20. package/dist/lib/commands/context.d.ts +1 -0
  21. package/dist/lib/commands/context.d.ts.map +1 -1
  22. package/dist/lib/commands/context.js +1 -0
  23. package/dist/lib/commands/context.js.map +1 -1
  24. package/dist/lib/commands/core.d.ts.map +1 -1
  25. package/dist/lib/commands/core.js +25 -8
  26. package/dist/lib/commands/core.js.map +1 -1
  27. package/dist/lib/commands/response.js +1 -1
  28. package/dist/lib/commands/response.js.map +1 -1
  29. package/dist/lib/commands/utilities.js +164 -164
  30. package/dist/lib/commands/word-tools.js +8 -8
  31. package/dist/lib/grammar.js +3 -3
  32. package/dist/lib/import.d.ts +14 -0
  33. package/dist/lib/import.d.ts.map +1 -1
  34. package/dist/lib/import.js +23 -0
  35. package/dist/lib/import.js.map +1 -1
  36. package/dist/lib/input.d.ts +67 -0
  37. package/dist/lib/input.d.ts.map +1 -0
  38. package/dist/lib/input.js +164 -0
  39. package/dist/lib/input.js.map +1 -0
  40. package/dist/lib/macro-filter.lua +201 -201
  41. package/dist/lib/pdf-comments.js +44 -44
  42. package/dist/lib/plugins.js +57 -57
  43. package/dist/lib/pptx-color-filter.lua +37 -37
  44. package/dist/lib/pptx-themes.js +115 -115
  45. package/dist/lib/response.d.ts +1 -1
  46. package/dist/lib/response.d.ts.map +1 -1
  47. package/dist/lib/response.js +5 -2
  48. package/dist/lib/response.js.map +1 -1
  49. package/dist/lib/schema.d.ts.map +1 -1
  50. package/dist/lib/schema.js +8 -2
  51. package/dist/lib/schema.js.map +1 -1
  52. package/dist/lib/spelling.js +2 -2
  53. package/dist/lib/templates.js +387 -387
  54. package/dist/lib/themes.js +51 -51
  55. package/eslint.config.js +27 -27
  56. package/lib/anchor-match.ts +307 -307
  57. package/lib/annotations.ts +695 -664
  58. package/lib/build.ts +2047 -1956
  59. package/lib/citations.ts +160 -160
  60. package/lib/commands/build.ts +885 -885
  61. package/lib/commands/citations.ts +515 -515
  62. package/lib/commands/comments.ts +1077 -1050
  63. package/lib/commands/context.ts +185 -176
  64. package/lib/commands/core.ts +328 -309
  65. package/lib/commands/doi.ts +451 -451
  66. package/lib/commands/file-ops.ts +372 -372
  67. package/lib/commands/history.ts +320 -320
  68. package/lib/commands/index.ts +87 -87
  69. package/lib/commands/init.ts +259 -259
  70. package/lib/commands/merge-resolve.ts +378 -378
  71. package/lib/commands/preview.ts +178 -178
  72. package/lib/commands/project-info.ts +244 -244
  73. package/lib/commands/quality.ts +517 -517
  74. package/lib/commands/response.ts +454 -454
  75. package/lib/commands/section-boundaries.ts +82 -82
  76. package/lib/commands/sections.ts +451 -451
  77. package/lib/commands/sync.ts +689 -689
  78. package/lib/commands/text-ops.ts +449 -449
  79. package/lib/commands/utilities.ts +448 -448
  80. package/lib/commands/verify-anchors.ts +272 -272
  81. package/lib/commands/word-tools.ts +340 -340
  82. package/lib/comment-realign.ts +99 -99
  83. package/lib/config.ts +84 -84
  84. package/lib/crossref.ts +781 -781
  85. package/lib/csl.ts +191 -191
  86. package/lib/dependencies.ts +132 -132
  87. package/lib/diff-engine.ts +465 -465
  88. package/lib/doi-cache.ts +115 -115
  89. package/lib/doi.ts +879 -879
  90. package/lib/equations.ts +506 -506
  91. package/lib/errors.ts +350 -350
  92. package/lib/format.ts +541 -541
  93. package/lib/git.ts +326 -326
  94. package/lib/grammar.ts +303 -303
  95. package/lib/image-registry.ts +180 -180
  96. package/lib/import.ts +932 -906
  97. package/lib/input.ts +174 -0
  98. package/lib/journals.ts +543 -543
  99. package/lib/macro-filter.lua +201 -201
  100. package/lib/macros.ts +273 -273
  101. package/lib/merge.ts +633 -633
  102. package/lib/ooxml.ts +768 -768
  103. package/lib/orcid.ts +144 -144
  104. package/lib/pdf-comments.ts +263 -263
  105. package/lib/pdf-import.ts +524 -524
  106. package/lib/plugins.ts +362 -362
  107. package/lib/postprocess.ts +188 -188
  108. package/lib/pptx-color-filter.lua +37 -37
  109. package/lib/pptx-template.ts +469 -469
  110. package/lib/pptx-themes.ts +483 -483
  111. package/lib/protect-restore.ts +520 -520
  112. package/lib/rate-limiter.ts +127 -127
  113. package/lib/response.ts +200 -197
  114. package/lib/restore-references.ts +240 -240
  115. package/lib/review.ts +327 -327
  116. package/lib/schema.ts +494 -488
  117. package/lib/scientific-words.ts +73 -73
  118. package/lib/sections.ts +428 -428
  119. package/lib/slides.ts +756 -756
  120. package/lib/spelling.ts +334 -334
  121. package/lib/templates.ts +526 -526
  122. package/lib/themes.ts +742 -742
  123. package/lib/trackchanges.ts +166 -166
  124. package/lib/tui.ts +450 -450
  125. package/lib/types.ts +546 -546
  126. package/lib/undo.ts +250 -250
  127. package/lib/utils.ts +69 -69
  128. package/lib/variables.ts +179 -179
  129. package/lib/word-extraction.ts +525 -525
  130. package/lib/word.ts +526 -526
  131. package/lib/wordcomments.ts +789 -789
  132. package/package.json +1 -1
  133. package/scripts/postbuild.js +47 -47
  134. package/skill/REFERENCE.md +550 -550
  135. package/skill/SKILL.md +302 -302
  136. package/tsconfig.json +26 -26
  137. package/types/index.d.ts +531 -531
  138. package/issues.md +0 -180
  139. package/site/assets/extra.css +0 -208
  140. package/site/commands.html +0 -926
  141. package/site/configuration.html +0 -469
  142. package/site/index.html +0 -288
  143. package/site/troubleshooting.html +0 -461
  144. package/site/workflow.html +0 -518
@@ -1,689 +1,689 @@
1
- /**
2
- * SYNC command: Import feedback from Word/PDF back to section files
3
- *
4
- * Split from sections.ts for maintainability.
5
- */
6
-
7
- import {
8
- chalk,
9
- fs,
10
- path,
11
- fmt,
12
- findFiles,
13
- resolveSectionsConfig,
14
- getOrderedSections,
15
- extractSectionsFromText,
16
- countAnnotations,
17
- buildRegistry,
18
- convertHardcodedRefs,
19
- inlineDiffPreview,
20
- } from './context.js';
21
- import type { Command } from 'commander';
22
- import type { SectionsConfig } from '../types.js';
23
- import * as readline from 'readline';
24
-
25
- interface ImportStats {
26
- insertions: number;
27
- deletions: number;
28
- substitutions: number;
29
- comments: number;
30
- total: number;
31
- }
32
-
33
- interface SyncOptions {
34
- config: string;
35
- dir: string;
36
- crossref?: boolean;
37
- diff?: boolean;
38
- force?: boolean;
39
- dryRun?: boolean;
40
- /** Commander maps `--comments-only` (a positive flag) cleanly. `--no-overwrite`
41
- * conflicts with the existing `overwrite` semantics in `--force`-style flags
42
- * and Commander's `--no-X` convention assigns `options.x === false`. */
43
- commentsOnly?: boolean;
44
- }
45
-
46
- /**
47
- * Register the sync command with the program
48
- */
49
- export function register(program: Command): void {
50
- // ==========================================================================
51
- // SYNC command - Import with section awareness
52
- // ==========================================================================
53
-
54
- program
55
- .command('sync')
56
- .alias('sections')
57
- .description('Sync feedback from Word/PDF back to section files')
58
- .argument('[file]', 'Word (.docx) or PDF file from reviewer (default: most recent)')
59
- .argument('[sections...]', 'Specific sections to sync (default: all)')
60
- .option('-c, --config <file>', 'Sections config file', 'sections.yaml')
61
- .option('-d, --dir <directory>', 'Directory with section files', '.')
62
- .option('--no-crossref', 'Skip converting hardcoded figure/table refs')
63
- .option('--no-diff', 'Skip showing diff preview')
64
- .option('--force', 'Overwrite files without conflict warning')
65
- .option('--dry-run', 'Preview without writing files')
66
- .option('--comments-only', 'Insert comments at fuzzy-matched anchors only; never modify existing prose or apply track changes (use when markdown was revised after the docx was sent for review)')
67
- .action(async (docx: string | undefined, sections: string[], options: SyncOptions) => {
68
- // Auto-detect most recent docx or pdf if not provided
69
- if (!docx) {
70
- const docxFiles = findFiles('.docx');
71
- const pdfFiles = findFiles('.pdf');
72
- const allFiles = [...docxFiles, ...pdfFiles];
73
-
74
- if (allFiles.length === 0) {
75
- console.error(fmt.status('error', 'No .docx or .pdf files found in current directory.'));
76
- process.exit(1);
77
- }
78
- const sorted = allFiles
79
- .map(f => ({ name: f, mtime: fs.statSync(f).mtime }))
80
- .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
81
- docx = sorted[0].name;
82
- console.log(fmt.status('info', `Using most recent: ${docx}`));
83
- console.log();
84
- }
85
-
86
- if (!fs.existsSync(docx)) {
87
- console.error(fmt.status('error', `File not found: ${docx}`));
88
- process.exit(1);
89
- }
90
-
91
- // Handle PDF files
92
- if (docx.toLowerCase().endsWith('.pdf')) {
93
- const { extractPdfComments, formatPdfComments, getPdfCommentStats } = await import('../pdf-import.js');
94
-
95
- const spin = fmt.spinner(`Extracting comments from ${path.basename(docx)}...`).start();
96
-
97
- try {
98
- const comments = await extractPdfComments(docx);
99
- spin.stop();
100
-
101
- if (comments.length === 0) {
102
- console.log(fmt.status('info', 'No comments found in PDF.'));
103
- return;
104
- }
105
-
106
- const stats = getPdfCommentStats(comments);
107
- console.log(fmt.header(`PDF Comments from ${path.basename(docx)}`));
108
- console.log();
109
- console.log(formatPdfComments(comments));
110
- console.log();
111
-
112
- const authorList = Object.entries(stats.byAuthor)
113
- .map(([author, count]) => `${author} (${count})`)
114
- .join(', ');
115
- console.log(chalk.dim(`Total: ${stats.total} comments from ${authorList}`));
116
- console.log();
117
-
118
- const resolved = resolveSectionsConfig(options.dir, options.config);
119
- if (resolved && !options.dryRun) {
120
- const mainSection = getOrderedSections(resolved.config)[0];
121
-
122
- if (mainSection) {
123
- const mainPath = path.join(options.dir, mainSection);
124
- if (fs.existsSync(mainPath)) {
125
- console.log(chalk.dim(`Use 'rev pdf-comments ${docx} --append ${mainSection}' to add comments to markdown.`));
126
- }
127
- }
128
- }
129
- } catch (err) {
130
- spin.stop();
131
- const error = err as Error;
132
- console.error(fmt.status('error', `Failed to extract PDF comments: ${error.message}`));
133
- if (process.env.DEBUG) console.error(error.stack);
134
- process.exit(1);
135
- }
136
- return;
137
- }
138
-
139
- // Resolve the section config: an explicit sections.yaml if present,
140
- // otherwise the `sections:` list in rev.yaml (single source of truth).
141
- const resolved = resolveSectionsConfig(options.dir, options.config);
142
- if (!resolved) {
143
- console.error(fmt.status('error', `No section config found in ${path.resolve(options.dir)}`));
144
- console.error(chalk.dim(' Add a `sections:` list to rev.yaml, or run "rev init" to generate sections.yaml.'));
145
- process.exit(1);
146
- }
147
- const sectionsConfig = resolved.config;
148
-
149
- // --comments-only: import comments only, never modify existing prose.
150
- // Use this when the markdown has been revised since the docx was sent
151
- // out — track changes from a stale draft would clobber newer edits.
152
- if (options.commentsOnly) {
153
- await syncCommentsOnly(docx, sections, options, sectionsConfig);
154
- return;
155
- }
156
-
157
- // Check pandoc availability upfront and warn
158
- const { hasPandoc, getInstallInstructions } = await import('../dependencies.js');
159
- if (!hasPandoc()) {
160
- console.log(fmt.status('warning', `Pandoc not installed. Track changes will be extracted from XML (formatting may differ).`));
161
- console.log(chalk.dim(` Install for best results: ${getInstallInstructions('pandoc')}`));
162
- console.log();
163
- }
164
-
165
- const spin = fmt.spinner(`Importing ${path.basename(docx)}...`).start();
166
-
167
- try {
168
- const config = sectionsConfig;
169
- const { importFromWord, extractWordComments, extractCommentAnchors, insertCommentsIntoMarkdown, extractFromWord, extractHeadings } = await import('../import.js');
170
- const { computeSectionBoundaries } = await import('./section-boundaries.js');
171
-
172
- let registry = null;
173
- let totalRefConversions = 0;
174
- if (options.crossref !== false) {
175
- registry = buildRegistry(options.dir);
176
- }
177
-
178
- const comments = await extractWordComments(docx);
179
- const { anchors, fullDocText: xmlDocText } = await extractCommentAnchors(docx);
180
-
181
- // Extract Word text (uses pandoc if available, falls back to XML extraction)
182
- const wordExtraction = await extractFromWord(docx, { mediaDir: options.dir });
183
- let wordText = wordExtraction.text;
184
- const wordTables = wordExtraction.tables || [];
185
-
186
- // Log extraction messages (warnings about pandoc, track change stats, etc.)
187
- for (const msg of wordExtraction.messages || []) {
188
- if (msg.type === 'warning') {
189
- spin.stop();
190
- console.log(fmt.status('warning', msg.message));
191
- spin.start();
192
- }
193
- }
194
-
195
- // Restore crossref on FULL text BEFORE splitting into sections
196
- // This ensures duplicate labels from track changes are handled correctly
197
- // (the same figure may appear multiple times in old/new versions)
198
- const { restoreCrossrefFromWord, restoreImagesFromRegistry } = await import('../import.js');
199
- const crossrefResult = restoreCrossrefFromWord(wordText, options.dir);
200
- wordText = crossrefResult.text;
201
- if (crossrefResult.restored > 0) {
202
- console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
203
- }
204
-
205
- // Also restore images from registry using shared restoredLabels
206
- const imageRestoreResult = restoreImagesFromRegistry(wordText, options.dir, crossrefResult.restoredLabels);
207
- wordText = imageRestoreResult.text;
208
- if (imageRestoreResult.restored > 0) {
209
- console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
210
- }
211
-
212
- let wordSections = extractSectionsFromText(wordText, config.sections);
213
-
214
- if (wordSections.length === 0) {
215
- spin.stop();
216
- console.error(fmt.status('warning', 'No sections detected in Word document.'));
217
- console.error(chalk.dim(' Check that headings match sections.yaml'));
218
- process.exit(1);
219
- }
220
-
221
- if (sections && sections.length > 0) {
222
- const onlyList = sections.map(s => s.trim().toLowerCase());
223
- wordSections = wordSections.filter(section => {
224
- const fileName = section.file.replace(/\.md$/i, '').toLowerCase();
225
- const header = section.header.toLowerCase();
226
- return onlyList.some(name => fileName === name || fileName.includes(name) || header.includes(name));
227
- });
228
- if (wordSections.length === 0) {
229
- spin.stop();
230
- console.error(fmt.status('error', `No sections matched: ${sections.join(', ')}`));
231
- console.error(chalk.dim(` Available: ${extractSectionsFromText(wordText, config.sections).map(s => s.file.replace(/\.md$/i, '')).join(', ')}`));
232
- process.exit(1);
233
- }
234
- }
235
-
236
- spin.stop();
237
- console.log(fmt.header(`Import from ${path.basename(docx)}`));
238
- console.log();
239
-
240
- // Conflict detection
241
- if (!options.force && !options.dryRun) {
242
- const conflicts: Array<{ file: string; annotations: number }> = [];
243
- for (const section of wordSections) {
244
- const sectionPath = path.join(options.dir, section.file);
245
- if (fs.existsSync(sectionPath)) {
246
- const existing = fs.readFileSync(sectionPath, 'utf-8');
247
- const existingCounts = countAnnotations(existing);
248
- if (existingCounts.total > 0) {
249
- conflicts.push({
250
- file: section.file,
251
- annotations: existingCounts.total,
252
- });
253
- }
254
- }
255
- }
256
-
257
- if (conflicts.length > 0) {
258
- console.log(fmt.status('warning', 'Files with existing annotations will be overwritten:'));
259
- for (const c of conflicts) {
260
- console.log(chalk.yellow(` - ${c.file} (${c.annotations} annotations)`));
261
- }
262
- console.log();
263
-
264
- const rl = readline.createInterface({
265
- input: process.stdin,
266
- output: process.stdout,
267
- });
268
-
269
- const answer = await new Promise<string>((resolve) =>
270
- rl.question(chalk.cyan('Continue and overwrite? [y/N] '), resolve)
271
- );
272
- rl.close();
273
-
274
- if (answer.toLowerCase() !== 'y') {
275
- console.log(chalk.dim('Aborted. Use --force to skip this check.'));
276
- process.exit(0);
277
- }
278
- console.log();
279
- }
280
- }
281
-
282
- const sectionResults: Array<{
283
- file: string;
284
- header: string;
285
- status: string;
286
- stats?: ImportStats;
287
- refs?: number;
288
- }> = [];
289
- let totalChanges = 0;
290
-
291
- // Route comments to sections using boundaries from the document's real
292
- // heading paragraphs. docPosition (from extractCommentAnchors) and each
293
- // heading's docPosition share one coordinate system over xmlDocText, so
294
- // every configured section gets a boundary — not only the handful that
295
- // matched a hardcoded keyword list. Sections named "Objectives" or
296
- // "Annex 2" used to get no boundary, so their comments were silently
297
- // dropped while the summary still claimed every comment was placed.
298
- // This is the same routing path as `sync --comments-only`.
299
- const headings = await extractHeadings(docx);
300
- const sectionBoundaries = computeSectionBoundaries(config.sections, headings, xmlDocText.length);
301
- const firstBoundaryStart = sectionBoundaries.length > 0 ? sectionBoundaries[0].start : 0;
302
-
303
- // Truthful comment accounting: track which comments were routed to a
304
- // synced section and how many were actually written, so the summary
305
- // reports placements rather than the raw extracted count.
306
- const routedCommentIds = new Set<string>();
307
- let totalCommentsPlaced = 0;
308
- let totalCommentsLowConfidence = 0;
309
- let totalCommentsDeduped = 0;
310
- let totalCommentsUnmatched = 0;
311
-
312
- for (const section of wordSections) {
313
- const sectionPath = path.join(options.dir, section.file);
314
-
315
- if (!fs.existsSync(sectionPath)) {
316
- sectionResults.push({
317
- file: section.file,
318
- header: section.header,
319
- status: 'skipped',
320
- stats: undefined,
321
- });
322
- continue;
323
- }
324
-
325
- // A section that appears in the reviewed document as a bare heading
326
- // with no body was not really part of this build (e.g. a "no-annex"
327
- // export synced against the full project). Importing it would diff
328
- // real prose against an empty body and rewrite the file to near-empty.
329
- // Leave it untouched.
330
- const bodyEmpty = section.content.trim() === section.header.trim();
331
- if (bodyEmpty) {
332
- sectionResults.push({
333
- file: section.file,
334
- header: section.header,
335
- status: 'untouched',
336
- stats: undefined,
337
- });
338
- continue;
339
- }
340
-
341
- const result = await importFromWord(docx, sectionPath, {
342
- sectionContent: section.content,
343
- author: 'Reviewer',
344
- wordTables: wordTables,
345
- });
346
-
347
- let { annotated, stats } = result;
348
-
349
- let refConversions: Array<{ from: string; to: string }> = [];
350
- if (registry && options.crossref !== false) {
351
- const crossrefResult = convertHardcodedRefs(annotated, registry);
352
- annotated = crossrefResult.converted;
353
- refConversions = crossrefResult.conversions;
354
- totalRefConversions += refConversions.length;
355
- }
356
-
357
- if (comments.length > 0 && anchors.size > 0) {
358
- // Filter comments to those whose docPosition falls in this section's
359
- // boundary (docPosition and boundaries share xmlDocText coordinates).
360
- // The section owning the first boundary also catches comments placed
361
- // before any heading.
362
- const boundary = sectionBoundaries.find(b => b.file === section.file);
363
- const ownsFirstBoundary = !!boundary && boundary.start === firstBoundaryStart;
364
-
365
- const sectionComments = comments.filter((c: { id: string }) => {
366
- const anchorData = anchors.get(c.id);
367
- if (!anchorData || anchorData.docPosition === undefined || !boundary) return false;
368
- if (anchorData.docPosition >= boundary.start && anchorData.docPosition < boundary.end) return true;
369
- if (ownsFirstBoundary && anchorData.docPosition < firstBoundaryStart) return true;
370
- return false;
371
- });
372
-
373
- if (process.env.DEBUG) {
374
- console.log(`[DEBUG] ${section.file}: ${sectionComments.length} comments to place (boundary: ${boundary?.start}-${boundary?.end})`);
375
- }
376
-
377
- if (sectionComments.length > 0) {
378
- for (const c of sectionComments) routedCommentIds.add(c.id);
379
- const cstats = { placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0 };
380
- annotated = insertCommentsIntoMarkdown(annotated, sectionComments, anchors, {
381
- quiet: !process.env.DEBUG,
382
- sectionBoundary: boundary,
383
- outStats: cstats,
384
- });
385
- // Both confident and approximate placements are written to the
386
- // file; count both as placed, but track the approximate ones so
387
- // the summary can flag them for review.
388
- stats.comments = (stats.comments || 0) + cstats.placed + cstats.lowConfidence;
389
- totalCommentsPlaced += cstats.placed + cstats.lowConfidence;
390
- totalCommentsLowConfidence += cstats.lowConfidence;
391
- totalCommentsDeduped += cstats.deduped;
392
- totalCommentsUnmatched += cstats.unmatched;
393
-
394
- if (process.env.DEBUG) {
395
- console.log(`[DEBUG] ${section.file}: placed ${cstats.placed}, approx ${cstats.lowConfidence}, deduped ${cstats.deduped}, unmatched ${cstats.unmatched} of ${sectionComments.length}`);
396
- }
397
- }
398
- }
399
-
400
- totalChanges += stats.total;
401
-
402
- sectionResults.push({
403
- file: section.file,
404
- header: section.header,
405
- status: 'ok',
406
- stats,
407
- refs: refConversions.length,
408
- });
409
-
410
- if (!options.dryRun) {
411
- // Preserve any preamble content (YAML frontmatter, author blocks, metadata)
412
- // that exists before the first heading in the original file.
413
- // This content is never included in the Word build output, so it won't
414
- // appear in the Word doc and would otherwise be lost during sync.
415
- const originalContent = fs.readFileSync(sectionPath, 'utf-8');
416
- const firstHeadingMatch = originalContent.match(/^(#\s)/m);
417
- if (firstHeadingMatch && firstHeadingMatch.index !== undefined && firstHeadingMatch.index > 0) {
418
- const preamble = originalContent.slice(0, firstHeadingMatch.index);
419
- // Only prepend if preamble has non-whitespace content
420
- if (preamble.trim().length > 0) {
421
- annotated = preamble + annotated;
422
- }
423
- }
424
- fs.writeFileSync(sectionPath, annotated, 'utf-8');
425
- }
426
- }
427
-
428
- const tableRows = sectionResults.map((r) => {
429
- if (r.status === 'skipped' || r.status === 'untouched') {
430
- return [
431
- chalk.dim(r.file),
432
- chalk.dim(r.header.slice(0, 25)),
433
- chalk.yellow(r.status),
434
- '',
435
- '',
436
- '',
437
- '',
438
- ];
439
- }
440
- const s = r.stats!;
441
- return [
442
- chalk.bold(r.file),
443
- r.header.length > 25 ? r.header.slice(0, 22) + '...' : r.header,
444
- s.insertions > 0 ? chalk.green(`+${s.insertions}`) : chalk.dim('-'),
445
- s.deletions > 0 ? chalk.red(`-${s.deletions}`) : chalk.dim('-'),
446
- s.substitutions > 0 ? chalk.yellow(`~${s.substitutions}`) : chalk.dim('-'),
447
- s.comments > 0 ? chalk.blue(`#${s.comments}`) : chalk.dim('-'),
448
- r.refs! > 0 ? chalk.magenta(`@${r.refs}`) : chalk.dim('-'),
449
- ];
450
- });
451
-
452
- console.log(fmt.table(
453
- ['File', 'Section', 'Ins', 'Del', 'Sub', 'Cmt', 'Ref'],
454
- tableRows,
455
- { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] }
456
- ));
457
- console.log();
458
-
459
- if (options.diff !== false && totalChanges > 0) {
460
- console.log(fmt.header('Changes Preview'));
461
- console.log();
462
- for (const result of sectionResults) {
463
- if (result.status === 'ok' && result.stats && result.stats.total > 0) {
464
- const sectionPath = path.join(options.dir, result.file);
465
- if (fs.existsSync(sectionPath)) {
466
- const content = fs.readFileSync(sectionPath, 'utf-8');
467
- const preview = inlineDiffPreview(content, { maxLines: 3 });
468
- if (preview) {
469
- console.log(chalk.bold(result.file) + ':');
470
- console.log(preview);
471
- console.log();
472
- }
473
- }
474
- }
475
- }
476
- }
477
-
478
- // Comments carried by the document but never routed to a synced section
479
- // (they fell in a skipped/untouched/absent section). Surfacing these
480
- // keeps the summary honest instead of reporting every extracted comment
481
- // as placed.
482
- const unroutedComments = comments.length - routedCommentIds.size;
483
-
484
- if (options.dryRun) {
485
- console.log(fmt.box(chalk.yellow('Dry run - no files written'), { padding: 0 }));
486
- } else if (totalChanges > 0 || totalRefConversions > 0 || comments.length > 0) {
487
- const summaryLines: string[] = [];
488
- summaryLines.push(`${chalk.bold(wordSections.length)} sections processed`);
489
- if (totalChanges > 0) summaryLines.push(`${chalk.bold(totalChanges)} annotations imported`);
490
- if (totalCommentsPlaced > 0) {
491
- summaryLines.push(`${chalk.bold(totalCommentsPlaced)} of ${comments.length} comments placed`);
492
- }
493
- if (totalCommentsLowConfidence > 0) {
494
- summaryLines.push(`${chalk.yellow(totalCommentsLowConfidence)} placed approximately (review with verify-anchors)`);
495
- }
496
- if (totalCommentsDeduped > 0) {
497
- summaryLines.push(`${chalk.cyan(totalCommentsDeduped)} already present (skipped)`);
498
- }
499
- if (totalCommentsUnmatched > 0) {
500
- summaryLines.push(`${chalk.yellow(totalCommentsUnmatched)} unmatched (anchor not in current prose)`);
501
- }
502
- if (unroutedComments > 0) {
503
- summaryLines.push(`${chalk.yellow(unroutedComments)} not routed to any synced section`);
504
- }
505
- if (totalRefConversions > 0) summaryLines.push(`${chalk.bold(totalRefConversions)} refs converted to @-syntax`);
506
-
507
- console.log(fmt.box(summaryLines.join('\n'), { title: 'Summary', padding: 0 }));
508
- console.log();
509
- if (totalCommentsUnmatched > 0 || unroutedComments > 0) {
510
- console.log(chalk.yellow(` ${totalCommentsUnmatched + unroutedComments} comment(s) were not written. Run "rev verify-anchors" or re-sync with the full document.`));
511
- console.log();
512
- }
513
- console.log(chalk.dim('Next steps:'));
514
- console.log(chalk.dim(' 1. rev review <section.md> - Accept/reject changes'));
515
- console.log(chalk.dim(' 2. rev comments <section.md> - View/address comments'));
516
- console.log(chalk.dim(' 3. rev build docx - Rebuild Word doc'));
517
- } else {
518
- console.log(fmt.status('success', 'No changes detected.'));
519
- }
520
- } catch (err) {
521
- spin.stop();
522
- const error = err as Error;
523
- console.error(fmt.status('error', error.message));
524
- if (process.env.DEBUG) console.error(error.stack);
525
- process.exit(1);
526
- }
527
- });
528
- }
529
-
530
- /**
531
- * `sync --comments-only`: import only Word comments at fuzzy-matched anchors.
532
- *
533
- * Skips the Word→Markdown diff entirely (no track changes, no pandoc, no
534
- * prose modifications). Useful when the markdown has been edited after the
535
- * docx was sent for review — applying track changes from a stale draft
536
- * would overwrite newer edits.
537
- */
538
- async function syncCommentsOnly(
539
- docx: string,
540
- sectionFilter: string[] | undefined,
541
- options: SyncOptions,
542
- config: SectionsConfig,
543
- ): Promise<void> {
544
- const { extractWordComments, extractCommentAnchors, extractHeadings, insertCommentsIntoMarkdown } = await import('../import.js');
545
- const { computeSectionBoundaries } = await import('./section-boundaries.js');
546
-
547
- const spin = fmt.spinner(`Reading comments from ${path.basename(docx)}...`).start();
548
-
549
- let comments;
550
- let anchors;
551
- let headings;
552
- let fullDocText = '';
553
- try {
554
- comments = await extractWordComments(docx);
555
- const result = await extractCommentAnchors(docx);
556
- anchors = result.anchors;
557
- fullDocText = result.fullDocText;
558
- headings = await extractHeadings(docx);
559
- spin.stop();
560
- } catch (err) {
561
- spin.stop();
562
- const error = err as Error;
563
- console.error(fmt.status('error', error.message));
564
- process.exit(1);
565
- }
566
-
567
- console.log(fmt.header(`Comments from ${path.basename(docx)} (comments-only)`));
568
- console.log();
569
-
570
- if (comments.length === 0) {
571
- console.log(fmt.status('info', 'No comments found in document.'));
572
- return;
573
- }
574
-
575
- const boundaries = computeSectionBoundaries(config.sections, headings, fullDocText.length);
576
-
577
- if (boundaries.length === 0) {
578
- console.error(fmt.status('warning', 'No section headings detected in Word document.'));
579
- console.error(chalk.dim(' Check that headers in sections.yaml match heading paragraphs in the docx.'));
580
- process.exit(1);
581
- }
582
-
583
- // Apply optional section filter from CLI
584
- let activeBoundaries = boundaries;
585
- if (sectionFilter && sectionFilter.length > 0) {
586
- const wanted = sectionFilter.map(s => s.trim().toLowerCase());
587
- activeBoundaries = boundaries.filter(b => {
588
- const base = b.file.replace(/\.md$/i, '').toLowerCase();
589
- return wanted.some(name => base === name || base.includes(name));
590
- });
591
- if (activeBoundaries.length === 0) {
592
- console.error(fmt.status('error', `No sections matched: ${sectionFilter.join(', ')}`));
593
- process.exit(1);
594
- }
595
- }
596
-
597
- const firstBoundaryStart = boundaries[0].start;
598
- const results: Array<{ file: string; placed: number; lowConfidence: number; deduped: number; unmatched: number; skipped: boolean }> = [];
599
-
600
- for (const boundary of activeBoundaries) {
601
- const sectionPath = path.join(options.dir, boundary.file);
602
- if (!fs.existsSync(sectionPath)) {
603
- results.push({ file: boundary.file, placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0, skipped: true });
604
- continue;
605
- }
606
-
607
- const isFirstSection = boundary === activeBoundaries[0];
608
- const sectionComments = comments.filter((c: { id: string }) => {
609
- const anchor = anchors.get(c.id);
610
- if (!anchor || anchor.docPosition === undefined) return false;
611
- if (anchor.docPosition >= boundary.start && anchor.docPosition < boundary.end) return true;
612
- // Comments before the first heading land in the first matched section
613
- if (isFirstSection && anchor.docPosition < firstBoundaryStart) return true;
614
- return false;
615
- });
616
-
617
- if (sectionComments.length === 0) {
618
- results.push({ file: boundary.file, placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0, skipped: false });
619
- continue;
620
- }
621
-
622
- const original = fs.readFileSync(sectionPath, 'utf-8');
623
-
624
- const stats = { placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0 };
625
- const annotated = insertCommentsIntoMarkdown(original, sectionComments, anchors, {
626
- quiet: !process.env.DEBUG,
627
- sectionBoundary: { start: boundary.start, end: boundary.end },
628
- wrapAnchor: false,
629
- outStats: stats,
630
- });
631
-
632
- if (!options.dryRun && (stats.placed > 0 || stats.lowConfidence > 0)) {
633
- fs.writeFileSync(sectionPath, annotated, 'utf-8');
634
- }
635
- results.push({ file: boundary.file, ...stats, skipped: false });
636
- }
637
-
638
- const tableRows = results.map(r => {
639
- if (r.skipped) {
640
- return [chalk.dim(r.file), chalk.yellow('missing'), '', '', ''];
641
- }
642
- return [
643
- chalk.bold(r.file),
644
- chalk.green(`${r.placed}`),
645
- r.lowConfidence > 0 ? chalk.yellow(`${r.lowConfidence}`) : chalk.dim('-'),
646
- r.deduped > 0 ? chalk.cyan(`${r.deduped}`) : chalk.dim('-'),
647
- r.unmatched > 0 ? chalk.yellow(`${r.unmatched}`) : chalk.dim('-'),
648
- chalk.dim('comments only'),
649
- ];
650
- });
651
-
652
- console.log(fmt.table(
653
- ['File', 'Placed', 'Approx', 'Already', 'Unmatched', 'Mode'],
654
- tableRows,
655
- { align: ['left', 'right', 'right', 'right', 'right', 'left'] },
656
- ));
657
- console.log();
658
-
659
- const totalPlaced = results.reduce((s, r) => s + r.placed, 0);
660
- const totalLowConfidence = results.reduce((s, r) => s + r.lowConfidence, 0);
661
- const totalDeduped = results.reduce((s, r) => s + r.deduped, 0);
662
- const totalUnmatched = results.reduce((s, r) => s + r.unmatched, 0);
663
-
664
- const lines: string[] = [];
665
- lines.push(`${chalk.bold(comments.length)} comments in document`);
666
- if (totalPlaced > 0) {
667
- lines.push(`${chalk.bold(totalPlaced)} placed at anchors`);
668
- }
669
- if (totalLowConfidence > 0) {
670
- lines.push(`${chalk.yellow(totalLowConfidence)} placed approximately (review with verify-anchors)`);
671
- }
672
- if (totalDeduped > 0) {
673
- lines.push(`${chalk.cyan(totalDeduped)} already present (skipped to avoid duplication)`);
674
- }
675
- if (totalUnmatched > 0) {
676
- lines.push(`${chalk.yellow(totalUnmatched)} unmatched (no anchor in current prose)`);
677
- }
678
- if (options.dryRun) {
679
- lines.push(chalk.yellow('Dry run — no files written'));
680
- } else if (totalPlaced > 0 || totalLowConfidence > 0) {
681
- lines.push(chalk.dim('Existing prose unchanged.'));
682
- }
683
- console.log(fmt.box(lines.join('\n'), { title: 'Summary', padding: 0 }));
684
-
685
- if (totalUnmatched > 0 || totalLowConfidence > 0) {
686
- console.log();
687
- console.log(chalk.dim('Tip: run "rev verify-anchors" to see which comments drifted.'));
688
- }
689
- }
1
+ /**
2
+ * SYNC command: Import feedback from Word/PDF back to section files
3
+ *
4
+ * Split from sections.ts for maintainability.
5
+ */
6
+
7
+ import {
8
+ chalk,
9
+ fs,
10
+ path,
11
+ fmt,
12
+ findFiles,
13
+ resolveSectionsConfig,
14
+ getOrderedSections,
15
+ extractSectionsFromText,
16
+ countAnnotations,
17
+ buildRegistry,
18
+ convertHardcodedRefs,
19
+ inlineDiffPreview,
20
+ } from './context.js';
21
+ import type { Command } from 'commander';
22
+ import type { SectionsConfig } from '../types.js';
23
+ import * as readline from 'readline';
24
+
25
+ interface ImportStats {
26
+ insertions: number;
27
+ deletions: number;
28
+ substitutions: number;
29
+ comments: number;
30
+ total: number;
31
+ }
32
+
33
+ interface SyncOptions {
34
+ config: string;
35
+ dir: string;
36
+ crossref?: boolean;
37
+ diff?: boolean;
38
+ force?: boolean;
39
+ dryRun?: boolean;
40
+ /** Commander maps `--comments-only` (a positive flag) cleanly. `--no-overwrite`
41
+ * conflicts with the existing `overwrite` semantics in `--force`-style flags
42
+ * and Commander's `--no-X` convention assigns `options.x === false`. */
43
+ commentsOnly?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Register the sync command with the program
48
+ */
49
+ export function register(program: Command): void {
50
+ // ==========================================================================
51
+ // SYNC command - Import with section awareness
52
+ // ==========================================================================
53
+
54
+ program
55
+ .command('sync')
56
+ .alias('sections')
57
+ .description('Sync feedback from Word/PDF back to section files')
58
+ .argument('[file]', 'Word (.docx) or PDF file from reviewer (default: most recent)')
59
+ .argument('[sections...]', 'Specific sections to sync (default: all)')
60
+ .option('-c, --config <file>', 'Sections config file', 'sections.yaml')
61
+ .option('-d, --dir <directory>', 'Directory with section files', '.')
62
+ .option('--no-crossref', 'Skip converting hardcoded figure/table refs')
63
+ .option('--no-diff', 'Skip showing diff preview')
64
+ .option('--force', 'Overwrite files without conflict warning')
65
+ .option('--dry-run', 'Preview without writing files')
66
+ .option('--comments-only', 'Insert comments at fuzzy-matched anchors only; never modify existing prose or apply track changes (use when markdown was revised after the docx was sent for review)')
67
+ .action(async (docx: string | undefined, sections: string[], options: SyncOptions) => {
68
+ // Auto-detect most recent docx or pdf if not provided
69
+ if (!docx) {
70
+ const docxFiles = findFiles('.docx');
71
+ const pdfFiles = findFiles('.pdf');
72
+ const allFiles = [...docxFiles, ...pdfFiles];
73
+
74
+ if (allFiles.length === 0) {
75
+ console.error(fmt.status('error', 'No .docx or .pdf files found in current directory.'));
76
+ process.exit(1);
77
+ }
78
+ const sorted = allFiles
79
+ .map(f => ({ name: f, mtime: fs.statSync(f).mtime }))
80
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
81
+ docx = sorted[0].name;
82
+ console.log(fmt.status('info', `Using most recent: ${docx}`));
83
+ console.log();
84
+ }
85
+
86
+ if (!fs.existsSync(docx)) {
87
+ console.error(fmt.status('error', `File not found: ${docx}`));
88
+ process.exit(1);
89
+ }
90
+
91
+ // Handle PDF files
92
+ if (docx.toLowerCase().endsWith('.pdf')) {
93
+ const { extractPdfComments, formatPdfComments, getPdfCommentStats } = await import('../pdf-import.js');
94
+
95
+ const spin = fmt.spinner(`Extracting comments from ${path.basename(docx)}...`).start();
96
+
97
+ try {
98
+ const comments = await extractPdfComments(docx);
99
+ spin.stop();
100
+
101
+ if (comments.length === 0) {
102
+ console.log(fmt.status('info', 'No comments found in PDF.'));
103
+ return;
104
+ }
105
+
106
+ const stats = getPdfCommentStats(comments);
107
+ console.log(fmt.header(`PDF Comments from ${path.basename(docx)}`));
108
+ console.log();
109
+ console.log(formatPdfComments(comments));
110
+ console.log();
111
+
112
+ const authorList = Object.entries(stats.byAuthor)
113
+ .map(([author, count]) => `${author} (${count})`)
114
+ .join(', ');
115
+ console.log(chalk.dim(`Total: ${stats.total} comments from ${authorList}`));
116
+ console.log();
117
+
118
+ const resolved = resolveSectionsConfig(options.dir, options.config);
119
+ if (resolved && !options.dryRun) {
120
+ const mainSection = getOrderedSections(resolved.config)[0];
121
+
122
+ if (mainSection) {
123
+ const mainPath = path.join(options.dir, mainSection);
124
+ if (fs.existsSync(mainPath)) {
125
+ console.log(chalk.dim(`Use 'rev pdf-comments ${docx} --append ${mainSection}' to add comments to markdown.`));
126
+ }
127
+ }
128
+ }
129
+ } catch (err) {
130
+ spin.stop();
131
+ const error = err as Error;
132
+ console.error(fmt.status('error', `Failed to extract PDF comments: ${error.message}`));
133
+ if (process.env.DEBUG) console.error(error.stack);
134
+ process.exit(1);
135
+ }
136
+ return;
137
+ }
138
+
139
+ // Resolve the section config: an explicit sections.yaml if present,
140
+ // otherwise the `sections:` list in rev.yaml (single source of truth).
141
+ const resolved = resolveSectionsConfig(options.dir, options.config);
142
+ if (!resolved) {
143
+ console.error(fmt.status('error', `No section config found in ${path.resolve(options.dir)}`));
144
+ console.error(chalk.dim(' Add a `sections:` list to rev.yaml, or run "rev init" to generate sections.yaml.'));
145
+ process.exit(1);
146
+ }
147
+ const sectionsConfig = resolved.config;
148
+
149
+ // --comments-only: import comments only, never modify existing prose.
150
+ // Use this when the markdown has been revised since the docx was sent
151
+ // out — track changes from a stale draft would clobber newer edits.
152
+ if (options.commentsOnly) {
153
+ await syncCommentsOnly(docx, sections, options, sectionsConfig);
154
+ return;
155
+ }
156
+
157
+ // Check pandoc availability upfront and warn
158
+ const { hasPandoc, getInstallInstructions } = await import('../dependencies.js');
159
+ if (!hasPandoc()) {
160
+ console.log(fmt.status('warning', `Pandoc not installed. Track changes will be extracted from XML (formatting may differ).`));
161
+ console.log(chalk.dim(` Install for best results: ${getInstallInstructions('pandoc')}`));
162
+ console.log();
163
+ }
164
+
165
+ const spin = fmt.spinner(`Importing ${path.basename(docx)}...`).start();
166
+
167
+ try {
168
+ const config = sectionsConfig;
169
+ const { importFromWord, extractWordComments, extractCommentAnchors, insertCommentsIntoMarkdown, extractFromWord, extractHeadings } = await import('../import.js');
170
+ const { computeSectionBoundaries } = await import('./section-boundaries.js');
171
+
172
+ let registry = null;
173
+ let totalRefConversions = 0;
174
+ if (options.crossref !== false) {
175
+ registry = buildRegistry(options.dir);
176
+ }
177
+
178
+ const comments = await extractWordComments(docx);
179
+ const { anchors, fullDocText: xmlDocText } = await extractCommentAnchors(docx);
180
+
181
+ // Extract Word text (uses pandoc if available, falls back to XML extraction)
182
+ const wordExtraction = await extractFromWord(docx, { mediaDir: options.dir });
183
+ let wordText = wordExtraction.text;
184
+ const wordTables = wordExtraction.tables || [];
185
+
186
+ // Log extraction messages (warnings about pandoc, track change stats, etc.)
187
+ for (const msg of wordExtraction.messages || []) {
188
+ if (msg.type === 'warning') {
189
+ spin.stop();
190
+ console.log(fmt.status('warning', msg.message));
191
+ spin.start();
192
+ }
193
+ }
194
+
195
+ // Restore crossref on FULL text BEFORE splitting into sections
196
+ // This ensures duplicate labels from track changes are handled correctly
197
+ // (the same figure may appear multiple times in old/new versions)
198
+ const { restoreCrossrefFromWord, restoreImagesFromRegistry } = await import('../import.js');
199
+ const crossrefResult = restoreCrossrefFromWord(wordText, options.dir);
200
+ wordText = crossrefResult.text;
201
+ if (crossrefResult.restored > 0) {
202
+ console.log(`Restored ${crossrefResult.restored} crossref reference(s)`);
203
+ }
204
+
205
+ // Also restore images from registry using shared restoredLabels
206
+ const imageRestoreResult = restoreImagesFromRegistry(wordText, options.dir, crossrefResult.restoredLabels);
207
+ wordText = imageRestoreResult.text;
208
+ if (imageRestoreResult.restored > 0) {
209
+ console.log(`Restored ${imageRestoreResult.restored} image(s) from registry`);
210
+ }
211
+
212
+ let wordSections = extractSectionsFromText(wordText, config.sections);
213
+
214
+ if (wordSections.length === 0) {
215
+ spin.stop();
216
+ console.error(fmt.status('warning', 'No sections detected in Word document.'));
217
+ console.error(chalk.dim(' Check that headings match sections.yaml'));
218
+ process.exit(1);
219
+ }
220
+
221
+ if (sections && sections.length > 0) {
222
+ const onlyList = sections.map(s => s.trim().toLowerCase());
223
+ wordSections = wordSections.filter(section => {
224
+ const fileName = section.file.replace(/\.md$/i, '').toLowerCase();
225
+ const header = section.header.toLowerCase();
226
+ return onlyList.some(name => fileName === name || fileName.includes(name) || header.includes(name));
227
+ });
228
+ if (wordSections.length === 0) {
229
+ spin.stop();
230
+ console.error(fmt.status('error', `No sections matched: ${sections.join(', ')}`));
231
+ console.error(chalk.dim(` Available: ${extractSectionsFromText(wordText, config.sections).map(s => s.file.replace(/\.md$/i, '')).join(', ')}`));
232
+ process.exit(1);
233
+ }
234
+ }
235
+
236
+ spin.stop();
237
+ console.log(fmt.header(`Import from ${path.basename(docx)}`));
238
+ console.log();
239
+
240
+ // Conflict detection
241
+ if (!options.force && !options.dryRun) {
242
+ const conflicts: Array<{ file: string; annotations: number }> = [];
243
+ for (const section of wordSections) {
244
+ const sectionPath = path.join(options.dir, section.file);
245
+ if (fs.existsSync(sectionPath)) {
246
+ const existing = fs.readFileSync(sectionPath, 'utf-8');
247
+ const existingCounts = countAnnotations(existing);
248
+ if (existingCounts.total > 0) {
249
+ conflicts.push({
250
+ file: section.file,
251
+ annotations: existingCounts.total,
252
+ });
253
+ }
254
+ }
255
+ }
256
+
257
+ if (conflicts.length > 0) {
258
+ console.log(fmt.status('warning', 'Files with existing annotations will be overwritten:'));
259
+ for (const c of conflicts) {
260
+ console.log(chalk.yellow(` - ${c.file} (${c.annotations} annotations)`));
261
+ }
262
+ console.log();
263
+
264
+ const rl = readline.createInterface({
265
+ input: process.stdin,
266
+ output: process.stdout,
267
+ });
268
+
269
+ const answer = await new Promise<string>((resolve) =>
270
+ rl.question(chalk.cyan('Continue and overwrite? [y/N] '), resolve)
271
+ );
272
+ rl.close();
273
+
274
+ if (answer.toLowerCase() !== 'y') {
275
+ console.log(chalk.dim('Aborted. Use --force to skip this check.'));
276
+ process.exit(0);
277
+ }
278
+ console.log();
279
+ }
280
+ }
281
+
282
+ const sectionResults: Array<{
283
+ file: string;
284
+ header: string;
285
+ status: string;
286
+ stats?: ImportStats;
287
+ refs?: number;
288
+ }> = [];
289
+ let totalChanges = 0;
290
+
291
+ // Route comments to sections using boundaries from the document's real
292
+ // heading paragraphs. docPosition (from extractCommentAnchors) and each
293
+ // heading's docPosition share one coordinate system over xmlDocText, so
294
+ // every configured section gets a boundary — not only the handful that
295
+ // matched a hardcoded keyword list. Sections named "Objectives" or
296
+ // "Annex 2" used to get no boundary, so their comments were silently
297
+ // dropped while the summary still claimed every comment was placed.
298
+ // This is the same routing path as `sync --comments-only`.
299
+ const headings = await extractHeadings(docx);
300
+ const sectionBoundaries = computeSectionBoundaries(config.sections, headings, xmlDocText.length);
301
+ const firstBoundaryStart = sectionBoundaries.length > 0 ? sectionBoundaries[0].start : 0;
302
+
303
+ // Truthful comment accounting: track which comments were routed to a
304
+ // synced section and how many were actually written, so the summary
305
+ // reports placements rather than the raw extracted count.
306
+ const routedCommentIds = new Set<string>();
307
+ let totalCommentsPlaced = 0;
308
+ let totalCommentsLowConfidence = 0;
309
+ let totalCommentsDeduped = 0;
310
+ let totalCommentsUnmatched = 0;
311
+
312
+ for (const section of wordSections) {
313
+ const sectionPath = path.join(options.dir, section.file);
314
+
315
+ if (!fs.existsSync(sectionPath)) {
316
+ sectionResults.push({
317
+ file: section.file,
318
+ header: section.header,
319
+ status: 'skipped',
320
+ stats: undefined,
321
+ });
322
+ continue;
323
+ }
324
+
325
+ // A section that appears in the reviewed document as a bare heading
326
+ // with no body was not really part of this build (e.g. a "no-annex"
327
+ // export synced against the full project). Importing it would diff
328
+ // real prose against an empty body and rewrite the file to near-empty.
329
+ // Leave it untouched.
330
+ const bodyEmpty = section.content.trim() === section.header.trim();
331
+ if (bodyEmpty) {
332
+ sectionResults.push({
333
+ file: section.file,
334
+ header: section.header,
335
+ status: 'untouched',
336
+ stats: undefined,
337
+ });
338
+ continue;
339
+ }
340
+
341
+ const result = await importFromWord(docx, sectionPath, {
342
+ sectionContent: section.content,
343
+ author: 'Reviewer',
344
+ wordTables: wordTables,
345
+ });
346
+
347
+ let { annotated, stats } = result;
348
+
349
+ let refConversions: Array<{ from: string; to: string }> = [];
350
+ if (registry && options.crossref !== false) {
351
+ const crossrefResult = convertHardcodedRefs(annotated, registry);
352
+ annotated = crossrefResult.converted;
353
+ refConversions = crossrefResult.conversions;
354
+ totalRefConversions += refConversions.length;
355
+ }
356
+
357
+ if (comments.length > 0 && anchors.size > 0) {
358
+ // Filter comments to those whose docPosition falls in this section's
359
+ // boundary (docPosition and boundaries share xmlDocText coordinates).
360
+ // The section owning the first boundary also catches comments placed
361
+ // before any heading.
362
+ const boundary = sectionBoundaries.find(b => b.file === section.file);
363
+ const ownsFirstBoundary = !!boundary && boundary.start === firstBoundaryStart;
364
+
365
+ const sectionComments = comments.filter((c: { id: string }) => {
366
+ const anchorData = anchors.get(c.id);
367
+ if (!anchorData || anchorData.docPosition === undefined || !boundary) return false;
368
+ if (anchorData.docPosition >= boundary.start && anchorData.docPosition < boundary.end) return true;
369
+ if (ownsFirstBoundary && anchorData.docPosition < firstBoundaryStart) return true;
370
+ return false;
371
+ });
372
+
373
+ if (process.env.DEBUG) {
374
+ console.log(`[DEBUG] ${section.file}: ${sectionComments.length} comments to place (boundary: ${boundary?.start}-${boundary?.end})`);
375
+ }
376
+
377
+ if (sectionComments.length > 0) {
378
+ for (const c of sectionComments) routedCommentIds.add(c.id);
379
+ const cstats = { placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0 };
380
+ annotated = insertCommentsIntoMarkdown(annotated, sectionComments, anchors, {
381
+ quiet: !process.env.DEBUG,
382
+ sectionBoundary: boundary,
383
+ outStats: cstats,
384
+ });
385
+ // Both confident and approximate placements are written to the
386
+ // file; count both as placed, but track the approximate ones so
387
+ // the summary can flag them for review.
388
+ stats.comments = (stats.comments || 0) + cstats.placed + cstats.lowConfidence;
389
+ totalCommentsPlaced += cstats.placed + cstats.lowConfidence;
390
+ totalCommentsLowConfidence += cstats.lowConfidence;
391
+ totalCommentsDeduped += cstats.deduped;
392
+ totalCommentsUnmatched += cstats.unmatched;
393
+
394
+ if (process.env.DEBUG) {
395
+ console.log(`[DEBUG] ${section.file}: placed ${cstats.placed}, approx ${cstats.lowConfidence}, deduped ${cstats.deduped}, unmatched ${cstats.unmatched} of ${sectionComments.length}`);
396
+ }
397
+ }
398
+ }
399
+
400
+ totalChanges += stats.total;
401
+
402
+ sectionResults.push({
403
+ file: section.file,
404
+ header: section.header,
405
+ status: 'ok',
406
+ stats,
407
+ refs: refConversions.length,
408
+ });
409
+
410
+ if (!options.dryRun) {
411
+ // Preserve any preamble content (YAML frontmatter, author blocks, metadata)
412
+ // that exists before the first heading in the original file.
413
+ // This content is never included in the Word build output, so it won't
414
+ // appear in the Word doc and would otherwise be lost during sync.
415
+ const originalContent = fs.readFileSync(sectionPath, 'utf-8');
416
+ const firstHeadingMatch = originalContent.match(/^(#\s)/m);
417
+ if (firstHeadingMatch && firstHeadingMatch.index !== undefined && firstHeadingMatch.index > 0) {
418
+ const preamble = originalContent.slice(0, firstHeadingMatch.index);
419
+ // Only prepend if preamble has non-whitespace content
420
+ if (preamble.trim().length > 0) {
421
+ annotated = preamble + annotated;
422
+ }
423
+ }
424
+ fs.writeFileSync(sectionPath, annotated, 'utf-8');
425
+ }
426
+ }
427
+
428
+ const tableRows = sectionResults.map((r) => {
429
+ if (r.status === 'skipped' || r.status === 'untouched') {
430
+ return [
431
+ chalk.dim(r.file),
432
+ chalk.dim(r.header.slice(0, 25)),
433
+ chalk.yellow(r.status),
434
+ '',
435
+ '',
436
+ '',
437
+ '',
438
+ ];
439
+ }
440
+ const s = r.stats!;
441
+ return [
442
+ chalk.bold(r.file),
443
+ r.header.length > 25 ? r.header.slice(0, 22) + '...' : r.header,
444
+ s.insertions > 0 ? chalk.green(`+${s.insertions}`) : chalk.dim('-'),
445
+ s.deletions > 0 ? chalk.red(`-${s.deletions}`) : chalk.dim('-'),
446
+ s.substitutions > 0 ? chalk.yellow(`~${s.substitutions}`) : chalk.dim('-'),
447
+ s.comments > 0 ? chalk.blue(`#${s.comments}`) : chalk.dim('-'),
448
+ r.refs! > 0 ? chalk.magenta(`@${r.refs}`) : chalk.dim('-'),
449
+ ];
450
+ });
451
+
452
+ console.log(fmt.table(
453
+ ['File', 'Section', 'Ins', 'Del', 'Sub', 'Cmt', 'Ref'],
454
+ tableRows,
455
+ { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] }
456
+ ));
457
+ console.log();
458
+
459
+ if (options.diff !== false && totalChanges > 0) {
460
+ console.log(fmt.header('Changes Preview'));
461
+ console.log();
462
+ for (const result of sectionResults) {
463
+ if (result.status === 'ok' && result.stats && result.stats.total > 0) {
464
+ const sectionPath = path.join(options.dir, result.file);
465
+ if (fs.existsSync(sectionPath)) {
466
+ const content = fs.readFileSync(sectionPath, 'utf-8');
467
+ const preview = inlineDiffPreview(content, { maxLines: 3 });
468
+ if (preview) {
469
+ console.log(chalk.bold(result.file) + ':');
470
+ console.log(preview);
471
+ console.log();
472
+ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ // Comments carried by the document but never routed to a synced section
479
+ // (they fell in a skipped/untouched/absent section). Surfacing these
480
+ // keeps the summary honest instead of reporting every extracted comment
481
+ // as placed.
482
+ const unroutedComments = comments.length - routedCommentIds.size;
483
+
484
+ if (options.dryRun) {
485
+ console.log(fmt.box(chalk.yellow('Dry run - no files written'), { padding: 0 }));
486
+ } else if (totalChanges > 0 || totalRefConversions > 0 || comments.length > 0) {
487
+ const summaryLines: string[] = [];
488
+ summaryLines.push(`${chalk.bold(wordSections.length)} sections processed`);
489
+ if (totalChanges > 0) summaryLines.push(`${chalk.bold(totalChanges)} annotations imported`);
490
+ if (totalCommentsPlaced > 0) {
491
+ summaryLines.push(`${chalk.bold(totalCommentsPlaced)} of ${comments.length} comments placed`);
492
+ }
493
+ if (totalCommentsLowConfidence > 0) {
494
+ summaryLines.push(`${chalk.yellow(totalCommentsLowConfidence)} placed approximately (review with verify-anchors)`);
495
+ }
496
+ if (totalCommentsDeduped > 0) {
497
+ summaryLines.push(`${chalk.cyan(totalCommentsDeduped)} already present (skipped)`);
498
+ }
499
+ if (totalCommentsUnmatched > 0) {
500
+ summaryLines.push(`${chalk.yellow(totalCommentsUnmatched)} unmatched (anchor not in current prose)`);
501
+ }
502
+ if (unroutedComments > 0) {
503
+ summaryLines.push(`${chalk.yellow(unroutedComments)} not routed to any synced section`);
504
+ }
505
+ if (totalRefConversions > 0) summaryLines.push(`${chalk.bold(totalRefConversions)} refs converted to @-syntax`);
506
+
507
+ console.log(fmt.box(summaryLines.join('\n'), { title: 'Summary', padding: 0 }));
508
+ console.log();
509
+ if (totalCommentsUnmatched > 0 || unroutedComments > 0) {
510
+ console.log(chalk.yellow(` ${totalCommentsUnmatched + unroutedComments} comment(s) were not written. Run "rev verify-anchors" or re-sync with the full document.`));
511
+ console.log();
512
+ }
513
+ console.log(chalk.dim('Next steps:'));
514
+ console.log(chalk.dim(' 1. rev review <section.md> - Accept/reject changes'));
515
+ console.log(chalk.dim(' 2. rev comments <section.md> - View/address comments'));
516
+ console.log(chalk.dim(' 3. rev build docx - Rebuild Word doc'));
517
+ } else {
518
+ console.log(fmt.status('success', 'No changes detected.'));
519
+ }
520
+ } catch (err) {
521
+ spin.stop();
522
+ const error = err as Error;
523
+ console.error(fmt.status('error', error.message));
524
+ if (process.env.DEBUG) console.error(error.stack);
525
+ process.exit(1);
526
+ }
527
+ });
528
+ }
529
+
530
+ /**
531
+ * `sync --comments-only`: import only Word comments at fuzzy-matched anchors.
532
+ *
533
+ * Skips the Word→Markdown diff entirely (no track changes, no pandoc, no
534
+ * prose modifications). Useful when the markdown has been edited after the
535
+ * docx was sent for review — applying track changes from a stale draft
536
+ * would overwrite newer edits.
537
+ */
538
+ async function syncCommentsOnly(
539
+ docx: string,
540
+ sectionFilter: string[] | undefined,
541
+ options: SyncOptions,
542
+ config: SectionsConfig,
543
+ ): Promise<void> {
544
+ const { extractWordComments, extractCommentAnchors, extractHeadings, insertCommentsIntoMarkdown } = await import('../import.js');
545
+ const { computeSectionBoundaries } = await import('./section-boundaries.js');
546
+
547
+ const spin = fmt.spinner(`Reading comments from ${path.basename(docx)}...`).start();
548
+
549
+ let comments;
550
+ let anchors;
551
+ let headings;
552
+ let fullDocText = '';
553
+ try {
554
+ comments = await extractWordComments(docx);
555
+ const result = await extractCommentAnchors(docx);
556
+ anchors = result.anchors;
557
+ fullDocText = result.fullDocText;
558
+ headings = await extractHeadings(docx);
559
+ spin.stop();
560
+ } catch (err) {
561
+ spin.stop();
562
+ const error = err as Error;
563
+ console.error(fmt.status('error', error.message));
564
+ process.exit(1);
565
+ }
566
+
567
+ console.log(fmt.header(`Comments from ${path.basename(docx)} (comments-only)`));
568
+ console.log();
569
+
570
+ if (comments.length === 0) {
571
+ console.log(fmt.status('info', 'No comments found in document.'));
572
+ return;
573
+ }
574
+
575
+ const boundaries = computeSectionBoundaries(config.sections, headings, fullDocText.length);
576
+
577
+ if (boundaries.length === 0) {
578
+ console.error(fmt.status('warning', 'No section headings detected in Word document.'));
579
+ console.error(chalk.dim(' Check that headers in sections.yaml match heading paragraphs in the docx.'));
580
+ process.exit(1);
581
+ }
582
+
583
+ // Apply optional section filter from CLI
584
+ let activeBoundaries = boundaries;
585
+ if (sectionFilter && sectionFilter.length > 0) {
586
+ const wanted = sectionFilter.map(s => s.trim().toLowerCase());
587
+ activeBoundaries = boundaries.filter(b => {
588
+ const base = b.file.replace(/\.md$/i, '').toLowerCase();
589
+ return wanted.some(name => base === name || base.includes(name));
590
+ });
591
+ if (activeBoundaries.length === 0) {
592
+ console.error(fmt.status('error', `No sections matched: ${sectionFilter.join(', ')}`));
593
+ process.exit(1);
594
+ }
595
+ }
596
+
597
+ const firstBoundaryStart = boundaries[0].start;
598
+ const results: Array<{ file: string; placed: number; lowConfidence: number; deduped: number; unmatched: number; skipped: boolean }> = [];
599
+
600
+ for (const boundary of activeBoundaries) {
601
+ const sectionPath = path.join(options.dir, boundary.file);
602
+ if (!fs.existsSync(sectionPath)) {
603
+ results.push({ file: boundary.file, placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0, skipped: true });
604
+ continue;
605
+ }
606
+
607
+ const isFirstSection = boundary === activeBoundaries[0];
608
+ const sectionComments = comments.filter((c: { id: string }) => {
609
+ const anchor = anchors.get(c.id);
610
+ if (!anchor || anchor.docPosition === undefined) return false;
611
+ if (anchor.docPosition >= boundary.start && anchor.docPosition < boundary.end) return true;
612
+ // Comments before the first heading land in the first matched section
613
+ if (isFirstSection && anchor.docPosition < firstBoundaryStart) return true;
614
+ return false;
615
+ });
616
+
617
+ if (sectionComments.length === 0) {
618
+ results.push({ file: boundary.file, placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0, skipped: false });
619
+ continue;
620
+ }
621
+
622
+ const original = fs.readFileSync(sectionPath, 'utf-8');
623
+
624
+ const stats = { placed: 0, lowConfidence: 0, deduped: 0, unmatched: 0 };
625
+ const annotated = insertCommentsIntoMarkdown(original, sectionComments, anchors, {
626
+ quiet: !process.env.DEBUG,
627
+ sectionBoundary: { start: boundary.start, end: boundary.end },
628
+ wrapAnchor: false,
629
+ outStats: stats,
630
+ });
631
+
632
+ if (!options.dryRun && (stats.placed > 0 || stats.lowConfidence > 0)) {
633
+ fs.writeFileSync(sectionPath, annotated, 'utf-8');
634
+ }
635
+ results.push({ file: boundary.file, ...stats, skipped: false });
636
+ }
637
+
638
+ const tableRows = results.map(r => {
639
+ if (r.skipped) {
640
+ return [chalk.dim(r.file), chalk.yellow('missing'), '', '', ''];
641
+ }
642
+ return [
643
+ chalk.bold(r.file),
644
+ chalk.green(`${r.placed}`),
645
+ r.lowConfidence > 0 ? chalk.yellow(`${r.lowConfidence}`) : chalk.dim('-'),
646
+ r.deduped > 0 ? chalk.cyan(`${r.deduped}`) : chalk.dim('-'),
647
+ r.unmatched > 0 ? chalk.yellow(`${r.unmatched}`) : chalk.dim('-'),
648
+ chalk.dim('comments only'),
649
+ ];
650
+ });
651
+
652
+ console.log(fmt.table(
653
+ ['File', 'Placed', 'Approx', 'Already', 'Unmatched', 'Mode'],
654
+ tableRows,
655
+ { align: ['left', 'right', 'right', 'right', 'right', 'left'] },
656
+ ));
657
+ console.log();
658
+
659
+ const totalPlaced = results.reduce((s, r) => s + r.placed, 0);
660
+ const totalLowConfidence = results.reduce((s, r) => s + r.lowConfidence, 0);
661
+ const totalDeduped = results.reduce((s, r) => s + r.deduped, 0);
662
+ const totalUnmatched = results.reduce((s, r) => s + r.unmatched, 0);
663
+
664
+ const lines: string[] = [];
665
+ lines.push(`${chalk.bold(comments.length)} comments in document`);
666
+ if (totalPlaced > 0) {
667
+ lines.push(`${chalk.bold(totalPlaced)} placed at anchors`);
668
+ }
669
+ if (totalLowConfidence > 0) {
670
+ lines.push(`${chalk.yellow(totalLowConfidence)} placed approximately (review with verify-anchors)`);
671
+ }
672
+ if (totalDeduped > 0) {
673
+ lines.push(`${chalk.cyan(totalDeduped)} already present (skipped to avoid duplication)`);
674
+ }
675
+ if (totalUnmatched > 0) {
676
+ lines.push(`${chalk.yellow(totalUnmatched)} unmatched (no anchor in current prose)`);
677
+ }
678
+ if (options.dryRun) {
679
+ lines.push(chalk.yellow('Dry run — no files written'));
680
+ } else if (totalPlaced > 0 || totalLowConfidence > 0) {
681
+ lines.push(chalk.dim('Existing prose unchanged.'));
682
+ }
683
+ console.log(fmt.box(lines.join('\n'), { title: 'Summary', padding: 0 }));
684
+
685
+ if (totalUnmatched > 0 || totalLowConfidence > 0) {
686
+ console.log();
687
+ console.log(chalk.dim('Tip: run "rev verify-anchors" to see which comments drifted.'));
688
+ }
689
+ }