docrev 0.2.0 → 0.3.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 CHANGED
@@ -48,7 +48,7 @@ import * as fmt from '../lib/format.js';
48
48
  import { inlineDiffPreview } from '../lib/format.js';
49
49
  import { parseCommentsWithReplies, collectComments, generateResponseLetter, groupByReviewer } from '../lib/response.js';
50
50
  import { validateCitations, getCitationStats } from '../lib/citations.js';
51
- import { extractEquations, getEquationStats, createEquationsDoc } from '../lib/equations.js';
51
+ import { extractEquations, getEquationStats, createEquationsDoc, extractEquationsFromWord, getWordEquationStats } from '../lib/equations.js';
52
52
  import { parseBibEntries, checkBibDois, fetchBibtex, addToBib, isValidDoiFormat, lookupDoi, lookupMissingDois } from '../lib/doi.js';
53
53
 
54
54
  program
@@ -185,6 +185,7 @@ program
185
185
  .argument('<file>', 'Markdown file')
186
186
  .option('-p, --pending', 'Show only pending (unresolved) comments')
187
187
  .option('-r, --resolved', 'Show only resolved comments')
188
+ .option('-e, --export <csvFile>', 'Export comments to CSV file')
188
189
  .action((file, options) => {
189
190
  if (!fs.existsSync(file)) {
190
191
  console.error(chalk.red(`Error: File not found: ${file}`));
@@ -197,6 +198,34 @@ program
197
198
  resolvedOnly: options.resolved,
198
199
  });
199
200
 
201
+ // CSV export mode
202
+ if (options.export) {
203
+ const csvEscape = (str) => {
204
+ if (!str) return '';
205
+ str = String(str);
206
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
207
+ return '"' + str.replace(/"/g, '""') + '"';
208
+ }
209
+ return str;
210
+ };
211
+
212
+ const header = ['number', 'author', 'comment', 'context', 'status', 'file', 'line'];
213
+ const rows = comments.map((c, i) => [
214
+ i + 1,
215
+ csvEscape(c.author || ''),
216
+ csvEscape(c.content),
217
+ csvEscape(c.before ? c.before.trim() : ''),
218
+ c.resolved ? 'resolved' : 'pending',
219
+ path.basename(file),
220
+ c.line,
221
+ ].join(','));
222
+
223
+ const csv = [header.join(','), ...rows].join('\n');
224
+ fs.writeFileSync(options.export, csv, 'utf-8');
225
+ console.log(fmt.status('success', `Exported ${comments.length} comments to ${options.export}`));
226
+ return;
227
+ }
228
+
200
229
  if (comments.length === 0) {
201
230
  if (options.pending) {
202
231
  console.log(fmt.status('success', 'No pending comments'));
@@ -1019,6 +1048,148 @@ program
1019
1048
  }
1020
1049
  });
1021
1050
 
