docrev 0.6.7 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,862 @@
1
+ /**
2
+ * Section commands: import, extract, split, sync, merge
3
+ *
4
+ * Commands for importing Word documents, splitting/syncing section files.
5
+ */
6
+
7
+ import {
8
+ chalk,
9
+ fs,
10
+ path,
11
+ fmt,
12
+ findFiles,
13
+ countAnnotations,
14
+ loadConfig,
15
+ extractSectionsFromText,
16
+ splitAnnotatedPaper,
17
+ buildRegistry,
18
+ convertHardcodedRefs,
19
+ inlineDiffPreview,
20
+ } from './context.js';
21
+
22
+ /**
23
+ * Detect sections from Word document text
24
+ * Looks for common academic paper section headers
25
+ */
26
+ function detectSectionsFromWord(text) {
27
+ const lines = text.split('\n');
28
+ const sections = [];
29
+
30
+ const headerPatterns = [
31
+ /^(Abstract|Summary)$/i,
32
+ /^(Introduction|Background)$/i,
33
+ /^(Methods?|Materials?\s*(and|&)\s*Methods?|Methodology|Experimental\s*Methods?)$/i,
34
+ /^(Results?)$/i,
35
+ /^(Results?\s*(and|&)\s*Discussion)$/i,
36
+ /^(Discussion)$/i,
37
+ /^(Conclusions?|Summary\s*(and|&)?\s*Conclusions?)$/i,
38
+ /^(Acknowledgements?|Acknowledgments?)$/i,
39
+ /^(References|Bibliography|Literature\s*Cited|Works\s*Cited)$/i,
40
+ /^(Appendix|Appendices|Supplementary\s*(Materials?|Information)?|Supporting\s*Information)$/i,
41
+ /^(Literature\s*Review|Related\s*Work|Previous\s*Work)$/i,
42
+ /^(Study\s*Area|Study\s*Site|Site\s*Description)$/i,
43
+ /^(Data\s*Analysis|Statistical\s*Analysis|Data\s*Collection)$/i,
44
+ /^(Theoretical\s*Framework|Conceptual\s*Framework)$/i,
45
+ /^(Case\s*Study|Case\s*Studies)$/i,
46
+ /^(Limitations?)$/i,
47
+ /^(Future\s*Work|Future\s*Directions?)$/i,
48
+ /^(Funding|Author\s*Contributions?|Conflict\s*of\s*Interest|Data\s*Availability)$/i,
49
+ ];
50
+
51
+ const numberedHeaderPattern = /^(\d+\.?\s+)(Abstract|Introduction|Background|Methods?|Materials|Results?|Discussion|Conclusions?|References|Acknowledgements?|Appendix)/i;
52
+
53
+ let currentSection = null;
54
+ let currentContent = [];
55
+ let preambleContent = [];
56
+
57
+ for (const line of lines) {
58
+ const trimmed = line.trim();
59
+ if (!trimmed) {
60
+ if (currentSection) {
61
+ currentContent.push(line);
62
+ } else {
63
+ preambleContent.push(line);
64
+ }
65
+ continue;
66
+ }
67
+
68
+ let isHeader = false;
69
+ let headerText = trimmed;
70
+
71
+ for (const pattern of headerPatterns) {
72
+ if (pattern.test(trimmed)) {
73
+ isHeader = true;
74
+ break;
75
+ }
76
+ }
77
+
78
+ if (!isHeader) {
79
+ const match = trimmed.match(numberedHeaderPattern);
80
+ if (match) {
81
+ isHeader = true;
82
+ headerText = trimmed.replace(/^\d+\.?\s+/, '');
83
+ }
84
+ }
85
+
86
+ if (isHeader) {
87
+ if (currentSection) {
88
+ sections.push({
89
+ header: currentSection,
90
+ content: currentContent.join('\n'),
91
+ file: headerToFilename(currentSection),
92
+ });
93
+ } else if (preambleContent.some(l => l.trim())) {
94
+ sections.push({
95
+ header: 'Preamble',
96
+ content: preambleContent.join('\n'),
97
+ file: 'preamble.md',
98
+ });
99
+ }
100
+ currentSection = headerText;
101
+ currentContent = [];
102
+ } else if (currentSection) {
103
+ currentContent.push(line);
104
+ } else {
105
+ preambleContent.push(line);
106
+ }
107
+ }
108
+
109
+ if (currentSection) {
110
+ sections.push({
111
+ header: currentSection,
112
+ content: currentContent.join('\n'),
113
+ file: headerToFilename(currentSection),
114
+ });
115
+ }
116
+
117
+ if (sections.length === 0) {
118
+ const allContent = [...preambleContent, ...currentContent].join('\n');
119
+ if (allContent.trim()) {
120
+ sections.push({
121
+ header: 'Content',
122
+ content: allContent,
123
+ file: 'content.md',
124
+ });
125
+ }
126
+ }
127
+
128
+ return sections;
129
+ }
130
+
131
+ /**
132
+ * Convert a section header to a filename
133
+ */
134
+ function headerToFilename(header) {
135
+ return header
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9]+/g, '-')
138
+ .replace(/^-|-$/g, '')
139
+ .slice(0, 30) + '.md';
140
+ }
141
+
142
+ /**
143
+ * Bootstrap a new project from a Word document
144
+ */
145
+ async function bootstrapFromWord(docx, options) {
146
+ const outputDir = path.resolve(options.output);
147
+
148
+ console.log(chalk.cyan(`Bootstrapping project from ${path.basename(docx)}...\n`));
149
+
150
+ try {
151
+ const mammoth = await import('mammoth');
152
+ const { default: YAML } = await import('yaml');
153
+
154
+ const result = await mammoth.extractRawText({ path: docx });
155
+ const text = result.value;
156
+
157
+ const sections = detectSectionsFromWord(text);
158
+
159
+ if (sections.length === 0) {
160
+ console.error(chalk.yellow('No sections detected. Creating single content.md file.'));
161
+ sections.push({ header: 'Content', content: text, file: 'content.md' });
162
+ }
163
+
164
+ console.log(chalk.green(`Detected ${sections.length} section(s):\n`));
165
+
166
+ if (!fs.existsSync(outputDir)) {
167
+ fs.mkdirSync(outputDir, { recursive: true });
168
+ }
169
+
170
+ const sectionFiles = [];
171
+ for (const section of sections) {
172
+ const filePath = path.join(outputDir, section.file);
173
+ const content = `# ${section.header}\n\n${section.content.trim()}\n`;
174
+
175
+ console.log(` ${chalk.bold(section.file)} - "${section.header}" (${section.content.split('\n').length} lines)`);
176
+
177
+ if (!options.dryRun) {
178
+ fs.writeFileSync(filePath, content, 'utf-8');
179
+ }
180
+ sectionFiles.push(section.file);
181
+ }
182
+
183
+ const docxName = path.basename(docx, '.docx');
184
+ const title = docxName.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
185
+
186
+ const config = {
187
+ title: title,
188
+ authors: [],
189
+ sections: sectionFiles,
190
+ bibliography: null,
191
+ crossref: {
192
+ figureTitle: 'Figure',
193
+ tableTitle: 'Table',
194
+ figPrefix: ['Fig.', 'Figs.'],
195
+ tblPrefix: ['Table', 'Tables'],
196
+ },
197
+ pdf: {
198
+ documentclass: 'article',
199
+ fontsize: '12pt',
200
+ geometry: 'margin=1in',
201
+ linestretch: 1.5,
202
+ },
203
+ docx: {
204
+ keepComments: true,
205
+ },
206
+ };
207
+
208
+ const configPath = path.join(outputDir, 'rev.yaml');
209
+ console.log(`\n ${chalk.bold('rev.yaml')} - project configuration`);
210
+
211
+ if (!options.dryRun) {
212
+ fs.writeFileSync(configPath, YAML.stringify(config), 'utf-8');
213
+ }
214
+
215
+ const figuresDir = path.join(outputDir, 'figures');
216
+ if (!fs.existsSync(figuresDir) && !options.dryRun) {
217
+ fs.mkdirSync(figuresDir, { recursive: true });
218
+ console.log(` ${chalk.dim('figures/')} - image directory`);
219
+ }
220
+
221
+ if (options.dryRun) {
222
+ console.log(chalk.yellow('\n(Dry run - no files written)'));
223
+ } else {
224
+ console.log(chalk.green('\nProject created!'));
225
+ console.log(chalk.cyan('\nNext steps:'));
226
+ if (outputDir !== process.cwd()) {
227
+ console.log(chalk.dim(` cd ${path.relative(process.cwd(), outputDir) || '.'}`));
228
+ }
229
+ console.log(chalk.dim(' # Edit rev.yaml to add authors and adjust settings'));
230
+ console.log(chalk.dim(' # Review and clean up section files'));
231
+ console.log(chalk.dim(' rev build # Build PDF and DOCX'));
232
+ }
233
+ } catch (err) {
234
+ console.error(chalk.red(`Error: ${err.message}`));
235
+ if (process.env.DEBUG) console.error(err.stack);
236
+ process.exit(1);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Register section commands with the program
242
+ * @param {import('commander').Command} program
243
+ */
244
+ export function register(program) {
245
+ // ==========================================================================
246
+ // IMPORT command - Import from Word (bootstrap or diff mode)
247
+ // ==========================================================================
248
+
249
+ program
250
+ .command('import')
251
+ .description('Import from Word: creates sections from scratch, or diffs against existing MD')
252
+ .argument('<docx>', 'Word document')
253
+ .argument('[original]', 'Optional: original Markdown file to compare against')
254
+ .option('-o, --output <dir>', 'Output directory for bootstrap mode', '.')
255
+ .option('-a, --author <name>', 'Author name for changes (diff mode)', 'Reviewer')
256
+ .option('--dry-run', 'Preview without saving')
257
+ .action(async (docx, original, options) => {
258
+ if (!fs.existsSync(docx)) {
259
+ console.error(chalk.red(`Error: Word file not found: ${docx}`));
260
+ process.exit(1);
261
+ }
262
+
263
+ if (!original) {
264
+ await bootstrapFromWord(docx, options);
265
+ return;
266
+ }
267
+
268
+ if (!fs.existsSync(original)) {
269
+ console.error(chalk.red(`Error: Original MD not found: ${original}`));
270
+ process.exit(1);
271
+ }
272
+
273
+ console.log(chalk.cyan(`Comparing ${path.basename(docx)} against ${path.basename(original)}...`));
274
+
275
+ try {
276
+ const { importFromWord } = await import('../import.js');
277
+ const { annotated, stats } = await importFromWord(docx, original, {
278
+ author: options.author,
279
+ });
280
+
281
+ console.log(chalk.cyan('\nChanges detected:'));
282
+ if (stats.insertions > 0) console.log(chalk.green(` + Insertions: ${stats.insertions}`));
283
+ if (stats.deletions > 0) console.log(chalk.red(` - Deletions: ${stats.deletions}`));
284
+ if (stats.substitutions > 0) console.log(chalk.yellow(` ~ Substitutions: ${stats.substitutions}`));
285
+ if (stats.comments > 0) console.log(chalk.blue(` # Comments: ${stats.comments}`));
286
+
287
+ if (stats.total === 0) {
288
+ console.log(chalk.green('\nNo changes detected.'));
289
+ return;
290
+ }
291
+
292
+ console.log(chalk.dim(`\n Total: ${stats.total}`));
293
+
294
+ if (options.dryRun) {
295
+ console.log(chalk.cyan('\n--- Preview (first 1000 chars) ---\n'));
296
+ console.log(annotated.slice(0, 1000));
297
+ if (annotated.length > 1000) console.log(chalk.dim('\n... (truncated)'));
298
+ return;
299
+ }
300
+
301
+ const outputPath = options.output || original;
302
+ fs.writeFileSync(outputPath, annotated, 'utf-8');
303
+ console.log(chalk.green(`\nSaved annotated version to ${outputPath}`));
304
+ console.log(chalk.cyan('\nNext steps:'));
305
+ console.log(` 1. ${chalk.bold('rev review ' + outputPath)} - Accept/reject track changes`);
306
+ console.log(` 2. Work with Claude to address comments`);
307
+ console.log(` 3. ${chalk.bold('rev build docx')} - Rebuild Word doc`);
308
+
309
+ } catch (err) {
310
+ console.error(chalk.red(`Error: ${err.message}`));
311
+ if (process.env.DEBUG) console.error(err.stack);
312
+ process.exit(1);
313
+ }
314
+ });
315
+
316
+ // ==========================================================================
317
+ // EXTRACT command - Just extract text from Word
318
+ // ==========================================================================
319
+
320
+ program
321
+ .command('extract')
322
+ .description('Extract plain text from Word document (no diff)')
323
+ .argument('<docx>', 'Word document')
324
+ .option('-o, --output <file>', 'Output file (default: stdout)')
325
+ .action(async (docx, options) => {
326
+ if (!fs.existsSync(docx)) {
327
+ console.error(chalk.red(`Error: File not found: ${docx}`));
328
+ process.exit(1);
329
+ }
330
+
331
+ try {
332
+ const mammoth = await import('mammoth');
333
+ const result = await mammoth.extractRawText({ path: docx });
334
+
335
+ if (options.output) {
336
+ fs.writeFileSync(options.output, result.value, 'utf-8');
337
+ console.error(chalk.green(`Extracted to ${options.output}`));
338
+ } else {
339
+ process.stdout.write(result.value);
340
+ }
341
+ } catch (err) {
342
+ console.error(chalk.red(`Error: ${err.message}`));
343
+ process.exit(1);
344
+ }
345
+ });
346
+
347
+ // ==========================================================================
348
+ // SPLIT command - Split annotated paper.md back to section files
349
+ // ==========================================================================
350
+
351
+ program
352
+ .command('split')
353
+ .description('Split annotated paper.md back to section files')
354
+ .argument('<file>', 'Annotated paper.md file')
355
+ .option('-c, --config <file>', 'Sections config file', 'sections.yaml')
356
+ .option('-d, --dir <directory>', 'Output directory for section files', '.')
357
+ .option('--dry-run', 'Preview without writing files')
358
+ .action((file, options) => {
359
+ if (!fs.existsSync(file)) {
360
+ console.error(chalk.red(`File not found: ${file}`));
361
+ process.exit(1);
362
+ }
363
+
364
+ const configPath = path.resolve(options.dir, options.config);
365
+ if (!fs.existsSync(configPath)) {
366
+ console.error(chalk.red(`Config not found: ${configPath}`));
367
+ console.error(chalk.dim('Run "rev init" first to generate sections.yaml'));
368
+ process.exit(1);
369
+ }
370
+
371
+ console.log(chalk.cyan(`Splitting ${file} using ${options.config}...`));
372
+
373
+ const config = loadConfig(configPath);
374
+ const paperContent = fs.readFileSync(file, 'utf-8');
375
+ const sections = splitAnnotatedPaper(paperContent, config.sections);
376
+
377
+ if (sections.size === 0) {
378
+ console.error(chalk.yellow('No sections detected.'));
379
+ console.error(chalk.dim('Check that headers match sections.yaml'));
380
+ process.exit(1);
381
+ }
382
+
383
+ console.log(chalk.green(`\nFound ${sections.size} sections:\n`));
384
+
385
+ for (const [sectionFile, content] of sections) {
386
+ const outputPath = path.join(options.dir, sectionFile);
387
+ const lines = content.split('\n').length;
388
+ const annotations = countAnnotations(content);
389
+
390
+ console.log(` ${chalk.bold(sectionFile)} (${lines} lines)`);
391
+ if (annotations.total > 0) {
392
+ const parts = [];
393
+ if (annotations.inserts > 0) parts.push(chalk.green(`+${annotations.inserts}`));
394
+ if (annotations.deletes > 0) parts.push(chalk.red(`-${annotations.deletes}`));
395
+ if (annotations.substitutes > 0) parts.push(chalk.yellow(`~${annotations.substitutes}`));
396
+ if (annotations.comments > 0) parts.push(chalk.blue(`#${annotations.comments}`));
397
+ console.log(chalk.dim(` Annotations: ${parts.join(' ')}`));
398
+ }
399
+
400
+ if (!options.dryRun) {
401
+ fs.writeFileSync(outputPath, content, 'utf-8');
402
+ }
403
+ }
404
+
405
+ if (options.dryRun) {
406
+ console.log(chalk.yellow('\n(Dry run - no files written)'));
407
+ } else {
408
+ console.log(chalk.green('\nSection files updated.'));
409
+ console.log(chalk.cyan('\nNext: rev review <section.md> for each section'));
410
+ }
411
+ });
412
+
413
+ // ==========================================================================
414
+ // SYNC command - Import with section awareness
415
+ // ==========================================================================
416
+
417
+ program
418
+ .command('sync')
419
+ .alias('sections')
420
+ .description('Sync feedback from Word/PDF back to section files')
421
+ .argument('[file]', 'Word (.docx) or PDF file from reviewer (default: most recent)')
422
+ .argument('[sections...]', 'Specific sections to sync (default: all)')
423
+ .option('-c, --config <file>', 'Sections config file', 'sections.yaml')
424
+ .option('-d, --dir <directory>', 'Directory with section files', '.')
425
+ .option('--no-crossref', 'Skip converting hardcoded figure/table refs')
426
+ .option('--no-diff', 'Skip showing diff preview')
427
+ .option('--force', 'Overwrite files without conflict warning')
428
+ .option('--dry-run', 'Preview without writing files')
429
+ .action(async (docx, sections, options) => {
430
+ // Auto-detect most recent docx or pdf if not provided
431
+ if (!docx) {
432
+ const docxFiles = findFiles('.docx');
433
+ const pdfFiles = findFiles('.pdf');
434
+ const allFiles = [...docxFiles, ...pdfFiles];
435
+
436
+ if (allFiles.length === 0) {
437
+ console.error(fmt.status('error', 'No .docx or .pdf files found in current directory.'));
438
+ process.exit(1);
439
+ }
440
+ const sorted = allFiles
441
+ .map(f => ({ name: f, mtime: fs.statSync(f).mtime }))
442
+ .sort((a, b) => b.mtime - a.mtime);
443
+ docx = sorted[0].name;
444
+ console.log(fmt.status('info', `Using most recent: ${docx}`));
445
+ console.log();
446
+ }
447
+
448
+ if (!fs.existsSync(docx)) {
449
+ console.error(fmt.status('error', `File not found: ${docx}`));
450
+ process.exit(1);
451
+ }
452
+
453
+ // Handle PDF files
454
+ if (docx.toLowerCase().endsWith('.pdf')) {
455
+ const { extractPdfComments, formatPdfComments, getPdfCommentStats } = await import('../pdf-import.js');
456
+
457
+ const spin = fmt.spinner(`Extracting comments from ${path.basename(docx)}...`).start();
458
+
459
+ try {
460
+ const comments = await extractPdfComments(docx);
461
+ spin.stop();
462
+
463
+ if (comments.length === 0) {
464
+ console.log(fmt.status('info', 'No comments found in PDF.'));
465
+ return;
466
+ }
467
+
468
+ const stats = getPdfCommentStats(comments);
469
+ console.log(fmt.header(`PDF Comments from ${path.basename(docx)}`));
470
+ console.log();
471
+ console.log(formatPdfComments(comments));
472
+ console.log();
473
+
474
+ const authorList = Object.entries(stats.byAuthor)
475
+ .map(([author, count]) => `${author} (${count})`)
476
+ .join(', ');
477
+ console.log(chalk.dim(`Total: ${stats.total} comments from ${authorList}`));
478
+ console.log();
479
+
480
+ const configPath = path.resolve(options.dir, options.config);
481
+ if (fs.existsSync(configPath) && !options.dryRun) {
482
+ const config = loadConfig(configPath);
483
+ const mainSection = config.sections?.[0];
484
+
485
+ if (mainSection) {
486
+ const mainPath = path.join(options.dir, mainSection);
487
+ if (fs.existsSync(mainPath)) {
488
+ console.log(chalk.dim(`Use 'rev pdf-comments ${docx} --append ${mainSection}' to add comments to markdown.`));
489
+ }
490
+ }
491
+ }
492
+ } catch (err) {
493
+ spin.stop();
494
+ console.error(fmt.status('error', `Failed to extract PDF comments: ${err.message}`));
495
+ if (process.env.DEBUG) console.error(err.stack);
496
+ process.exit(1);
497
+ }
498
+ return;
499
+ }
500
+
501
+ const configPath = path.resolve(options.dir, options.config);
502
+ if (!fs.existsSync(configPath)) {
503
+ console.error(fmt.status('error', `Config not found: ${configPath}`));
504
+ console.error(chalk.dim(' Run "rev init" first to generate sections.yaml'));
505
+ process.exit(1);
506
+ }
507
+
508
+ const spin = fmt.spinner(`Importing ${path.basename(docx)}...`).start();
509
+
510
+ try {
511
+ const config = loadConfig(configPath);
512
+ const mammoth = await import('mammoth');
513
+ const { importFromWord, extractWordComments, extractCommentAnchors, insertCommentsIntoMarkdown } = await import('../import.js');
514
+
515
+ let registry = null;
516
+ let totalRefConversions = 0;
517
+ if (options.crossref !== false) {
518
+ registry = buildRegistry(options.dir);
519
+ }
520
+
521
+ const comments = await extractWordComments(docx);
522
+ const anchors = await extractCommentAnchors(docx);
523
+
524
+ const wordResult = await mammoth.extractRawText({ path: docx });
525
+ const wordText = wordResult.value;
526
+
527
+ let wordSections = extractSectionsFromText(wordText, config.sections);
528
+
529
+ if (wordSections.length === 0) {
530
+ spin.stop();
531
+ console.error(fmt.status('warning', 'No sections detected in Word document.'));
532
+ console.error(chalk.dim(' Check that headings match sections.yaml'));
533
+ process.exit(1);
534
+ }
535
+
536
+ if (sections && sections.length > 0) {
537
+ const onlyList = sections.map(s => s.trim().toLowerCase());
538
+ wordSections = wordSections.filter(section => {
539
+ const fileName = section.file.replace(/\.md$/i, '').toLowerCase();
540
+ const header = section.header.toLowerCase();
541
+ return onlyList.some(name => fileName === name || fileName.includes(name) || header.includes(name));
542
+ });
543
+ if (wordSections.length === 0) {
544
+ spin.stop();
545
+ console.error(fmt.status('error', `No sections matched: ${sections.join(', ')}`));
546
+ console.error(chalk.dim(` Available: ${extractSectionsFromText(wordText, config.sections).map(s => s.file.replace(/\.md$/i, '')).join(', ')}`));
547
+ process.exit(1);
548
+ }
549
+ }
550
+
551
+ spin.stop();
552
+ console.log(fmt.header(`Import from ${path.basename(docx)}`));
553
+ console.log();
554
+
555
+ // Conflict detection
556
+ if (!options.force && !options.dryRun) {
557
+ const conflicts = [];
558
+ for (const section of wordSections) {
559
+ const sectionPath = path.join(options.dir, section.file);
560
+ if (fs.existsSync(sectionPath)) {
561
+ const existing = fs.readFileSync(sectionPath, 'utf-8');
562
+ const existingCounts = countAnnotations(existing);
563
+ if (existingCounts.total > 0) {
564
+ conflicts.push({
565
+ file: section.file,
566
+ annotations: existingCounts.total,
567
+ });
568
+ }
569
+ }
570
+ }
571
+
572
+ if (conflicts.length > 0) {
573
+ console.log(fmt.status('warning', 'Files with existing annotations will be overwritten:'));
574
+ for (const c of conflicts) {
575
+ console.log(chalk.yellow(` - ${c.file} (${c.annotations} annotations)`));
576
+ }
577
+ console.log();
578
+
579
+ const rl = await import('readline');
580
+ const readline = rl.createInterface({
581
+ input: process.stdin,
582
+ output: process.stdout,
583
+ });
584
+
585
+ const answer = await new Promise((resolve) =>
586
+ readline.question(chalk.cyan('Continue and overwrite? [y/N] '), resolve)
587
+ );
588
+ readline.close();
589
+
590
+ if (answer.toLowerCase() !== 'y') {
591
+ console.log(chalk.dim('Aborted. Use --force to skip this check.'));
592
+ process.exit(0);
593
+ }
594
+ console.log();
595
+ }
596
+ }
597
+
598
+ const sectionResults = [];
599
+ let totalChanges = 0;
600
+
601
+ for (const section of wordSections) {
602
+ const sectionPath = path.join(options.dir, section.file);
603
+
604
+ if (!fs.existsSync(sectionPath)) {
605
+ sectionResults.push({
606
+ file: section.file,
607
+ header: section.header,
608
+ status: 'skipped',
609
+ stats: null,
610
+ });
611
+ continue;
612
+ }
613
+
614
+ const result = await importFromWord(docx, sectionPath, {
615
+ sectionContent: section.content,
616
+ author: 'Reviewer',
617
+ });
618
+
619
+ let { annotated, stats } = result;
620
+
621
+ let refConversions = [];
622
+ if (registry && options.crossref !== false) {
623
+ const crossrefResult = convertHardcodedRefs(annotated, registry);
624
+ annotated = crossrefResult.converted;
625
+ refConversions = crossrefResult.conversions;
626
+ totalRefConversions += refConversions.length;
627
+ }
628
+
629
+ let commentsInserted = 0;
630
+ if (comments.length > 0 && anchors.size > 0) {
631
+ annotated = insertCommentsIntoMarkdown(annotated, comments, anchors, { quiet: true });
632
+ commentsInserted = (annotated.match(/\{>>/g) || []).length - (result.annotated?.match(/\{>>/g) || []).length;
633
+ if (commentsInserted > 0) {
634
+ stats.comments = (stats.comments || 0) + commentsInserted;
635
+ }
636
+ }
637
+
638
+ totalChanges += stats.total;
639
+
640
+ sectionResults.push({
641
+ file: section.file,
642
+ header: section.header,
643
+ status: 'ok',
644
+ stats,
645
+ refs: refConversions.length,
646
+ });
647
+
648
+ if (!options.dryRun && (stats.total > 0 || refConversions.length > 0)) {
649
+ fs.writeFileSync(sectionPath, annotated, 'utf-8');
650
+ }
651
+ }
652
+
653
+ const tableRows = sectionResults.map((r) => {
654
+ if (r.status === 'skipped') {
655
+ return [
656
+ chalk.dim(r.file),
657
+ chalk.dim(r.header.slice(0, 25)),
658
+ chalk.yellow('skipped'),
659
+ '',
660
+ '',
661
+ '',
662
+ '',
663
+ ];
664
+ }
665
+ const s = r.stats;
666
+ return [
667
+ chalk.bold(r.file),
668
+ r.header.length > 25 ? r.header.slice(0, 22) + '...' : r.header,
669
+ s.insertions > 0 ? chalk.green(`+${s.insertions}`) : chalk.dim('-'),
670
+ s.deletions > 0 ? chalk.red(`-${s.deletions}`) : chalk.dim('-'),
671
+ s.substitutions > 0 ? chalk.yellow(`~${s.substitutions}`) : chalk.dim('-'),
672
+ s.comments > 0 ? chalk.blue(`#${s.comments}`) : chalk.dim('-'),
673
+ r.refs > 0 ? chalk.magenta(`@${r.refs}`) : chalk.dim('-'),
674
+ ];
675
+ });
676
+
677
+ console.log(fmt.table(
678
+ ['File', 'Section', 'Ins', 'Del', 'Sub', 'Cmt', 'Ref'],
679
+ tableRows,
680
+ { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] }
681
+ ));
682
+ console.log();
683
+
684
+ if (options.diff !== false && totalChanges > 0) {
685
+ console.log(fmt.header('Changes Preview'));
686
+ console.log();
687
+ for (const result of sectionResults) {
688
+ if (result.status === 'ok' && result.stats && result.stats.total > 0) {
689
+ const sectionPath = path.join(options.dir, result.file);
690
+ if (fs.existsSync(sectionPath)) {
691
+ const content = fs.readFileSync(sectionPath, 'utf-8');
692
+ const preview = inlineDiffPreview(content, { maxLines: 3 });
693
+ if (preview) {
694
+ console.log(chalk.bold(result.file) + ':');
695
+ console.log(preview);
696
+ console.log();
697
+ }
698
+ }
699
+ }
700
+ }
701
+ }
702
+
703
+ if (options.dryRun) {
704
+ console.log(fmt.box(chalk.yellow('Dry run - no files written'), { padding: 0 }));
705
+ } else if (totalChanges > 0 || totalRefConversions > 0 || comments.length > 0) {
706
+ const summaryLines = [];
707
+ summaryLines.push(`${chalk.bold(wordSections.length)} sections processed`);
708
+ if (totalChanges > 0) summaryLines.push(`${chalk.bold(totalChanges)} annotations imported`);
709
+ if (comments.length > 0) summaryLines.push(`${chalk.bold(comments.length)} comments placed`);
710
+ if (totalRefConversions > 0) summaryLines.push(`${chalk.bold(totalRefConversions)} refs converted to @-syntax`);
711
+
712
+ console.log(fmt.box(summaryLines.join('\n'), { title: 'Summary', padding: 0 }));
713
+ console.log();
714
+ console.log(chalk.dim('Next steps:'));
715
+ console.log(chalk.dim(' 1. rev review <section.md> - Accept/reject changes'));
716
+ console.log(chalk.dim(' 2. rev comments <section.md> - View/address comments'));
717
+ console.log(chalk.dim(' 3. rev build docx - Rebuild Word doc'));
718
+ } else {
719
+ console.log(fmt.status('success', 'No changes detected.'));
720
+ }
721
+ } catch (err) {
722
+ spin.stop();
723
+ console.error(fmt.status('error', err.message));
724
+ if (process.env.DEBUG) console.error(err.stack);
725
+ process.exit(1);
726
+ }
727
+ });
728
+
729
+ // ==========================================================================
730
+ // MERGE command - Combine feedback from multiple reviewers
731
+ // ==========================================================================
732
+
733
+ program
734
+ .command('merge')
735
+ .description('Merge feedback from multiple Word documents')
736
+ .argument('<original>', 'Original markdown file')
737
+ .argument('<docx...>', 'Word documents from reviewers')
738
+ .option('-o, --output <file>', 'Output file (default: original-merged.md)')
739
+ .option('--names <names>', 'Reviewer names (comma-separated, in order of docx files)')
740
+ .option('--auto', 'Auto-resolve conflicts by taking first change')
741
+ .option('--dry-run', 'Show conflicts without writing')
742
+ .action(async (original, docxFiles, options) => {
743
+ const { mergeReviewerDocs, formatConflict, resolveConflict } = await import('../merge.js');
744
+
745
+ if (!fs.existsSync(original)) {
746
+ console.error(fmt.status('error', `Original file not found: ${original}`));
747
+ process.exit(1);
748
+ }
749
+
750
+ for (const docx of docxFiles) {
751
+ if (!fs.existsSync(docx)) {
752
+ console.error(fmt.status('error', `Reviewer file not found: ${docx}`));
753
+ process.exit(1);
754
+ }
755
+ }
756
+
757
+ const names = options.names
758
+ ? options.names.split(',').map(n => n.trim())
759
+ : docxFiles.map((f, i) => `Reviewer ${i + 1}`);
760
+
761
+ if (names.length < docxFiles.length) {
762
+ for (let i = names.length; i < docxFiles.length; i++) {
763
+ names.push(`Reviewer ${i + 1}`);
764
+ }
765
+ }
766
+
767
+ const reviewerDocs = docxFiles.map((p, i) => ({
768
+ path: p,
769
+ name: names[i],
770
+ }));
771
+
772
+ console.log(fmt.header('Multi-Reviewer Merge'));
773
+ console.log();
774
+ console.log(chalk.dim(` Original: ${original}`));
775
+ console.log(chalk.dim(` Reviewers: ${names.join(', ')}`));
776
+ console.log();
777
+
778
+ const spin = fmt.spinner('Analyzing changes...').start();
779
+
780
+ try {
781
+ const { merged, conflicts, stats, originalText } = await mergeReviewerDocs(original, reviewerDocs, {
782
+ autoResolve: options.auto,
783
+ });
784
+
785
+ spin.stop();
786
+
787
+ console.log(fmt.table(['Metric', 'Count'], [
788
+ ['Total changes', stats.totalChanges.toString()],
789
+ ['Non-conflicting', stats.nonConflicting.toString()],
790
+ ['Conflicts', stats.conflicts.toString()],
791
+ ['Comments', stats.comments.toString()],
792
+ ]));
793
+ console.log();
794
+
795
+ if (conflicts.length > 0) {
796
+ console.log(chalk.yellow(`Found ${conflicts.length} conflict(s):\n`));
797
+
798
+ let resolvedMerged = merged;
799
+
800
+ for (let i = 0; i < conflicts.length; i++) {
801
+ const conflict = conflicts[i];
802
+ console.log(chalk.bold(`Conflict ${i + 1}/${conflicts.length}:`));
803
+ console.log(formatConflict(conflict, originalText));
804
+ console.log();
805
+
806
+ if (options.auto) {
807
+ console.log(chalk.dim(` Auto-resolved: using ${conflict.changes[0].reviewer}'s change`));
808
+ resolvedMerged = resolveConflict(resolvedMerged, conflict, 0, originalText);
809
+ } else if (!options.dryRun) {
810
+ const rl = await import('readline');
811
+ const readline = rl.createInterface({
812
+ input: process.stdin,
813
+ output: process.stdout,
814
+ });
815
+
816
+ const answer = await new Promise((resolve) =>
817
+ readline.question(chalk.cyan(` Choose (1-${conflict.changes.length}, s=skip): `), resolve)
818
+ );
819
+ readline.close();
820
+
821
+ if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
822
+ const choice = parseInt(answer) - 1;
823
+ if (choice >= 0 && choice < conflict.changes.length) {
824
+ resolvedMerged = resolveConflict(resolvedMerged, conflict, choice, originalText);
825
+ console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
826
+ }
827
+ } else {
828
+ console.log(chalk.dim(' Skipped'));
829
+ }
830
+ console.log();
831
+ }
832
+ }
833
+
834
+ if (!options.dryRun) {
835
+ const outPath = options.output || original.replace(/\.md$/, '-merged.md');
836
+ fs.writeFileSync(outPath, resolvedMerged, 'utf-8');
837
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
838
+ }
839
+ } else {
840
+ if (!options.dryRun) {
841
+ const outPath = options.output || original.replace(/\.md$/, '-merged.md');
842
+ fs.writeFileSync(outPath, merged, 'utf-8');
843
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
844
+ } else {
845
+ console.log(fmt.status('info', 'Dry run - no output written'));
846
+ }
847
+ }
848
+
849
+ if (!options.dryRun && stats.nonConflicting > 0) {
850
+ console.log();
851
+ console.log(chalk.dim('Next steps:'));
852
+ console.log(chalk.dim(' 1. rev review <merged.md> - Review all changes'));
853
+ console.log(chalk.dim(' 2. rev comments <merged.md> - Address comments'));
854
+ }
855
+ } catch (err) {
856
+ spin.stop();
857
+ console.error(fmt.status('error', err.message));
858
+ if (process.env.DEBUG) console.error(err.stack);
859
+ process.exit(1);
860
+ }
861
+ });
862
+ }