docrev 0.2.1 → 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/README.md CHANGED
@@ -118,6 +118,7 @@ rev build docx
118
118
  |---------|-------------|
119
119
  | `rev build [formats...]` | Build PDF/DOCX/TEX from sections |
120
120
  | `rev build --toc` | Build with table of contents |
121
+ | `rev build --show-changes` | Export DOCX with visible track changes |
121
122
  | `rev new <name>` | Create new project from template |
122
123
  | `rev new --list` | List available templates |
123
124
  | `rev install` | Check/install dependencies (pandoc-crossref) |
@@ -176,6 +177,16 @@ rev build docx
176
177
  | `rev equations from-word <docx>` | Extract equations from Word to LaTeX |
177
178
  | `rev response [files]` | Generate response letter from comments |
178
179
  | `rev anonymize <file>` | Prepare document for blind review |
180
+ | `rev validate --journal <name>` | Check manuscript against journal requirements |
181
+ | `rev validate --list` | List available journal profiles |
182
+
183
+ ### Multi-Reviewer & Git
184
+
185
+ | Command | Description |
186
+ |---------|-------------|
187
+ | `rev merge <md> <docx...>` | Merge feedback from multiple Word documents |
188
+ | `rev diff [ref]` | Compare sections against git history |
189
+ | `rev history [file]` | Show revision history for sections |
179
190
 
180
191
  ### Configuration
181
192
 
package/bin/rev.js CHANGED
@@ -1048,6 +1048,148 @@ program
1048
1048
  }
1049
1049
  });
1050
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
+
1051
1193
  // ============================================================================
1052
1194
  // REFS command - Show figure/table reference status
1053
1195
  // ============================================================================
@@ -1381,6 +1523,7 @@ program
1381
1523
  .option('-d, --dir <directory>', 'Project directory', '.')
1382
1524
  .option('--no-crossref', 'Skip pandoc-crossref filter')
1383
1525
  .option('--toc', 'Include table of contents')
1526
+ .option('--show-changes', 'Export DOCX with visible track changes (audit mode)')
1384
1527
  .action(async (formats, options) => {
1385
1528
  const dir = path.resolve(options.dir);
1386
1529
 
@@ -1414,6 +1557,7 @@ program
1414
1557
  console.log(chalk.dim(` Formats: ${targetFormats.join(', ')}`));
1415
1558
  console.log(chalk.dim(` Crossref: ${hasPandocCrossref() && options.crossref !== false ? 'enabled' : 'disabled'}`));
1416
1559
  if (tocEnabled) console.log(chalk.dim(` TOC: enabled`));
1560
+ if (options.showChanges) console.log(chalk.dim(` Track changes: visible`));
1417
1561
  console.log('');
1418
1562
 
1419
1563
  // Override config with CLI options
@@ -1422,6 +1566,57 @@ program
1422
1566
  config.docx.toc = true;
1423
1567
  }
1424
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
+
1425
1620
  const spin = fmt.spinner('Building...').start();
1426
1621
 
1427
1622
  try {
@@ -1726,6 +1921,103 @@ program
1726
1921
  console.log(fmt.status('success', `Created ${outputPath}`));
1727
1922
  });
1728
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
+
1729
2021
  // ============================================================================
1730
2022
  // ANONYMIZE command - Prepare document for blind review
1731
2023
  // ============================================================================
@@ -2497,6 +2789,188 @@ program
2497
2789
  }
2498
2790
  });
2499
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
+
2500
2974
  // ============================================================================
2501
2975
  // HELP command - Comprehensive help
2502
2976
  // ============================================================================