1051
+ // ============================================================================
1052
+ // MERGE command - Combine feedback from multiple reviewers
1053
+ // ============================================================================
1054
+
1055
+ program
1056
+ .command('merge')
1057
+ .description('Merge feedback from multiple Word documents')
1058
+ .argument('<original>', 'Original markdown file')
1059
+ .argument('<docx...>', 'Word documents from reviewers')
1060
+ .option('-o, --output <file>', 'Output file (default: original-merged.md)')
1061
+ .option('--names <names>', 'Reviewer names (comma-separated, in order of docx files)')
1062
+ .option('--auto', 'Auto-resolve conflicts by taking first change')
1063
+ .option('--dry-run', 'Show conflicts without writing')
1064
+ .action(async (original, docxFiles, options) => {
1065
+ const { mergeReviewerDocs, formatConflict, applyChangesAsAnnotations, resolveConflict } = await import('../lib/merge.js');
1066
+
1067
+ if (!fs.existsSync(original)) {
1068
+ console.error(fmt.status('error', `Original file not found: ${original}`));
1069
+ process.exit(1);
1070
+ }
1071
+
1072
+ // Validate all docx files exist
1073
+ for (const docx of docxFiles) {
1074
+ if (!fs.existsSync(docx)) {
1075
+ console.error(fmt.status('error', `Reviewer file not found: ${docx}`));
1076
+ process.exit(1);
1077
+ }
1078
+ }
1079
+
1080
+ // Parse reviewer names
1081
+ const names = options.names
1082
+ ? options.names.split(',').map(n => n.trim())
1083
+ : docxFiles.map((f, i) => `Reviewer ${i + 1}`);
1084
+
1085
+ if (names.length < docxFiles.length) {
1086
+ // Pad with default names
1087
+ for (let i = names.length; i < docxFiles.length; i++) {
1088
+ names.push(`Reviewer ${i + 1}`);
1089
+ }
1090
+ }
1091
+
1092
+ const reviewerDocs = docxFiles.map((p, i) => ({
1093
+ path: p,
1094
+ name: names[i],
1095
+ }));
1096
+
1097
+ console.log(fmt.header('Multi-Reviewer Merge'));
1098
+ console.log();
1099
+ console.log(chalk.dim(` Original: ${original}`));
1100
+ console.log(chalk.dim(` Reviewers: ${names.join(', ')}`));
1101
+ console.log();
1102
+
1103
+ const spin = fmt.spinner('Analyzing changes...').start();
1104
+
1105
+ try {
1106
+ const { merged, conflicts, stats, originalText } = await mergeReviewerDocs(original, reviewerDocs, {
1107
+ autoResolve: options.auto,
1108
+ });
1109
+
1110
+ spin.stop();
1111
+
1112
+ // Show stats
1113
+ console.log(fmt.table(['Metric', 'Count'], [
1114
+ ['Total changes', stats.totalChanges.toString()],
1115
+ ['Non-conflicting', stats.nonConflicting.toString()],
1116
+ ['Conflicts', stats.conflicts.toString()],
1117
+ ['Comments', stats.comments.toString()],
1118
+ ]));
1119
+ console.log();
1120
+
1121
+ // Handle conflicts
1122
+ if (conflicts.length > 0) {
1123
+ console.log(chalk.yellow(`Found ${conflicts.length} conflict(s):\n`));
1124
+
1125
+ let resolvedMerged = merged;
1126
+
1127
+ for (let i = 0; i < conflicts.length; i++) {
1128
+ const conflict = conflicts[i];
1129
+ console.log(chalk.bold(`Conflict ${i + 1}/${conflicts.length}:`));
1130
+ console.log(formatConflict(conflict, originalText));
1131
+ console.log();
1132
+
1133
+ if (options.auto) {
1134
+ // Auto-resolve: take first reviewer's change
1135
+ console.log(chalk.dim(` Auto-resolved: using ${conflict.changes[0].reviewer}'s change`));
1136
+ resolvedMerged = resolveConflict(resolvedMerged, conflict, 0, originalText);
1137
+ } else if (!options.dryRun) {
1138
+ // Interactive resolution
1139
+ const rl = await import('readline');
1140
+ const readline = rl.createInterface({
1141
+ input: process.stdin,
1142
+ output: process.stdout,
1143
+ });
1144
+
1145
+ const answer = await new Promise((resolve) =>
1146
+ readline.question(chalk.cyan(` Choose (1-${conflict.changes.length}, s=skip): `), resolve)
1147
+ );
1148
+ readline.close();
1149
+
1150
+ if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
1151
+ const choice = parseInt(answer) - 1;
1152
+ if (choice >= 0 && choice < conflict.changes.length) {
1153
+ resolvedMerged = resolveConflict(resolvedMerged, conflict, choice, originalText);
1154
+ console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
1155
+ }
1156
+ } else {
1157
+ console.log(chalk.dim(' Skipped'));
1158
+ }
1159
+ console.log();
1160
+ }
1161
+ }
1162
+
1163
+ if (!options.dryRun) {
1164
+ const outPath = options.output || original.replace(/\.md$/, '-merged.md');
1165
+ fs.writeFileSync(outPath, resolvedMerged, 'utf-8');
1166
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
1167
+ }
1168
+ } else {
1169
+ // No conflicts
1170
+ if (!options.dryRun) {
1171
+ const outPath = options.output || original.replace(/\.md$/, '-merged.md');
1172
+ fs.writeFileSync(outPath, merged, 'utf-8');
1173
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
1174
+ } else {
1175
+ console.log(fmt.status('info', 'Dry run - no output written'));
1176
+ }
1177
+ }
1178
+
1179
+ if (!options.dryRun && stats.nonConflicting > 0) {
1180
+ console.log();
1181
+ console.log(chalk.dim('Next steps:'));
1182
+ console.log(chalk.dim(' 1. rev review <merged.md> - Review all changes'));
1183
+ console.log(chalk.dim(' 2. rev comments <merged.md> - Address comments'));
1184
+ }
1185
+ } catch (err) {
1186
+ spin.stop();
1187
+ console.error(fmt.status('error', err.message));
1188
+ if (process.env.DEBUG) console.error(err.stack);
1189
+ process.exit(1);
1190
+ }
1191
+ });
1192
+
1022
1193
  // ============================================================================
