docrev 0.3.0 → 0.5.1
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/.rev-dictionary +4 -0
- package/CHANGELOG.md +76 -0
- package/README.md +78 -0
- package/bin/rev.js +1652 -0
- package/completions/rev.bash +127 -0
- package/completions/rev.zsh +207 -0
- package/lib/annotations.js +75 -12
- package/lib/build.js +12 -1
- package/lib/doi.js +6 -2
- package/lib/equations.js +29 -12
- package/lib/grammar.js +290 -0
- package/lib/import.js +6 -1
- package/lib/journals.js +185 -0
- package/lib/merge.js +3 -3
- package/lib/scientific-words.js +73 -0
- package/lib/spelling.js +350 -0
- package/lib/trackchanges.js +159 -203
- package/lib/variables.js +173 -0
- package/package.json +79 -2
- package/skill/REFERENCE.md +279 -0
- package/skill/SKILL.md +137 -0
- package/types/index.d.ts +525 -0
- package/CLAUDE.md +0 -75
package/bin/rev.js
CHANGED
|
@@ -3333,4 +3333,1656 @@ ${chalk.bold('rev help')} [topic]
|
|
|
3333
3333
|
`);
|
|
3334
3334
|
}
|
|
3335
3335
|
|
|
3336
|
+
// ============================================================================
|
|
3337
|
+
// COMPLETIONS command - Shell completions
|
|
3338
|
+
// ============================================================================
|
|
3339
|
+
|
|
3340
|
+
program
|
|
3341
|
+
.command('completions')
|
|
3342
|
+
.description('Output shell completions')
|
|
3343
|
+
.argument('<shell>', 'Shell type: bash, zsh')
|
|
3344
|
+
.action((shell) => {
|
|
3345
|
+
const completionsDir = path.join(import.meta.dirname, '..', 'completions');
|
|
3346
|
+
|
|
3347
|
+
if (shell === 'bash') {
|
|
3348
|
+
const bashFile = path.join(completionsDir, 'rev.bash');
|
|
3349
|
+
if (fs.existsSync(bashFile)) {
|
|
3350
|
+
console.log(fs.readFileSync(bashFile, 'utf-8'));
|
|
3351
|
+
} else {
|
|
3352
|
+
console.error(chalk.red('Bash completions not found'));
|
|
3353
|
+
process.exit(1);
|
|
3354
|
+
}
|
|
3355
|
+
} else if (shell === 'zsh') {
|
|
3356
|
+
const zshFile = path.join(completionsDir, 'rev.zsh');
|
|
3357
|
+
if (fs.existsSync(zshFile)) {
|
|
3358
|
+
console.log(fs.readFileSync(zshFile, 'utf-8'));
|
|
3359
|
+
} else {
|
|
3360
|
+
console.error(chalk.red('Zsh completions not found'));
|
|
3361
|
+
process.exit(1);
|
|
3362
|
+
}
|
|
3363
|
+
} else {
|
|
3364
|
+
console.error(chalk.red(`Unknown shell: ${shell}`));
|
|
3365
|
+
console.log(chalk.dim('Supported shells: bash, zsh'));
|
|
3366
|
+
process.exit(1);
|
|
3367
|
+
}
|
|
3368
|
+
});
|
|
3369
|
+
|
|
3370
|
+
// ============================================================================
|
|
3371
|
+
// WORD-COUNT command - Per-section word counts
|
|
3372
|
+
// ============================================================================
|
|
3373
|
+
|
|
3374
|
+
program
|
|
3375
|
+
.command('word-count')
|
|
3376
|
+
.alias('wc')
|
|
3377
|
+
.description('Show word counts per section')
|
|
3378
|
+
.option('-l, --limit <number>', 'Warn if total exceeds limit', parseInt)
|
|
3379
|
+
.option('-j, --journal <name>', 'Use journal word limit')
|
|
3380
|
+
.action(async (options) => {
|
|
3381
|
+
let config = {};
|
|
3382
|
+
try {
|
|
3383
|
+
config = loadBuildConfig() || {};
|
|
3384
|
+
} catch {
|
|
3385
|
+
// Not in a rev project, that's ok
|
|
3386
|
+
}
|
|
3387
|
+
const sections = config.sections || [];
|
|
3388
|
+
|
|
3389
|
+
if (sections.length === 0) {
|
|
3390
|
+
// Try to find .md files
|
|
3391
|
+
const mdFiles = fs.readdirSync('.').filter(f =>
|
|
3392
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
|
|
3393
|
+
);
|
|
3394
|
+
if (mdFiles.length === 0) {
|
|
3395
|
+
console.error(chalk.red('No section files found. Run from a rev project directory.'));
|
|
3396
|
+
process.exit(1);
|
|
3397
|
+
}
|
|
3398
|
+
sections.push(...mdFiles);
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
const countWords = (text) => {
|
|
3402
|
+
return text
|
|
3403
|
+
.replace(/^---[\s\S]*?---/m, '')
|
|
3404
|
+
.replace(/!\[.*?\]\(.*?\)/g, '')
|
|
3405
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
3406
|
+
.replace(/#+\s*/g, '')
|
|
3407
|
+
.replace(/\*\*|__|[*_`]/g, '')
|
|
3408
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
3409
|
+
.replace(/\{[^}]+\}/g, '')
|
|
3410
|
+
.replace(/@\w+:\w+/g, '')
|
|
3411
|
+
.replace(/@\w+/g, '')
|
|
3412
|
+
.replace(/\|[^|]+\|/g, ' ')
|
|
3413
|
+
.replace(/\n+/g, ' ')
|
|
3414
|
+
.trim()
|
|
3415
|
+
.split(/\s+/)
|
|
3416
|
+
.filter(w => w.length > 0).length;
|
|
3417
|
+
};
|
|
3418
|
+
|
|
3419
|
+
let total = 0;
|
|
3420
|
+
const rows = [];
|
|
3421
|
+
|
|
3422
|
+
for (const section of sections) {
|
|
3423
|
+
if (!fs.existsSync(section)) continue;
|
|
3424
|
+
const text = fs.readFileSync(section, 'utf-8');
|
|
3425
|
+
const words = countWords(text);
|
|
3426
|
+
total += words;
|
|
3427
|
+
rows.push([section, words.toLocaleString()]);
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
rows.push(['', '']);
|
|
3431
|
+
rows.push([chalk.bold('Total'), chalk.bold(total.toLocaleString())]);
|
|
3432
|
+
|
|
3433
|
+
console.log(fmt.header('Word Count'));
|
|
3434
|
+
console.log(fmt.table(['Section', 'Words'], rows));
|
|
3435
|
+
|
|
3436
|
+
// Check limit
|
|
3437
|
+
let limit = options.limit;
|
|
3438
|
+
if (options.journal) {
|
|
3439
|
+
const { getJournalProfile } = await import('../lib/journals.js');
|
|
3440
|
+
const profile = getJournalProfile(options.journal);
|
|
3441
|
+
if (profile?.requirements?.wordLimit?.main) {
|
|
3442
|
+
limit = profile.requirements.wordLimit.main;
|
|
3443
|
+
console.log(chalk.dim(`\nUsing ${profile.name} word limit: ${limit.toLocaleString()}`));
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
if (limit && total > limit) {
|
|
3448
|
+
console.log(chalk.red(`\n⚠ Over limit by ${(total - limit).toLocaleString()} words`));
|
|
3449
|
+
} else if (limit) {
|
|
3450
|
+
console.log(chalk.green(`\n✓ Within limit (${(limit - total).toLocaleString()} words remaining)`));
|
|
3451
|
+
}
|
|
3452
|
+
});
|
|
3453
|
+
|
|
3454
|
+
// ============================================================================
|
|
3455
|
+
// STATS command - Project dashboard
|
|
3456
|
+
// ============================================================================
|
|
3457
|
+
|
|
3458
|
+
program
|
|
3459
|
+
.command('stats')
|
|
3460
|
+
.description('Show project statistics dashboard')
|
|
3461
|
+
.action(async () => {
|
|
3462
|
+
let config = {};
|
|
3463
|
+
try {
|
|
3464
|
+
config = loadBuildConfig() || {};
|
|
3465
|
+
} catch {
|
|
3466
|
+
// Not in a rev project, that's ok
|
|
3467
|
+
}
|
|
3468
|
+
let sections = config.sections || [];
|
|
3469
|
+
|
|
3470
|
+
if (sections.length === 0) {
|
|
3471
|
+
sections = fs.readdirSync('.').filter(f =>
|
|
3472
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
|
|
3473
|
+
);
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
const countWords = (text) => {
|
|
3477
|
+
return text
|
|
3478
|
+
.replace(/^---[\s\S]*?---/m, '')
|
|
3479
|
+
.replace(/!\[.*?\]\(.*?\)/g, '')
|
|
3480
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
3481
|
+
.replace(/[#*_`]/g, '')
|
|
3482
|
+
.replace(/\{[^}]+\}/g, '')
|
|
3483
|
+
.replace(/@\w+/g, '')
|
|
3484
|
+
.replace(/\n+/g, ' ')
|
|
3485
|
+
.trim()
|
|
3486
|
+
.split(/\s+/)
|
|
3487
|
+
.filter(w => w.length > 0).length;
|
|
3488
|
+
};
|
|
3489
|
+
|
|
3490
|
+
let totalWords = 0;
|
|
3491
|
+
let totalFigures = 0;
|
|
3492
|
+
let totalTables = 0;
|
|
3493
|
+
let totalComments = 0;
|
|
3494
|
+
let pendingComments = 0;
|
|
3495
|
+
const citations = new Set();
|
|
3496
|
+
|
|
3497
|
+
for (const section of sections) {
|
|
3498
|
+
if (!fs.existsSync(section)) continue;
|
|
3499
|
+
const text = fs.readFileSync(section, 'utf-8');
|
|
3500
|
+
|
|
3501
|
+
totalWords += countWords(text);
|
|
3502
|
+
totalFigures += (text.match(/!\[.*?\]\(.*?\)/g) || []).length;
|
|
3503
|
+
totalTables += (text.match(/^\|[^|]+\|/gm) || []).length / 5; // Approximate
|
|
3504
|
+
|
|
3505
|
+
const comments = getComments(text);
|
|
3506
|
+
totalComments += comments.length;
|
|
3507
|
+
pendingComments += comments.filter(c => !c.resolved).length;
|
|
3508
|
+
|
|
3509
|
+
const cites = text.match(/@(\w+)(?![:\w])/g) || [];
|
|
3510
|
+
cites.forEach(c => citations.add(c.slice(1)));
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
console.log(fmt.header('Project Statistics'));
|
|
3514
|
+
console.log();
|
|
3515
|
+
|
|
3516
|
+
const stats = [
|
|
3517
|
+
['Sections', sections.length],
|
|
3518
|
+
['Words', totalWords.toLocaleString()],
|
|
3519
|
+
['Figures', Math.round(totalFigures)],
|
|
3520
|
+
['Tables', Math.round(totalTables)],
|
|
3521
|
+
['Citations', citations.size],
|
|
3522
|
+
['Comments', `${totalComments} (${pendingComments} pending)`],
|
|
3523
|
+
];
|
|
3524
|
+
|
|
3525
|
+
for (const [label, value] of stats) {
|
|
3526
|
+
console.log(` ${chalk.dim(label.padEnd(12))} ${chalk.bold(value)}`);
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
// Bibliography stats
|
|
3530
|
+
const bibPath = config.bibliography || 'references.bib';
|
|
3531
|
+
if (fs.existsSync(bibPath)) {
|
|
3532
|
+
const bibContent = fs.readFileSync(bibPath, 'utf-8');
|
|
3533
|
+
const bibEntries = (bibContent.match(/@\w+\s*\{/g) || []).length;
|
|
3534
|
+
console.log(` ${chalk.dim('Bib entries'.padEnd(12))} ${chalk.bold(bibEntries)}`);
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
console.log();
|
|
3538
|
+
});
|
|
3539
|
+
|
|
3540
|
+
// ============================================================================
|
|
3541
|
+
// SEARCH command - Search across section files
|
|
3542
|
+
// ============================================================================
|
|
3543
|
+
|
|
3544
|
+
program
|
|
3545
|
+
.command('search')
|
|
3546
|
+
.description('Search across all section files')
|
|
3547
|
+
.argument('<query>', 'Search query (supports regex)')
|
|
3548
|
+
.option('-i, --ignore-case', 'Case-insensitive search')
|
|
3549
|
+
.option('-c, --context <lines>', 'Show context lines', parseInt, 1)
|
|
3550
|
+
.action((query, options) => {
|
|
3551
|
+
let config = {};
|
|
3552
|
+
try {
|
|
3553
|
+
config = loadBuildConfig() || {};
|
|
3554
|
+
} catch {
|
|
3555
|
+
// Not in a rev project, that's ok
|
|
3556
|
+
}
|
|
3557
|
+
let sections = config.sections || [];
|
|
3558
|
+
|
|
3559
|
+
if (sections.length === 0) {
|
|
3560
|
+
sections = fs.readdirSync('.').filter(f =>
|
|
3561
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
|
|
3562
|
+
);
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
const flags = options.ignoreCase ? 'gi' : 'g';
|
|
3566
|
+
let pattern;
|
|
3567
|
+
try {
|
|
3568
|
+
pattern = new RegExp(query, flags);
|
|
3569
|
+
} catch {
|
|
3570
|
+
pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
let totalMatches = 0;
|
|
3574
|
+
|
|
3575
|
+
for (const section of sections) {
|
|
3576
|
+
if (!fs.existsSync(section)) continue;
|
|
3577
|
+
const text = fs.readFileSync(section, 'utf-8');
|
|
3578
|
+
const lines = text.split('\n');
|
|
3579
|
+
|
|
3580
|
+
const matches = [];
|
|
3581
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3582
|
+
if (pattern.test(lines[i])) {
|
|
3583
|
+
matches.push({ line: i + 1, text: lines[i] });
|
|
3584
|
+
pattern.lastIndex = 0;
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
if (matches.length > 0) {
|
|
3589
|
+
console.log(chalk.cyan.bold(`\n${section}`));
|
|
3590
|
+
for (const match of matches) {
|
|
3591
|
+
const highlighted = match.text.replace(pattern, (m) => chalk.yellow.bold(m));
|
|
3592
|
+
console.log(` ${chalk.dim(match.line + ':')} ${highlighted}`);
|
|
3593
|
+
}
|
|
3594
|
+
totalMatches += matches.length;
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
if (totalMatches === 0) {
|
|
3599
|
+
console.log(chalk.yellow(`No matches found for "${query}"`));
|
|
3600
|
+
} else {
|
|
3601
|
+
console.log(chalk.dim(`\n${totalMatches} match${totalMatches === 1 ? '' : 'es'} found`));
|
|
3602
|
+
}
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
// ============================================================================
|
|
3606
|
+
// BACKUP command - Timestamped project backup
|
|
3607
|
+
// ============================================================================
|
|
3608
|
+
|
|
3609
|
+
program
|
|
3610
|
+
.command('backup')
|
|
3611
|
+
.description('Create timestamped project backup')
|
|
3612
|
+
.option('-n, --name <name>', 'Custom backup name')
|
|
3613
|
+
.option('-o, --output <dir>', 'Output directory', '.')
|
|
3614
|
+
.action(async (options) => {
|
|
3615
|
+
const { default: AdmZip } = await import('adm-zip');
|
|
3616
|
+
const zip = new AdmZip();
|
|
3617
|
+
|
|
3618
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
3619
|
+
const name = options.name || `backup-${date}`;
|
|
3620
|
+
const outputPath = path.join(options.output, `${name}.zip`);
|
|
3621
|
+
|
|
3622
|
+
// Files to include
|
|
3623
|
+
const includePatterns = [
|
|
3624
|
+
'*.md', '*.yaml', '*.yml', '*.bib', '*.csl',
|
|
3625
|
+
'figures/*', 'images/*', 'assets/*'
|
|
3626
|
+
];
|
|
3627
|
+
|
|
3628
|
+
// Files to exclude
|
|
3629
|
+
const excludePatterns = [
|
|
3630
|
+
'node_modules', '.git', '*.docx', '*.pdf', '*.zip',
|
|
3631
|
+
'paper.md' // Generated file
|
|
3632
|
+
];
|
|
3633
|
+
|
|
3634
|
+
const shouldInclude = (file) => {
|
|
3635
|
+
for (const pattern of excludePatterns) {
|
|
3636
|
+
if (file.includes(pattern.replace('*', ''))) return false;
|
|
3637
|
+
}
|
|
3638
|
+
return true;
|
|
3639
|
+
};
|
|
3640
|
+
|
|
3641
|
+
const addDir = (dir, zipPath = '') => {
|
|
3642
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
3643
|
+
for (const entry of entries) {
|
|
3644
|
+
const fullPath = path.join(dir, entry.name);
|
|
3645
|
+
const entryZipPath = path.join(zipPath, entry.name);
|
|
3646
|
+
|
|
3647
|
+
if (!shouldInclude(entry.name)) continue;
|
|
3648
|
+
|
|
3649
|
+
if (entry.isDirectory()) {
|
|
3650
|
+
addDir(fullPath, entryZipPath);
|
|
3651
|
+
} else {
|
|
3652
|
+
zip.addLocalFile(fullPath, zipPath || undefined);
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
};
|
|
3656
|
+
|
|
3657
|
+
// Add current directory
|
|
3658
|
+
const entries = fs.readdirSync('.', { withFileTypes: true });
|
|
3659
|
+
for (const entry of entries) {
|
|
3660
|
+
if (!shouldInclude(entry.name)) continue;
|
|
3661
|
+
|
|
3662
|
+
if (entry.isDirectory()) {
|
|
3663
|
+
addDir(entry.name, entry.name);
|
|
3664
|
+
} else if (entry.isFile()) {
|
|
3665
|
+
zip.addLocalFile(entry.name);
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
zip.writeZip(outputPath);
|
|
3670
|
+
console.log(fmt.status('success', `Backup created: ${outputPath}`));
|
|
3671
|
+
});
|
|
3672
|
+
|
|
3673
|
+
// ============================================================================
|
|
3674
|
+
// EXPORT command - Export project as distributable zip
|
|
3675
|
+
// ============================================================================
|
|
3676
|
+
|
|
3677
|
+
program
|
|
3678
|
+
.command('export')
|
|
3679
|
+
.description('Export project as distributable zip')
|
|
3680
|
+
.option('-o, --output <file>', 'Output filename')
|
|
3681
|
+
.option('--include-output', 'Include built PDF/DOCX files')
|
|
3682
|
+
.action(async (options) => {
|
|
3683
|
+
const { default: AdmZip } = await import('adm-zip');
|
|
3684
|
+
let config = {};
|
|
3685
|
+
try {
|
|
3686
|
+
config = loadBuildConfig() || {};
|
|
3687
|
+
} catch {
|
|
3688
|
+
// Not in a rev project, that's ok
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
// Build first if including output
|
|
3692
|
+
if (options.includeOutput) {
|
|
3693
|
+
console.log(chalk.dim('Building documents...'));
|
|
3694
|
+
await build(['pdf', 'docx']);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
const zip = new AdmZip();
|
|
3698
|
+
const projectName = config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'project';
|
|
3699
|
+
const outputPath = options.output || `${projectName}-export.zip`;
|
|
3700
|
+
|
|
3701
|
+
const exclude = ['node_modules', '.git', '.DS_Store', '*.zip'];
|
|
3702
|
+
|
|
3703
|
+
const shouldInclude = (name) => {
|
|
3704
|
+
if (!options.includeOutput && (name.endsWith('.pdf') || name.endsWith('.docx'))) {
|
|
3705
|
+
return false;
|
|
3706
|
+
}
|
|
3707
|
+
for (const pattern of exclude) {
|
|
3708
|
+
if (name === pattern || name.includes(pattern.replace('*', ''))) return false;
|
|
3709
|
+
}
|
|
3710
|
+
return true;
|
|
3711
|
+
};
|
|
3712
|
+
|
|
3713
|
+
const addDir = (dir, zipPath = '') => {
|
|
3714
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
3715
|
+
for (const entry of entries) {
|
|
3716
|
+
const fullPath = path.join(dir, entry.name);
|
|
3717
|
+
const entryZipPath = path.join(zipPath, entry.name);
|
|
3718
|
+
|
|
3719
|
+
if (!shouldInclude(entry.name)) continue;
|
|
3720
|
+
|
|
3721
|
+
if (entry.isDirectory()) {
|
|
3722
|
+
addDir(fullPath, entryZipPath);
|
|
3723
|
+
} else {
|
|
3724
|
+
zip.addLocalFile(fullPath, zipPath || undefined);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
};
|
|
3728
|
+
|
|
3729
|
+
const entries = fs.readdirSync('.', { withFileTypes: true });
|
|
3730
|
+
for (const entry of entries) {
|
|
3731
|
+
if (!shouldInclude(entry.name)) continue;
|
|
3732
|
+
|
|
3733
|
+
if (entry.isDirectory()) {
|
|
3734
|
+
addDir(entry.name, entry.name);
|
|
3735
|
+
} else if (entry.isFile()) {
|
|
3736
|
+
zip.addLocalFile(entry.name);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
zip.writeZip(outputPath);
|
|
3741
|
+
console.log(fmt.status('success', `Exported: ${outputPath}`));
|
|
3742
|
+
});
|
|
3743
|
+
|
|
3744
|
+
// ============================================================================
|
|
3745
|
+
// PREVIEW command - Build and open document
|
|
3746
|
+
// ============================================================================
|
|
3747
|
+
|
|
3748
|
+
program
|
|
3749
|
+
.command('preview')
|
|
3750
|
+
.description('Build and open document in default app')
|
|
3751
|
+
.argument('[format]', 'Format to preview: pdf, docx', 'pdf')
|
|
3752
|
+
.action(async (format) => {
|
|
3753
|
+
const { exec } = require('child_process');
|
|
3754
|
+
let config = {};
|
|
3755
|
+
try {
|
|
3756
|
+
config = loadBuildConfig() || {};
|
|
3757
|
+
} catch (err) {
|
|
3758
|
+
console.error(chalk.red('Not in a rev project directory (no rev.yaml found)'));
|
|
3759
|
+
process.exit(1);
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
console.log(chalk.dim(`Building ${format}...`));
|
|
3763
|
+
const results = await build([format]);
|
|
3764
|
+
|
|
3765
|
+
const result = results.find(r => r.format === format);
|
|
3766
|
+
if (!result?.success) {
|
|
3767
|
+
console.error(chalk.red(`Build failed: ${result?.error || 'Unknown error'}`));
|
|
3768
|
+
process.exit(1);
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
const outputFile = result.output;
|
|
3772
|
+
if (!fs.existsSync(outputFile)) {
|
|
3773
|
+
console.error(chalk.red(`Output file not found: ${outputFile}`));
|
|
3774
|
+
process.exit(1);
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
// Open with system default
|
|
3778
|
+
const openCmd = process.platform === 'darwin' ? 'open' :
|
|
3779
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
3780
|
+
|
|
3781
|
+
exec(`${openCmd} "${outputFile}"`, (err) => {
|
|
3782
|
+
if (err) {
|
|
3783
|
+
console.error(chalk.red(`Could not open file: ${err.message}`));
|
|
3784
|
+
} else {
|
|
3785
|
+
console.log(fmt.status('success', `Opened ${outputFile}`));
|
|
3786
|
+
}
|
|
3787
|
+
});
|
|
3788
|
+
});
|
|
3789
|
+
|
|
3790
|
+
// ============================================================================
|
|
3791
|
+
// WATCH command - Auto-rebuild on changes
|
|
3792
|
+
// ============================================================================
|
|
3793
|
+
|
|
3794
|
+
program
|
|
3795
|
+
.command('watch')
|
|
3796
|
+
.description('Watch files and auto-rebuild on changes')
|
|
3797
|
+
.argument('[format]', 'Format to build: pdf, docx, all', 'pdf')
|
|
3798
|
+
.option('--no-open', 'Do not open after first build')
|
|
3799
|
+
.action(async (format, options) => {
|
|
3800
|
+
const { exec } = require('child_process');
|
|
3801
|
+
let config = {};
|
|
3802
|
+
try {
|
|
3803
|
+
config = loadBuildConfig() || {};
|
|
3804
|
+
} catch (err) {
|
|
3805
|
+
console.error(chalk.red('Not in a rev project directory (no rev.yaml found)'));
|
|
3806
|
+
process.exit(1);
|
|
3807
|
+
}
|
|
3808
|
+
let sections = config.sections || [];
|
|
3809
|
+
|
|
3810
|
+
if (sections.length === 0) {
|
|
3811
|
+
sections = fs.readdirSync('.').filter(f =>
|
|
3812
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
|
|
3813
|
+
);
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
const filesToWatch = [
|
|
3817
|
+
...sections,
|
|
3818
|
+
'rev.yaml',
|
|
3819
|
+
config.bibliography || 'references.bib'
|
|
3820
|
+
].filter(f => fs.existsSync(f));
|
|
3821
|
+
|
|
3822
|
+
console.log(fmt.header('Watch Mode'));
|
|
3823
|
+
console.log(chalk.dim(`Watching: ${filesToWatch.join(', ')}`));
|
|
3824
|
+
console.log(chalk.dim('Press Ctrl+C to stop\n'));
|
|
3825
|
+
|
|
3826
|
+
let building = false;
|
|
3827
|
+
let pendingBuild = false;
|
|
3828
|
+
|
|
3829
|
+
const doBuild = async () => {
|
|
3830
|
+
if (building) {
|
|
3831
|
+
pendingBuild = true;
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
building = true;
|
|
3836
|
+
console.log(chalk.dim(`\n[${new Date().toLocaleTimeString()}] Rebuilding...`));
|
|
3837
|
+
|
|
3838
|
+
try {
|
|
3839
|
+
const formats = format === 'all' ? ['pdf', 'docx'] : [format];
|
|
3840
|
+
const results = await build(formats);
|
|
3841
|
+
|
|
3842
|
+
for (const r of results) {
|
|
3843
|
+
if (r.success) {
|
|
3844
|
+
console.log(chalk.green(` ✓ ${r.format}: ${r.output}`));
|
|
3845
|
+
} else {
|
|
3846
|
+
console.log(chalk.red(` ✗ ${r.format}: ${r.error}`));
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
} catch (err) {
|
|
3850
|
+
console.error(chalk.red(` Build error: ${err.message}`));
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
building = false;
|
|
3854
|
+
if (pendingBuild) {
|
|
3855
|
+
pendingBuild = false;
|
|
3856
|
+
doBuild();
|
|
3857
|
+
}
|
|
3858
|
+
};
|
|
3859
|
+
|
|
3860
|
+
// Initial build
|
|
3861
|
+
await doBuild();
|
|
3862
|
+
|
|
3863
|
+
// Open after first build
|
|
3864
|
+
if (options.open) {
|
|
3865
|
+
const outputFile = format === 'docx' ?
|
|
3866
|
+
(config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'paper') + '.docx' :
|
|
3867
|
+
(config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'paper') + '.pdf';
|
|
3868
|
+
|
|
3869
|
+
if (fs.existsSync(outputFile)) {
|
|
3870
|
+
const openCmd = process.platform === 'darwin' ? 'open' :
|
|
3871
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
3872
|
+
exec(`${openCmd} "${outputFile}"`);
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
// Watch files
|
|
3877
|
+
for (const file of filesToWatch) {
|
|
3878
|
+
fs.watch(file, { persistent: true }, (eventType) => {
|
|
3879
|
+
if (eventType === 'change') {
|
|
3880
|
+
doBuild();
|
|
3881
|
+
}
|
|
3882
|
+
});
|
|
3883
|
+
}
|
|
3884
|
+
});
|
|
3885
|
+
|
|
3886
|
+
// ============================================================================
|
|
3887
|
+
// LINT command - Check for common issues
|
|
3888
|
+
// ============================================================================
|
|
3889
|
+
|
|
3890
|
+
program
|
|
3891
|
+
.command('lint')
|
|
3892
|
+
.description('Check for common issues in the project')
|
|
3893
|
+
.option('--fix', 'Auto-fix issues where possible')
|
|
3894
|
+
.action(async (options) => {
|
|
3895
|
+
let config = {};
|
|
3896
|
+
try {
|
|
3897
|
+
config = loadBuildConfig() || {};
|
|
3898
|
+
} catch {
|
|
3899
|
+
// Not in a rev project, that's ok
|
|
3900
|
+
}
|
|
3901
|
+
let sections = config.sections || [];
|
|
3902
|
+
|
|
3903
|
+
if (sections.length === 0) {
|
|
3904
|
+
sections = fs.readdirSync('.').filter(f =>
|
|
3905
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
|
|
3906
|
+
);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
const issues = [];
|
|
3910
|
+
const warnings = [];
|
|
3911
|
+
|
|
3912
|
+
// Collect all content
|
|
3913
|
+
let allText = '';
|
|
3914
|
+
for (const section of sections) {
|
|
3915
|
+
if (fs.existsSync(section)) {
|
|
3916
|
+
allText += fs.readFileSync(section, 'utf-8') + '\n';
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
// Check 1: Broken cross-references
|
|
3921
|
+
const figAnchors = new Set();
|
|
3922
|
+
const tblAnchors = new Set();
|
|
3923
|
+
const eqAnchors = new Set();
|
|
3924
|
+
|
|
3925
|
+
const anchorPattern = /\{#(fig|tbl|eq):([^}]+)\}/g;
|
|
3926
|
+
let match;
|
|
3927
|
+
while ((match = anchorPattern.exec(allText)) !== null) {
|
|
3928
|
+
if (match[1] === 'fig') figAnchors.add(match[2]);
|
|
3929
|
+
else if (match[1] === 'tbl') tblAnchors.add(match[2]);
|
|
3930
|
+
else if (match[1] === 'eq') eqAnchors.add(match[2]);
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
const refPattern = /@(fig|tbl|eq):([a-zA-Z0-9_-]+)/g;
|
|
3934
|
+
while ((match = refPattern.exec(allText)) !== null) {
|
|
3935
|
+
const type = match[1];
|
|
3936
|
+
const label = match[2];
|
|
3937
|
+
const anchors = type === 'fig' ? figAnchors : type === 'tbl' ? tblAnchors : eqAnchors;
|
|
3938
|
+
|
|
3939
|
+
if (!anchors.has(label)) {
|
|
3940
|
+
issues.push({
|
|
3941
|
+
type: 'error',
|
|
3942
|
+
message: `Broken reference: @${type}:${label}`,
|
|
3943
|
+
fix: null
|
|
3944
|
+
});
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
// Check 2: Orphaned figures (defined but not referenced)
|
|
3949
|
+
for (const label of figAnchors) {
|
|
3950
|
+
if (!allText.includes(`@fig:${label}`)) {
|
|
3951
|
+
warnings.push({
|
|
3952
|
+
type: 'warning',
|
|
3953
|
+
message: `Unreferenced figure: {#fig:${label}}`,
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
// Check 3: Missing citations
|
|
3959
|
+
const bibPath = config.bibliography || 'references.bib';
|
|
3960
|
+
if (fs.existsSync(bibPath)) {
|
|
3961
|
+
const bibContent = fs.readFileSync(bibPath, 'utf-8');
|
|
3962
|
+
const bibKeys = new Set();
|
|
3963
|
+
const bibPattern = /@\w+\s*\{\s*([^,]+)/g;
|
|
3964
|
+
while ((match = bibPattern.exec(bibContent)) !== null) {
|
|
3965
|
+
bibKeys.add(match[1].trim());
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
const citePattern = /@([a-zA-Z][a-zA-Z0-9_-]*)(?![:\w])/g;
|
|
3969
|
+
while ((match = citePattern.exec(allText)) !== null) {
|
|
3970
|
+
const key = match[1];
|
|
3971
|
+
if (!bibKeys.has(key) && !['fig', 'tbl', 'eq'].includes(key)) {
|
|
3972
|
+
issues.push({
|
|
3973
|
+
type: 'error',
|
|
3974
|
+
message: `Missing citation: @${key}`,
|
|
3975
|
+
});
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
// Check 4: Unresolved comments
|
|
3981
|
+
const comments = getComments(allText);
|
|
3982
|
+
const pending = comments.filter(c => !c.resolved);
|
|
3983
|
+
if (pending.length > 0) {
|
|
3984
|
+
warnings.push({
|
|
3985
|
+
type: 'warning',
|
|
3986
|
+
message: `${pending.length} unresolved comment${pending.length === 1 ? '' : 's'}`,
|
|
3987
|
+
});
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
// Check 5: Empty sections
|
|
3991
|
+
for (const section of sections) {
|
|
3992
|
+
if (fs.existsSync(section)) {
|
|
3993
|
+
const content = fs.readFileSync(section, 'utf-8').trim();
|
|
3994
|
+
if (content.length < 50) {
|
|
3995
|
+
warnings.push({
|
|
3996
|
+
type: 'warning',
|
|
3997
|
+
message: `Section appears empty: ${section}`,
|
|
3998
|
+
});
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
// Output results
|
|
4004
|
+
console.log(fmt.header('Lint Results'));
|
|
4005
|
+
console.log();
|
|
4006
|
+
|
|
4007
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
4008
|
+
console.log(chalk.green('✓ No issues found'));
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
for (const issue of issues) {
|
|
4013
|
+
console.log(chalk.red(` ✗ ${issue.message}`));
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
for (const warning of warnings) {
|
|
4017
|
+
console.log(chalk.yellow(` ⚠ ${warning.message}`));
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
console.log();
|
|
4021
|
+
console.log(chalk.dim(`${issues.length} error${issues.length === 1 ? '' : 's'}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`));
|
|
4022
|
+
|
|
4023
|
+
if (issues.length > 0) {
|
|
4024
|
+
process.exit(1);
|
|
4025
|
+
}
|
|
4026
|
+
});
|
|
4027
|
+
|
|
4028
|
+
// ============================================================================
|
|
4029
|
+
// GRAMMAR command - Check grammar and style
|
|
4030
|
+
// ============================================================================
|
|
4031
|
+
|
|
4032
|
+
program
|
|
4033
|
+
.command('grammar')
|
|
4034
|
+
.description('Check grammar and style issues')
|
|
4035
|
+
.argument('[files...]', 'Markdown files to check')
|
|
4036
|
+
.option('--learn <word>', 'Add word to custom dictionary')
|
|
4037
|
+
.option('--forget <word>', 'Remove word from custom dictionary')
|
|
4038
|
+
.option('--list', 'List custom dictionary words')
|
|
4039
|
+
.option('--rules', 'List available grammar rules')
|
|
4040
|
+
.option('--no-scientific', 'Disable scientific writing rules')
|
|
4041
|
+
.option('-s, --severity <level>', 'Minimum severity: error, warning, info', 'info')
|
|
4042
|
+
.action(async (files, options) => {
|
|
4043
|
+
const {
|
|
4044
|
+
checkGrammar,
|
|
4045
|
+
getGrammarSummary,
|
|
4046
|
+
loadDictionary,
|
|
4047
|
+
addToDictionary,
|
|
4048
|
+
removeFromDictionary,
|
|
4049
|
+
listRules,
|
|
4050
|
+
} = await import('../lib/grammar.js');
|
|
4051
|
+
|
|
4052
|
+
// Handle dictionary management
|
|
4053
|
+
if (options.learn) {
|
|
4054
|
+
const added = addToDictionary(options.learn);
|
|
4055
|
+
if (added) {
|
|
4056
|
+
console.log(fmt.status('success', `Added "${options.learn}" to dictionary`));
|
|
4057
|
+
} else {
|
|
4058
|
+
console.log(chalk.dim(`"${options.learn}" already in dictionary`));
|
|
4059
|
+
}
|
|
4060
|
+
return;
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
if (options.forget) {
|
|
4064
|
+
const removed = removeFromDictionary(options.forget);
|
|
4065
|
+
if (removed) {
|
|
4066
|
+
console.log(fmt.status('success', `Removed "${options.forget}" from dictionary`));
|
|
4067
|
+
} else {
|
|
4068
|
+
console.log(chalk.yellow(`"${options.forget}" not in dictionary`));
|
|
4069
|
+
}
|
|
4070
|
+
return;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
if (options.list) {
|
|
4074
|
+
const words = loadDictionary();
|
|
4075
|
+
console.log(fmt.header('Custom Dictionary'));
|
|
4076
|
+
console.log();
|
|
4077
|
+
if (words.size === 0) {
|
|
4078
|
+
console.log(chalk.dim(' No custom words defined'));
|
|
4079
|
+
console.log(chalk.dim(' Use --learn <word> to add words'));
|
|
4080
|
+
} else {
|
|
4081
|
+
const sorted = [...words].sort();
|
|
4082
|
+
for (const word of sorted) {
|
|
4083
|
+
console.log(` ${word}`);
|
|
4084
|
+
}
|
|
4085
|
+
console.log();
|
|
4086
|
+
console.log(chalk.dim(`${words.size} word(s)`));
|
|
4087
|
+
}
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
if (options.rules) {
|
|
4092
|
+
const rules = listRules(options.scientific);
|
|
4093
|
+
console.log(fmt.header('Grammar Rules'));
|
|
4094
|
+
console.log();
|
|
4095
|
+
for (const rule of rules) {
|
|
4096
|
+
const icon = rule.severity === 'error' ? chalk.red('●') :
|
|
4097
|
+
rule.severity === 'warning' ? chalk.yellow('●') :
|
|
4098
|
+
chalk.blue('●');
|
|
4099
|
+
console.log(` ${icon} ${chalk.bold(rule.id)}`);
|
|
4100
|
+
console.log(chalk.dim(` ${rule.message}`));
|
|
4101
|
+
}
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
// Get files to check
|
|
4106
|
+
let mdFiles = files;
|
|
4107
|
+
if (!mdFiles || mdFiles.length === 0) {
|
|
4108
|
+
let config = {};
|
|
4109
|
+
try {
|
|
4110
|
+
config = loadBuildConfig() || {};
|
|
4111
|
+
} catch {
|
|
4112
|
+
// Not in a rev project
|
|
4113
|
+
}
|
|
4114
|
+
mdFiles = config.sections || [];
|
|
4115
|
+
|
|
4116
|
+
if (mdFiles.length === 0) {
|
|
4117
|
+
mdFiles = fs.readdirSync('.').filter(f =>
|
|
4118
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
|
|
4119
|
+
);
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
if (mdFiles.length === 0) {
|
|
4124
|
+
console.error(chalk.red('No markdown files found'));
|
|
4125
|
+
process.exit(1);
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
console.log(fmt.header('Grammar Check'));
|
|
4129
|
+
console.log();
|
|
4130
|
+
|
|
4131
|
+
const severityLevels = { error: 3, warning: 2, info: 1 };
|
|
4132
|
+
const minSeverity = severityLevels[options.severity] || 1;
|
|
4133
|
+
|
|
4134
|
+
let allIssues = [];
|
|
4135
|
+
|
|
4136
|
+
for (const file of mdFiles) {
|
|
4137
|
+
if (!fs.existsSync(file)) continue;
|
|
4138
|
+
|
|
4139
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
4140
|
+
const issues = checkGrammar(text, { scientific: options.scientific });
|
|
4141
|
+
|
|
4142
|
+
// Filter by severity
|
|
4143
|
+
const filtered = issues.filter(i => severityLevels[i.severity] >= minSeverity);
|
|
4144
|
+
|
|
4145
|
+
if (filtered.length > 0) {
|
|
4146
|
+
console.log(chalk.cyan.bold(file));
|
|
4147
|
+
|
|
4148
|
+
for (const issue of filtered) {
|
|
4149
|
+
const icon = issue.severity === 'error' ? chalk.red('●') :
|
|
4150
|
+
issue.severity === 'warning' ? chalk.yellow('●') :
|
|
4151
|
+
chalk.blue('●');
|
|
4152
|
+
|
|
4153
|
+
console.log(` ${chalk.dim(`L${issue.line}:`)} ${icon} ${issue.message}`);
|
|
4154
|
+
console.log(chalk.dim(` "${issue.match}" in: ${issue.context.slice(0, 60)}...`));
|
|
4155
|
+
}
|
|
4156
|
+
console.log();
|
|
4157
|
+
allIssues.push(...filtered.map(i => ({ ...i, file })));
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
const summary = getGrammarSummary(allIssues);
|
|
4162
|
+
|
|
4163
|
+
if (summary.total === 0) {
|
|
4164
|
+
console.log(chalk.green('✓ No issues found'));
|
|
4165
|
+
} else {
|
|
4166
|
+
console.log(chalk.dim(`Found ${summary.total} issue(s): ${summary.errors} errors, ${summary.warnings} warnings, ${summary.info} info`));
|
|
4167
|
+
console.log();
|
|
4168
|
+
console.log(chalk.dim('Tip: Use --learn <word> to add words to dictionary'));
|
|
4169
|
+
}
|
|
4170
|
+
});
|
|
4171
|
+
|
|
4172
|
+
// ============================================================================
|
|
4173
|
+
// ANNOTATE command - Add comments to Word document
|
|
4174
|
+
// ============================================================================
|
|
4175
|
+
|
|
4176
|
+
program
|
|
4177
|
+
.command('annotate')
|
|
4178
|
+
.description('Add comment to Word document')
|
|
4179
|
+
.argument('<docx>', 'Word document')
|
|
4180
|
+
.option('-m, --message <text>', 'Comment text')
|
|
4181
|
+
.option('-s, --search <text>', 'Text to attach comment to')
|
|
4182
|
+
.option('-a, --author <name>', 'Comment author')
|
|
4183
|
+
.action(async (docxPath, options) => {
|
|
4184
|
+
if (!fs.existsSync(docxPath)) {
|
|
4185
|
+
console.error(chalk.red(`File not found: ${docxPath}`));
|
|
4186
|
+
process.exit(1);
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
if (!options.message) {
|
|
4190
|
+
console.error(chalk.red('Comment message required (-m)'));
|
|
4191
|
+
process.exit(1);
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
const { default: AdmZip } = await import('adm-zip');
|
|
4195
|
+
const zip = new AdmZip(docxPath);
|
|
4196
|
+
|
|
4197
|
+
// Read document.xml
|
|
4198
|
+
const docEntry = zip.getEntry('word/document.xml');
|
|
4199
|
+
if (!docEntry) {
|
|
4200
|
+
console.error(chalk.red('Invalid Word document'));
|
|
4201
|
+
process.exit(1);
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
let docXml = zip.readAsText(docEntry);
|
|
4205
|
+
|
|
4206
|
+
// Read or create comments.xml
|
|
4207
|
+
let commentsEntry = zip.getEntry('word/comments.xml');
|
|
4208
|
+
let commentsXml;
|
|
4209
|
+
let nextCommentId = 1;
|
|
4210
|
+
|
|
4211
|
+
if (commentsEntry) {
|
|
4212
|
+
commentsXml = zip.readAsText(commentsEntry);
|
|
4213
|
+
// Find highest existing comment ID
|
|
4214
|
+
const idMatches = commentsXml.match(/w:id="(\d+)"/g) || [];
|
|
4215
|
+
for (const m of idMatches) {
|
|
4216
|
+
const id = parseInt(m.match(/\d+/)[0]);
|
|
4217
|
+
if (id >= nextCommentId) nextCommentId = id + 1;
|
|
4218
|
+
}
|
|
4219
|
+
} else {
|
|
4220
|
+
commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4221
|
+
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
4222
|
+
</w:comments>`;
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
const author = options.author || getUserName() || 'Claude';
|
|
4226
|
+
const date = new Date().toISOString();
|
|
4227
|
+
const commentId = nextCommentId;
|
|
4228
|
+
|
|
4229
|
+
// Add comment to comments.xml
|
|
4230
|
+
const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
|
|
4231
|
+
<w:p><w:r><w:t>${options.message}</w:t></w:r></w:p>
|
|
4232
|
+
</w:comment>`;
|
|
4233
|
+
|
|
4234
|
+
commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
|
|
4235
|
+
|
|
4236
|
+
// Find text and add comment markers
|
|
4237
|
+
if (options.search) {
|
|
4238
|
+
const searchText = options.search;
|
|
4239
|
+
const textPattern = new RegExp(`(<w:t[^>]*>)([^<]*${searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^<]*)(<\/w:t>)`, 'i');
|
|
4240
|
+
|
|
4241
|
+
if (textPattern.test(docXml)) {
|
|
4242
|
+
docXml = docXml.replace(textPattern, (match, start, text, end) => {
|
|
4243
|
+
return `<w:commentRangeStart w:id="${commentId}"/>${start}${text}${end}<w:commentRangeEnd w:id="${commentId}"/><w:r><w:commentReference w:id="${commentId}"/></w:r>`;
|
|
4244
|
+
});
|
|
4245
|
+
} else {
|
|
4246
|
+
console.log(chalk.yellow(`Text "${searchText}" not found in document. Comment added without anchor.`));
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
// Update zip
|
|
4251
|
+
zip.updateFile('word/document.xml', Buffer.from(docXml));
|
|
4252
|
+
|
|
4253
|
+
if (commentsEntry) {
|
|
4254
|
+
zip.updateFile('word/comments.xml', Buffer.from(commentsXml));
|
|
4255
|
+
} else {
|
|
4256
|
+
zip.addFile('word/comments.xml', Buffer.from(commentsXml));
|
|
4257
|
+
|
|
4258
|
+
// Update [Content_Types].xml to include comments
|
|
4259
|
+
const ctEntry = zip.getEntry('[Content_Types].xml');
|
|
4260
|
+
if (ctEntry) {
|
|
4261
|
+
let ctXml = zip.readAsText(ctEntry);
|
|
4262
|
+
if (!ctXml.includes('comments.xml')) {
|
|
4263
|
+
ctXml = ctXml.replace('</Types>',
|
|
4264
|
+
'<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n</Types>');
|
|
4265
|
+
zip.updateFile('[Content_Types].xml', Buffer.from(ctXml));
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
// Update document.xml.rels
|
|
4270
|
+
const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
|
|
4271
|
+
if (relsEntry) {
|
|
4272
|
+
let relsXml = zip.readAsText(relsEntry);
|
|
4273
|
+
if (!relsXml.includes('comments.xml')) {
|
|
4274
|
+
const newRelId = `rId${Date.now()}`;
|
|
4275
|
+
relsXml = relsXml.replace('</Relationships>',
|
|
4276
|
+
`<Relationship Id="${newRelId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n</Relationships>`);
|
|
4277
|
+
zip.updateFile('word/_rels/document.xml.rels', Buffer.from(relsXml));
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
// Write back
|
|
4283
|
+
zip.writeZip(docxPath);
|
|
4284
|
+
console.log(fmt.status('success', `Added comment to ${docxPath}`));
|
|
4285
|
+
});
|
|
4286
|
+
|
|
4287
|
+
// ============================================================================
|
|
4288
|
+
// APPLY command - Apply MD annotations as Word track changes
|
|
4289
|
+
// ============================================================================
|
|
4290
|
+
|
|
4291
|
+
program
|
|
4292
|
+
.command('apply')
|
|
4293
|
+
.description('Apply markdown annotations to Word document as track changes')
|
|
4294
|
+
.argument('<md>', 'Markdown file with annotations')
|
|
4295
|
+
.argument('<docx>', 'Output Word document')
|
|
4296
|
+
.option('-a, --author <name>', 'Author name for track changes')
|
|
4297
|
+
.action(async (mdPath, docxPath, options) => {
|
|
4298
|
+
if (!fs.existsSync(mdPath)) {
|
|
4299
|
+
console.error(chalk.red(`File not found: ${mdPath}`));
|
|
4300
|
+
process.exit(1);
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
const mdContent = fs.readFileSync(mdPath, 'utf-8');
|
|
4304
|
+
const annotations = parseAnnotations(mdContent);
|
|
4305
|
+
|
|
4306
|
+
if (annotations.length === 0) {
|
|
4307
|
+
console.log(chalk.yellow('No annotations found in markdown file'));
|
|
4308
|
+
// Still build the document
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
const author = options.author || getUserName() || 'Author';
|
|
4312
|
+
|
|
4313
|
+
// Build document with track changes
|
|
4314
|
+
const { buildWithTrackChanges } = await import('../lib/trackchanges.js');
|
|
4315
|
+
|
|
4316
|
+
try {
|
|
4317
|
+
const result = await buildWithTrackChanges(mdPath, docxPath, { author });
|
|
4318
|
+
|
|
4319
|
+
if (result.success) {
|
|
4320
|
+
console.log(fmt.status('success', result.message));
|
|
4321
|
+
console.log(chalk.dim(` ${annotations.length} annotations applied as track changes`));
|
|
4322
|
+
} else {
|
|
4323
|
+
console.error(chalk.red(result.message));
|
|
4324
|
+
process.exit(1);
|
|
4325
|
+
}
|
|
4326
|
+
} catch (err) {
|
|
4327
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
4328
|
+
process.exit(1);
|
|
4329
|
+
}
|
|
4330
|
+
});
|
|
4331
|
+
|
|
4332
|
+
// ============================================================================
|
|
4333
|
+
// COMMENT command - Interactive comment addition to DOCX
|
|
4334
|
+
// ============================================================================
|
|
4335
|
+
|
|
4336
|
+
program
|
|
4337
|
+
.command('comment')
|
|
4338
|
+
.description('Add comments to Word document interactively')
|
|
4339
|
+
.argument('<docx>', 'Word document')
|
|
4340
|
+
.option('-a, --author <name>', 'Comment author')
|
|
4341
|
+
.action(async (docxPath, options) => {
|
|
4342
|
+
if (!fs.existsSync(docxPath)) {
|
|
4343
|
+
console.error(chalk.red(`File not found: ${docxPath}`));
|
|
4344
|
+
process.exit(1);
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
const { default: AdmZip } = await import('adm-zip');
|
|
4348
|
+
const rl = (await import('readline')).createInterface({
|
|
4349
|
+
input: process.stdin,
|
|
4350
|
+
output: process.stdout,
|
|
4351
|
+
});
|
|
4352
|
+
|
|
4353
|
+
const ask = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
|
|
4354
|
+
|
|
4355
|
+
const author = options.author || getUserName() || 'Reviewer';
|
|
4356
|
+
|
|
4357
|
+
console.log(fmt.header('Interactive Comment Mode'));
|
|
4358
|
+
console.log(chalk.dim(` Document: ${docxPath}`));
|
|
4359
|
+
console.log(chalk.dim(` Author: ${author}`));
|
|
4360
|
+
console.log(chalk.dim(' Type your comment, then the text to attach it to.'));
|
|
4361
|
+
console.log(chalk.dim(' Enter empty comment to quit.\n'));
|
|
4362
|
+
|
|
4363
|
+
let commentsAdded = 0;
|
|
4364
|
+
|
|
4365
|
+
while (true) {
|
|
4366
|
+
const message = await ask(chalk.cyan('Comment: '));
|
|
4367
|
+
|
|
4368
|
+
if (!message.trim()) {
|
|
4369
|
+
break;
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
const searchText = await ask(chalk.cyan('Attach to text: '));
|
|
4373
|
+
|
|
4374
|
+
// Load document fresh each time
|
|
4375
|
+
const zip = new AdmZip(docxPath);
|
|
4376
|
+
const docEntry = zip.getEntry('word/document.xml');
|
|
4377
|
+
|
|
4378
|
+
if (!docEntry) {
|
|
4379
|
+
console.error(chalk.red('Invalid Word document'));
|
|
4380
|
+
rl.close();
|
|
4381
|
+
process.exit(1);
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
let docXml = zip.readAsText(docEntry);
|
|
4385
|
+
|
|
4386
|
+
// Read or create comments.xml
|
|
4387
|
+
let commentsEntry = zip.getEntry('word/comments.xml');
|
|
4388
|
+
let commentsXml;
|
|
4389
|
+
let nextCommentId = 1;
|
|
4390
|
+
|
|
4391
|
+
if (commentsEntry) {
|
|
4392
|
+
commentsXml = zip.readAsText(commentsEntry);
|
|
4393
|
+
const idMatches = commentsXml.match(/w:id="(\d+)"/g) || [];
|
|
4394
|
+
for (const m of idMatches) {
|
|
4395
|
+
const id = parseInt(m.match(/\d+/)[0]);
|
|
4396
|
+
if (id >= nextCommentId) nextCommentId = id + 1;
|
|
4397
|
+
}
|
|
4398
|
+
} else {
|
|
4399
|
+
commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
4400
|
+
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
4401
|
+
</w:comments>`;
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4404
|
+
const date = new Date().toISOString();
|
|
4405
|
+
const commentId = nextCommentId;
|
|
4406
|
+
|
|
4407
|
+
// Add comment to comments.xml
|
|
4408
|
+
const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
|
|
4409
|
+
<w:p><w:r><w:t>${message}</w:t></w:r></w:p>
|
|
4410
|
+
</w:comment>`;
|
|
4411
|
+
|
|
4412
|
+
commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
|
|
4413
|
+
|
|
4414
|
+
// Find text and add comment markers
|
|
4415
|
+
if (searchText.trim()) {
|
|
4416
|
+
const escapedSearch = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4417
|
+
const textPattern = new RegExp(`(<w:t[^>]*>)([^<]*${escapedSearch}[^<]*)(<\/w:t>)`, 'i');
|
|
4418
|
+
|
|
4419
|
+
if (textPattern.test(docXml)) {
|
|
4420
|
+
docXml = docXml.replace(textPattern, (match, start, text, end) => {
|
|
4421
|
+
return `<w:commentRangeStart w:id="${commentId}"/>${start}${text}${end}<w:commentRangeEnd w:id="${commentId}"/><w:r><w:commentReference w:id="${commentId}"/></w:r>`;
|
|
4422
|
+
});
|
|
4423
|
+
console.log(chalk.green(` ✓ Comment added at "${searchText}"`));
|
|
4424
|
+
} else {
|
|
4425
|
+
console.log(chalk.yellow(` Text not found. Comment added without anchor.`));
|
|
4426
|
+
}
|
|
4427
|
+
} else {
|
|
4428
|
+
console.log(chalk.dim(` Comment added without anchor.`));
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
// Update zip
|
|
4432
|
+
zip.updateFile('word/document.xml', Buffer.from(docXml));
|
|
4433
|
+
|
|
4434
|
+
if (commentsEntry) {
|
|
4435
|
+
zip.updateFile('word/comments.xml', Buffer.from(commentsXml));
|
|
4436
|
+
} else {
|
|
4437
|
+
zip.addFile('word/comments.xml', Buffer.from(commentsXml));
|
|
4438
|
+
|
|
4439
|
+
// Update [Content_Types].xml
|
|
4440
|
+
const ctEntry = zip.getEntry('[Content_Types].xml');
|
|
4441
|
+
if (ctEntry) {
|
|
4442
|
+
let ctXml = zip.readAsText(ctEntry);
|
|
4443
|
+
if (!ctXml.includes('comments.xml')) {
|
|
4444
|
+
ctXml = ctXml.replace('</Types>',
|
|
4445
|
+
'<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n</Types>');
|
|
4446
|
+
zip.updateFile('[Content_Types].xml', Buffer.from(ctXml));
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
// Update document.xml.rels
|
|
4451
|
+
const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
|
|
4452
|
+
if (relsEntry) {
|
|
4453
|
+
let relsXml = zip.readAsText(relsEntry);
|
|
4454
|
+
if (!relsXml.includes('comments.xml')) {
|
|
4455
|
+
const newRelId = `rId${Date.now()}`;
|
|
4456
|
+
relsXml = relsXml.replace('</Relationships>',
|
|
4457
|
+
`<Relationship Id="${newRelId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n</Relationships>`);
|
|
4458
|
+
zip.updateFile('word/_rels/document.xml.rels', Buffer.from(relsXml));
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
zip.writeZip(docxPath);
|
|
4464
|
+
commentsAdded++;
|
|
4465
|
+
console.log();
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
rl.close();
|
|
4469
|
+
console.log();
|
|
4470
|
+
|
|
4471
|
+
if (commentsAdded > 0) {
|
|
4472
|
+
console.log(fmt.status('success', `Added ${commentsAdded} comment(s) to ${docxPath}`));
|
|
4473
|
+
} else {
|
|
4474
|
+
console.log(chalk.dim('No comments added.'));
|
|
4475
|
+
}
|
|
4476
|
+
});
|
|
4477
|
+
|
|
4478
|
+
// ============================================================================
|
|
4479
|
+
// CLEAN command - Remove generated files
|
|
4480
|
+
// ============================================================================
|
|
4481
|
+
|
|
4482
|
+
program
|
|
4483
|
+
.command('clean')
|
|
4484
|
+
.description('Remove generated files (paper.md, PDFs, DOCXs)')
|
|
4485
|
+
.option('-n, --dry-run', 'Show what would be deleted without deleting')
|
|
4486
|
+
.option('--all', 'Also remove backup and export zips')
|
|
4487
|
+
.action((options) => {
|
|
4488
|
+
let config = {};
|
|
4489
|
+
try {
|
|
4490
|
+
config = loadBuildConfig() || {};
|
|
4491
|
+
} catch {
|
|
4492
|
+
// Not in a rev project, that's ok
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
const projectName = config.title?.toLowerCase().replace(/\s+/g, '-') || 'paper';
|
|
4496
|
+
|
|
4497
|
+
// Files to clean
|
|
4498
|
+
const patterns = [
|
|
4499
|
+
'paper.md',
|
|
4500
|
+
'*.pdf',
|
|
4501
|
+
`${projectName}.docx`,
|
|
4502
|
+
`${projectName}.pdf`,
|
|
4503
|
+
`${projectName}.tex`,
|
|
4504
|
+
'.paper-*.md', // Temp build files
|
|
4505
|
+
];
|
|
4506
|
+
|
|
4507
|
+
if (options.all) {
|
|
4508
|
+
patterns.push('*.zip', 'backup-*.zip', '*-export.zip');
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
const toDelete = [];
|
|
4512
|
+
|
|
4513
|
+
for (const pattern of patterns) {
|
|
4514
|
+
if (pattern.includes('*')) {
|
|
4515
|
+
// Glob pattern
|
|
4516
|
+
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
4517
|
+
const files = fs.readdirSync('.').filter(f => regex.test(f));
|
|
4518
|
+
toDelete.push(...files);
|
|
4519
|
+
} else if (fs.existsSync(pattern)) {
|
|
4520
|
+
toDelete.push(pattern);
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
if (toDelete.length === 0) {
|
|
4525
|
+
console.log(chalk.dim('No generated files to clean.'));
|
|
4526
|
+
return;
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
console.log(fmt.header('Clean'));
|
|
4530
|
+
console.log();
|
|
4531
|
+
|
|
4532
|
+
for (const file of toDelete) {
|
|
4533
|
+
if (options.dryRun) {
|
|
4534
|
+
console.log(chalk.dim(` Would delete: ${file}`));
|
|
4535
|
+
} else {
|
|
4536
|
+
fs.unlinkSync(file);
|
|
4537
|
+
console.log(chalk.red(` Deleted: ${file}`));
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
|
|
4541
|
+
console.log();
|
|
4542
|
+
if (options.dryRun) {
|
|
4543
|
+
console.log(chalk.dim(`Would delete ${toDelete.length} file(s). Run without --dry-run to delete.`));
|
|
4544
|
+
} else {
|
|
4545
|
+
console.log(fmt.status('success', `Cleaned ${toDelete.length} file(s)`));
|
|
4546
|
+
}
|
|
4547
|
+
});
|
|
4548
|
+
|
|
4549
|
+
// ============================================================================
|
|
4550
|
+
// CHECK command - Pre-submission check (lint + grammar + citations)
|
|
4551
|
+
// ============================================================================
|
|
4552
|
+
|
|
4553
|
+
program
|
|
4554
|
+
.command('check')
|
|
4555
|
+
.description('Run all checks before submission (lint + grammar + citations)')
|
|
4556
|
+
.option('--fix', 'Auto-fix issues where possible')
|
|
4557
|
+
.option('-s, --severity <level>', 'Minimum grammar severity', 'warning')
|
|
4558
|
+
.action(async (options) => {
|
|
4559
|
+
console.log(fmt.header('Pre-Submission Check'));
|
|
4560
|
+
console.log();
|
|
4561
|
+
|
|
4562
|
+
let hasErrors = false;
|
|
4563
|
+
let totalIssues = 0;
|
|
4564
|
+
|
|
4565
|
+
// 1. Run lint
|
|
4566
|
+
console.log(chalk.cyan.bold('1. Linting...'));
|
|
4567
|
+
let config = {};
|
|
4568
|
+
try {
|
|
4569
|
+
config = loadBuildConfig() || {};
|
|
4570
|
+
} catch {
|
|
4571
|
+
// Not in a rev project
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
let sections = config.sections || [];
|
|
4575
|
+
if (sections.length === 0) {
|
|
4576
|
+
sections = fs.readdirSync('.').filter(f =>
|
|
4577
|
+
f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
|
|
4578
|
+
);
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
const lintIssues = [];
|
|
4582
|
+
const lintWarnings = [];
|
|
4583
|
+
|
|
4584
|
+
for (const file of sections) {
|
|
4585
|
+
if (!fs.existsSync(file)) continue;
|
|
4586
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
4587
|
+
|
|
4588
|
+
// Check for broken cross-references
|
|
4589
|
+
const refs = content.match(/@(fig|tbl|eq|sec):\w+/g) || [];
|
|
4590
|
+
const anchors = content.match(/\{#(fig|tbl|eq|sec):[^}]+\}/g) || [];
|
|
4591
|
+
const anchorLabels = anchors.map(a => a.match(/#([^}]+)/)[1]);
|
|
4592
|
+
|
|
4593
|
+
for (const ref of refs) {
|
|
4594
|
+
const label = ref.slice(1);
|
|
4595
|
+
if (!anchorLabels.includes(label)) {
|
|
4596
|
+
lintIssues.push({ file, message: `Broken reference: ${ref}` });
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
// Check for unresolved comments
|
|
4601
|
+
const unresolvedComments = (content.match(/\{>>[^<]*<<\}/g) || [])
|
|
4602
|
+
.filter(c => !c.includes('[RESOLVED]'));
|
|
4603
|
+
if (unresolvedComments.length > 0) {
|
|
4604
|
+
lintWarnings.push({ file, message: `${unresolvedComments.length} unresolved comment(s)` });
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
if (lintIssues.length > 0) {
|
|
4609
|
+
for (const issue of lintIssues) {
|
|
4610
|
+
console.log(chalk.red(` ✗ ${issue.file}: ${issue.message}`));
|
|
4611
|
+
}
|
|
4612
|
+
hasErrors = true;
|
|
4613
|
+
totalIssues += lintIssues.length;
|
|
4614
|
+
}
|
|
4615
|
+
for (const warning of lintWarnings) {
|
|
4616
|
+
console.log(chalk.yellow(` ⚠ ${warning.file}: ${warning.message}`));
|
|
4617
|
+
totalIssues++;
|
|
4618
|
+
}
|
|
4619
|
+
if (lintIssues.length === 0 && lintWarnings.length === 0) {
|
|
4620
|
+
console.log(chalk.green(' ✓ No lint issues'));
|
|
4621
|
+
}
|
|
4622
|
+
console.log();
|
|
4623
|
+
|
|
4624
|
+
// 2. Run grammar check
|
|
4625
|
+
console.log(chalk.cyan.bold('2. Grammar check...'));
|
|
4626
|
+
const {
|
|
4627
|
+
checkGrammar,
|
|
4628
|
+
getGrammarSummary,
|
|
4629
|
+
} = await import('../lib/grammar.js');
|
|
4630
|
+
|
|
4631
|
+
const severityLevels = { error: 3, warning: 2, info: 1 };
|
|
4632
|
+
const minSeverity = severityLevels[options.severity] || 2;
|
|
4633
|
+
let grammarIssues = [];
|
|
4634
|
+
|
|
4635
|
+
for (const file of sections) {
|
|
4636
|
+
if (!fs.existsSync(file)) continue;
|
|
4637
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
4638
|
+
const issues = checkGrammar(text, { scientific: true });
|
|
4639
|
+
const filtered = issues.filter(i => severityLevels[i.severity] >= minSeverity);
|
|
4640
|
+
grammarIssues.push(...filtered.map(i => ({ ...i, file })));
|
|
4641
|
+
}
|
|
4642
|
+
|
|
4643
|
+
const grammarSummary = getGrammarSummary(grammarIssues);
|
|
4644
|
+
if (grammarSummary.errors > 0) {
|
|
4645
|
+
hasErrors = true;
|
|
4646
|
+
}
|
|
4647
|
+
totalIssues += grammarSummary.total;
|
|
4648
|
+
|
|
4649
|
+
if (grammarSummary.total > 0) {
|
|
4650
|
+
console.log(chalk.yellow(` ⚠ ${grammarSummary.total} grammar issue(s): ${grammarSummary.errors} errors, ${grammarSummary.warnings} warnings`));
|
|
4651
|
+
} else {
|
|
4652
|
+
console.log(chalk.green(' ✓ No grammar issues'));
|
|
4653
|
+
}
|
|
4654
|
+
console.log();
|
|
4655
|
+
|
|
4656
|
+
// 3. Run citation check
|
|
4657
|
+
console.log(chalk.cyan.bold('3. Citation check...'));
|
|
4658
|
+
const bibFile = config.bibliography || 'references.bib';
|
|
4659
|
+
if (fs.existsSync(bibFile)) {
|
|
4660
|
+
const { validateCitations } = await import('../lib/citations.js');
|
|
4661
|
+
const allContent = sections
|
|
4662
|
+
.filter(f => fs.existsSync(f))
|
|
4663
|
+
.map(f => fs.readFileSync(f, 'utf-8'))
|
|
4664
|
+
.join('\n');
|
|
4665
|
+
const bibContent = fs.readFileSync(bibFile, 'utf-8');
|
|
4666
|
+
|
|
4667
|
+
const result = validateCitations(allContent, bibContent);
|
|
4668
|
+
|
|
4669
|
+
if (result.missing.length > 0) {
|
|
4670
|
+
console.log(chalk.red(` ✗ ${result.missing.length} missing citation(s): ${result.missing.slice(0, 3).join(', ')}${result.missing.length > 3 ? '...' : ''}`));
|
|
4671
|
+
hasErrors = true;
|
|
4672
|
+
totalIssues += result.missing.length;
|
|
4673
|
+
}
|
|
4674
|
+
if (result.unused.length > 0) {
|
|
4675
|
+
console.log(chalk.yellow(` ⚠ ${result.unused.length} unused citation(s)`));
|
|
4676
|
+
totalIssues += result.unused.length;
|
|
4677
|
+
}
|
|
4678
|
+
if (result.missing.length === 0 && result.unused.length === 0) {
|
|
4679
|
+
console.log(chalk.green(' ✓ All citations valid'));
|
|
4680
|
+
}
|
|
4681
|
+
} else {
|
|
4682
|
+
console.log(chalk.dim(' - No bibliography file found'));
|
|
4683
|
+
}
|
|
4684
|
+
console.log();
|
|
4685
|
+
|
|
4686
|
+
// Summary
|
|
4687
|
+
console.log(chalk.bold('Summary'));
|
|
4688
|
+
if (hasErrors) {
|
|
4689
|
+
console.log(chalk.red(` ${totalIssues} issue(s) found. Please fix before submission.`));
|
|
4690
|
+
process.exit(1);
|
|
4691
|
+
} else if (totalIssues > 0) {
|
|
4692
|
+
console.log(chalk.yellow(` ${totalIssues} warning(s). Review before submission.`));
|
|
4693
|
+
} else {
|
|
4694
|
+
console.log(chalk.green(' ✓ All checks passed! Ready for submission.'));
|
|
4695
|
+
}
|
|
4696
|
+
});
|
|
4697
|
+
|
|
4698
|
+
// ============================================================================
|
|
4699
|
+
// OPEN command - Open project folder or file
|
|
4700
|
+
// ============================================================================
|
|
4701
|
+
|
|
4702
|
+
program
|
|
4703
|
+
.command('open')
|
|
4704
|
+
.description('Open project folder or file in default app')
|
|
4705
|
+
.argument('[file]', 'File to open (default: project folder)')
|
|
4706
|
+
.action(async (file) => {
|
|
4707
|
+
const { exec } = await import('child_process');
|
|
4708
|
+
const target = file || '.';
|
|
4709
|
+
|
|
4710
|
+
if (!fs.existsSync(target)) {
|
|
4711
|
+
console.error(chalk.red(`File not found: ${target}`));
|
|
4712
|
+
process.exit(1);
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
// Platform-specific open command
|
|
4716
|
+
const platform = process.platform;
|
|
4717
|
+
let command;
|
|
4718
|
+
|
|
4719
|
+
if (platform === 'darwin') {
|
|
4720
|
+
command = `open "${target}"`;
|
|
4721
|
+
} else if (platform === 'win32') {
|
|
4722
|
+
command = `start "" "${target}"`;
|
|
4723
|
+
} else {
|
|
4724
|
+
command = `xdg-open "${target}"`;
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
exec(command, (err) => {
|
|
4728
|
+
if (err) {
|
|
4729
|
+
console.error(chalk.red(`Failed to open: ${err.message}`));
|
|
4730
|
+
process.exit(1);
|
|
4731
|
+
}
|
|
4732
|
+
console.log(fmt.status('success', `Opened ${target}`));
|
|
4733
|
+
});
|
|
4734
|
+
});
|
|
4735
|
+
|
|
4736
|
+
// ============================================================================
|
|
4737
|
+
// INSTALL-CLI-SKILL command - Install Claude Code skill
|
|
4738
|
+
// ============================================================================
|
|
4739
|
+
|
|
4740
|
+
program
|
|
4741
|
+
.command('install-cli-skill')
|
|
4742
|
+
.description('Install docrev skill for Claude Code')
|
|
4743
|
+
.action(() => {
|
|
4744
|
+
const homedir = process.env.HOME || process.env.USERPROFILE;
|
|
4745
|
+
const skillDir = path.join(homedir, '.claude', 'skills', 'docrev');
|
|
4746
|
+
const sourceDir = path.join(import.meta.dirname, '..', 'skill');
|
|
4747
|
+
|
|
4748
|
+
// Check if source skill files exist
|
|
4749
|
+
const skillFile = path.join(sourceDir, 'SKILL.md');
|
|
4750
|
+
if (!fs.existsSync(skillFile)) {
|
|
4751
|
+
console.error(chalk.red('Skill files not found in package'));
|
|
4752
|
+
process.exit(1);
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
// Create skill directory
|
|
4756
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
4757
|
+
|
|
4758
|
+
// Copy skill files
|
|
4759
|
+
const files = ['SKILL.md', 'REFERENCE.md'];
|
|
4760
|
+
for (const file of files) {
|
|
4761
|
+
const src = path.join(sourceDir, file);
|
|
4762
|
+
const dest = path.join(skillDir, file);
|
|
4763
|
+
if (fs.existsSync(src)) {
|
|
4764
|
+
fs.copyFileSync(src, dest);
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
console.log(fmt.status('success', 'Installed docrev skill for Claude Code'));
|
|
4769
|
+
console.log(chalk.dim(` Location: ${skillDir}`));
|
|
4770
|
+
console.log(chalk.dim(' Restart Claude Code to activate'));
|
|
4771
|
+
});
|
|
4772
|
+
|
|
4773
|
+
program
|
|
4774
|
+
.command('uninstall-cli-skill')
|
|
4775
|
+
.description('Remove docrev skill from Claude Code')
|
|
4776
|
+
.action(() => {
|
|
4777
|
+
const homedir = process.env.HOME || process.env.USERPROFILE;
|
|
4778
|
+
const skillDir = path.join(homedir, '.claude', 'skills', 'docrev');
|
|
4779
|
+
|
|
4780
|
+
if (fs.existsSync(skillDir)) {
|
|
4781
|
+
fs.rmSync(skillDir, { recursive: true });
|
|
4782
|
+
console.log(fmt.status('success', 'Removed docrev skill from Claude Code'));
|
|
4783
|
+
} else {
|
|
4784
|
+
console.log(chalk.yellow('Skill not installed'));
|
|
4785
|
+
}
|
|
4786
|
+
});
|
|
4787
|
+
|
|
4788
|
+
// ============================================================================
|
|
4789
|
+
// SPELLING command - Spellcheck with global dictionary
|
|
4790
|
+
// ============================================================================
|
|
4791
|
+
|
|
4792
|
+
program
|
|
4793
|
+
.command('spelling')
|
|
4794
|
+
.description('Check spelling in markdown files')
|
|
4795
|
+
.argument('[files...]', 'Files to check (default: section files)')
|
|
4796
|
+
.option('--learn <word>', 'Add word to global dictionary')
|
|
4797
|
+
.option('--learn-project <word>', 'Add word to project dictionary')
|
|
4798
|
+
.option('--forget <word>', 'Remove word from global dictionary')
|
|
4799
|
+
.option('--forget-project <word>', 'Remove word from project dictionary')
|
|
4800
|
+
.option('--list', 'List global dictionary words')
|
|
4801
|
+
.option('--list-project', 'List project dictionary words')
|
|
4802
|
+
.option('--list-all', 'List all custom words (global + project)')
|
|
4803
|
+
.option('--british', 'Use British English dictionary')
|
|
4804
|
+
.option('--add-names', 'Add detected names to global dictionary')
|
|
4805
|
+
.action(async (files, options) => {
|
|
4806
|
+
const spelling = await import('../lib/spelling.js');
|
|
4807
|
+
|
|
4808
|
+
// Handle dictionary management
|
|
4809
|
+
if (options.learn) {
|
|
4810
|
+
const added = spelling.addWord(options.learn, true);
|
|
4811
|
+
if (added) {
|
|
4812
|
+
console.log(fmt.status('success', `Added "${options.learn}" to global dictionary`));
|
|
4813
|
+
} else {
|
|
4814
|
+
console.log(chalk.yellow(`"${options.learn}" already in dictionary`));
|
|
4815
|
+
}
|
|
4816
|
+
return;
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
if (options.learnProject) {
|
|
4820
|
+
const added = spelling.addWord(options.learnProject, false);
|
|
4821
|
+
if (added) {
|
|
4822
|
+
console.log(fmt.status('success', `Added "${options.learnProject}" to project dictionary`));
|
|
4823
|
+
} else {
|
|
4824
|
+
console.log(chalk.yellow(`"${options.learnProject}" already in dictionary`));
|
|
4825
|
+
}
|
|
4826
|
+
return;
|
|
4827
|
+
}
|
|
4828
|
+
|
|
4829
|
+
if (options.forget) {
|
|
4830
|
+
const removed = spelling.removeWord(options.forget, true);
|
|
4831
|
+
if (removed) {
|
|
4832
|
+
console.log(fmt.status('success', `Removed "${options.forget}" from global dictionary`));
|
|
4833
|
+
} else {
|
|
4834
|
+
console.log(chalk.yellow(`"${options.forget}" not in dictionary`));
|
|
4835
|
+
}
|
|
4836
|
+
return;
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
if (options.forgetProject) {
|
|
4840
|
+
const removed = spelling.removeWord(options.forgetProject, false);
|
|
4841
|
+
if (removed) {
|
|
4842
|
+
console.log(fmt.status('success', `Removed "${options.forgetProject}" from project dictionary`));
|
|
4843
|
+
} else {
|
|
4844
|
+
console.log(chalk.yellow(`"${options.forgetProject}" not in dictionary`));
|
|
4845
|
+
}
|
|
4846
|
+
return;
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
if (options.list) {
|
|
4850
|
+
const words = spelling.listWords(true);
|
|
4851
|
+
console.log(fmt.header('Global Dictionary'));
|
|
4852
|
+
if (words.length === 0) {
|
|
4853
|
+
console.log(chalk.dim(' No custom words'));
|
|
4854
|
+
console.log(chalk.dim(' Use --learn <word> to add words'));
|
|
4855
|
+
} else {
|
|
4856
|
+
for (const word of words) {
|
|
4857
|
+
console.log(` ${word}`);
|
|
4858
|
+
}
|
|
4859
|
+
console.log(chalk.dim(`\n${words.length} word(s)`));
|
|
4860
|
+
}
|
|
4861
|
+
return;
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
if (options.listProject) {
|
|
4865
|
+
const words = spelling.listWords(false);
|
|
4866
|
+
console.log(fmt.header('Project Dictionary'));
|
|
4867
|
+
if (words.length === 0) {
|
|
4868
|
+
console.log(chalk.dim(' No custom words'));
|
|
4869
|
+
console.log(chalk.dim(' Use --learn-project <word> to add words'));
|
|
4870
|
+
} else {
|
|
4871
|
+
for (const word of words) {
|
|
4872
|
+
console.log(` ${word}`);
|
|
4873
|
+
}
|
|
4874
|
+
console.log(chalk.dim(`\n${words.length} word(s)`));
|
|
4875
|
+
}
|
|
4876
|
+
return;
|
|
4877
|
+
}
|
|
4878
|
+
|
|
4879
|
+
if (options.listAll) {
|
|
4880
|
+
const globalWords = spelling.listWords(true);
|
|
4881
|
+
const projectWords = spelling.listWords(false);
|
|
4882
|
+
|
|
4883
|
+
console.log(fmt.header('Global Dictionary'));
|
|
4884
|
+
if (globalWords.length === 0) {
|
|
4885
|
+
console.log(chalk.dim(' No custom words'));
|
|
4886
|
+
} else {
|
|
4887
|
+
for (const word of globalWords) {
|
|
4888
|
+
console.log(` ${word}`);
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
console.log(fmt.header('Project Dictionary'));
|
|
4893
|
+
if (projectWords.length === 0) {
|
|
4894
|
+
console.log(chalk.dim(' No custom words'));
|
|
4895
|
+
} else {
|
|
4896
|
+
for (const word of projectWords) {
|
|
4897
|
+
console.log(` ${word}`);
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
console.log(chalk.dim(`\nTotal: ${globalWords.length + projectWords.length} word(s)`));
|
|
4902
|
+
return;
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
// Check spelling in files
|
|
4906
|
+
let filesToCheck = files;
|
|
4907
|
+
|
|
4908
|
+
if (filesToCheck.length === 0) {
|
|
4909
|
+
// Default to section files if in a project
|
|
4910
|
+
if (fs.existsSync('rev.yaml')) {
|
|
4911
|
+
const { getSectionFiles } = await import('../lib/sections.js');
|
|
4912
|
+
filesToCheck = getSectionFiles('.');
|
|
4913
|
+
} else {
|
|
4914
|
+
// Check all .md files in current directory
|
|
4915
|
+
filesToCheck = fs.readdirSync('.')
|
|
4916
|
+
.filter(f => f.endsWith('.md') && !f.startsWith('.'));
|
|
4917
|
+
}
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4920
|
+
if (filesToCheck.length === 0) {
|
|
4921
|
+
console.log(chalk.yellow('No markdown files found'));
|
|
4922
|
+
return;
|
|
4923
|
+
}
|
|
4924
|
+
|
|
4925
|
+
const lang = options.british ? 'en-gb' : 'en';
|
|
4926
|
+
console.log(fmt.header(`Spelling Check (${options.british ? 'British' : 'US'} English)`));
|
|
4927
|
+
let totalMisspelled = 0;
|
|
4928
|
+
const allNames = new Set();
|
|
4929
|
+
|
|
4930
|
+
for (const file of filesToCheck) {
|
|
4931
|
+
if (!fs.existsSync(file)) {
|
|
4932
|
+
console.log(chalk.yellow(`File not found: ${file}`));
|
|
4933
|
+
continue;
|
|
4934
|
+
}
|
|
4935
|
+
|
|
4936
|
+
const result = await spelling.checkFile(file, { projectDir: '.', lang });
|
|
4937
|
+
const { misspelled, possibleNames } = result;
|
|
4938
|
+
|
|
4939
|
+
// Collect names
|
|
4940
|
+
for (const n of possibleNames) {
|
|
4941
|
+
allNames.add(n.word);
|
|
4942
|
+
}
|
|
4943
|
+
|
|
4944
|
+
if (misspelled.length > 0) {
|
|
4945
|
+
console.log(chalk.cyan(`\n${file}:`));
|
|
4946
|
+
for (const issue of misspelled) {
|
|
4947
|
+
const suggestions = issue.suggestions.length > 0
|
|
4948
|
+
? chalk.dim(` → ${issue.suggestions.join(', ')}`)
|
|
4949
|
+
: '';
|
|
4950
|
+
console.log(` ${chalk.yellow(issue.word)} ${chalk.dim(`(line ${issue.line})`)}${suggestions}`);
|
|
4951
|
+
}
|
|
4952
|
+
totalMisspelled += misspelled.length;
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
// Show possible names separately
|
|
4957
|
+
if (allNames.size > 0) {
|
|
4958
|
+
const nameList = [...allNames].sort();
|
|
4959
|
+
|
|
4960
|
+
if (options.addNames) {
|
|
4961
|
+
// Add all names to dictionary
|
|
4962
|
+
console.log(fmt.header('Adding Names to Dictionary'));
|
|
4963
|
+
for (const name of nameList) {
|
|
4964
|
+
spelling.addWord(name, true);
|
|
4965
|
+
console.log(chalk.green(` ✓ ${name}`));
|
|
4966
|
+
}
|
|
4967
|
+
console.log(chalk.dim(`\nAdded ${nameList.length} name(s) to global dictionary`));
|
|
4968
|
+
} else {
|
|
4969
|
+
console.log(fmt.header('Possible Names'));
|
|
4970
|
+
console.log(chalk.dim(` ${nameList.join(', ')}`));
|
|
4971
|
+
console.log(chalk.dim(`\n Run with --add-names to add all to dictionary`));
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
|
|
4975
|
+
if (totalMisspelled === 0 && allNames.size === 0) {
|
|
4976
|
+
console.log(fmt.status('success', 'No spelling errors found'));
|
|
4977
|
+
} else {
|
|
4978
|
+
if (totalMisspelled > 0) {
|
|
4979
|
+
console.log(chalk.yellow(`\n${totalMisspelled} spelling error(s)`));
|
|
4980
|
+
}
|
|
4981
|
+
if (allNames.size > 0) {
|
|
4982
|
+
console.log(chalk.blue(`${allNames.size} possible name(s)`));
|
|
4983
|
+
}
|
|
4984
|
+
console.log(chalk.dim('Use --learn <word> to add words to dictionary'));
|
|
4985
|
+
}
|
|
4986
|
+
});
|
|
4987
|
+
|
|
3336
4988
|
program.parse();
|