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/CLAUDE.md +2 -2
- package/README.md +35 -2
- package/bin/rev.js +696 -5
- package/lib/build.js +10 -2
- package/lib/crossref.js +138 -49
- package/lib/equations.js +235 -0
- package/lib/git.js +238 -0
- package/lib/journals.js +420 -0
- package/lib/merge.js +365 -0
- package/lib/trackchanges.js +273 -0
- package/lib/word.js +225 -0
- package/package.json +7 -5
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 === '
|
|
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
|
// ============================================================================
|