1023
1194
  // REFS command - Show figure/table reference status
1024
1195
  // ============================================================================
@@ -1351,6 +1522,8 @@ program
1351
1522
  .argument('[formats...]', 'Output formats: pdf, docx, tex, all', ['pdf', 'docx'])
1352
1523
  .option('-d, --dir <directory>', 'Project directory', '.')
1353
1524
  .option('--no-crossref', 'Skip pandoc-crossref filter')
1525
+ .option('--toc', 'Include table of contents')
1526
+ .option('--show-changes', 'Export DOCX with visible track changes (audit mode)')
1354
1527
  .action(async (formats, options) => {
1355
1528
  const dir = path.resolve(options.dir);
1356
1529
 
@@ -1380,15 +1553,76 @@ program
1380
1553
 
1381
1554
  // Show what we're building
1382
1555
  const targetFormats = formats.length > 0 ? formats : ['pdf', 'docx'];
1556
+ const tocEnabled = options.toc || config.pdf?.toc || config.docx?.toc;
1383
1557
  console.log(chalk.dim(` Formats: ${targetFormats.join(', ')}`));
1384
1558
  console.log(chalk.dim(` Crossref: ${hasPandocCrossref() && options.crossref !== false ? 'enabled' : 'disabled'}`));
1559
+ if (tocEnabled) console.log(chalk.dim(` TOC: enabled`));
1560
+ if (options.showChanges) console.log(chalk.dim(` Track changes: visible`));
1385
1561
  console.log('');
1386
1562
 
1563
+ // Override config with CLI options
1564
+ if (options.toc) {
1565
+ config.pdf.toc = true;
1566
+ config.docx.toc = true;
1567
+ }
1568
+
1569
+ // Handle --show-changes mode (audit export)
1570
+ if (options.showChanges) {
1571
+ if (!targetFormats.includes('docx') && !targetFormats.includes('all')) {
1572
+ console.error(fmt.status('error', '--show-changes only applies to DOCX output'));
1573
+ process.exit(1);
1574
+ }
1575
+
1576
+ const { combineSections } = await import('../lib/build.js');
1577
+ const { buildWithTrackChanges } = await import('../lib/trackchanges.js');
1578
+
1579
+ const spin = fmt.spinner('Building with track changes...').start();
1580
+
1581
+ try {
1582
+ // Combine sections first
1583
+ const paperPath = combineSections(dir, config);
1584
+ spin.stop();
1585
+ console.log(chalk.cyan('Combined sections → paper.md'));
1586
+ console.log(chalk.dim(` ${paperPath}\n`));
1587
+
1588
+ // Build DOCX with track changes
1589
+ const baseName = config.title
1590
+ ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
1591
+ : 'paper';
1592
+ const outputPath = path.join(dir, `${baseName}-changes.docx`);
1593
+
1594
+ const spinTc = fmt.spinner('Applying track changes...').start();
1595
+ const result = await buildWithTrackChanges(paperPath, outputPath, {
1596
+ author: getUserName() || 'Author',
1597
+ });
1598
+ spinTc.stop();
1599
+
1600
+ if (result.success) {
1601
+ console.log(chalk.cyan('Output (with track changes):'));
1602
+ console.log(` DOCX: ${path.basename(outputPath)}`);
1603
+ if (result.stats) {
1604
+ console.log(chalk.dim(` ${result.stats.insertions} insertions, ${result.stats.deletions} deletions, ${result.stats.substitutions} substitutions`));
1605
+ }
1606
+ console.log(chalk.green('\nBuild complete!'));
1607
+ } else {
1608
+ console.error(fmt.status('error', result.message));
1609
+ process.exit(1);
1610
+ }
1611
+ } catch (err) {
1612
+ spin.stop();
1613
+ console.error(fmt.status('error', err.message));
1614
+ if (process.env.DEBUG) console.error(err.stack);
1615
+ process.exit(1);
1616
+ }
1617
+ return;
1618
+ }
1619
+
1387
1620
  const spin = fmt.spinner('Building...').start();
1388
1621
 
1389
1622
  try {
1390
1623
  const { results, paperPath } = await build(dir, targetFormats, {
1391
1624
  crossref: options.crossref,
1625
+ config, // Pass modified config
1392
1626
  });
1393
1627
 
1394
1628
  spin.stop();
@@ -1687,6 +1921,220 @@ program
1687
1921
  console.log(fmt.status('success', `Created ${outputPath}`));
1688
1922
  });
1689
1923
 
1924
+ // ============================================================================
1925
+ // VALIDATE command - Check manuscript against journal requirements
1926
+ // ============================================================================
1927
+
1928
+ program
1929
+ .command('validate')
1930
+ .description('Validate manuscript against journal requirements')
1931
+ .argument('[files...]', 'Markdown files to validate (default: all section files)')
1932
+ .option('-j, --journal <name>', 'Journal profile (e.g., nature, plos-one, science)')
1933
+ .option('--list', 'List available journal profiles')
1934
+ .action(async (files, options) => {
1935
+ const { listJournals, validateProject, getJournalProfile } = await import('../lib/journals.js');
1936
+
1937
+ if (options.list) {
1938
+ console.log(fmt.header('Available Journal Profiles'));
1939
+ console.log();
1940
+ const journals = listJournals();
1941
+ for (const j of journals) {
1942
+ console.log(` ${chalk.bold(j.id)} - ${j.name}`);
1943
+ console.log(chalk.dim(` ${j.url}`));
1944
+ }
1945
+ console.log();
1946
+ console.log(chalk.dim('Usage: rev validate --journal <name>'));
1947
+ return;
1948
+ }
1949
+
1950
+ if (!options.journal) {
1951
+ console.error(fmt.status('error', 'Please specify a journal with --journal <name>'));
1952
+ console.error(chalk.dim('Use --list to see available profiles'));
1953
+ process.exit(1);
1954
+ }
1955
+
1956
+ const profile = getJournalProfile(options.journal);
1957
+ if (!profile) {
1958
+ console.error(fmt.status('error', `Unknown journal: ${options.journal}`));
1959
+ console.error(chalk.dim('Use --list to see available profiles'));
1960
+ process.exit(1);
1961
+ }
1962
+
1963
+ // Find files to validate
1964
+ let mdFiles = files;
1965
+ if (!mdFiles || mdFiles.length === 0) {
1966
+ mdFiles = fs.readdirSync('.').filter(f =>
1967
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
1968
+ );
1969
+ }
1970
+
1971
+ if (mdFiles.length === 0) {
1972
+ console.error(fmt.status('error', 'No markdown files found'));
1973
+ process.exit(1);
1974
+ }
1975
+
1976
+ console.log(fmt.header(`Validating for ${profile.name}`));
1977
+ console.log(chalk.dim(` ${profile.url}`));
1978
+ console.log();
1979
+
1980
+ const result = validateProject(mdFiles, options.journal);
1981
+
1982
+ // Show stats
1983
+ console.log(chalk.cyan('Manuscript Stats:'));
1984
+ console.log(fmt.table(['Metric', 'Value'], [
1985
+ ['Word count', result.stats.wordCount.toString()],
1986
+ ['Abstract', `${result.stats.abstractWords} words`],
1987
+ ['Title', `${result.stats.titleChars} chars`],
1988
+ ['Figures', result.stats.figures.toString()],
1989
+ ['Tables', result.stats.tables.toString()],
1990
+ ['References', result.stats.references.toString()],
1991
+ ]));
1992
+ console.log();
1993
+
1994
+ // Show errors
1995
+ if (result.errors.length > 0) {
1996
+ console.log(chalk.red('Errors:'));
1997
+ for (const err of result.errors) {
1998
+ console.log(chalk.red(` ✗ ${err}`));
1999
+ }
2000
+ console.log();
2001
+ }
2002
+
2003
+ // Show warnings
2004
+ if (result.warnings.length > 0) {
2005
+ console.log(chalk.yellow('Warnings:'));
2006
+ for (const warn of result.warnings) {
2007
+ console.log(chalk.yellow(` ⚠ ${warn}`));
2008
+ }
2009
+ console.log();
2010
+ }
2011
+
2012
+ // Summary
2013
+ if (result.valid) {
2014
+ console.log(fmt.status('success', `Manuscript meets ${profile.name} requirements`));
2015
+ } else {
2016
+ console.log(fmt.status('error', `Manuscript has ${result.errors.length} error(s)`));
2017
+ process.exit(1);
2018
+ }
2019
+ });
2020
+
2021
+ // ============================================================================
2022
+ // ANONYMIZE command - Prepare document for blind review
2023
+ // ============================================================================
2024
+
2025
+ program
2026
+ .command('anonymize')
2027
+ .description('Prepare document for blind review')
2028
+ .argument('<input>', 'Input markdown file or directory')
2029
+ .option('-o, --output <file>', 'Output file (default: input-anonymous.md)')
2030
+ .option('--authors <names>', 'Author names to redact (comma-separated)')
2031
+ .option('--dry-run', 'Show what would be changed without writing')
2032
+ .action((input, options) => {
2033
+ const isDir = fs.existsSync(input) && fs.statSync(input).isDirectory();
2034
+ const files = isDir
2035
+ ? fs.readdirSync(input)
2036
+ .filter(f => f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f))
2037
+ .map(f => path.join(input, f))
2038
+ : [input];
2039
+
2040
+ if (files.length === 0) {
2041
+ console.error(fmt.status('error', 'No markdown files found'));
2042
+ process.exit(1);
2043
+ }
2044
+
2045
+ // Get author names to redact
2046
+ let authorNames = [];
2047
+ if (options.authors) {
2048
+ authorNames = options.authors.split(',').map(n => n.trim());
2049
+ } else {
2050
+ // Try to load from rev.yaml
2051
+ const configPath = isDir ? path.join(input, 'rev.yaml') : 'rev.yaml';
2052
+ if (fs.existsSync(configPath)) {
2053
+ try {
2054
+ const config = yaml.load(fs.readFileSync(configPath, 'utf-8'));
2055
+ if (config.authors) {
2056
+ authorNames = config.authors.map(a => typeof a === 'string' ? a : a.name).filter(Boolean);
2057
+ }
2058
+ } catch { /* ignore */ }
2059
+ }
2060
+ }
2061
+
2062
+ console.log(fmt.header('Anonymizing Document'));
2063
+ console.log();
2064
+
2065
+ let totalChanges = 0;
2066
+
2067
+ for (const file of files) {
2068
+ if (!fs.existsSync(file)) {
2069
+ console.error(chalk.yellow(` Skipping: ${file} (not found)`));
2070
+ continue;
2071
+ }
2072
+
2073
+ let text = fs.readFileSync(file, 'utf-8');
2074
+ let changes = 0;
2075
+
2076
+ // Remove YAML frontmatter author block
2077
+ text = text.replace(/^---\n([\s\S]*?)\n---/, (match, fm) => {
2078
+ let modified = fm;
2079
+ // Remove author/authors field
2080
+ modified = modified.replace(/^author:.*(?:\n(?: |\t).*)*$/m, '');
2081
+ modified = modified.replace(/^authors:.*(?:\n(?: |\t|-\s+).*)*$/m, '');
2082
+ // Remove affiliation/email
2083
+ modified = modified.replace(/^affiliation:.*$/m, '');
2084
+ modified = modified.replace(/^email:.*$/m, '');
2085
+ if (modified !== fm) changes++;
2086
+ return '---\n' + modified.replace(/\n{3,}/g, '\n\n').trim() + '\n---';
2087
+ });
2088
+
2089
+ // Remove acknowledgments section
2090
+ const ackPatterns = [
2091
+ /^#+\s*Acknowledgments?[\s\S]*?(?=^#|\Z)/gmi,
2092
+ /^#+\s*Funding[\s\S]*?(?=^#|\Z)/gmi,
2093
+ ];
2094
+ for (const pattern of ackPatterns) {
2095
+ const before = text;
2096
+ text = text.replace(pattern, '');
2097
+ if (text !== before) changes++;
2098
+ }
2099
+
2100
+ // Redact author names
2101
+ for (const name of authorNames) {
2102
+ const namePattern = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
2103
+ const before = text;
2104
+ text = text.replace(namePattern, '[AUTHOR]');
2105
+ if (text !== before) changes++;
2106
+ }
2107
+
2108
+ // Replace self-citations: @AuthorLastName2024 -> @AUTHOR2024
2109
+ for (const name of authorNames) {
2110
+ const lastName = name.split(/\s+/).pop();
2111
+ if (lastName && lastName.length > 2) {
2112
+ const citePat = new RegExp(`@${lastName}(\\d{4})`, 'gi');
2113
+ const before = text;
2114
+ text = text.replace(citePat, '@AUTHOR$1');
2115
+ if (text !== before) changes++;
2116
+ }
2117
+ }
2118
+
2119
+ totalChanges += changes;
2120
+
2121
+ if (options.dryRun) {
2122
+ console.log(chalk.dim(` ${path.basename(file)}: ${changes} change(s)`));
2123
+ } else {
2124
+ const outPath = options.output || file.replace(/\.md$/, '-anonymous.md');
2125
+ fs.writeFileSync(outPath, text, 'utf-8');
2126
+ console.log(fmt.status('success', `${path.basename(file)} → ${path.basename(outPath)} (${changes} changes)`));
2127
+ }
2128
+ }
2129
+
2130
+ console.log();
2131
+ if (options.dryRun) {
2132
+ console.log(chalk.dim(` Total: ${totalChanges} change(s) would be made`));
2133
+ } else {
2134
+ console.log(fmt.status('success', `Anonymized ${files.length} file(s)`));
2135
+ }
2136
+ });
2137
+
1690
2138
  // ============================================================================
