docrev 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/rev.js ADDED
@@ -0,0 +1,2645 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * rev - Revision workflow for Word ↔ Markdown round-trips
5
+ *
6
+ * Handles track changes and comments when collaborating on academic papers.
7
+ * Preserves reviewer feedback through the Markdown editing workflow.
8
+ */
9
+
10
+ import { program } from 'commander';
11
+ import chalk from 'chalk';
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import {
15
+ parseAnnotations,
16
+ stripAnnotations,
17
+ countAnnotations,
18
+ getComments,
19
+ setCommentStatus,
20
+ } from '../lib/annotations.js';
21
+ import { interactiveReview, listComments } from '../lib/review.js';
22
+ import {
23
+ generateConfig,
24
+ loadConfig,
25
+ saveConfig,
26
+ matchHeading,
27
+ extractSectionsFromText,
28
+ splitAnnotatedPaper,
29
+ getOrderedSections,
30
+ } from '../lib/sections.js';
31
+ import {
32
+ buildRegistry,
33
+ detectHardcodedRefs,
34
+ convertHardcodedRefs,
35
+ getRefStatus,
36
+ formatRegistry,
37
+ } from '../lib/crossref.js';
38
+ import {
39
+ build,
40
+ loadConfig as loadBuildConfig,
41
+ hasPandoc,
42
+ hasPandocCrossref,
43
+ formatBuildResults,
44
+ } from '../lib/build.js';
45
+ import { getTemplate, listTemplates } from '../lib/templates.js';
46
+ import { getUserName, setUserName, getConfigPath } from '../lib/config.js';
47
+ import * as fmt from '../lib/format.js';
48
+ import { inlineDiffPreview } from '../lib/format.js';
49
+ import { parseCommentsWithReplies, collectComments, generateResponseLetter, groupByReviewer } from '../lib/response.js';
50
+ import { validateCitations, getCitationStats } from '../lib/citations.js';
51
+ import { extractEquations, getEquationStats, createEquationsDoc } from '../lib/equations.js';
52
+ import { parseBibEntries, checkBibDois, fetchBibtex, addToBib, isValidDoiFormat, lookupDoi, lookupMissingDois } from '../lib/doi.js';
53
+
54
+ program
55
+ .name('rev')
56
+ .description('Revision workflow for Word ↔ Markdown round-trips')
57
+ .version('0.2.0');
58
+
59
+ // ============================================================================
60
+ // REVIEW command - Interactive track change review
61
+ // ============================================================================
62
+
63
+ program
64
+ .command('review')
65
+ .description('Interactively review and accept/reject track changes')
66
+ .argument('<file>', 'Markdown file to review')
67
+ .action(async (file) => {
68
+ if (!fs.existsSync(file)) {
69
+ console.error(chalk.red(`Error: File not found: ${file}`));
70
+ process.exit(1);
71
+ }
72
+
73
+ const text = fs.readFileSync(file, 'utf-8');
74
+ const result = await interactiveReview(text);
75
+
76
+ if (result.accepted > 0 || result.rejected > 0) {
77
+ // Confirm save
78
+ const rl = await import('readline');
79
+ const readline = rl.createInterface({
80
+ input: process.stdin,
81
+ output: process.stdout,
82
+ });
83
+
84
+ readline.question(chalk.cyan(`\nSave changes to ${file}? [y/N] `), (answer) => {
85
+ readline.close();
86
+ if (answer.toLowerCase() === 'y') {
87
+ fs.writeFileSync(file, result.text, 'utf-8');
88
+ console.log(chalk.green(`Saved ${file}`));
89
+ } else {
90
+ console.log(chalk.yellow('Changes not saved.'));
91
+ }
92
+ });
93
+ }
94
+ });
95
+
96
+ // ============================================================================
97
+ // STRIP command - Remove annotations
98
+ // ============================================================================
99
+
100
+ program
101
+ .command('strip')
102
+ .description('Strip annotations, outputting clean Markdown')
103
+ .argument('<file>', 'Markdown file to strip')
104
+ .option('-o, --output <file>', 'Output file (default: stdout)')
105
+ .option('-c, --keep-comments', 'Keep comment annotations')
106
+ .action((file, options) => {
107
+ if (!fs.existsSync(file)) {
108
+ console.error(chalk.red(`Error: File not found: ${file}`));
109
+ process.exit(1);
110
+ }
111
+
112
+ const text = fs.readFileSync(file, 'utf-8');
113
+ const clean = stripAnnotations(text, { keepComments: options.keepComments });
114
+
115
+ if (options.output) {
116
+ fs.writeFileSync(options.output, clean, 'utf-8');
117
+ console.error(chalk.green(`Written to ${options.output}`));
118
+ } else {
119
+ process.stdout.write(clean);
120
+ }
121
+ });
122
+
123
+ // ============================================================================
124
+ // STATUS command - Show annotation statistics
125
+ // ============================================================================
126
+
127
+ program
128
+ .command('status')
129
+ .description('Show annotation statistics')
130
+ .argument('<file>', 'Markdown file to analyze')
131
+ .action((file) => {
132
+ if (!fs.existsSync(file)) {
133
+ console.error(chalk.red(`Error: File not found: ${file}`));
134
+ process.exit(1);
135
+ }
136
+
137
+ const text = fs.readFileSync(file, 'utf-8');
138
+ const counts = countAnnotations(text);
139
+
140
+ if (counts.total === 0) {
141
+ console.log(fmt.status('success', 'No annotations found.'));
142
+ return;
143
+ }
144
+
145
+ console.log(fmt.header(`Annotations in ${path.basename(file)}`));
146
+ console.log();
147
+
148
+ // Build stats table
149
+ const rows = [];
150
+ if (counts.inserts > 0) rows.push([chalk.green('+'), 'Insertions', chalk.green(counts.inserts)]);
151
+ if (counts.deletes > 0) rows.push([chalk.red('-'), 'Deletions', chalk.red(counts.deletes)]);
152
+ if (counts.substitutes > 0) rows.push([chalk.yellow('~'), 'Substitutions', chalk.yellow(counts.substitutes)]);
153
+ if (counts.comments > 0) rows.push([chalk.blue('#'), 'Comments', chalk.blue(counts.comments)]);
154
+ rows.push([chalk.dim('Σ'), chalk.dim('Total'), chalk.dim(counts.total)]);
155
+
156
+ console.log(fmt.table(['', 'Type', 'Count'], rows, { align: ['center', 'left', 'right'] }));
157
+
158
+ // List comments with authors in a table
159
+ const comments = getComments(text);
160
+ if (comments.length > 0) {
161
+ console.log();
162
+ console.log(fmt.header('Comments'));
163
+ console.log();
164
+
165
+ const commentRows = comments.map((c, i) => [
166
+ chalk.dim(i + 1),
167
+ c.author ? chalk.blue(c.author) : chalk.dim('Anonymous'),
168
+ c.content.length > 45 ? c.content.slice(0, 45) + '...' : c.content,
169
+ chalk.dim(`L${c.line}`),
170
+ ]);
171
+
172
+ console.log(fmt.table(['#', 'Author', 'Comment', 'Line'], commentRows, {
173
+ align: ['right', 'left', 'left', 'right'],
174
+ }));
175
+ }
176
+ });
177
+
178
+ // ============================================================================
179
+ // COMMENTS command - List all comments
180
+ // ============================================================================
181
+
182
+ program
183
+ .command('comments')
184
+ .description('List all comments in the document')
185
+ .argument('<file>', 'Markdown file')
186
+ .option('-p, --pending', 'Show only pending (unresolved) comments')
187
+ .option('-r, --resolved', 'Show only resolved comments')
188
+ .action((file, options) => {
189
+ if (!fs.existsSync(file)) {
190
+ console.error(chalk.red(`Error: File not found: ${file}`));
191
+ process.exit(1);
192
+ }
193
+
194
+ const text = fs.readFileSync(file, 'utf-8');
195
+ const comments = getComments(text, {
196
+ pendingOnly: options.pending,
197
+ resolvedOnly: options.resolved,
198
+ });
199
+
200
+ if (comments.length === 0) {
201
+ if (options.pending) {
202
+ console.log(fmt.status('success', 'No pending comments'));
203
+ } else if (options.resolved) {
204
+ console.log(fmt.status('info', 'No resolved comments'));
205
+ } else {
206
+ console.log(fmt.status('info', 'No comments found'));
207
+ }
208
+ return;
209
+ }
210
+
211
+ const filter = options.pending ? ' (pending)' : options.resolved ? ' (resolved)' : '';
212
+ console.log(fmt.header(`Comments in ${path.basename(file)}${filter}`));
213
+ console.log();
214
+
215
+ for (let i = 0; i < comments.length; i++) {
216
+ const c = comments[i];
217
+ const statusIcon = c.resolved ? chalk.green('✓') : chalk.yellow('○');
218
+ const authorLabel = c.author ? chalk.blue(`[${c.author}]`) : chalk.dim('[Anonymous]');
219
+ const preview = c.content.length > 60 ? c.content.slice(0, 60) + '...' : c.content;
220
+
221
+ console.log(` ${chalk.bold(`#${i + 1}`)} ${statusIcon} ${authorLabel} ${chalk.dim(`L${c.line}`)}`);
222
+ console.log(` ${preview}`);
223
+ if (c.before) {
224
+ console.log(chalk.dim(` "${c.before.trim().slice(-40)}..."`));
225
+ }
226
+ console.log();
227
+ }
228
+
229
+ // Summary
230
+ const allComments = getComments(text);
231
+ const pending = allComments.filter((c) => !c.resolved).length;
232
+ const resolved = allComments.filter((c) => c.resolved).length;
233
+ console.log(chalk.dim(` Total: ${allComments.length} | Pending: ${pending} | Resolved: ${resolved}`));
234
+ });
235
+
236
+ // ============================================================================
237
+ // RESOLVE command - Mark comments as resolved/pending
238
+ // ============================================================================
239
+
240
+ program
241
+ .command('resolve')
242
+ .description('Mark comments as resolved or pending')
243
+ .argument('<file>', 'Markdown file')
244
+ .option('-n, --number <n>', 'Comment number to toggle', parseInt)
245
+ .option('-a, --all', 'Mark all comments as resolved')
246
+ .option('-u, --unresolve', 'Mark as pending (unresolve)')
247
+ .action((file, options) => {
248
+ if (!fs.existsSync(file)) {
249
+ console.error(chalk.red(`Error: File not found: ${file}`));
250
+ process.exit(1);
251
+ }
252
+
253
+ let text = fs.readFileSync(file, 'utf-8');
254
+ const comments = getComments(text);
255
+
256
+ if (comments.length === 0) {
257
+ console.log(fmt.status('info', 'No comments found'));
258
+ return;
259
+ }
260
+
261
+ const resolveStatus = !options.unresolve;
262
+
263
+ if (options.all) {
264
+ // Mark all comments
265
+ let count = 0;
266
+ for (const comment of comments) {
267
+ if (comment.resolved !== resolveStatus) {
268
+ text = setCommentStatus(text, comment, resolveStatus);
269
+ count++;
270
+ }
271
+ }
272
+ fs.writeFileSync(file, text, 'utf-8');
273
+ console.log(fmt.status('success', `Marked ${count} comment(s) as ${resolveStatus ? 'resolved' : 'pending'}`));
274
+ return;
275
+ }
276
+
277
+ if (options.number !== undefined) {
278
+ const idx = options.number - 1;
279
+ if (idx < 0 || idx >= comments.length) {
280
+ console.error(chalk.red(`Invalid comment number. File has ${comments.length} comments.`));
281
+ process.exit(1);
282
+ }
283
+ const comment = comments[idx];
284
+ text = setCommentStatus(text, comment, resolveStatus);
285
+ fs.writeFileSync(file, text, 'utf-8');
286
+ console.log(fmt.status('success', `Comment #${options.number} marked as ${resolveStatus ? 'resolved' : 'pending'}`));
287
+ return;
288
+ }
289
+
290
+ // No options: show current status
291
+ console.log(fmt.header(`Comment Status in ${path.basename(file)}`));
292
+ console.log();
293
+
294
+ for (let i = 0; i < comments.length; i++) {
295
+ const c = comments[i];
296
+ const statusIcon = c.resolved ? chalk.green('✓') : chalk.yellow('○');
297
+ const preview = c.content.length > 50 ? c.content.slice(0, 50) + '...' : c.content;
298
+ console.log(` ${statusIcon} #${i + 1} ${preview}`);
299
+ }
300
+
301
+ console.log();
302
+ const pending = comments.filter((c) => !c.resolved).length;
303
+ const resolved = comments.filter((c) => c.resolved).length;
304
+ console.log(chalk.dim(` Pending: ${pending} | Resolved: ${resolved}`));
305
+ console.log();
306
+ console.log(chalk.dim(' Usage: rev resolve <file> -n <number> Mark specific comment'));
307
+ console.log(chalk.dim(' rev resolve <file> -a Mark all as resolved'));
308
+ console.log(chalk.dim(' rev resolve <file> -n 1 -u Unresolve comment #1'));
309
+ });
310
+
311
+ // ============================================================================
312
+ // IMPORT command - Import from Word (bootstrap or diff mode)
313
+ // ============================================================================
314
+
315
+ program
316
+ .command('import')
317
+ .description('Import from Word: creates sections from scratch, or diffs against existing MD')
318
+ .argument('<docx>', 'Word document')
319
+ .argument('[original]', 'Optional: original Markdown file to compare against')
320
+ .option('-o, --output <dir>', 'Output directory for bootstrap mode', '.')
321
+ .option('-a, --author <name>', 'Author name for changes (diff mode)', 'Reviewer')
322
+ .option('--dry-run', 'Preview without saving')
323
+ .action(async (docx, original, options) => {
324
+ if (!fs.existsSync(docx)) {
325
+ console.error(chalk.red(`Error: Word file not found: ${docx}`));
326
+ process.exit(1);
327
+ }
328
+
329
+ // If no original provided, bootstrap mode: create sections from Word
330
+ if (!original) {
331
+ await bootstrapFromWord(docx, options);
332
+ return;
333
+ }
334
+
335
+ // Diff mode: compare against original
336
+ if (!fs.existsSync(original)) {
337
+ console.error(chalk.red(`Error: Original MD not found: ${original}`));
338
+ process.exit(1);
339
+ }
340
+
341
+ console.log(chalk.cyan(`Comparing ${path.basename(docx)} against ${path.basename(original)}...`));
342
+
343
+ try {
344
+ const { importFromWord } = await import('../lib/import.js');
345
+ const { annotated, stats } = await importFromWord(docx, original, {
346
+ author: options.author,
347
+ });
348
+
349
+ // Show stats
350
+ console.log(chalk.cyan('\nChanges detected:'));
351
+ if (stats.insertions > 0) console.log(chalk.green(` + Insertions: ${stats.insertions}`));
352
+ if (stats.deletions > 0) console.log(chalk.red(` - Deletions: ${stats.deletions}`));
353
+ if (stats.substitutions > 0) console.log(chalk.yellow(` ~ Substitutions: ${stats.substitutions}`));
354
+ if (stats.comments > 0) console.log(chalk.blue(` # Comments: ${stats.comments}`));
355
+
356
+ if (stats.total === 0) {
357
+ console.log(chalk.green('\nNo changes detected.'));
358
+ return;
359
+ }
360
+
361
+ console.log(chalk.dim(`\n Total: ${stats.total}`));
362
+
363
+ if (options.dryRun) {
364
+ console.log(chalk.cyan('\n--- Preview (first 1000 chars) ---\n'));
365
+ console.log(annotated.slice(0, 1000));
366
+ if (annotated.length > 1000) console.log(chalk.dim('\n... (truncated)'));
367
+ return;
368
+ }
369
+
370
+ // Save
371
+ const outputPath = options.output || original;
372
+ fs.writeFileSync(outputPath, annotated, 'utf-8');
373
+ console.log(chalk.green(`\nSaved annotated version to ${outputPath}`));
374
+ console.log(chalk.cyan('\nNext steps:'));
375
+ console.log(` 1. ${chalk.bold('rev review ' + outputPath)} - Accept/reject track changes`);
376
+ console.log(` 2. Work with Claude to address comments`);
377
+ console.log(` 3. ${chalk.bold('rev build docx')} - Rebuild Word doc`);
378
+
379
+ } catch (err) {
380
+ console.error(chalk.red(`Error: ${err.message}`));
381
+ if (process.env.DEBUG) console.error(err.stack);
382
+ process.exit(1);
383
+ }
384
+ });
385
+
386
+ /**
387
+ * Bootstrap a new project from a Word document
388
+ * Creates section files and rev.yaml
389
+ */
390
+ async function bootstrapFromWord(docx, options) {
391
+ const outputDir = path.resolve(options.output);
392
+
393
+ console.log(chalk.cyan(`Bootstrapping project from ${path.basename(docx)}...\n`));
394
+
395
+ try {
396
+ const mammoth = await import('mammoth');
397
+ const yaml = (await import('js-yaml')).default;
398
+
399
+ // Extract text from Word
400
+ const result = await mammoth.extractRawText({ path: docx });
401
+ const text = result.value;
402
+
403
+ // Detect sections by finding headers (lines that look like section titles)
404
+ const sections = detectSectionsFromWord(text);
405
+
406
+ if (sections.length === 0) {
407
+ console.error(chalk.yellow('No sections detected. Creating single content.md file.'));
408
+ sections.push({ header: 'Content', content: text, file: 'content.md' });
409
+ }
410
+
411
+ console.log(chalk.green(`Detected ${sections.length} section(s):\n`));
412
+
413
+ // Create output directory if needed
414
+ if (!fs.existsSync(outputDir)) {
415
+ fs.mkdirSync(outputDir, { recursive: true });
416
+ }
417
+
418
+ // Write section files
419
+ const sectionFiles = [];
420
+ for (const section of sections) {
421
+ const filePath = path.join(outputDir, section.file);
422
+ const content = `# ${section.header}\n\n${section.content.trim()}\n`;
423
+
424
+ console.log(` ${chalk.bold(section.file)} - "${section.header}" (${section.content.split('\n').length} lines)`);
425
+
426
+ if (!options.dryRun) {
427
+ fs.writeFileSync(filePath, content, 'utf-8');
428
+ }
429
+ sectionFiles.push(section.file);
430
+ }
431
+
432
+ // Extract title from first line or filename
433
+ const docxName = path.basename(docx, '.docx');
434
+ const title = docxName.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
435
+
436
+ // Create rev.yaml
437
+ const config = {
438
+ title: title,
439
+ authors: [],
440
+ sections: sectionFiles,
441
+ bibliography: null,
442
+ crossref: {
443
+ figureTitle: 'Figure',
444
+ tableTitle: 'Table',
445
+ figPrefix: ['Fig.', 'Figs.'],
446
+ tblPrefix: ['Table', 'Tables'],
447
+ },
448
+ pdf: {
449
+ documentclass: 'article',
450
+ fontsize: '12pt',
451
+ geometry: 'margin=1in',
452
+ linestretch: 1.5,
453
+ },
454
+ docx: {
455
+ keepComments: true,
456
+ },
457
+ };
458
+
459
+ const configPath = path.join(outputDir, 'rev.yaml');
460
+ console.log(`\n ${chalk.bold('rev.yaml')} - project configuration`);
461
+
462
+ if (!options.dryRun) {
463
+ fs.writeFileSync(configPath, yaml.dump(config), 'utf-8');
464
+ }
465
+
466
+ // Create figures directory
467
+ const figuresDir = path.join(outputDir, 'figures');
468
+ if (!fs.existsSync(figuresDir) && !options.dryRun) {
469
+ fs.mkdirSync(figuresDir, { recursive: true });
470
+ console.log(` ${chalk.dim('figures/')} - image directory`);
471
+ }
472
+
473
+ if (options.dryRun) {
474
+ console.log(chalk.yellow('\n(Dry run - no files written)'));
475
+ } else {
476
+ console.log(chalk.green('\nProject created!'));
477
+ console.log(chalk.cyan('\nNext steps:'));
478
+ if (outputDir !== process.cwd()) {
479
+ console.log(chalk.dim(` cd ${path.relative(process.cwd(), outputDir) || '.'}`));
480
+ }
481
+ console.log(chalk.dim(' # Edit rev.yaml to add authors and adjust settings'));
482
+ console.log(chalk.dim(' # Review and clean up section files'));
483
+ console.log(chalk.dim(' rev build # Build PDF and DOCX'));
484
+ }
485
+ } catch (err) {
486
+ console.error(chalk.red(`Error: ${err.message}`));
487
+ if (process.env.DEBUG) console.error(err.stack);
488
+ process.exit(1);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Detect sections from Word document text
494
+ * Looks for common academic paper section headers
495
+ * Conservative: only detects well-known section names to avoid false positives
496
+ */
497
+ function detectSectionsFromWord(text) {
498
+ const lines = text.split('\n');
499
+ const sections = [];
500
+
501
+ // Only detect well-known academic section headers (conservative)
502
+ const headerPatterns = [
503
+ /^(Abstract|Summary)$/i,
504
+ /^(Introduction|Background)$/i,
505
+ /^(Methods?|Materials?\s*(and|&)\s*Methods?|Methodology|Experimental\s*Methods?)$/i,
506
+ /^(Results?)$/i,
507
+ /^(Results?\s*(and|&)\s*Discussion)$/i,
508
+ /^(Discussion)$/i,
509
+ /^(Conclusions?|Summary\s*(and|&)?\s*Conclusions?)$/i,
510
+ /^(Acknowledgements?|Acknowledgments?)$/i,
511
+ /^(References|Bibliography|Literature\s*Cited|Works\s*Cited)$/i,
512
+ /^(Appendix|Appendices|Supplementary\s*(Materials?|Information)?|Supporting\s*Information)$/i,
513
+ /^(Literature\s*Review|Related\s*Work|Previous\s*Work)$/i,
514
+ /^(Study\s*Area|Study\s*Site|Site\s*Description)$/i,
515
+ /^(Data\s*Analysis|Statistical\s*Analysis|Data\s*Collection)$/i,
516
+ /^(Theoretical\s*Framework|Conceptual\s*Framework)$/i,
517
+ /^(Case\s*Study|Case\s*Studies)$/i,
518
+ /^(Limitations?)$/i,
519
+ /^(Future\s*Work|Future\s*Directions?)$/i,
520
+ /^(Funding|Author\s*Contributions?|Conflict\s*of\s*Interest|Data\s*Availability)$/i,
521
+ ];
522
+
523
+ // Numbered sections: "1. Introduction", "2. Methods", etc.
524
+ // Must have a number followed by a known section word
525
+ const numberedHeaderPattern = /^(\d+\.?\s+)(Abstract|Introduction|Background|Methods?|Materials|Results?|Discussion|Conclusions?|References|Acknowledgements?|Appendix)/i;
526
+
527
+ let currentSection = null;
528
+ let currentContent = [];
529
+ let preambleContent = []; // Content before first header (title, authors, etc.)
530
+
531
+ for (const line of lines) {
532
+ const trimmed = line.trim();
533
+ if (!trimmed) {
534
+ // Keep empty lines
535
+ if (currentSection) {
536
+ currentContent.push(line);
537
+ } else {
538
+ preambleContent.push(line);
539
+ }
540
+ continue;
541
+ }
542
+
543
+ // Check if this line is a section header
544
+ let isHeader = false;
545
+ let headerText = trimmed;
546
+
547
+ // Check against known patterns
548
+ for (const pattern of headerPatterns) {
549
+ if (pattern.test(trimmed)) {
550
+ isHeader = true;
551
+ break;
552
+ }
553
+ }
554
+
555
+ // Check numbered pattern (e.g., "1. Introduction")
556
+ if (!isHeader) {
557
+ const match = trimmed.match(numberedHeaderPattern);
558
+ if (match) {
559
+ isHeader = true;
560
+ // Remove the number prefix for the header text
561
+ headerText = trimmed.replace(/^\d+\.?\s+/, '');
562
+ }
563
+ }
564
+
565
+ if (isHeader) {
566
+ // Save previous section
567
+ if (currentSection) {
568
+ sections.push({
569
+ header: currentSection,
570
+ content: currentContent.join('\n'),
571
+ file: headerToFilename(currentSection),
572
+ });
573
+ } else if (preambleContent.some(l => l.trim())) {
574
+ // Save preamble as "preamble" section (title, authors, etc.)
575
+ sections.push({
576
+ header: 'Preamble',
577
+ content: preambleContent.join('\n'),
578
+ file: 'preamble.md',
579
+ });
580
+ }
581
+ currentSection = headerText;
582
+ currentContent = [];
583
+ } else if (currentSection) {
584
+ currentContent.push(line);
585
+ } else {
586
+ preambleContent.push(line);
587
+ }
588
+ }
589
+
590
+ // Save last section
591
+ if (currentSection) {
592
+ sections.push({
593
+ header: currentSection,
594
+ content: currentContent.join('\n'),
595
+ file: headerToFilename(currentSection),
596
+ });
597
+ }
598
+
599
+ // If no sections detected, create single content file
600
+ if (sections.length === 0) {
601
+ const allContent = [...preambleContent, ...currentContent].join('\n');
602
+ if (allContent.trim()) {
603
+ sections.push({
604
+ header: 'Content',
605
+ content: allContent,
606
+ file: 'content.md',
607
+ });
608
+ }
609
+ }
610
+
611
+ return sections;
612
+ }
613
+
614
+ /**
615
+ * Convert a section header to a filename
616
+ */
617
+ function headerToFilename(header) {
618
+ return header
619
+ .toLowerCase()
620
+ .replace(/[^a-z0-9]+/g, '-')
621
+ .replace(/^-|-$/g, '')
622
+ .slice(0, 30) + '.md';
623
+ }
624
+
625
+ // ============================================================================
626
+ // EXTRACT command - Just extract text from Word (simple mode)
627
+ // ============================================================================
628
+
629
+ program
630
+ .command('extract')
631
+ .description('Extract plain text from Word document (no diff)')
632
+ .argument('<docx>', 'Word document')
633
+ .option('-o, --output <file>', 'Output file (default: stdout)')
634
+ .action(async (docx, options) => {
635
+ if (!fs.existsSync(docx)) {
636
+ console.error(chalk.red(`Error: File not found: ${docx}`));
637
+ process.exit(1);
638
+ }
639
+
640
+ try {
641
+ const mammoth = await import('mammoth');
642
+ const result = await mammoth.extractRawText({ path: docx });
643
+
644
+ if (options.output) {
645
+ fs.writeFileSync(options.output, result.value, 'utf-8');
646
+ console.error(chalk.green(`Extracted to ${options.output}`));
647
+ } else {
648
+ process.stdout.write(result.value);
649
+ }
650
+ } catch (err) {
651
+ console.error(chalk.red(`Error: ${err.message}`));
652
+ process.exit(1);
653
+ }
654
+ });
655
+
656
+ // ============================================================================
657
+ // INIT command - Generate sections.yaml config
658
+ // ============================================================================
659
+
660
+ program
661
+ .command('init')
662
+ .description('Generate sections.yaml from existing .md files')
663
+ .option('-d, --dir <directory>', 'Directory to scan', '.')
664
+ .option('-o, --output <file>', 'Output config file', 'sections.yaml')
665
+ .option('--force', 'Overwrite existing config')
666
+ .action((options) => {
667
+ const dir = path.resolve(options.dir);
668
+
669
+ if (!fs.existsSync(dir)) {
670
+ console.error(chalk.red(`Directory not found: ${dir}`));
671
+ process.exit(1);
672
+ }
673
+
674
+ const outputPath = path.resolve(options.dir, options.output);
675
+
676
+ if (fs.existsSync(outputPath) && !options.force) {
677
+ console.error(chalk.yellow(`Config already exists: ${outputPath}`));
678
+ console.error(chalk.dim('Use --force to overwrite'));
679
+ process.exit(1);
680
+ }
681
+
682
+ console.log(chalk.cyan(`Scanning ${dir} for .md files...`));
683
+
684
+ const config = generateConfig(dir);
685
+ const sectionCount = Object.keys(config.sections).length;
686
+
687
+ if (sectionCount === 0) {
688
+ console.error(chalk.yellow('No .md files found (excluding paper.md, README.md)'));
689
+ process.exit(1);
690
+ }
691
+
692
+ saveConfig(outputPath, config);
693
+
694
+ console.log(chalk.green(`\nCreated ${outputPath} with ${sectionCount} sections:\n`));
695
+
696
+ for (const [file, section] of Object.entries(config.sections)) {
697
+ console.log(` ${chalk.bold(file)}`);
698
+ console.log(chalk.dim(` header: "${section.header}"`));
699
+ if (section.aliases?.length > 0) {
700
+ console.log(chalk.dim(` aliases: ${JSON.stringify(section.aliases)}`));
701
+ }
702
+ }
703
+
704
+ console.log(chalk.cyan('\nEdit this file to:'));
705
+ console.log(chalk.dim(' • Add aliases for header variations'));
706
+ console.log(chalk.dim(' • Adjust order if needed'));
707
+ console.log(chalk.dim(' • Update headers if they change'));
708
+ });
709
+
710
+ // ============================================================================
711
+ // SPLIT command - Split annotated paper.md back to section files
712
+ // ============================================================================
713
+
714
+ program
715
+ .command('split')
716
+ .description('Split annotated paper.md back to section files')
717
+ .argument('<file>', 'Annotated paper.md file')
718
+ .option('-c, --config <file>', 'Sections config file', 'sections.yaml')
719
+ .option('-d, --dir <directory>', 'Output directory for section files', '.')
720
+ .option('--dry-run', 'Preview without writing files')
721
+ .action((file, options) => {
722
+ if (!fs.existsSync(file)) {
723
+ console.error(chalk.red(`File not found: ${file}`));
724
+ process.exit(1);
725
+ }
726
+
727
+ const configPath = path.resolve(options.dir, options.config);
728
+ if (!fs.existsSync(configPath)) {
729
+ console.error(chalk.red(`Config not found: ${configPath}`));
730
+ console.error(chalk.dim('Run "rev init" first to generate sections.yaml'));
731
+ process.exit(1);
732
+ }
733
+
734
+ console.log(chalk.cyan(`Splitting ${file} using ${options.config}...`));
735
+
736
+ const config = loadConfig(configPath);
737
+ const paperContent = fs.readFileSync(file, 'utf-8');
738
+ const sections = splitAnnotatedPaper(paperContent, config.sections);
739
+
740
+ if (sections.size === 0) {
741
+ console.error(chalk.yellow('No sections detected.'));
742
+ console.error(chalk.dim('Check that headers match sections.yaml'));
743
+ process.exit(1);
744
+ }
745
+
746
+ console.log(chalk.green(`\nFound ${sections.size} sections:\n`));
747
+
748
+ for (const [sectionFile, content] of sections) {
749
+ const outputPath = path.join(options.dir, sectionFile);
750
+ const lines = content.split('\n').length;
751
+ const annotations = countAnnotations(content);
752
+
753
+ console.log(` ${chalk.bold(sectionFile)} (${lines} lines)`);
754
+ if (annotations.total > 0) {
755
+ const parts = [];
756
+ if (annotations.inserts > 0) parts.push(chalk.green(`+${annotations.inserts}`));
757
+ if (annotations.deletes > 0) parts.push(chalk.red(`-${annotations.deletes}`));
758
+ if (annotations.substitutes > 0) parts.push(chalk.yellow(`~${annotations.substitutes}`));
759
+ if (annotations.comments > 0) parts.push(chalk.blue(`#${annotations.comments}`));
760
+ console.log(chalk.dim(` Annotations: ${parts.join(' ')}`));
761
+ }
762
+
763
+ if (!options.dryRun) {
764
+ fs.writeFileSync(outputPath, content, 'utf-8');
765
+ }
766
+ }
767
+
768
+ if (options.dryRun) {
769
+ console.log(chalk.yellow('\n(Dry run - no files written)'));
770
+ } else {
771
+ console.log(chalk.green('\nSection files updated.'));
772
+ console.log(chalk.cyan('\nNext: rev review <section.md> for each section'));
773
+ }
774
+ });
775
+
776
+ // ============================================================================
777
+ // SECTIONS command - Import with section awareness
778
+ // ============================================================================
779
+
780
+ program
781
+ .command('sections')
782
+ .description('Import Word doc directly to section files')
783
+ .argument('<docx>', 'Word document from reviewer')
784
+ .option('-c, --config <file>', 'Sections config file', 'sections.yaml')
785
+ .option('-d, --dir <directory>', 'Directory with section files', '.')
786
+ .option('--no-crossref', 'Skip converting hardcoded figure/table refs')
787
+ .option('--no-diff', 'Skip showing diff preview')
788
+ .option('--force', 'Overwrite files without conflict warning')
789
+ .option('--dry-run', 'Preview without writing files')
790
+ .action(async (docx, options) => {
791
+ if (!fs.existsSync(docx)) {
792
+ console.error(fmt.status('error', `File not found: ${docx}`));
793
+ process.exit(1);
794
+ }
795
+
796
+ const configPath = path.resolve(options.dir, options.config);
797
+ if (!fs.existsSync(configPath)) {
798
+ console.error(fmt.status('error', `Config not found: ${configPath}`));
799
+ console.error(chalk.dim(' Run "rev init" first to generate sections.yaml'));
800
+ process.exit(1);
801
+ }
802
+
803
+ const spin = fmt.spinner(`Importing ${path.basename(docx)}...`).start();
804
+
805
+ try {
806
+ const config = loadConfig(configPath);
807
+ const mammoth = await import('mammoth');
808
+ const { importFromWord, extractWordComments, extractCommentAnchors, insertCommentsIntoMarkdown } = await import('../lib/import.js');
809
+
810
+ // Build crossref registry for converting hardcoded refs
811
+ let registry = null;
812
+ let totalRefConversions = 0;
813
+ if (options.crossref !== false) {
814
+ registry = buildRegistry(options.dir);
815
+ }
816
+
817
+ // Extract comments and anchors from Word doc
818
+ const comments = await extractWordComments(docx);
819
+ const anchors = await extractCommentAnchors(docx);
820
+
821
+ // Extract text from Word
822
+ const wordResult = await mammoth.extractRawText({ path: docx });
823
+ const wordText = wordResult.value;
824
+
825
+ // Extract sections from Word text
826
+ const wordSections = extractSectionsFromText(wordText, config.sections);
827
+
828
+ if (wordSections.length === 0) {
829
+ spin.stop();
830
+ console.error(fmt.status('warning', 'No sections detected in Word document.'));
831
+ console.error(chalk.dim(' Check that headings match sections.yaml'));
832
+ process.exit(1);
833
+ }
834
+
835
+ spin.stop();
836
+ console.log(fmt.header(`Import from ${path.basename(docx)}`));
837
+ console.log();
838
+
839
+ // Conflict detection: check if files already have annotations
840
+ if (!options.force && !options.dryRun) {
841
+ const conflicts = [];
842
+ for (const section of wordSections) {
843
+ const sectionPath = path.join(options.dir, section.file);
844
+ if (fs.existsSync(sectionPath)) {
845
+ const existing = fs.readFileSync(sectionPath, 'utf-8');
846
+ const existingCounts = countAnnotations(existing);
847
+ if (existingCounts.total > 0) {
848
+ conflicts.push({
849
+ file: section.file,
850
+ annotations: existingCounts.total,
851
+ });
852
+ }
853
+ }
854
+ }
855
+
856
+ if (conflicts.length > 0) {
857
+ console.log(fmt.status('warning', 'Files with existing annotations will be overwritten:'));
858
+ for (const c of conflicts) {
859
+ console.log(chalk.yellow(` - ${c.file} (${c.annotations} annotations)`));
860
+ }
861
+ console.log();
862
+
863
+ // Prompt for confirmation
864
+ const rl = await import('readline');
865
+ const readline = rl.createInterface({
866
+ input: process.stdin,
867
+ output: process.stdout,
868
+ });
869
+
870
+ const answer = await new Promise((resolve) =>
871
+ readline.question(chalk.cyan('Continue and overwrite? [y/N] '), resolve)
872
+ );
873
+ readline.close();
874
+
875
+ if (answer.toLowerCase() !== 'y') {
876
+ console.log(chalk.dim('Aborted. Use --force to skip this check.'));
877
+ process.exit(0);
878
+ }
879
+ console.log();
880
+ }
881
+ }
882
+
883
+ // Collect results for summary table
884
+ const sectionResults = [];
885
+ let totalChanges = 0;
886
+
887
+ for (const section of wordSections) {
888
+ const sectionPath = path.join(options.dir, section.file);
889
+
890
+ if (!fs.existsSync(sectionPath)) {
891
+ sectionResults.push({
892
+ file: section.file,
893
+ header: section.header,
894
+ status: 'skipped',
895
+ stats: null,
896
+ });
897
+ continue;
898
+ }
899
+
900
+ // Import this section
901
+ const result = await importFromWord(docx, sectionPath, {
902
+ sectionContent: section.content,
903
+ author: 'Reviewer',
904
+ });
905
+
906
+ let { annotated, stats } = result;
907
+
908
+ // Convert hardcoded refs to dynamic refs
909
+ let refConversions = [];
910
+ if (registry && options.crossref !== false) {
911
+ const crossrefResult = convertHardcodedRefs(annotated, registry);
912
+ annotated = crossrefResult.converted;
913
+ refConversions = crossrefResult.conversions;
914
+ totalRefConversions += refConversions.length;
915
+ }
916
+
917
+ // Insert Word comments into the annotated markdown (quiet mode - no warnings)
918
+ let commentsInserted = 0;
919
+ if (comments.length > 0 && anchors.size > 0) {
920
+ annotated = insertCommentsIntoMarkdown(annotated, comments, anchors, { quiet: true });
921
+ commentsInserted = (annotated.match(/\{>>/g) || []).length - (result.annotated?.match(/\{>>/g) || []).length;
922
+ if (commentsInserted > 0) {
923
+ stats.comments = (stats.comments || 0) + commentsInserted;
924
+ }
925
+ }
926
+
927
+ totalChanges += stats.total;
928
+
929
+ sectionResults.push({
930
+ file: section.file,
931
+ header: section.header,
932
+ status: 'ok',
933
+ stats,
934
+ refs: refConversions.length,
935
+ });
936
+
937
+ if (!options.dryRun && (stats.total > 0 || refConversions.length > 0)) {
938
+ fs.writeFileSync(sectionPath, annotated, 'utf-8');
939
+ }
940
+ }
941
+
942
+ // Build summary table
943
+ const tableRows = sectionResults.map((r) => {
944
+ if (r.status === 'skipped') {
945
+ return [
946
+ chalk.dim(r.file),
947
+ chalk.dim(r.header.slice(0, 25)),
948
+ chalk.yellow('skipped'),
949
+ '',
950
+ '',
951
+ '',
952
+ '',
953
+ ];
954
+ }
955
+ const s = r.stats;
956
+ return [
957
+ chalk.bold(r.file),
958
+ r.header.length > 25 ? r.header.slice(0, 22) + '...' : r.header,
959
+ s.insertions > 0 ? chalk.green(`+${s.insertions}`) : chalk.dim('-'),
960
+ s.deletions > 0 ? chalk.red(`-${s.deletions}`) : chalk.dim('-'),
961
+ s.substitutions > 0 ? chalk.yellow(`~${s.substitutions}`) : chalk.dim('-'),
962
+ s.comments > 0 ? chalk.blue(`#${s.comments}`) : chalk.dim('-'),
963
+ r.refs > 0 ? chalk.magenta(`@${r.refs}`) : chalk.dim('-'),
964
+ ];
965
+ });
966
+
967
+ console.log(fmt.table(
968
+ ['File', 'Section', 'Ins', 'Del', 'Sub', 'Cmt', 'Ref'],
969
+ tableRows,
970
+ { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] }
971
+ ));
972
+ console.log();
973
+
974
+ // Show diff preview if there are changes
975
+ if (options.diff !== false && totalChanges > 0) {
976
+ console.log(fmt.header('Changes Preview'));
977
+ console.log();
978
+ // Collect all annotated content for preview
979
+ for (const result of sectionResults) {
980
+ if (result.status === 'ok' && result.stats && result.stats.total > 0) {
981
+ const sectionPath = path.join(options.dir, result.file);
982
+ if (fs.existsSync(sectionPath)) {
983
+ const content = fs.readFileSync(sectionPath, 'utf-8');
984
+ const preview = inlineDiffPreview(content, { maxLines: 3 });
985
+ if (preview) {
986
+ console.log(chalk.bold(result.file) + ':');
987
+ console.log(preview);
988
+ console.log();
989
+ }
990
+ }
991
+ }
992
+ }
993
+ }
994
+
995
+ // Summary box
996
+ if (options.dryRun) {
997
+ console.log(fmt.box(chalk.yellow('Dry run - no files written'), { padding: 0 }));
998
+ } else if (totalChanges > 0 || totalRefConversions > 0 || comments.length > 0) {
999
+ const summaryLines = [];
1000
+ summaryLines.push(`${chalk.bold(wordSections.length)} sections processed`);
1001
+ if (totalChanges > 0) summaryLines.push(`${chalk.bold(totalChanges)} annotations imported`);
1002
+ if (comments.length > 0) summaryLines.push(`${chalk.bold(comments.length)} comments placed`);
1003
+ if (totalRefConversions > 0) summaryLines.push(`${chalk.bold(totalRefConversions)} refs converted to @-syntax`);
1004
+
1005
+ console.log(fmt.box(summaryLines.join('\n'), { title: 'Summary', padding: 0 }));
1006
+ console.log();
1007
+ console.log(chalk.dim('Next steps:'));
1008
+ console.log(chalk.dim(' 1. rev review <section.md> - Accept/reject changes'));
1009
+ console.log(chalk.dim(' 2. rev comments <section.md> - View/address comments'));
1010
+ console.log(chalk.dim(' 3. rev build docx - Rebuild Word doc'));
1011
+ } else {
1012
+ console.log(fmt.status('success', 'No changes detected.'));
1013
+ }
1014
+ } catch (err) {
1015
+ spin.stop();
1016
+ console.error(fmt.status('error', err.message));
1017
+ if (process.env.DEBUG) console.error(err.stack);
1018
+ process.exit(1);
1019
+ }
1020
+ });
1021
+
1022
+ // ============================================================================
1023
+ // REFS command - Show figure/table reference status
1024
+ // ============================================================================
1025
+
1026
+ program
1027
+ .command('refs')
1028
+ .description('Show figure/table reference registry and status')
1029
+ .argument('[file]', 'Optional file to analyze for references')
1030
+ .option('-d, --dir <directory>', 'Directory to scan for anchors', '.')
1031
+ .action((file, options) => {
1032
+ const dir = path.resolve(options.dir);
1033
+
1034
+ if (!fs.existsSync(dir)) {
1035
+ console.error(chalk.red(`Directory not found: ${dir}`));
1036
+ process.exit(1);
1037
+ }
1038
+
1039
+ console.log(chalk.cyan('Building figure/table registry...\n'));
1040
+
1041
+ const registry = buildRegistry(dir);
1042
+
1043
+ // Show registry
1044
+ console.log(chalk.bold('Registry:'));
1045
+ console.log(formatRegistry(registry));
1046
+
1047
+ // If file provided, analyze it
1048
+ if (file) {
1049
+ if (!fs.existsSync(file)) {
1050
+ console.error(chalk.red(`\nFile not found: ${file}`));
1051
+ process.exit(1);
1052
+ }
1053
+
1054
+ const text = fs.readFileSync(file, 'utf-8');
1055
+ const status = getRefStatus(text, registry);
1056
+
1057
+ console.log(chalk.cyan(`\nReferences in ${path.basename(file)}:\n`));
1058
+
1059
+ if (status.dynamic.length > 0) {
1060
+ console.log(chalk.green(` Dynamic (@fig:, @tbl:): ${status.dynamic.length}`));
1061
+ for (const ref of status.dynamic.slice(0, 5)) {
1062
+ console.log(chalk.dim(` ${ref.match}`));
1063
+ }
1064
+ if (status.dynamic.length > 5) {
1065
+ console.log(chalk.dim(` ... and ${status.dynamic.length - 5} more`));
1066
+ }
1067
+ }
1068
+
1069
+ if (status.hardcoded.length > 0) {
1070
+ console.log(chalk.yellow(`\n Hardcoded (Figure 1, Table 2): ${status.hardcoded.length}`));
1071
+ for (const ref of status.hardcoded.slice(0, 5)) {
1072
+ console.log(chalk.dim(` "${ref.match}"`));
1073
+ }
1074
+ if (status.hardcoded.length > 5) {
1075
+ console.log(chalk.dim(` ... and ${status.hardcoded.length - 5} more`));
1076
+ }
1077
+ console.log(chalk.cyan(`\n Run ${chalk.bold(`rev migrate ${file}`)} to convert to dynamic refs`));
1078
+ }
1079
+
1080
+ if (status.dynamic.length === 0 && status.hardcoded.length === 0) {
1081
+ console.log(chalk.dim(' No figure/table references found.'));
1082
+ }
1083
+ }
1084
+ });
1085
+
1086
+ // ============================================================================
1087
+ // MIGRATE command - Convert hardcoded refs to dynamic
1088
+ // ============================================================================
1089
+
1090
+ program
1091
+ .command('migrate')
1092
+ .description('Convert hardcoded figure/table refs to dynamic @-syntax')
1093
+ .argument('<file>', 'Markdown file to migrate')
1094
+ .option('-d, --dir <directory>', 'Directory for registry', '.')
1095
+ .option('--auto', 'Auto-convert without prompting')
1096
+ .option('--dry-run', 'Preview without saving')
1097
+ .action(async (file, options) => {
1098
+ if (!fs.existsSync(file)) {
1099
+ console.error(chalk.red(`File not found: ${file}`));
1100
+ process.exit(1);
1101
+ }
1102
+
1103
+ const dir = path.resolve(options.dir);
1104
+ console.log(chalk.cyan('Building figure/table registry...\n'));
1105
+
1106
+ const registry = buildRegistry(dir);
1107
+ const text = fs.readFileSync(file, 'utf-8');
1108
+ const refs = detectHardcodedRefs(text);
1109
+
1110
+ if (refs.length === 0) {
1111
+ console.log(chalk.green('No hardcoded references found.'));
1112
+ return;
1113
+ }
1114
+
1115
+ console.log(chalk.yellow(`Found ${refs.length} hardcoded reference(s):\n`));
1116
+
1117
+ if (options.auto) {
1118
+ // Auto-convert all
1119
+ const { converted, conversions, warnings } = convertHardcodedRefs(text, registry);
1120
+
1121
+ for (const w of warnings) {
1122
+ console.log(chalk.yellow(` Warning: ${w}`));
1123
+ }
1124
+
1125
+ for (const c of conversions) {
1126
+ console.log(chalk.green(` "${c.from}" → ${c.to}`));
1127
+ }
1128
+
1129
+ if (options.dryRun) {
1130
+ console.log(chalk.yellow('\n(Dry run - no changes saved)'));
1131
+ } else if (conversions.length > 0) {
1132
+ fs.writeFileSync(file, converted, 'utf-8');
1133
+ console.log(chalk.green(`\nConverted ${conversions.length} reference(s) in ${file}`));
1134
+ }
1135
+ } else {
1136
+ // Interactive mode
1137
+ const rl = await import('readline');
1138
+ const readline = rl.createInterface({
1139
+ input: process.stdin,
1140
+ output: process.stdout,
1141
+ });
1142
+
1143
+ let result = text;
1144
+ let converted = 0;
1145
+ let skipped = 0;
1146
+
1147
+ const askQuestion = (prompt) =>
1148
+ new Promise((resolve) => readline.question(prompt, resolve));
1149
+
1150
+ // Process in reverse to preserve positions
1151
+ const sortedRefs = [...refs].sort((a, b) => b.position - a.position);
1152
+
1153
+ for (const ref of sortedRefs) {
1154
+ // Try to find the label for this reference
1155
+ const num = ref.numbers[0];
1156
+ const { numberToLabel } = await import('../lib/crossref.js');
1157
+ const label = numberToLabel(ref.type, num.num, num.isSupp, registry);
1158
+
1159
+ if (!label) {
1160
+ console.log(chalk.yellow(` "${ref.match}" - no matching anchor found, skipping`));
1161
+ skipped++;
1162
+ continue;
1163
+ }
1164
+
1165
+ const replacement = `@${ref.type}:${label}`;
1166
+ console.log(`\n ${chalk.yellow(`"${ref.match}"`)} → ${chalk.green(replacement)}`);
1167
+
1168
+ const answer = await askQuestion(chalk.cyan(' Convert? [y/n/a/q] '));
1169
+
1170
+ if (answer.toLowerCase() === 'q') {
1171
+ console.log(chalk.dim(' Quitting...'));
1172
+ break;
1173
+ } else if (answer.toLowerCase() === 'a') {
1174
+ // Accept all remaining
1175
+ result = result.slice(0, ref.position) + replacement + result.slice(ref.position + ref.match.length);
1176
+ converted++;
1177
+
1178
+ // Convert remaining without asking
1179
+ for (const remaining of sortedRefs.slice(sortedRefs.indexOf(ref) + 1)) {
1180
+ const rNum = remaining.numbers[0];
1181
+ const rLabel = numberToLabel(remaining.type, rNum.num, rNum.isSupp, registry);
1182
+ if (rLabel) {
1183
+ const rReplacement = `@${remaining.type}:${rLabel}`;
1184
+ result = result.slice(0, remaining.position) + rReplacement + result.slice(remaining.position + remaining.match.length);
1185
+ converted++;
1186
+ console.log(chalk.green(` "${remaining.match}" → ${rReplacement}`));
1187
+ }
1188
+ }
1189
+ break;
1190
+ } else if (answer.toLowerCase() === 'y') {
1191
+ result = result.slice(0, ref.position) + replacement + result.slice(ref.position + ref.match.length);
1192
+ converted++;
1193
+ } else {
1194
+ skipped++;
1195
+ }
1196
+ }
1197
+
1198
+ readline.close();
1199
+
1200
+ console.log(chalk.cyan(`\nConverted: ${converted}, Skipped: ${skipped}`));
1201
+
1202
+ if (converted > 0 && !options.dryRun) {
1203
+ fs.writeFileSync(file, result, 'utf-8');
1204
+ console.log(chalk.green(`Saved ${file}`));
1205
+ } else if (options.dryRun) {
1206
+ console.log(chalk.yellow('(Dry run - no changes saved)'));
1207
+ }
1208
+ }
1209
+ });
1210
+
1211
+ // ============================================================================
1212
+ // INSTALL command - Install dependencies (pandoc-crossref)
1213
+ // ============================================================================
1214
+
1215
+ program
1216
+ .command('install')
1217
+ .description('Check and install dependencies (pandoc-crossref)')
1218
+ .option('--check', 'Only check, don\'t install')
1219
+ .action(async (options) => {
1220
+ const os = await import('os');
1221
+ const { execSync, spawn } = await import('child_process');
1222
+ const platform = os.platform();
1223
+
1224
+ console.log(chalk.cyan('Checking dependencies...\n'));
1225
+
1226
+ // Check pandoc
1227
+ let hasPandoc = false;
1228
+ try {
1229
+ const version = execSync('pandoc --version', { encoding: 'utf-8' }).split('\n')[0];
1230
+ console.log(chalk.green(` ✓ ${version}`));
1231
+ hasPandoc = true;
1232
+ } catch {
1233
+ console.log(chalk.red(' ✗ pandoc not found'));
1234
+ }
1235
+
1236
+ // Check pandoc-crossref
1237
+ let hasCrossref = false;
1238
+ try {
1239
+ const version = execSync('pandoc-crossref --version', { encoding: 'utf-8' }).split('\n')[0];
1240
+ console.log(chalk.green(` ✓ pandoc-crossref ${version}`));
1241
+ hasCrossref = true;
1242
+ } catch {
1243
+ console.log(chalk.yellow(' ✗ pandoc-crossref not found'));
1244
+ }
1245
+
1246
+ // Check mammoth (Node dep - should always be there)
1247
+ try {
1248
+ await import('mammoth');
1249
+ console.log(chalk.green(' ✓ mammoth (Word parsing)'));
1250
+ } catch {
1251
+ console.log(chalk.red(' ✗ mammoth not found - run: npm install'));
1252
+ }
1253
+
1254
+ console.log('');
1255
+
1256
+ if (hasPandoc && hasCrossref) {
1257
+ console.log(chalk.green('All dependencies installed!'));
1258
+ return;
1259
+ }
1260
+
1261
+ if (options.check) {
1262
+ if (!hasCrossref) {
1263
+ console.log(chalk.yellow('pandoc-crossref is optional but recommended for @fig: references.'));
1264
+ }
1265
+ return;
1266
+ }
1267
+
1268
+ // Provide installation instructions
1269
+ if (!hasPandoc || !hasCrossref) {
1270
+ console.log(chalk.cyan('Installation options:\n'));
1271
+
1272
+ if (platform === 'darwin') {
1273
+ // macOS
1274
+ console.log(chalk.bold('macOS (Homebrew):'));
1275
+ if (!hasPandoc) console.log(chalk.dim(' brew install pandoc'));
1276
+ if (!hasCrossref) console.log(chalk.dim(' brew install pandoc-crossref'));
1277
+ console.log('');
1278
+ } else if (platform === 'win32') {
1279
+ // Windows
1280
+ console.log(chalk.bold('Windows (Chocolatey):'));
1281
+ if (!hasPandoc) console.log(chalk.dim(' choco install pandoc'));
1282
+ if (!hasCrossref) console.log(chalk.dim(' choco install pandoc-crossref'));
1283
+ console.log('');
1284
+ console.log(chalk.bold('Windows (Scoop):'));
1285
+ if (!hasPandoc) console.log(chalk.dim(' scoop install pandoc'));
1286
+ if (!hasCrossref) console.log(chalk.dim(' scoop install pandoc-crossref'));
1287
+ console.log('');
1288
+ } else {
1289
+ // Linux
1290
+ console.log(chalk.bold('Linux (apt):'));
1291
+ if (!hasPandoc) console.log(chalk.dim(' sudo apt install pandoc'));
1292
+ console.log('');
1293
+ }
1294
+
1295
+ // Cross-platform conda option
1296
+ console.log(chalk.bold('Cross-platform (conda):'));
1297
+ if (!hasPandoc) console.log(chalk.dim(' conda install -c conda-forge pandoc'));
1298
+ if (!hasCrossref) console.log(chalk.dim(' conda install -c conda-forge pandoc-crossref'));
1299
+ console.log('');
1300
+
1301
+ // Manual download
1302
+ if (!hasCrossref) {
1303
+ console.log(chalk.bold('Manual download:'));
1304
+ console.log(chalk.dim(' https://github.com/lierdakil/pandoc-crossref/releases'));
1305
+ console.log('');
1306
+ }
1307
+
1308
+ // Ask to auto-install via conda if available
1309
+ try {
1310
+ execSync('conda --version', { encoding: 'utf-8', stdio: 'pipe' });
1311
+ console.log(chalk.cyan('Conda detected. Install missing dependencies? [y/N] '));
1312
+
1313
+ const rl = (await import('readline')).createInterface({
1314
+ input: process.stdin,
1315
+ output: process.stdout,
1316
+ });
1317
+
1318
+ rl.question('', (answer) => {
1319
+ rl.close();
1320
+ if (answer.toLowerCase() === 'y') {
1321
+ console.log(chalk.cyan('\nInstalling via conda...'));
1322
+ try {
1323
+ if (!hasPandoc) {
1324
+ console.log(chalk.dim(' Installing pandoc...'));
1325
+ execSync('conda install -y -c conda-forge pandoc', { stdio: 'inherit' });
1326
+ }
1327
+ if (!hasCrossref) {
1328
+ console.log(chalk.dim(' Installing pandoc-crossref...'));
1329
+ execSync('conda install -y -c conda-forge pandoc-crossref', { stdio: 'inherit' });
1330
+ }
1331
+ console.log(chalk.green('\nDone! Run "rev install --check" to verify.'));
1332
+ } catch (err) {
1333
+ console.log(chalk.red(`\nInstallation failed: ${err.message}`));
1334
+ console.log(chalk.dim('Try installing manually with the commands above.'));
1335
+ }
1336
+ }
1337
+ });
1338
+ } catch {
1339
+ // Conda not available
1340
+ }
1341
+ }
1342
+ });
1343
+
1344
+ // ============================================================================
1345
+ // BUILD command - Combine sections and run pandoc
1346
+ // ============================================================================
1347
+
1348
+ program
1349
+ .command('build')
1350
+ .description('Build PDF/DOCX/TEX from sections')
1351
+ .argument('[formats...]', 'Output formats: pdf, docx, tex, all', ['pdf', 'docx'])
1352
+ .option('-d, --dir <directory>', 'Project directory', '.')
1353
+ .option('--no-crossref', 'Skip pandoc-crossref filter')
1354
+ .action(async (formats, options) => {
1355
+ const dir = path.resolve(options.dir);
1356
+
1357
+ if (!fs.existsSync(dir)) {
1358
+ console.error(chalk.red(`Directory not found: ${dir}`));
1359
+ process.exit(1);
1360
+ }
1361
+
1362
+ // Check for pandoc
1363
+ if (!hasPandoc()) {
1364
+ console.error(chalk.red('pandoc not found.'));
1365
+ console.error(chalk.dim('Run "rev install" to install dependencies.'));
1366
+ process.exit(1);
1367
+ }
1368
+
1369
+ // Load config
1370
+ const config = loadBuildConfig(dir);
1371
+
1372
+ if (!config._configPath) {
1373
+ console.error(chalk.yellow('No rev.yaml found.'));
1374
+ console.error(chalk.dim('Run "rev new" to create a project, or "rev init" for existing files.'));
1375
+ process.exit(1);
1376
+ }
1377
+
1378
+ console.log(fmt.header(`Building ${config.title || 'document'}`));
1379
+ console.log();
1380
+
1381
+ // Show what we're building
1382
+ const targetFormats = formats.length > 0 ? formats : ['pdf', 'docx'];
1383
+ console.log(chalk.dim(` Formats: ${targetFormats.join(', ')}`));
1384
+ console.log(chalk.dim(` Crossref: ${hasPandocCrossref() && options.crossref !== false ? 'enabled' : 'disabled'}`));
1385
+ console.log('');
1386
+
1387
+ const spin = fmt.spinner('Building...').start();
1388
+
1389
+ try {
1390
+ const { results, paperPath } = await build(dir, targetFormats, {
1391
+ crossref: options.crossref,
1392
+ });
1393
+
1394
+ spin.stop();
1395
+
1396
+ // Report results
1397
+ console.log(chalk.cyan('Combined sections → paper.md'));
1398
+ console.log(chalk.dim(` ${paperPath}\n`));
1399
+
1400
+ console.log(chalk.cyan('Output:'));
1401
+ console.log(formatBuildResults(results));
1402
+
1403
+ const failed = results.filter((r) => !r.success);
1404
+ if (failed.length > 0) {
1405
+ console.log('');
1406
+ for (const f of failed) {
1407
+ console.error(chalk.red(`\n${f.format} error:\n${f.error}`));
1408
+ }
1409
+ process.exit(1);
1410
+ }
1411
+
1412
+ console.log(chalk.green('\nBuild complete!'));
1413
+ } catch (err) {
1414
+ spin.stop();
1415
+ console.error(fmt.status('error', err.message));
1416
+ if (process.env.DEBUG) console.error(err.stack);
1417
+ process.exit(1);
1418
+ }
1419
+ });
1420
+
1421
+ // ============================================================================
1422
+ // NEW command - Create new paper project
1423
+ // ============================================================================
1424
+
1425
+ program
1426
+ .command('new')
1427
+ .description('Create a new paper project from template')
1428
+ .argument('[name]', 'Project directory name')
1429
+ .option('-t, --template <name>', 'Template: paper, minimal, thesis, review', 'paper')
1430
+ .option('--list', 'List available templates')
1431
+ .action(async (name, options) => {
1432
+ if (options.list) {
1433
+ console.log(chalk.cyan('Available templates:\n'));
1434
+ for (const t of listTemplates()) {
1435
+ console.log(` ${chalk.bold(t.id)} - ${t.description}`);
1436
+ }
1437
+ return;
1438
+ }
1439
+
1440
+ if (!name) {
1441
+ console.error(chalk.red('Error: project name is required'));
1442
+ console.error(chalk.dim('Usage: rev new <name>'));
1443
+ process.exit(1);
1444
+ }
1445
+
1446
+ const template = getTemplate(options.template);
1447
+ if (!template) {
1448
+ console.error(chalk.red(`Unknown template: ${options.template}`));
1449
+ console.error(chalk.dim('Use --list to see available templates.'));
1450
+ process.exit(1);
1451
+ }
1452
+
1453
+ const projectDir = path.resolve(name);
1454
+
1455
+ if (fs.existsSync(projectDir)) {
1456
+ console.error(chalk.red(`Directory already exists: ${name}`));
1457
+ process.exit(1);
1458
+ }
1459
+
1460
+ console.log(chalk.cyan(`Creating ${template.name} project in ${name}/...\n`));
1461
+
1462
+ // Create directory
1463
+ fs.mkdirSync(projectDir, { recursive: true });
1464
+
1465
+ // Create subdirectories
1466
+ for (const subdir of template.directories || []) {
1467
+ fs.mkdirSync(path.join(projectDir, subdir), { recursive: true });
1468
+ console.log(chalk.dim(` Created ${subdir}/`));
1469
+ }
1470
+
1471
+ // Create files
1472
+ for (const [filename, content] of Object.entries(template.files)) {
1473
+ const filePath = path.join(projectDir, filename);
1474
+ fs.writeFileSync(filePath, content, 'utf-8');
1475
+ console.log(chalk.dim(` Created ${filename}`));
1476
+ }
1477
+
1478
+ console.log(chalk.green(`\nProject created!`));
1479
+ console.log(chalk.cyan('\nNext steps:'));
1480
+ console.log(chalk.dim(` cd ${name}`));
1481
+ console.log(chalk.dim(' # Edit rev.yaml with your paper details'));
1482
+ console.log(chalk.dim(' # Write your sections'));
1483
+ console.log(chalk.dim(' rev build # Build PDF and DOCX'));
1484
+ console.log(chalk.dim(' rev build pdf # Build PDF only'));
1485
+ });
1486
+
1487
+ // ============================================================================
1488
+ // CONFIG command - Set user preferences
1489
+ // ============================================================================
1490
+
1491
+ program
1492
+ .command('config')
1493
+ .description('Set user preferences')
1494
+ .argument('<key>', 'Config key: user')
1495
+ .argument('[value]', 'Value to set')
1496
+ .action((key, value) => {
1497
+ if (key === 'user') {
1498
+ if (value) {
1499
+ setUserName(value);
1500
+ console.log(chalk.green(`User name set to: ${value}`));
1501
+ console.log(chalk.dim(`Saved to ${getConfigPath()}`));
1502
+ } else {
1503
+ const name = getUserName();
1504
+ if (name) {
1505
+ console.log(`Current user: ${chalk.bold(name)}`);
1506
+ } else {
1507
+ console.log(chalk.yellow('No user name set.'));
1508
+ console.log(chalk.dim('Set with: rev config user "Your Name"'));
1509
+ }
1510
+ }
1511
+ } else {
1512
+ console.error(chalk.red(`Unknown config key: ${key}`));
1513
+ console.error(chalk.dim('Available keys: user'));
1514
+ process.exit(1);
1515
+ }
1516
+ });
1517
+
1518
+ // ============================================================================
1519
+ // REPLY command - Reply to comments in a file
1520
+ // ============================================================================
1521
+
1522
+ program
1523
+ .command('reply')
1524
+ .description('Reply to reviewer comments interactively')
1525
+ .argument('<file>', 'Markdown file with comments')
1526
+ .option('-m, --message <text>', 'Reply message (non-interactive)')
1527
+ .option('-n, --number <n>', 'Reply to specific comment number', parseInt)
1528
+ .option('-a, --author <name>', 'Override author name')
1529
+ .action(async (file, options) => {
1530
+ if (!fs.existsSync(file)) {
1531
+ console.error(chalk.red(`File not found: ${file}`));
1532
+ process.exit(1);
1533
+ }
1534
+
1535
+ // Get author name
1536
+ let author = options.author || getUserName();
1537
+ if (!author) {
1538
+ console.error(chalk.yellow('No user name set.'));
1539
+ console.error(chalk.dim('Set with: rev config user "Your Name"'));
1540
+ console.error(chalk.dim('Or use: rev reply <file> --author "Your Name"'));
1541
+ process.exit(1);
1542
+ }
1543
+
1544
+ const text = fs.readFileSync(file, 'utf-8');
1545
+ const comments = getComments(text);
1546
+
1547
+ if (comments.length === 0) {
1548
+ console.log(chalk.green('No comments found in this file.'));
1549
+ return;
1550
+ }
1551
+
1552
+ // Non-interactive mode: reply to specific comment
1553
+ if (options.message && options.number !== undefined) {
1554
+ const idx = options.number - 1;
1555
+ if (idx < 0 || idx >= comments.length) {
1556
+ console.error(chalk.red(`Invalid comment number. File has ${comments.length} comments.`));
1557
+ process.exit(1);
1558
+ }
1559
+ const result = addReply(text, comments[idx], author, options.message);
1560
+ fs.writeFileSync(file, result, 'utf-8');
1561
+ console.log(chalk.green(`Reply added to comment #${options.number}`));
1562
+ return;
1563
+ }
1564
+
1565
+ // Interactive mode
1566
+ console.log(chalk.cyan(`\nComments in ${path.basename(file)} (replying as ${chalk.bold(author)}):\n`));
1567
+
1568
+ const rl = (await import('readline')).createInterface({
1569
+ input: process.stdin,
1570
+ output: process.stdout,
1571
+ });
1572
+
1573
+ const askQuestion = (prompt) =>
1574
+ new Promise((resolve) => rl.question(prompt, resolve));
1575
+
1576
+ let result = text;
1577
+ let repliesAdded = 0;
1578
+
1579
+ for (let i = 0; i < comments.length; i++) {
1580
+ const c = comments[i];
1581
+ const authorLabel = c.author ? chalk.blue(`[${c.author}]`) : chalk.dim('[Anonymous]');
1582
+ const preview = c.content.length > 100 ? c.content.slice(0, 100) + '...' : c.content;
1583
+
1584
+ console.log(`\n${chalk.bold(`#${i + 1}`)} ${authorLabel}`);
1585
+ console.log(chalk.dim(` Line ${c.line}: "${c.before?.trim().slice(-30) || ''}..."`));
1586
+ console.log(` ${preview}`);
1587
+
1588
+ const answer = await askQuestion(chalk.cyan('\n Reply (or Enter to skip, q to quit): '));
1589
+
1590
+ if (answer.toLowerCase() === 'q') {
1591
+ break;
1592
+ }
1593
+
1594
+ if (answer.trim()) {
1595
+ result = addReply(result, c, author, answer.trim());
1596
+ repliesAdded++;
1597
+ console.log(chalk.green(' ✓ Reply added'));
1598
+ }
1599
+ }
1600
+
1601
+ rl.close();
1602
+
1603
+ if (repliesAdded > 0) {
1604
+ fs.writeFileSync(file, result, 'utf-8');
1605
+ console.log(chalk.green(`\nAdded ${repliesAdded} reply(ies) to ${file}`));
1606
+ } else {
1607
+ console.log(chalk.dim('\nNo replies added.'));
1608
+ }
1609
+ });
1610
+
1611
+ /**
1612
+ * Add a reply after a comment
1613
+ * @param {string} text - Full document text
1614
+ * @param {object} comment - Comment object with position and match
1615
+ * @param {string} author - Reply author name
1616
+ * @param {string} message - Reply message
1617
+ * @returns {string} Updated text
1618
+ */
1619
+ function addReply(text, comment, author, message) {
1620
+ const replyAnnotation = `{>>${author}: ${message}<<}`;
1621
+ const insertPos = comment.position + comment.match.length;
1622
+
1623
+ // Insert reply right after the original comment
1624
+ return text.slice(0, insertPos) + ' ' + replyAnnotation + text.slice(insertPos);
1625
+ }
1626
+
1627
+ // ============================================================================
1628
+ // RESPONSE command - Generate response letter for reviewers
1629
+ // ============================================================================
1630
+
1631
+ program
1632
+ .command('response')
1633
+ .description('Generate response letter from reviewer comments')
1634
+ .argument('[files...]', 'Markdown files to process (default: all section files)')
1635
+ .option('-o, --output <file>', 'Output file (default: response-letter.md)')
1636
+ .option('-a, --author <name>', 'Author name for identifying replies')
1637
+ .option('--no-context', 'Omit context snippets')
1638
+ .option('--no-location', 'Omit file:line references')
1639
+ .action(async (files, options) => {
1640
+ // If no files specified, find all .md files
1641
+ let mdFiles = files;
1642
+ if (!mdFiles || mdFiles.length === 0) {
1643
+ const allFiles = fs.readdirSync('.').filter(f =>
1644
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
1645
+ );
1646
+ mdFiles = allFiles;
1647
+ }
1648
+
1649
+ if (mdFiles.length === 0) {
1650
+ console.error(fmt.status('error', 'No markdown files found'));
1651
+ process.exit(1);
1652
+ }
1653
+
1654
+ const spin = fmt.spinner('Collecting comments...').start();
1655
+
1656
+ const comments = collectComments(mdFiles);
1657
+ spin.stop();
1658
+
1659
+ if (comments.length === 0) {
1660
+ console.log(fmt.status('info', 'No comments found in files'));
1661
+ return;
1662
+ }
1663
+
1664
+ // Generate response letter
1665
+ const letter = generateResponseLetter(comments, {
1666
+ authorName: options.author || getUserName() || 'Author',
1667
+ includeContext: options.context !== false,
1668
+ includeLocation: options.location !== false,
1669
+ });
1670
+
1671
+ const outputPath = options.output || 'response-letter.md';
1672
+ fs.writeFileSync(outputPath, letter, 'utf-8');
1673
+
1674
+ // Show summary
1675
+ const grouped = groupByReviewer(comments);
1676
+ const reviewers = [...grouped.keys()].filter(r =>
1677
+ !r.toLowerCase().includes('claude') &&
1678
+ r.toLowerCase() !== (options.author || '').toLowerCase()
1679
+ );
1680
+
1681
+ console.log(fmt.header('Response Letter Generated'));
1682
+ console.log();
1683
+
1684
+ const rows = reviewers.map(r => [r, grouped.get(r).length.toString()]);
1685
+ console.log(fmt.table(['Reviewer', 'Comments'], rows));
1686
+ console.log();
1687
+ console.log(fmt.status('success', `Created ${outputPath}`));
1688
+ });
1689
+
1690
+ // ============================================================================
1691
+ // CITATIONS command - Validate citations against .bib file
1692
+ // ============================================================================
1693
+
1694
+ program
1695
+ .command('citations')
1696
+ .alias('cite')
1697
+ .description('Validate citations against bibliography')
1698
+ .argument('[files...]', 'Markdown files to check (default: all section files)')
1699
+ .option('-b, --bib <file>', 'Bibliography file', 'references.bib')
1700
+ .action((files, options) => {
1701
+ // If no files specified, find all .md files
1702
+ let mdFiles = files;
1703
+ if (!mdFiles || mdFiles.length === 0) {
1704
+ mdFiles = fs.readdirSync('.').filter(f =>
1705
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
1706
+ );
1707
+ }
1708
+
1709
+ if (!fs.existsSync(options.bib)) {
1710
+ console.error(fmt.status('error', `Bibliography not found: ${options.bib}`));
1711
+ process.exit(1);
1712
+ }
1713
+
1714
+ const stats = getCitationStats(mdFiles, options.bib);
1715
+
1716
+ console.log(fmt.header('Citation Check'));
1717
+ console.log();
1718
+
1719
+ // Summary table
1720
+ const rows = [
1721
+ ['Total citations', stats.totalCitations.toString()],
1722
+ ['Unique keys cited', stats.uniqueCited.toString()],
1723
+ ['Bib entries', stats.bibEntries.toString()],
1724
+ [chalk.green('Valid'), chalk.green(stats.valid.toString())],
1725
+ [stats.missing > 0 ? chalk.red('Missing') : 'Missing', stats.missing > 0 ? chalk.red(stats.missing.toString()) : '0'],
1726
+ [chalk.dim('Unused in bib'), chalk.dim(stats.unused.toString())],
1727
+ ];
1728
+ console.log(fmt.table(['Metric', 'Count'], rows));
1729
+
1730
+ // Show missing keys
1731
+ if (stats.missingKeys.length > 0) {
1732
+ console.log();
1733
+ console.log(fmt.status('error', 'Missing citations:'));
1734
+ for (const key of stats.missingKeys) {
1735
+ console.log(chalk.red(` - ${key}`));
1736
+ }
1737
+ }
1738
+
1739
+ // Show unused (if verbose)
1740
+ if (stats.unusedKeys.length > 0 && stats.unusedKeys.length <= 10) {
1741
+ console.log();
1742
+ console.log(chalk.dim('Unused bib entries:'));
1743
+ for (const key of stats.unusedKeys.slice(0, 10)) {
1744
+ console.log(chalk.dim(` - ${key}`));
1745
+ }
1746
+ if (stats.unusedKeys.length > 10) {
1747
+ console.log(chalk.dim(` ... and ${stats.unusedKeys.length - 10} more`));
1748
+ }
1749
+ }
1750
+
1751
+ console.log();
1752
+ if (stats.missing === 0) {
1753
+ console.log(fmt.status('success', 'All citations valid'));
1754
+ } else {
1755
+ console.log(fmt.status('warning', `${stats.missing} citation(s) missing from ${options.bib}`));
1756
+ process.exit(1);
1757
+ }
1758
+ });
1759
+
1760
+ // ============================================================================
1761
+ // FIGURES command - Figure/table inventory
1762
+ // ============================================================================
1763
+
1764
+ program
1765
+ .command('figures')
1766
+ .alias('figs')
1767
+ .description('List all figures and tables with reference counts')
1768
+ .argument('[files...]', 'Markdown files to scan')
1769
+ .action((files) => {
1770
+ // If no files specified, find all .md files
1771
+ let mdFiles = files;
1772
+ if (!mdFiles || mdFiles.length === 0) {
1773
+ mdFiles = fs.readdirSync('.').filter(f =>
1774
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
1775
+ );
1776
+ }
1777
+
1778
+ // Build registry
1779
+ const registry = buildRegistry('.');
1780
+
1781
+ // Count references in files
1782
+ const refCounts = new Map();
1783
+ for (const file of mdFiles) {
1784
+ if (!fs.existsSync(file)) continue;
1785
+ const text = fs.readFileSync(file, 'utf-8');
1786
+
1787
+ // Count @fig: and @tbl: references
1788
+ const figRefs = text.matchAll(/@fig:([a-zA-Z0-9_-]+)/g);
1789
+ for (const match of figRefs) {
1790
+ const key = `fig:${match[1]}`;
1791
+ refCounts.set(key, (refCounts.get(key) || 0) + 1);
1792
+ }
1793
+
1794
+ const tblRefs = text.matchAll(/@tbl:([a-zA-Z0-9_-]+)/g);
1795
+ for (const match of tblRefs) {
1796
+ const key = `tbl:${match[1]}`;
1797
+ refCounts.set(key, (refCounts.get(key) || 0) + 1);
1798
+ }
1799
+ }
1800
+
1801
+ console.log(fmt.header('Figure & Table Inventory'));
1802
+ console.log();
1803
+
1804
+ // Figures
1805
+ if (registry.figures.size > 0) {
1806
+ const figRows = [...registry.figures.entries()].map(([label, info]) => {
1807
+ const key = `fig:${label}`;
1808
+ const refs = refCounts.get(key) || 0;
1809
+ const num = info.isSupp ? `S${info.num}` : info.num.toString();
1810
+ return [
1811
+ `Figure ${num}`,
1812
+ chalk.cyan(`@fig:${label}`),
1813
+ info.file,
1814
+ refs > 0 ? chalk.green(refs.toString()) : chalk.yellow('0'),
1815
+ ];
1816
+ });
1817
+ console.log(fmt.table(['#', 'Label', 'File', 'Refs'], figRows));
1818
+ console.log();
1819
+ }
1820
+
1821
+ // Tables
1822
+ if (registry.tables.size > 0) {
1823
+ const tblRows = [...registry.tables.entries()].map(([label, info]) => {
1824
+ const key = `tbl:${label}`;
1825
+ const refs = refCounts.get(key) || 0;
1826
+ const num = info.isSupp ? `S${info.num}` : info.num.toString();
1827
+ return [
1828
+ `Table ${num}`,
1829
+ chalk.cyan(`@tbl:${label}`),
1830
+ info.file,
1831
+ refs > 0 ? chalk.green(refs.toString()) : chalk.yellow('0'),
1832
+ ];
1833
+ });
1834
+ console.log(fmt.table(['#', 'Label', 'File', 'Refs'], tblRows));
1835
+ console.log();
1836
+ }
1837
+
1838
+ if (registry.figures.size === 0 && registry.tables.size === 0) {
1839
+ console.log(chalk.dim('No figures or tables found.'));
1840
+ console.log(chalk.dim('Add anchors like {#fig:label} to your figures.'));
1841
+ }
1842
+
1843
+ // Warn about unreferenced
1844
+ const unreferenced = [];
1845
+ for (const [label] of registry.figures) {
1846
+ if (!refCounts.get(`fig:${label}`)) unreferenced.push(`@fig:${label}`);
1847
+ }
1848
+ for (const [label] of registry.tables) {
1849
+ if (!refCounts.get(`tbl:${label}`)) unreferenced.push(`@tbl:${label}`);
1850
+ }
1851
+
1852
+ if (unreferenced.length > 0) {
1853
+ console.log(fmt.status('warning', `${unreferenced.length} unreferenced figure(s)/table(s)`));
1854
+ }
1855
+ });
1856
+
1857
+ // ============================================================================
1858
+ // EQUATIONS command - Extract and convert equations
1859
+ // ============================================================================
1860
+
1861
+ program
1862
+ .command('equations')
1863
+ .alias('eq')
1864
+ .description('Extract equations or convert to Word')
1865
+ .argument('<action>', 'Action: list, extract, convert')
1866
+ .argument('[input]', 'Input file (for extract/convert)')
1867
+ .option('-o, --output <file>', 'Output file')
1868
+ .action(async (action, input, options) => {
1869
+ if (action === 'list') {
1870
+ // List equations in all section files
1871
+ const mdFiles = fs.readdirSync('.').filter(f =>
1872
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
1873
+ );
1874
+
1875
+ const stats = getEquationStats(mdFiles);
1876
+
1877
+ console.log(fmt.header('Equations'));
1878
+ console.log();
1879
+
1880
+ if (stats.byFile.length === 0) {
1881
+ console.log(chalk.dim('No equations found.'));
1882
+ return;
1883
+ }
1884
+
1885
+ const rows = stats.byFile.map(f => [
1886
+ f.file,
1887
+ f.display > 0 ? chalk.cyan(f.display.toString()) : chalk.dim('-'),
1888
+ f.inline > 0 ? chalk.yellow(f.inline.toString()) : chalk.dim('-'),
1889
+ ]);
1890
+ rows.push([
1891
+ chalk.bold('Total'),
1892
+ chalk.bold.cyan(stats.display.toString()),
1893
+ chalk.bold.yellow(stats.inline.toString()),
1894
+ ]);
1895
+
1896
+ console.log(fmt.table(['File', 'Display', 'Inline'], rows));
1897
+
1898
+ } else if (action === 'extract') {
1899
+ if (!input) {
1900
+ console.error(fmt.status('error', 'Input file required'));
1901
+ process.exit(1);
1902
+ }
1903
+
1904
+ const output = options.output || input.replace('.md', '-equations.md');
1905
+ const result = await createEquationsDoc(input, output);
1906
+
1907
+ if (result.success) {
1908
+ console.log(fmt.status('success', result.message));
1909
+ console.log(chalk.dim(` ${result.stats.display} display, ${result.stats.inline} inline equations`));
1910
+ } else {
1911
+ console.error(fmt.status('error', result.message));
1912
+ process.exit(1);
1913
+ }
1914
+
1915
+ } else if (action === 'convert') {
1916
+ if (!input) {
1917
+ console.error(fmt.status('error', 'Input file required'));
1918
+ process.exit(1);
1919
+ }
1920
+
1921
+ const output = options.output || input.replace('.md', '.docx');
1922
+
1923
+ const spin = fmt.spinner(`Converting ${path.basename(input)} to Word...`).start();
1924
+
1925
+ try {
1926
+ const { exec } = await import('child_process');
1927
+ const { promisify } = await import('util');
1928
+ const execAsync = promisify(exec);
1929
+
1930
+ await execAsync(`pandoc "${input}" -o "${output}" --mathml`);
1931
+ spin.success(`Created ${output}`);
1932
+ } catch (err) {
1933
+ spin.error(err.message);
1934
+ process.exit(1);
1935
+ }
1936
+ } else {
1937
+ console.error(fmt.status('error', `Unknown action: ${action}`));
1938
+ console.log(chalk.dim('Actions: list, extract, convert'));
1939
+ process.exit(1);
1940
+ }
1941
+ });
1942
+
1943
+ // ============================================================================
1944
+ // DOI command - Validate and fetch DOIs
1945
+ // ============================================================================
1946
+
1947
+ program
1948
+ .command('doi')
1949
+ .description('Validate DOIs in bibliography or fetch citations from DOI')
1950
+ .argument('<action>', 'Action: check, fetch, add, lookup')
1951
+ .argument('[input]', 'DOI (for fetch/add) or .bib file (for check)')
1952
+ .option('-b, --bib <file>', 'Bibliography file', 'references.bib')
1953
+ .option('--strict', 'Fail on missing DOIs for articles')
1954
+ .option('--no-resolve', 'Only check format, skip resolution check')
1955
+ .option('--confidence <level>', 'Minimum confidence: high, medium, low (default: medium)', 'medium')
1956
+ .action(async (action, input, options) => {
1957
+ if (action === 'check') {
1958
+ const bibPath = input || options.bib;
1959
+
1960
+ if (!fs.existsSync(bibPath)) {
1961
+ console.error(fmt.status('error', `File not found: ${bibPath}`));
1962
+ process.exit(1);
1963
+ }
1964
+
1965
+ console.log(fmt.header(`DOI Check: ${path.basename(bibPath)}`));
1966
+ console.log();
1967
+
1968
+ const spin = fmt.spinner('Validating DOIs...').start();
1969
+
1970
+ try {
1971
+ const results = await checkBibDois(bibPath, {
1972
+ checkMissing: options.strict,
1973
+ });
1974
+
1975
+ spin.stop();
1976
+
1977
+ // Group results by status
1978
+ const valid = results.entries.filter(e => e.status === 'valid');
1979
+ const invalid = results.entries.filter(e => e.status === 'invalid');
1980
+ const missing = results.entries.filter(e => e.status === 'missing');
1981
+ const skipped = results.entries.filter(e => e.status === 'skipped');
1982
+
1983
+ // Summary table
1984
+ const summaryRows = [
1985
+ [chalk.green('Valid'), chalk.green(valid.length.toString())],
1986
+ [invalid.length > 0 ? chalk.red('Invalid') : 'Invalid', invalid.length > 0 ? chalk.red(invalid.length.toString()) : '0'],
1987
+ [missing.length > 0 ? chalk.yellow('Missing (articles)') : 'Missing', missing.length > 0 ? chalk.yellow(missing.length.toString()) : '0'],
1988
+ [chalk.dim('Skipped'), chalk.dim(skipped.length.toString())],
1989
+ ];
1990
+ console.log(fmt.table(['Status', 'Count'], summaryRows));
1991
+ console.log();
1992
+
1993
+ // Show invalid DOIs
1994
+ if (invalid.length > 0) {
1995
+ console.log(chalk.red('Invalid DOIs:'));
1996
+ for (const e of invalid) {
1997
+ console.log(` ${chalk.bold(e.key)}: ${e.doi || 'N/A'}`);
1998
+ console.log(chalk.dim(` ${e.message}`));
1999
+ }
2000
+ console.log();
2001
+ }
2002
+
2003
+ // Show missing (articles without DOI)
2004
+ if (missing.length > 0) {
2005
+ console.log(chalk.yellow('Missing DOIs (should have DOI):'));
2006
+ for (const e of missing) {
2007
+ console.log(` ${chalk.bold(e.key)} [${e.type}]`);
2008
+ if (e.title) console.log(chalk.dim(` "${e.title}"`));
2009
+ }
2010
+ console.log();
2011
+ }
2012
+
2013
+ // Show skipped breakdown
2014
+ if (skipped.length > 0) {
2015
+ // Count by reason
2016
+ const manualSkip = skipped.filter(e => e.message === 'Marked as no-doi');
2017
+ const bookTypes = skipped.filter(e => e.message?.includes('typically has no DOI'));
2018
+ const noField = skipped.filter(e => e.message === 'No DOI field');
2019
+
2020
+ console.log(chalk.dim('Skipped entries:'));
2021
+ if (manualSkip.length > 0) {
2022
+ console.log(chalk.dim(` ${manualSkip.length} marked with nodoi={true}`));
2023
+ }
2024
+ if (bookTypes.length > 0) {
2025
+ const types = [...new Set(bookTypes.map(e => e.type))].join(', ');
2026
+ console.log(chalk.dim(` ${bookTypes.length} by type (${types})`));
2027
+ }
2028
+ if (noField.length > 0) {
2029
+ console.log(chalk.dim(` ${noField.length} with no DOI field`));
2030
+ }
2031
+ console.log();
2032
+ }
2033
+
2034
+ // Final status
2035
+ if (invalid.length === 0 && missing.length === 0) {
2036
+ console.log(fmt.status('success', 'All DOIs valid'));
2037
+ } else if (invalid.length > 0) {
2038
+ console.log(fmt.status('error', `${invalid.length} invalid DOI(s) found`));
2039
+ if (options.strict) process.exit(1);
2040
+ } else {
2041
+ console.log(fmt.status('warning', `${missing.length} article(s) missing DOI`));
2042
+ }
2043
+
2044
+ // Hint about skipping
2045
+ console.log();
2046
+ console.log(chalk.dim('To skip DOI check for an entry, add: nodoi = {true}'));
2047
+ console.log(chalk.dim('Or add comment before entry: % no-doi'));
2048
+
2049
+ } catch (err) {
2050
+ spin.stop();
2051
+ console.error(fmt.status('error', err.message));
2052
+ process.exit(1);
2053
+ }
2054
+
2055
+ } else if (action === 'fetch') {
2056
+ if (!input) {
2057
+ console.error(fmt.status('error', 'DOI required'));
2058
+ console.log(chalk.dim('Usage: rev doi fetch 10.1234/example'));
2059
+ process.exit(1);
2060
+ }
2061
+
2062
+ const spin = fmt.spinner(`Fetching BibTeX for ${input}...`).start();
2063
+
2064
+ try {
2065
+ const result = await fetchBibtex(input);
2066
+
2067
+ if (result.success) {
2068
+ spin.success('BibTeX retrieved');
2069
+ console.log();
2070
+ console.log(result.bibtex);
2071
+ } else {
2072
+ spin.error(result.error);
2073
+ process.exit(1);
2074
+ }
2075
+ } catch (err) {
2076
+ spin.error(err.message);
2077
+ process.exit(1);
2078
+ }
2079
+
2080
+ } else if (action === 'add') {
2081
+ if (!input) {
2082
+ console.error(fmt.status('error', 'DOI required'));
2083
+ console.log(chalk.dim('Usage: rev doi add 10.1234/example'));
2084
+ process.exit(1);
2085
+ }
2086
+
2087
+ const bibPath = options.bib;
2088
+ const spin = fmt.spinner(`Fetching and adding ${input}...`).start();
2089
+
2090
+ try {
2091
+ const fetchResult = await fetchBibtex(input);
2092
+
2093
+ if (!fetchResult.success) {
2094
+ spin.error(fetchResult.error);
2095
+ process.exit(1);
2096
+ }
2097
+
2098
+ const addResult = addToBib(bibPath, fetchResult.bibtex);
2099
+
2100
+ if (addResult.success) {
2101
+ spin.success(`Added @${addResult.key} to ${bibPath}`);
2102
+ } else {
2103
+ spin.error(addResult.error);
2104
+ process.exit(1);
2105
+ }
2106
+ } catch (err) {
2107
+ spin.error(err.message);
2108
+ process.exit(1);
2109
+ }
2110
+
2111
+ } else if (action === 'lookup') {
2112
+ const bibPath = input || options.bib;
2113
+
2114
+ if (!fs.existsSync(bibPath)) {
2115
+ console.error(fmt.status('error', `File not found: ${bibPath}`));
2116
+ process.exit(1);
2117
+ }
2118
+
2119
+ console.log(fmt.header(`DOI Lookup: ${path.basename(bibPath)}`));
2120
+ console.log();
2121
+
2122
+ const entries = parseBibEntries(bibPath);
2123
+ const missing = entries.filter(e => !e.doi && !e.skip && e.expectDoi);
2124
+
2125
+ if (missing.length === 0) {
2126
+ console.log(fmt.status('success', 'No entries need DOI lookup'));
2127
+ return;
2128
+ }
2129
+
2130
+ console.log(chalk.dim(`Found ${missing.length} entries without DOIs to search...\n`));
2131
+
2132
+ let found = 0;
2133
+ let notFound = 0;
2134
+ let lowConfidence = 0;
2135
+ const results = [];
2136
+
2137
+ for (let i = 0; i < missing.length; i++) {
2138
+ const entry = missing[i];
2139
+
2140
+ // Extract first author last name
2141
+ let author = '';
2142
+ if (entry.authorRaw) {
2143
+ const firstAuthor = entry.authorRaw.split(' and ')[0];
2144
+ // Handle "Last, First" or "First Last" formats
2145
+ if (firstAuthor.includes(',')) {
2146
+ author = firstAuthor.split(',')[0].trim();
2147
+ } else {
2148
+ const parts = firstAuthor.trim().split(/\s+/);
2149
+ author = parts[parts.length - 1]; // Last word is usually surname
2150
+ }
2151
+ }
2152
+
2153
+ process.stdout.write(`\r${chalk.dim(`[${i + 1}/${missing.length}]`)} ${entry.key}...`);
2154
+
2155
+ const result = await lookupDoi(entry.title, author, entry.year, entry.journal);
2156
+
2157
+ if (result.found) {
2158
+ if (result.confidence === 'high') {
2159
+ found++;
2160
+ results.push({ entry, result, status: 'found' });
2161
+ } else if (result.confidence === 'medium') {
2162
+ found++;
2163
+ results.push({ entry, result, status: 'found' });
2164
+ } else {
2165
+ lowConfidence++;
2166
+ results.push({ entry, result, status: 'low' });
2167
+ }
2168
+ } else {
2169
+ notFound++;
2170
+ results.push({ entry, result, status: 'not-found' });
2171
+ }
2172
+
2173
+ // Rate limiting
2174
+ await new Promise(r => setTimeout(r, 200));
2175
+ }
2176
+
2177
+ // Clear progress line
2178
+ process.stdout.write('\r\x1B[K');
2179
+
2180
+ // Show results
2181
+ console.log(fmt.table(
2182
+ ['Status', 'Count'],
2183
+ [
2184
+ [chalk.green('Found (high/medium confidence)'), chalk.green(found.toString())],
2185
+ [chalk.yellow('Found (low confidence)'), chalk.yellow(lowConfidence.toString())],
2186
+ [chalk.dim('Not found'), chalk.dim(notFound.toString())],
2187
+ ]
2188
+ ));
2189
+ console.log();
2190
+
2191
+ // Filter by confidence level
2192
+ const confLevel = options.confidence || 'medium';
2193
+ const confLevels = { high: 3, medium: 2, low: 1 };
2194
+ const minConf = confLevels[confLevel] || 2;
2195
+
2196
+ const filteredResults = results.filter(r => {
2197
+ if (r.status === 'not-found') return false;
2198
+ const resultConf = confLevels[r.result.confidence] || 1;
2199
+ return resultConf >= minConf;
2200
+ });
2201
+
2202
+ const hiddenCount = results.filter(r => {
2203
+ if (r.status === 'not-found') return false;
2204
+ const resultConf = confLevels[r.result.confidence] || 1;
2205
+ return resultConf < minConf;
2206
+ }).length;
2207
+
2208
+ if (filteredResults.length > 0) {
2209
+ console.log(chalk.cyan(`Found DOIs (${confLevel}+ confidence):`));
2210
+ console.log();
2211
+
2212
+ for (const { entry, result } of filteredResults) {
2213
+ const conf = result.confidence === 'high' ? chalk.green('●') :
2214
+ result.confidence === 'medium' ? chalk.yellow('●') :
2215
+ chalk.red('○');
2216
+
2217
+ // Check year match
2218
+ const entryYear = entry.year;
2219
+ const foundYear = result.metadata?.year;
2220
+ const yearExact = entryYear && foundYear && entryYear === foundYear;
2221
+ const yearClose = entryYear && foundYear && Math.abs(entryYear - foundYear) === 1;
2222
+ const yearMismatch = entryYear && foundYear && Math.abs(entryYear - foundYear) > 1;
2223
+
2224
+ console.log(` ${conf} ${chalk.bold(entry.key)} (${entryYear || '?'})`);
2225
+ console.log(chalk.dim(` Title: ${entry.title}`));
2226
+ console.log(chalk.cyan(` DOI: ${result.doi}`));
2227
+
2228
+ if (result.metadata?.journal) {
2229
+ let yearDisplay;
2230
+ if (yearExact) {
2231
+ yearDisplay = chalk.green(`(${foundYear})`);
2232
+ } else if (yearClose) {
2233
+ yearDisplay = chalk.yellow(`(${foundYear}) ≈`);
2234
+ } else if (yearMismatch) {
2235
+ yearDisplay = chalk.red.bold(`(${foundYear}) ⚠ YEAR MISMATCH`);
2236
+ } else {
2237
+ yearDisplay = chalk.dim(`(${foundYear || '?'})`);
2238
+ }
2239
+ console.log(` ${chalk.dim('Found:')} ${result.metadata.journal} ${yearDisplay}`);
2240
+ }
2241
+
2242
+ // Extra warning for year mismatch
2243
+ if (yearMismatch) {
2244
+ console.log(chalk.red(` ⚠ Expected ${entryYear}, found ${foundYear} - verify this is correct!`));
2245
+ }
2246
+
2247
+ console.log();
2248
+ }
2249
+
2250
+ // Offer to add DOIs
2251
+ console.log(chalk.dim('To add a DOI to your .bib file:'));
2252
+ console.log(chalk.dim(' 1. Open references.bib'));
2253
+ console.log(chalk.dim(' 2. Add: doi = {10.xxxx/xxxxx}'));
2254
+ console.log();
2255
+ console.log(chalk.dim('Or use: rev doi add <doi> to fetch full BibTeX'));
2256
+ }
2257
+
2258
+ // Show hidden count
2259
+ if (hiddenCount > 0) {
2260
+ console.log(chalk.yellow(`\n${hiddenCount} lower-confidence matches hidden.`));
2261
+ if (confLevel === 'high') {
2262
+ console.log(chalk.dim('Use --confidence medium or --confidence low to show more.'));
2263
+ } else if (confLevel === 'medium') {
2264
+ console.log(chalk.dim('Use --confidence low to show all matches.'));
2265
+ }
2266
+ }
2267
+
2268
+ // Show not found
2269
+ if (notFound > 0) {
2270
+ console.log(chalk.dim(`${notFound} entries could not be matched. These may be:`));
2271
+ console.log(chalk.dim(' - Books, theses, or reports (often no DOI)'));
2272
+ console.log(chalk.dim(' - Very old papers (pre-DOI era)'));
2273
+ console.log(chalk.dim(' - Title mismatch (special characters, abbreviations)'));
2274
+ }
2275
+
2276
+ } else {
2277
+ console.error(fmt.status('error', `Unknown action: ${action}`));
2278
+ console.log(chalk.dim('Actions: check, fetch, add, lookup'));
2279
+ process.exit(1);
2280
+ }
2281
+ });
2282
+
2283
+ // ============================================================================
2284
+ // HELP command - Comprehensive help
2285
+ // ============================================================================
2286
+
2287
+ program
2288
+ .command('help')
2289
+ .description('Show detailed help and workflow guide')
2290
+ .argument('[topic]', 'Help topic: workflow, syntax, commands')
2291
+ .action((topic) => {
2292
+ if (!topic || topic === 'all') {
2293
+ showFullHelp();
2294
+ } else if (topic === 'workflow') {
2295
+ showWorkflowHelp();
2296
+ } else if (topic === 'syntax') {
2297
+ showSyntaxHelp();
2298
+ } else if (topic === 'commands') {
2299
+ showCommandsHelp();
2300
+ } else {
2301
+ console.log(chalk.yellow(`Unknown topic: ${topic}`));
2302
+ console.log(chalk.dim('Available topics: workflow, syntax, commands'));
2303
+ }
2304
+ });
2305
+
2306
+ function showFullHelp() {
2307
+ console.log(`
2308
+ ${chalk.bold.cyan('rev')} - Revision workflow for Word ↔ Markdown round-trips
2309
+
2310
+ ${chalk.bold('DESCRIPTION')}
2311
+ Handle reviewer feedback when collaborating on academic papers.
2312
+ Import changes from Word, review them interactively, and preserve
2313
+ comments for discussion with Claude.
2314
+
2315
+ ${chalk.bold('TYPICAL WORKFLOW')}
2316
+
2317
+ ${chalk.dim('1.')} You send ${chalk.yellow('paper.docx')} to reviewers
2318
+ ${chalk.dim('2.')} They return ${chalk.yellow('reviewed.docx')} with edits and comments
2319
+ ${chalk.dim('3.')} Import their changes (extracts both track changes AND comments):
2320
+
2321
+ ${chalk.green('rev sections reviewed.docx')}
2322
+
2323
+ ${chalk.dim('4.')} Review track changes interactively:
2324
+
2325
+ ${chalk.green('rev review paper.md')}
2326
+
2327
+ Use: ${chalk.dim('[a]ccept [r]eject [s]kip [A]ccept-all [q]uit')}
2328
+
2329
+ ${chalk.dim('5.')} Address comments with Claude:
2330
+
2331
+ ${chalk.dim('"Go through each comment in paper.md and help me address them"')}
2332
+
2333
+ ${chalk.dim('6.')} Rebuild:
2334
+
2335
+ ${chalk.green('./build.sh docx')}
2336
+
2337
+ ${chalk.bold('ANNOTATION SYNTAX')} ${chalk.dim('(CriticMarkup)')}
2338
+
2339
+ ${chalk.green('{++inserted text++}')} Text that was added
2340
+ ${chalk.red('{--deleted text--}')} Text that was removed
2341
+ ${chalk.yellow('{~~old~>new~~}')} Text that was changed
2342
+ ${chalk.blue('{>>Author: comment<<}')} Reviewer comment
2343
+
2344
+ ${chalk.bold('IMPORT & BUILD')}
2345
+
2346
+ ${chalk.bold('rev sections')} <docx> Import Word doc to section files
2347
+ ${chalk.bold('rev import')} <docx> [md] Import/diff Word against Markdown
2348
+ ${chalk.bold('rev extract')} <docx> Extract plain text from Word
2349
+ ${chalk.bold('rev build')} [formats] Build PDF/DOCX/TEX from sections
2350
+ ${chalk.bold('rev new')} [name] Create new project from template
2351
+
2352
+ ${chalk.bold('REVIEW & EDIT')}
2353
+
2354
+ ${chalk.bold('rev review')} <file> Interactive accept/reject TUI
2355
+ ${chalk.bold('rev status')} <file> Show annotation statistics
2356
+ ${chalk.bold('rev comments')} <file> List all comments with context
2357
+ ${chalk.bold('rev reply')} <file> Reply to reviewer comments
2358
+ ${chalk.bold('rev strip')} <file> Output clean text (no annotations)
2359
+ ${chalk.bold('rev resolve')} <file> Mark comments resolved/pending
2360
+
2361
+ ${chalk.bold('CROSS-REFERENCES')}
2362
+
2363
+ ${chalk.bold('rev refs')} [file] Show figure/table registry
2364
+ ${chalk.bold('rev migrate')} <file> Convert "Fig. 1" to @fig:label
2365
+ ${chalk.bold('rev figures')} [files] List figures with ref counts
2366
+
2367
+ ${chalk.bold('CITATIONS & EQUATIONS')}
2368
+
2369
+ ${chalk.bold('rev citations')} [files] Validate citations against .bib
2370
+ ${chalk.bold('rev equations')} <action> Extract/export LaTeX equations
2371
+ ${chalk.bold('rev response')} [files] Generate response letter
2372
+
2373
+ ${chalk.bold('CONFIGURATION')}
2374
+
2375
+ ${chalk.bold('rev config')} <key> [value] Set user preferences
2376
+ ${chalk.bold('rev init')} Generate sections.yaml
2377
+ ${chalk.bold('rev install')} Check/install dependencies
2378
+ ${chalk.bold('rev help')} [topic] Show help (workflow, syntax, commands)
2379
+
2380
+ ${chalk.bold('BIBLIOGRAPHY & DOIs')}
2381
+
2382
+ ${chalk.bold('rev doi check')} [file.bib] Validate DOIs via Crossref + DataCite
2383
+ ${chalk.dim('--strict')} Fail if articles missing DOIs
2384
+
2385
+ ${chalk.bold('rev doi lookup')} [file.bib] Search for missing DOIs by title/author/year
2386
+ ${chalk.dim('--confidence <level>')} Filter by: high, medium, low
2387
+
2388
+ ${chalk.bold('rev doi fetch')} <doi> Fetch BibTeX entry from DOI
2389
+ ${chalk.bold('rev doi add')} <doi> Fetch and add entry to bibliography
2390
+
2391
+ ${chalk.dim('Skip entries: add nodoi = {true} or % no-doi comment')}
2392
+
2393
+ ${chalk.bold('EXAMPLES')}
2394
+
2395
+ ${chalk.dim('# Import reviewer feedback')}
2396
+ rev import reviewed.docx methods.md
2397
+
2398
+ ${chalk.dim('# Preview changes without saving')}
2399
+ rev import reviewed.docx methods.md --dry-run
2400
+
2401
+ ${chalk.dim('# See what needs attention')}
2402
+ rev status paper.md
2403
+
2404
+ ${chalk.dim('# Accept/reject changes one by one')}
2405
+ rev review paper.md
2406
+
2407
+ ${chalk.dim('# List all pending comments')}
2408
+ rev comments paper.md
2409
+
2410
+ ${chalk.dim('# Get clean text for PDF build')}
2411
+ rev strip paper.md -o paper_clean.md
2412
+
2413
+ ${chalk.dim('# Validate DOIs in bibliography')}
2414
+ rev doi check references.bib
2415
+
2416
+ ${chalk.dim('# Find missing DOIs')}
2417
+ rev doi lookup references.bib --confidence medium
2418
+
2419
+ ${chalk.bold('BUILD INTEGRATION')}
2420
+
2421
+ The build scripts automatically handle annotations:
2422
+ ${chalk.dim('•')} ${chalk.bold('PDF build:')} Strips all annotations (clean output)
2423
+ ${chalk.dim('•')} ${chalk.bold('DOCX build:')} Keeps comments visible for tracking
2424
+
2425
+ ${chalk.bold('MORE HELP')}
2426
+
2427
+ rev help workflow Detailed workflow guide
2428
+ rev help syntax Annotation syntax reference
2429
+ rev help commands All commands with options
2430
+ `);
2431
+ }
2432
+
2433
+ function showWorkflowHelp() {
2434
+ console.log(`
2435
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Workflow Guide')}
2436
+
2437
+ ${chalk.bold('OVERVIEW')}
2438
+
2439
+ The rev workflow solves a common problem: you write in Markdown,
2440
+ but collaborators review in Word. When they return edited documents,
2441
+ you need to merge their changes back into your source files.
2442
+
2443
+ ${chalk.bold('STEP 1: SEND TO REVIEWERS')}
2444
+
2445
+ Build your Word document and send it:
2446
+
2447
+ ${chalk.green('./build.sh docx')}
2448
+ ${chalk.dim('# Send paper.docx to reviewers')}
2449
+
2450
+ ${chalk.bold('STEP 2: RECEIVE FEEDBACK')}
2451
+
2452
+ Reviewers edit the document, adding:
2453
+ ${chalk.dim('•')} Track changes (insertions, deletions)
2454
+ ${chalk.dim('•')} Comments (questions, suggestions)
2455
+
2456
+ ${chalk.bold('STEP 3: IMPORT CHANGES')}
2457
+
2458
+ Compare their version against your original:
2459
+
2460
+ ${chalk.green('rev import reviewed.docx paper.md')}
2461
+
2462
+ This generates annotated markdown showing all differences:
2463
+ ${chalk.dim('•')} ${chalk.green('{++new text++}')} - Reviewer added this
2464
+ ${chalk.dim('•')} ${chalk.red('{--old text--}')} - Reviewer deleted this
2465
+ ${chalk.dim('•')} ${chalk.yellow('{~~old~>new~~}')} - Reviewer changed this
2466
+ ${chalk.dim('•')} ${chalk.blue('{>>comment<<}')} - Reviewer comment
2467
+
2468
+ ${chalk.bold('STEP 4: REVIEW TRACK CHANGES')}
2469
+
2470
+ Go through changes interactively:
2471
+
2472
+ ${chalk.green('rev review paper.md')}
2473
+
2474
+ For each change, choose:
2475
+ ${chalk.dim('•')} ${chalk.bold('a')} - Accept (apply the change)
2476
+ ${chalk.dim('•')} ${chalk.bold('r')} - Reject (keep original)
2477
+ ${chalk.dim('•')} ${chalk.bold('s')} - Skip (decide later)
2478
+ ${chalk.dim('•')} ${chalk.bold('A')} - Accept all remaining
2479
+ ${chalk.dim('•')} ${chalk.bold('q')} - Quit
2480
+
2481
+ ${chalk.bold('STEP 5: ADDRESS COMMENTS')}
2482
+
2483
+ Comments remain in your file as ${chalk.blue('{>>Author: text<<}')}
2484
+
2485
+ Work with Claude to address them:
2486
+
2487
+ ${chalk.dim('"Go through each reviewer comment in methods.md')}
2488
+ ${chalk.dim(' and help me address them one by one"')}
2489
+
2490
+ Delete comment annotations as you resolve them.
2491
+
2492
+ ${chalk.bold('STEP 6: REBUILD')}
2493
+
2494
+ Generate new Word document:
2495
+
2496
+ ${chalk.green('./build.sh docx')}
2497
+
2498
+ ${chalk.dim('•')} Remaining comments stay visible in output
2499
+ ${chalk.dim('•')} PDF build strips all annotations
2500
+ `);
2501
+ }
2502
+
2503
+ function showSyntaxHelp() {
2504
+ console.log(`
2505
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Annotation Syntax (CriticMarkup)')}
2506
+
2507
+ ${chalk.bold('INSERTIONS')}
2508
+
2509
+ Syntax: ${chalk.green('{++inserted text++}')}
2510
+ Meaning: This text was added by the reviewer
2511
+
2512
+ Example:
2513
+ We ${chalk.green('{++specifically++}')} focused on neophytes.
2514
+ → Reviewer added the word "specifically"
2515
+
2516
+ ${chalk.bold('DELETIONS')}
2517
+
2518
+ Syntax: ${chalk.red('{--deleted text--}')}
2519
+ Meaning: This text was removed by the reviewer
2520
+
2521
+ Example:
2522
+ We focused on ${chalk.red('{--recent--}')} neophytes.
2523
+ → Reviewer removed the word "recent"
2524
+
2525
+ ${chalk.bold('SUBSTITUTIONS')}
2526
+
2527
+ Syntax: ${chalk.yellow('{~~old text~>new text~~}')}
2528
+ Meaning: Text was changed from old to new
2529
+
2530
+ Example:
2531
+ The effect was ${chalk.yellow('{~~significant~>substantial~~}')}.
2532
+ → Reviewer changed "significant" to "substantial"
2533
+
2534
+ ${chalk.bold('COMMENTS')}
2535
+
2536
+ Syntax: ${chalk.blue('{>>Author: comment text<<}')}
2537
+ Meaning: Reviewer left a comment at this location
2538
+
2539
+ Example:
2540
+ The results were significant. ${chalk.blue('{>>Dr. Smith: Add p-value<<}')}
2541
+ → Dr. Smith commented asking for a p-value
2542
+
2543
+ Comments are placed ${chalk.bold('after')} the text they reference.
2544
+
2545
+ ${chalk.bold('COMBINING ANNOTATIONS')}
2546
+
2547
+ Annotations can appear together:
2548
+
2549
+ We found ${chalk.yellow('{~~a~>the~~}')} ${chalk.green('{++significant++}')} effect.
2550
+ ${chalk.blue('{>>Reviewer: Is this the right word?<<}')}
2551
+
2552
+ ${chalk.bold('IN YOUR MARKDOWN FILES')}
2553
+
2554
+ Annotations work alongside normal Markdown:
2555
+
2556
+ ## Results
2557
+
2558
+ Species richness ${chalk.yellow('{~~increased~>showed a significant increase~~}')}
2559
+ in disturbed habitats (p < 0.001). ${chalk.blue('{>>Add effect size<<}')}
2560
+
2561
+ ${chalk.bold('ESCAPING')}
2562
+
2563
+ If you need literal {++ in your text, there's no escape mechanism.
2564
+ This is rarely an issue in academic writing.
2565
+ `);
2566
+ }
2567
+
2568
+ function showCommandsHelp() {
2569
+ console.log(`
2570
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Command Reference')}
2571
+
2572
+ ${chalk.bold('rev import')} <docx> <original-md>
2573
+
2574
+ Import changes from a Word document by comparing against your
2575
+ original Markdown source.
2576
+
2577
+ ${chalk.bold('Arguments:')}
2578
+ docx Word document from reviewer
2579
+ original-md Your original Markdown file
2580
+
2581
+ ${chalk.bold('Options:')}
2582
+ -o, --output <file> Write to different file (default: overwrites original)
2583
+ -a, --author <name> Author name for changes (default: "Reviewer")
2584
+ --dry-run Preview changes without saving
2585
+
2586
+ ${chalk.bold('Examples:')}
2587
+ rev import reviewed.docx paper.md
2588
+ rev import reviewed.docx paper.md -o paper_annotated.md
2589
+ rev import reviewed.docx paper.md --dry-run
2590
+
2591
+ ${chalk.bold('rev review')} <file>
2592
+
2593
+ Interactively review and accept/reject track changes.
2594
+ Comments are preserved; only track changes are processed.
2595
+
2596
+ ${chalk.bold('Keys:')}
2597
+ a Accept this change
2598
+ r Reject this change
2599
+ s Skip (decide later)
2600
+ A Accept all remaining changes
2601
+ L Reject all remaining changes
2602
+ q Quit without saving
2603
+
2604
+ ${chalk.bold('rev status')} <file>
2605
+
2606
+ Show annotation statistics: counts of insertions, deletions,
2607
+ substitutions, and comments. Lists comments with authors.
2608
+
2609
+ ${chalk.bold('rev comments')} <file>
2610
+
2611
+ List all comments with context. Shows surrounding text
2612
+ to help locate each comment.
2613
+
2614
+ ${chalk.bold('rev strip')} <file>
2615
+
2616
+ Remove annotations, outputting clean Markdown.
2617
+ Track changes are applied (insertions kept, deletions removed).
2618
+
2619
+ ${chalk.bold('Options:')}
2620
+ -o, --output <file> Write to file (default: stdout)
2621
+ -c, --keep-comments Keep comment annotations
2622
+
2623
+ ${chalk.bold('Examples:')}
2624
+ rev strip paper.md # Clean text to stdout
2625
+ rev strip paper.md -o clean.md # Clean text to file
2626
+ rev strip paper.md -c # Strip changes, keep comments
2627
+
2628
+ ${chalk.bold('rev extract')} <docx>
2629
+
2630
+ Extract plain text from a Word document.
2631
+ Simpler than import - no diffing, just text extraction.
2632
+
2633
+ ${chalk.bold('Options:')}
2634
+ -o, --output <file> Write to file (default: stdout)
2635
+
2636
+ ${chalk.bold('rev help')} [topic]
2637
+
2638
+ Show help. Optional topics:
2639
+ workflow Step-by-step workflow guide
2640
+ syntax Annotation syntax reference
2641
+ commands This command reference
2642
+ `);
2643
+ }
2644
+
2645
+ program.parse();