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/CLAUDE.md +75 -0
- package/README.md +313 -0
- package/bin/rev.js +2645 -0
- package/lib/annotations.js +321 -0
- package/lib/build.js +486 -0
- package/lib/citations.js +149 -0
- package/lib/config.js +60 -0
- package/lib/crossref.js +426 -0
- package/lib/doi.js +823 -0
- package/lib/equations.js +258 -0
- package/lib/format.js +420 -0
- package/lib/import.js +1018 -0
- package/lib/response.js +182 -0
- package/lib/review.js +208 -0
- package/lib/sections.js +345 -0
- package/lib/templates.js +305 -0
- package/package.json +43 -0
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();
|