1691
2139
  // CITATIONS command - Validate citations against .bib file
1692
2140
  // ============================================================================
@@ -1862,11 +2310,72 @@ program
1862
2310
  .command('equations')
1863
2311
  .alias('eq')
1864
2312
  .description('Extract equations or convert to Word')
1865
- .argument('<action>', 'Action: list, extract, convert')
1866
- .argument('[input]', 'Input file (for extract/convert)')
2313
+ .argument('<action>', 'Action: list, extract, convert, from-word')
2314
+ .argument('[input]', 'Input file (.md for extract/convert, .docx for from-word)')
1867
2315
  .option('-o, --output <file>', 'Output file')
1868
2316
  .action(async (action, input, options) => {
1869
- if (action === 'list') {
2317
+ if (action === 'from-word') {
2318
+ // Extract equations from Word document
2319
+ if (!input) {
2320
+ console.error(fmt.status('error', 'Word document required'));
2321
+ process.exit(1);
2322
+ }
2323
+
2324
+ if (!input.endsWith('.docx')) {
2325
+ console.error(fmt.status('error', 'Input must be a .docx file'));
2326
+ process.exit(1);
2327
+ }
2328
+
2329
+ const spin = fmt.spinner(`Extracting equations from ${path.basename(input)}...`).start();
2330
+
2331
+ const result = await extractEquationsFromWord(input);
2332
+
2333
+ if (!result.success) {
2334
+ spin.error(result.error);
2335
+ process.exit(1);
2336
+ }
2337
+
2338
+ spin.stop();
2339
+ console.log(fmt.header('Equations from Word'));
2340
+ console.log();
2341
+
2342
+ if (result.equations.length === 0) {
2343
+ console.log(chalk.dim('No equations found in document.'));
2344
+ return;
2345
+ }
2346
+
2347
+ const display = result.equations.filter(e => e.type === 'display');
2348
+ const inline = result.equations.filter(e => e.type === 'inline');
2349
+
2350
+ console.log(chalk.dim(`Found ${result.equations.length} equations (${display.length} display, ${inline.length} inline)`));
2351
+ console.log();
2352
+
2353
+ // Show equations
2354
+ for (let i = 0; i < result.equations.length; i++) {
2355
+ const eq = result.equations[i];
2356
+ const typeLabel = eq.type === 'display' ? chalk.cyan('[display]') : chalk.yellow('[inline]');
2357
+
2358
+ if (eq.latex) {
2359
+ console.log(`${chalk.bold(i + 1)}. ${typeLabel}`);
2360
+ console.log(chalk.dim(' LaTeX:'), eq.latex.length > 80 ? eq.latex.substring(0, 77) + '...' : eq.latex);
2361
+ } else {
2362
+ console.log(`${chalk.bold(i + 1)}. ${typeLabel} ${chalk.red('[conversion failed]')}`);
2363
+ }
2364
+ }
2365
+
2366
+ // Optionally save to file
2367
+ if (options.output) {
2368
+ const latex = result.equations
2369
+ .filter(e => e.latex)
2370
+ .map((e, i) => `%% Equation ${i + 1} (${e.type})\n${e.type === 'display' ? '$$' : '$'}${e.latex}${e.type === 'display' ? '$$' : '$'}`)
2371
+ .join('\n\n');
2372
+
2373
+ fs.writeFileSync(options.output, latex, 'utf-8');
2374
+ console.log();
2375
+ console.log(fmt.status('success', `Saved ${result.equations.filter(e => e.latex).length} equations to ${options.output}`));
2376
+ }
2377
+
2378
+ } else if (action === 'list') {
1870
2379
  // List equations in all section files
1871
2380
  const mdFiles = fs.readdirSync('.').filter(f =>
1872
2381
  f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
@@ -1935,7 +2444,7 @@ program
1935
2444
  }
1936
2445
  } else {
1937
2446
  console.error(fmt.status('error', `Unknown action: ${action}`));
1938
- console.log(chalk.dim('Actions: list, extract, convert'));
2447
+ console.log(chalk.dim('Actions: list, extract, convert, from-word'));
1939
2448
  process.exit(1);
1940
2449
  }
1941
2450
  });
@@ -2280,6 +2789,188 @@ program
2280
2789
  }
2281
2790
  });
2282
2791
 
2792
+ // ============================================================================
2793
+ // DIFF command - Compare sections against git history
2794
+ // ============================================================================
2795
+
2796
+ program
2797
+ .command('diff')
2798
+ .description('Compare sections against git history')
2799
+ .argument('[ref]', 'Git reference to compare against (default: main/master)')
2800
+ .option('-f, --files <files>', 'Specific files to compare (comma-separated)')
2801
+ .option('--stat', 'Show only statistics, not full diff')
2802
+ .action(async (ref, options) => {
2803
+ const {
2804
+ isGitRepo,
2805
+ getDefaultBranch,
2806
+ getCurrentBranch,
2807
+ getChangedFiles,
2808
+ getWordCountDiff,
2809
+ compareFileVersions,
2810
+ } = await import('../lib/git.js');
2811
+
2812
+ if (!isGitRepo()) {
2813
+ console.error(fmt.status('error', 'Not a git repository'));
2814
+ process.exit(1);
2815
+ }
2816
+
2817
+ const compareRef = ref || getDefaultBranch();
2818
+ const currentBranch = getCurrentBranch();
2819
+
2820
+ console.log(fmt.header('Git Diff'));
2821
+ console.log(chalk.dim(` Comparing: ${compareRef} → ${currentBranch || 'HEAD'}`));
2822
+ console.log();
2823
+
2824
+ // Get files to compare
2825
+ let filesToCompare;
2826
+ if (options.files) {
2827
+ filesToCompare = options.files.split(',').map(f => f.trim());
2828
+ } else {
2829
+ // Default to markdown section files
2830
+ filesToCompare = fs.readdirSync('.').filter(f =>
2831
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
2832
+ );
2833
+ }
2834
+
2835
+ if (filesToCompare.length === 0) {
2836
+ console.log(fmt.status('info', 'No markdown files found'));
2837
+ return;
2838
+ }
2839
+
2840
+ // Get changed files from git
2841
+ const changedFiles = getChangedFiles(compareRef);
2842
+ const changedSet = new Set(changedFiles.map(f => f.file));
2843
+
2844
+ // Get word count differences
2845
+ const { total, byFile } = getWordCountDiff(filesToCompare, compareRef);
2846
+
2847
+ // Show results
2848
+ const rows = [];
2849
+ for (const file of filesToCompare) {
2850
+ const stats = byFile[file];
2851
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
2852
+ const status = changedSet.has(file)
2853
+ ? changedFiles.find(f => f.file === file)?.status || 'modified'
2854
+ : 'unchanged';
2855
+ rows.push([
2856
+ file,
2857
+ status,
2858
+ chalk.green(`+${stats.added}`),
2859
+ chalk.red(`-${stats.removed}`),
2860
+ ]);
2861
+ }
2862
+ }
2863
+
2864
+ if (rows.length === 0) {
2865
+ console.log(fmt.status('success', 'No changes detected'));
2866
+ return;
2867
+ }
2868
+
2869
+ console.log(fmt.table(['File', 'Status', 'Added', 'Removed'], rows));
2870
+ console.log();
2871
+ console.log(chalk.dim(`Total: ${chalk.green(`+${total.added}`)} words, ${chalk.red(`-${total.removed}`)} words`));
2872
+
2873
+ // Show detailed diff if not --stat
2874
+ if (!options.stat && rows.length > 0) {
2875
+ console.log();
2876
+ console.log(chalk.cyan('Changed sections:'));
2877
+ for (const file of filesToCompare) {
2878
+ const stats = byFile[file];
2879
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
2880
+ const { changes } = compareFileVersions(file, compareRef);
2881
+ console.log(chalk.bold(`\n ${file}:`));
2882
+
2883
+ // Show first few significant changes
2884
+ let shown = 0;
2885
+ for (const change of changes) {
2886
+ if (shown >= 3) {
2887
+ console.log(chalk.dim(' ...'));
2888
+ break;
2889
+ }
2890
+ const preview = change.text.slice(0, 60).replace(/\n/g, ' ');
2891
+ if (change.type === 'add') {
2892
+ console.log(chalk.green(` + "${preview}..."`));
2893
+ } else {
2894
+ console.log(chalk.red(` - "${preview}..."`));
2895
+ }
2896
+ shown++;
2897
+ }
2898
+ }
2899
+ }
2900
+ }
2901
+ });
2902
+
2903
+ // ============================================================================
2904
+ // HISTORY command - Show revision history
2905
+ // ============================================================================
2906
+
2907
+ program
2908
+ .command('history')
2909
+ .description('Show revision history for section files')
2910
+ .argument('[file]', 'Specific file (default: all sections)')
2911
+ .option('-n, --limit <count>', 'Number of commits to show', '10')
2912
+ .action(async (file, options) => {
2913
+ const {
2914
+ isGitRepo,
2915
+ getFileHistory,
2916
+ getRecentCommits,
2917
+ hasUncommittedChanges,
2918
+ } = await import('../lib/git.js');
2919
+
2920
+ if (!isGitRepo()) {
2921
+ console.error(fmt.status('error', 'Not a git repository'));
2922
+ process.exit(1);
2923
+ }
2924
+
2925
+ const limit = parseInt(options.limit) || 10;
2926
+
2927
+ console.log(fmt.header('Revision History'));
2928
+ console.log();
2929
+
2930
+ if (file) {
2931
+ // Show history for specific file
2932
+ if (!fs.existsSync(file)) {
2933
+ console.error(fmt.status('error', `File not found: ${file}`));
2934
+ process.exit(1);
2935
+ }
2936
+
2937
+ const history = getFileHistory(file, limit);
2938
+
2939
+ if (history.length === 0) {
2940
+ console.log(fmt.status('info', 'No history found (file may not be committed)'));
2941
+ return;
2942
+ }
2943
+
2944
+ console.log(chalk.cyan(`History for ${file}:`));
2945
+ console.log();
2946
+
2947
+ for (const commit of history) {
2948
+ const date = new Date(commit.date).toLocaleDateString();
2949
+ console.log(` ${chalk.yellow(commit.hash)} ${chalk.dim(date)}`);
2950
+ console.log(` ${commit.message}`);
2951
+ }
2952
+ } else {
2953
+ // Show recent commits affecting any file
2954
+ const commits = getRecentCommits(limit);
2955
+
2956
+ if (commits.length === 0) {
2957
+ console.log(fmt.status('info', 'No commits found'));
2958
+ return;
2959
+ }
2960
+
2961
+ if (hasUncommittedChanges()) {
2962
+ console.log(chalk.yellow(' * Uncommitted changes'));
2963
+ console.log();
2964
+ }
2965
+
2966
+ for (const commit of commits) {
2967
+ const date = new Date(commit.date).toLocaleDateString();
2968
+ console.log(` ${chalk.yellow(commit.hash)} ${chalk.dim(date)} ${chalk.blue(commit.author)}`);
2969
+ console.log(` ${commit.message}`);
2970
+ }
2971
+ }
2972
+ });
2973
+
2283
2974
  // ============================================================================
2284
2975
  // HELP command - Comprehensive help
2285
2976
  // ============================================================================