docrev 0.9.5 → 0.9.7

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dev_notes/bug_repro_comment_parser.md +71 -0
  3. package/dist/lib/anchor-match.d.ts +41 -0
  4. package/dist/lib/anchor-match.d.ts.map +1 -0
  5. package/dist/lib/anchor-match.js +192 -0
  6. package/dist/lib/anchor-match.js.map +1 -0
  7. package/dist/lib/annotations.d.ts.map +1 -1
  8. package/dist/lib/annotations.js +8 -5
  9. package/dist/lib/annotations.js.map +1 -1
  10. package/dist/lib/commands/file-ops.d.ts +11 -0
  11. package/dist/lib/commands/file-ops.d.ts.map +1 -0
  12. package/dist/lib/commands/file-ops.js +301 -0
  13. package/dist/lib/commands/file-ops.js.map +1 -0
  14. package/dist/lib/commands/index.d.ts +10 -1
  15. package/dist/lib/commands/index.d.ts.map +1 -1
  16. package/dist/lib/commands/index.js +19 -1
  17. package/dist/lib/commands/index.js.map +1 -1
  18. package/dist/lib/commands/merge-resolve.d.ts +12 -0
  19. package/dist/lib/commands/merge-resolve.d.ts.map +1 -0
  20. package/dist/lib/commands/merge-resolve.js +318 -0
  21. package/dist/lib/commands/merge-resolve.js.map +1 -0
  22. package/dist/lib/commands/preview.d.ts +11 -0
  23. package/dist/lib/commands/preview.d.ts.map +1 -0
  24. package/dist/lib/commands/preview.js +138 -0
  25. package/dist/lib/commands/preview.js.map +1 -0
  26. package/dist/lib/commands/project-info.d.ts +11 -0
  27. package/dist/lib/commands/project-info.d.ts.map +1 -0
  28. package/dist/lib/commands/project-info.js +187 -0
  29. package/dist/lib/commands/project-info.js.map +1 -0
  30. package/dist/lib/commands/quality.d.ts +11 -0
  31. package/dist/lib/commands/quality.d.ts.map +1 -0
  32. package/dist/lib/commands/quality.js +384 -0
  33. package/dist/lib/commands/quality.js.map +1 -0
  34. package/dist/lib/commands/section-boundaries.d.ts +22 -0
  35. package/dist/lib/commands/section-boundaries.d.ts.map +1 -0
  36. package/dist/lib/commands/section-boundaries.js +53 -0
  37. package/dist/lib/commands/section-boundaries.js.map +1 -0
  38. package/dist/lib/commands/sections.d.ts +3 -2
  39. package/dist/lib/commands/sections.d.ts.map +1 -1
  40. package/dist/lib/commands/sections.js +4 -736
  41. package/dist/lib/commands/sections.js.map +1 -1
  42. package/dist/lib/commands/sync.d.ts +11 -0
  43. package/dist/lib/commands/sync.d.ts.map +1 -0
  44. package/dist/lib/commands/sync.js +576 -0
  45. package/dist/lib/commands/sync.js.map +1 -0
  46. package/dist/lib/commands/text-ops.d.ts +11 -0
  47. package/dist/lib/commands/text-ops.d.ts.map +1 -0
  48. package/dist/lib/commands/text-ops.js +357 -0
  49. package/dist/lib/commands/text-ops.js.map +1 -0
  50. package/dist/lib/commands/utilities.d.ts +2 -4
  51. package/dist/lib/commands/utilities.d.ts.map +1 -1
  52. package/dist/lib/commands/utilities.js +3 -1572
  53. package/dist/lib/commands/utilities.js.map +1 -1
  54. package/dist/lib/commands/verify-anchors.d.ts +17 -0
  55. package/dist/lib/commands/verify-anchors.d.ts.map +1 -0
  56. package/dist/lib/commands/verify-anchors.js +215 -0
  57. package/dist/lib/commands/verify-anchors.js.map +1 -0
  58. package/dist/lib/commands/word-tools.d.ts +11 -0
  59. package/dist/lib/commands/word-tools.d.ts.map +1 -0
  60. package/dist/lib/commands/word-tools.js +272 -0
  61. package/dist/lib/commands/word-tools.js.map +1 -0
  62. package/dist/lib/diff-engine.d.ts +25 -0
  63. package/dist/lib/diff-engine.d.ts.map +1 -0
  64. package/dist/lib/diff-engine.js +354 -0
  65. package/dist/lib/diff-engine.js.map +1 -0
  66. package/dist/lib/import.d.ts +44 -118
  67. package/dist/lib/import.d.ts.map +1 -1
  68. package/dist/lib/import.js +25 -1173
  69. package/dist/lib/import.js.map +1 -1
  70. package/dist/lib/restore-references.d.ts +35 -0
  71. package/dist/lib/restore-references.d.ts.map +1 -0
  72. package/dist/lib/restore-references.js +188 -0
  73. package/dist/lib/restore-references.js.map +1 -0
  74. package/dist/lib/word-extraction.d.ts +100 -0
  75. package/dist/lib/word-extraction.d.ts.map +1 -0
  76. package/dist/lib/word-extraction.js +594 -0
  77. package/dist/lib/word-extraction.js.map +1 -0
  78. package/lib/anchor-match.ts +238 -0
  79. package/lib/annotations.ts +9 -5
  80. package/lib/commands/file-ops.ts +372 -0
  81. package/lib/commands/index.ts +27 -0
  82. package/lib/commands/merge-resolve.ts +378 -0
  83. package/lib/commands/preview.ts +178 -0
  84. package/lib/commands/project-info.ts +244 -0
  85. package/lib/commands/quality.ts +517 -0
  86. package/lib/commands/section-boundaries.ts +72 -0
  87. package/lib/commands/sections.ts +3 -870
  88. package/lib/commands/sync.ts +701 -0
  89. package/lib/commands/text-ops.ts +449 -0
  90. package/lib/commands/utilities.ts +62 -2043
  91. package/lib/commands/verify-anchors.ts +261 -0
  92. package/lib/commands/word-tools.ts +340 -0
  93. package/lib/diff-engine.ts +465 -0
  94. package/lib/import.ts +108 -1504
  95. package/lib/restore-references.ts +240 -0
  96. package/lib/word-extraction.ts +759 -0
  97. package/package.json +1 -1
  98. package/skill/REFERENCE.md +29 -2
  99. package/skill/SKILL.md +12 -2
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Anchor matching primitives shared between sync (insertion) and
3
+ * verify-anchors (drift reporting). The functions are pure: given an
4
+ * anchor string and surrounding context, locate candidate positions in
5
+ * a target text using progressively looser strategies.
6
+ */
7
+
8
+ export type AnchorStrategy =
9
+ | 'direct'
10
+ | 'normalized'
11
+ | 'stripped'
12
+ | 'partial-start'
13
+ | 'partial-start-stripped'
14
+ | 'context-both'
15
+ | 'context-before'
16
+ | 'context-after'
17
+ | 'split-match'
18
+ | 'empty-anchor'
19
+ | 'failed';
20
+
21
+ export interface AnchorSearchResult {
22
+ occurrences: number[];
23
+ matchedAnchor: string | null;
24
+ strategy: AnchorStrategy;
25
+ stripped?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Strip CriticMarkup so the matcher sees plain prose instead of
30
+ * `{++inserted++}`/`{--deleted--}`/etc. Used when an anchor lives
31
+ * underneath previously imported track changes.
32
+ */
33
+ export function stripCriticMarkup(text: string): string {
34
+ return text
35
+ .replace(/\{\+\+([^+]*)\+\+\}/g, '$1') // insertions: keep new text
36
+ .replace(/\{--([^-]*)--\}/g, '') // deletions: remove old text
37
+ .replace(/\{~~([^~]*)~>([^~]*)~~\}/g, '$2') // substitutions: keep new text
38
+ .replace(/\{>>[\s\S]*?<<\}/g, '') // comments: remove (non-greedy; comment text may contain '<')
39
+ .replace(/\[([^\]]*)\]\{\.mark\}/g, '$1'); // marked text: keep text
40
+ }
41
+
42
+ /**
43
+ * Return every starting index where `needle` occurs in `haystack`.
44
+ * Empty needles return no occurrences (empty matches are not useful
45
+ * for anchor placement).
46
+ */
47
+ export function findAllOccurrences(haystack: string, needle: string): number[] {
48
+ if (!needle || needle.length === 0) return [];
49
+ const occurrences: number[] = [];
50
+ let idx = 0;
51
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
52
+ occurrences.push(idx);
53
+ idx += 1;
54
+ }
55
+ return occurrences;
56
+ }
57
+
58
+ /**
59
+ * Find candidate positions for `anchor` in `text`, falling back through
60
+ * progressively looser strategies (whitespace normalization, stripped
61
+ * CriticMarkup, partial-prefix, surrounding context, word splitting).
62
+ *
63
+ * The returned `strategy` lets callers distinguish a clean direct hit
64
+ * from a fuzzy approximation — useful for drift reporting.
65
+ */
66
+ export function findAnchorInText(
67
+ anchor: string,
68
+ text: string,
69
+ before: string = '',
70
+ after: string = ''
71
+ ): AnchorSearchResult {
72
+ // Empty anchor: skip directly to context-based matching
73
+ if (!anchor || anchor.trim().length === 0) {
74
+ if (before || after) {
75
+ const beforeLower = (before || '').toLowerCase();
76
+ const afterLower = (after || '').toLowerCase();
77
+ const textLower = text.toLowerCase();
78
+
79
+ if (before && after) {
80
+ const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
81
+ if (beforeIdx !== -1) {
82
+ const searchStart = beforeIdx + beforeLower.slice(-50).length;
83
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
84
+ if (afterIdx !== -1 && afterIdx - searchStart < 500) {
85
+ return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
86
+ }
87
+ }
88
+ }
89
+
90
+ if (before) {
91
+ const beforeIdx = textLower.lastIndexOf(beforeLower.slice(-30));
92
+ if (beforeIdx !== -1) {
93
+ return {
94
+ occurrences: [beforeIdx + beforeLower.slice(-30).length],
95
+ matchedAnchor: null,
96
+ strategy: 'context-before',
97
+ };
98
+ }
99
+ }
100
+
101
+ if (after) {
102
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 30));
103
+ if (afterIdx !== -1) {
104
+ return { occurrences: [afterIdx], matchedAnchor: null, strategy: 'context-after' };
105
+ }
106
+ }
107
+ }
108
+ return { occurrences: [], matchedAnchor: null, strategy: 'empty-anchor' };
109
+ }
110
+
111
+ const anchorLower = anchor.toLowerCase();
112
+ const textLower = text.toLowerCase();
113
+
114
+ // Strategy 1: direct match
115
+ let occurrences = findAllOccurrences(textLower, anchorLower);
116
+ if (occurrences.length > 0) {
117
+ return { occurrences, matchedAnchor: anchor, strategy: 'direct' };
118
+ }
119
+
120
+ // Strategy 2: normalized whitespace
121
+ const normalizedAnchor = anchor.replace(/\s+/g, ' ').toLowerCase();
122
+ const normalizedText = text.replace(/\s+/g, ' ').toLowerCase();
123
+ const idx = normalizedText.indexOf(normalizedAnchor);
124
+ if (idx !== -1) {
125
+ return { occurrences: [idx], matchedAnchor: anchor, strategy: 'normalized' };
126
+ }
127
+
128
+ // Strategy 3: match in stripped CriticMarkup version
129
+ const strippedText = stripCriticMarkup(text);
130
+ const strippedLower = strippedText.toLowerCase();
131
+ occurrences = findAllOccurrences(strippedLower, anchorLower);
132
+ if (occurrences.length > 0) {
133
+ return { occurrences, matchedAnchor: anchor, strategy: 'stripped', stripped: true };
134
+ }
135
+
136
+ // Strategy 4: first N words of anchor (long anchors)
137
+ const words = anchor.split(/\s+/);
138
+ if (words.length > 3) {
139
+ for (let n = Math.min(6, words.length); n >= 3; n--) {
140
+ const partialAnchor = words.slice(0, n).join(' ').toLowerCase();
141
+ if (partialAnchor.length >= 15) {
142
+ occurrences = findAllOccurrences(textLower, partialAnchor);
143
+ if (occurrences.length > 0) {
144
+ return { occurrences, matchedAnchor: words.slice(0, n).join(' '), strategy: 'partial-start' };
145
+ }
146
+ occurrences = findAllOccurrences(strippedLower, partialAnchor);
147
+ if (occurrences.length > 0) {
148
+ return {
149
+ occurrences,
150
+ matchedAnchor: words.slice(0, n).join(' '),
151
+ strategy: 'partial-start-stripped',
152
+ stripped: true,
153
+ };
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // Strategy 5: context (before/after) only
160
+ if (before || after) {
161
+ const beforeLower = before.toLowerCase();
162
+ const afterLower = after.toLowerCase();
163
+
164
+ if (before && after) {
165
+ const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
166
+ if (beforeIdx !== -1) {
167
+ const searchStart = beforeIdx + beforeLower.slice(-50).length;
168
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
169
+ if (afterIdx !== -1 && afterIdx - searchStart < 500) {
170
+ return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
171
+ }
172
+ }
173
+ }
174
+
175
+ if (before) {
176
+ const beforeIdx = textLower.lastIndexOf(beforeLower.slice(-30));
177
+ if (beforeIdx !== -1) {
178
+ return {
179
+ occurrences: [beforeIdx + beforeLower.slice(-30).length],
180
+ matchedAnchor: null,
181
+ strategy: 'context-before',
182
+ };
183
+ }
184
+ }
185
+
186
+ if (after) {
187
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 30));
188
+ if (afterIdx !== -1) {
189
+ return { occurrences: [afterIdx], matchedAnchor: null, strategy: 'context-after' };
190
+ }
191
+ }
192
+ }
193
+
194
+ // Strategy 6: split anchor on transition characters
195
+ const splitPatterns = [' ', ', ', '. ', ' - ', ' – '];
196
+ for (const sep of splitPatterns) {
197
+ if (anchor.includes(sep)) {
198
+ const parts = anchor.split(sep).filter(p => p.length >= 4);
199
+ for (const part of parts) {
200
+ const partLower = part.toLowerCase();
201
+ occurrences = findAllOccurrences(textLower, partLower);
202
+ if (occurrences.length > 0 && occurrences.length < 5) {
203
+ return { occurrences, matchedAnchor: part, strategy: 'split-match' };
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return { occurrences: [], matchedAnchor: null, strategy: 'failed' };
210
+ }
211
+
212
+ /**
213
+ * Classify a strategy as a clean hit, a fuzzy/drifted hit, or no hit.
214
+ * Used by `verify-anchors` to summarize per-comment match quality.
215
+ */
216
+ export type AnchorMatchQuality = 'clean' | 'drift' | 'context-only' | 'unmatched';
217
+
218
+ export function classifyStrategy(strategy: AnchorStrategy, occurrences: number): AnchorMatchQuality {
219
+ if (occurrences === 0) return 'unmatched';
220
+ switch (strategy) {
221
+ case 'direct':
222
+ case 'normalized':
223
+ return 'clean';
224
+ case 'stripped':
225
+ case 'partial-start':
226
+ case 'partial-start-stripped':
227
+ case 'split-match':
228
+ return 'drift';
229
+ case 'context-both':
230
+ case 'context-before':
231
+ case 'context-after':
232
+ return 'context-only';
233
+ case 'empty-anchor':
234
+ case 'failed':
235
+ default:
236
+ return 'unmatched';
237
+ }
238
+ }
@@ -91,16 +91,20 @@ function isCommentFalsePositive(commentContent: string, fullText: string, positi
91
91
  // Contains markdown figure reference syntax
92
92
  if (/\{#fig:|!\[/.test(commentContent)) return true;
93
93
 
94
- // Contains URL patterns (likely a link, not a comment)
95
- if (/https?:\/\/|www\./i.test(commentContent) && commentContent.length < 150) return true;
94
+ // Real comments typically have "Author:" at start. Accept hyphens, apostrophes,
95
+ // periods, and Unicode letters so names like "Jens-Christian Svenning" or
96
+ // "Camilla T Colding-Jørgensen" don't get rejected. See gcol33/docrev#1.
97
+ const hasAuthorPrefix = /^[\p{L}][\p{L}\s\-'.]{0,30}:\s/u.test(commentContent.trim());
98
+ const hasResolvedMark = /^[✓✔]\s/.test(commentContent.trim());
99
+
100
+ // Contains URL patterns (likely a link, not a comment) — only filter when
101
+ // there is no real author prefix, since reviewers legitimately cite URLs/DOIs.
102
+ if (!hasAuthorPrefix && /https?:\/\/|www\./i.test(commentContent) && commentContent.length < 150) return true;
96
103
 
97
104
  // Looks like code (contains programming patterns)
98
105
  if (/function\s*\(|=>|import\s+|export\s+|const\s+|let\s+|var\s+/.test(commentContent)) return true;
99
106
 
100
107
  // Very long without clear author pattern (likely caption, not comment)
101
- // Real comments typically have "Author:" at start and are shorter
102
- const hasAuthorPrefix = /^[A-Za-z][A-Za-z\s]{0,20}:\s/.test(commentContent.trim());
103
- const hasResolvedMark = /^[✓✔]\s/.test(commentContent.trim());
104
108
  if (!hasAuthorPrefix && !hasResolvedMark && commentContent.length > MAX_COMMENT_CONTENT_LENGTH) return true;
105
109
 
106
110
  // Looks like a figure caption (starts with "Fig" or contains typical caption words)
@@ -0,0 +1,372 @@
1
+ /**
2
+ * File operation commands: backup, archive, export, clean
3
+ *
4
+ * Commands that create, move, or delete project files.
5
+ */
6
+
7
+ import type { Command } from 'commander';
8
+ import {
9
+ chalk,
10
+ fs,
11
+ path,
12
+ fmt,
13
+ findFiles,
14
+ loadBuildConfig,
15
+ } from './context.js';
16
+
17
+ // Use the actual BuildConfig from build.ts which allows string|Author[]
18
+ type BuildConfig = ReturnType<typeof loadBuildConfig>;
19
+
20
+ interface ZipLike {
21
+ addLocalFile(localPath: string, zipPath?: string): void;
22
+ }
23
+
24
+ /**
25
+ * Recursively add directory contents to a zip archive, filtering by predicate
26
+ */
27
+ function addDirToZip(
28
+ zip: ZipLike,
29
+ dir: string,
30
+ shouldInclude: (name: string) => boolean,
31
+ zipPath = '',
32
+ ): void {
33
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
34
+ for (const entry of entries) {
35
+ const fullPath = path.join(dir, entry.name);
36
+ const entryZipPath = path.join(zipPath, entry.name);
37
+
38
+ if (!shouldInclude(entry.name)) continue;
39
+
40
+ if (entry.isDirectory()) {
41
+ addDirToZip(zip, fullPath, shouldInclude, entryZipPath);
42
+ } else {
43
+ zip.addLocalFile(fullPath, zipPath || undefined);
44
+ }
45
+ }
46
+ }
47
+
48
+ // Options interfaces
49
+ interface BackupOptions {
50
+ name?: string;
51
+ output?: string;
52
+ }
53
+
54
+ interface ArchiveOptions {
55
+ dir?: string;
56
+ by?: string;
57
+ rename?: boolean;
58
+ dryRun?: boolean;
59
+ }
60
+
61
+ interface ExportOptions {
62
+ output?: string;
63
+ includeOutput?: boolean;
64
+ }
65
+
66
+ interface CleanOptions {
67
+ dryRun?: boolean;
68
+ all?: boolean;
69
+ }
70
+
71
+ /**
72
+ * Register file-ops commands with the program
73
+ */
74
+ export function register(program: Command): void {
75
+ // ==========================================================================
76
+ // BACKUP command - Timestamped project backup
77
+ // ==========================================================================
78
+
79
+ program
80
+ .command('backup')
81
+ .description('Create timestamped project backup')
82
+ .option('-n, --name <name>', 'Custom backup name')
83
+ .option('-o, --output <dir>', 'Output directory', '.')
84
+ .action(async (options: BackupOptions) => {
85
+ const { default: AdmZip } = await import('adm-zip');
86
+ const zip = new AdmZip();
87
+
88
+ const date = new Date().toISOString().slice(0, 10);
89
+ const name = options.name || `backup-${date}`;
90
+ const outputPath = path.join(options.output || '.', `${name}.zip`);
91
+
92
+ // Files to exclude
93
+ const excludePatterns = [
94
+ 'node_modules', '.git', '.DS_Store', '*.zip',
95
+ 'paper.md' // Generated file
96
+ ];
97
+
98
+ const shouldInclude = (file: string): boolean => {
99
+ for (const pattern of excludePatterns) {
100
+ if (file.includes(pattern.replace('*', ''))) return false;
101
+ }
102
+ return true;
103
+ };
104
+
105
+ addDirToZip(zip, '.', shouldInclude);
106
+
107
+ zip.writeZip(outputPath);
108
+ console.log(fmt.status('success', `Backup created: ${outputPath}`));
109
+ });
110
+
111
+ // ==========================================================================
112
+ // ARCHIVE command - Archive reviewer docx files
113
+ // ==========================================================================
114
+
115
+ program
116
+ .command('archive')
117
+ .description('Move reviewer .docx files to archive folder')
118
+ .argument('[files...]', 'Specific files to archive (default: all .docx)')
119
+ .option('-d, --dir <folder>', 'Archive folder name', 'archive')
120
+ .option('--by <name>', 'Reviewer name (auto-detected if single commenter)')
121
+ .option('--no-rename', 'Keep original filenames')
122
+ .option('--dry-run', 'Preview without moving files')
123
+ .action(async (files: string[] | undefined, options: ArchiveOptions) => {
124
+ const { extractWordComments } = await import('../import.js');
125
+ const { default: YAML } = await import('yaml');
126
+
127
+ // Find docx files to archive
128
+ let docxFiles = files && files.length > 0
129
+ ? files.filter(f => f.endsWith('.docx') && fs.existsSync(f))
130
+ : findFiles('.docx');
131
+
132
+ // Exclude our own build outputs
133
+ let projectSlug: string | null = null;
134
+ const configPath = path.join(process.cwd(), 'rev.yaml');
135
+ if (fs.existsSync(configPath)) {
136
+ try {
137
+ const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
138
+ if (config.title) {
139
+ projectSlug = config.title
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, '-')
142
+ .replace(/^-|-$/g, '')
143
+ .slice(0, 50);
144
+ }
145
+ } catch (e) {
146
+ // Ignore config errors
147
+ }
148
+ }
149
+
150
+ // Filter out build outputs
151
+ if (projectSlug && (!files || files.length === 0)) {
152
+ const buildPatterns = [
153
+ `${projectSlug}.docx`,
154
+ `${projectSlug}_comments.docx`,
155
+ `${projectSlug}-changes.docx`,
156
+ 'paper.docx',
157
+ 'paper_comments.docx',
158
+ 'paper-changes.docx',
159
+ ];
160
+ const excluded: string[] = [];
161
+ docxFiles = docxFiles.filter(f => {
162
+ const base = path.basename(f).toLowerCase();
163
+ const isBuilt = buildPatterns.includes(base);
164
+ if (isBuilt) excluded.push(f);
165
+ return !isBuilt;
166
+ });
167
+ if (excluded.length > 0) {
168
+ console.log(chalk.dim(` Skipping build outputs: ${excluded.join(', ')}`));
169
+ console.log();
170
+ }
171
+ }
172
+
173
+ if (docxFiles.length === 0) {
174
+ console.log(fmt.status('info', 'No .docx files to archive.'));
175
+ return;
176
+ }
177
+
178
+ const projectTitle = projectSlug;
179
+
180
+ // Create archive folder
181
+ const archiveDir = path.resolve(options.dir || 'archive');
182
+ if (!options.dryRun && !fs.existsSync(archiveDir)) {
183
+ fs.mkdirSync(archiveDir, { recursive: true });
184
+ }
185
+
186
+ console.log(fmt.header('Archive'));
187
+ console.log();
188
+
189
+ const moved: string[] = [];
190
+ for (const file of docxFiles) {
191
+ const stat = fs.statSync(file);
192
+ const mtime = stat.mtime;
193
+ const timestamp = mtime.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '_');
194
+
195
+ // Determine reviewer name
196
+ let reviewer: string | null = options.by || null;
197
+ if (!reviewer && options.rename !== false) {
198
+ try {
199
+ const comments = await extractWordComments(file);
200
+ const authors = [...new Set(comments.map(c => c.author).filter(a => a && a !== 'Unknown'))];
201
+ if (authors.length === 1) {
202
+ reviewer = authors[0].replace(/[^a-zA-Z0-9]/g, '-').replace(/^-|-$/g, '');
203
+ }
204
+ } catch (e) {
205
+ // Ignore extraction errors
206
+ }
207
+ }
208
+
209
+ // Generate new name
210
+ let newName: string;
211
+ if (options.rename === false) {
212
+ newName = path.basename(file);
213
+ } else {
214
+ const base = path.basename(file, '.docx');
215
+ if (/^\d{8}_\d{6}_/.test(base)) {
216
+ newName = path.basename(file);
217
+ } else {
218
+ const namePart = projectTitle || base;
219
+ if (reviewer) {
220
+ newName = `${timestamp}_${reviewer}_${namePart}.docx`;
221
+ } else {
222
+ newName = `${timestamp}_${namePart}.docx`;
223
+ }
224
+ }
225
+ }
226
+
227
+ const destPath = path.join(archiveDir, newName);
228
+
229
+ if (options.dryRun) {
230
+ console.log(` ${chalk.dim(file)} → ${chalk.cyan(path.join(options.dir || 'archive', newName))}`);
231
+ } else {
232
+ // Handle name collision
233
+ let finalPath = destPath;
234
+ let counter = 1;
235
+ while (fs.existsSync(finalPath)) {
236
+ const ext = path.extname(newName);
237
+ const base = path.basename(newName, ext);
238
+ finalPath = path.join(archiveDir, `${base}_${counter}${ext}`);
239
+ counter++;
240
+ }
241
+ fs.renameSync(file, finalPath);
242
+ console.log(` ${chalk.dim(file)} → ${chalk.green(path.relative(process.cwd(), finalPath))}`);
243
+ }
244
+ moved.push(file);
245
+ }
246
+
247
+ console.log();
248
+ if (options.dryRun) {
249
+ console.log(fmt.status('info', `Would archive ${moved.length} file(s). Run without --dry-run to proceed.`));
250
+ } else {
251
+ console.log(fmt.status('success', `Archived ${moved.length} file(s) to ${options.dir || 'archive'}/`));
252
+ }
253
+ });
254
+
255
+ // ==========================================================================
256
+ // EXPORT command - Export project as distributable zip
257
+ // ==========================================================================
258
+
259
+ program
260
+ .command('export')
261
+ .description('Export project as distributable zip')
262
+ .option('-o, --output <file>', 'Output filename')
263
+ .option('--include-output', 'Include built PDF/DOCX files')
264
+ .action(async (options: ExportOptions) => {
265
+ const { default: AdmZip } = await import('adm-zip');
266
+ const { build } = await import('../build.js');
267
+
268
+ let config: Partial<BuildConfig> = {};
269
+ try {
270
+ config = loadBuildConfig('.') || {};
271
+ } catch {
272
+ // Not in a rev project, that's ok
273
+ }
274
+
275
+ // Build first if including output
276
+ if (options.includeOutput) {
277
+ console.log(chalk.dim('Building documents...'));
278
+ await build('.', ['pdf', 'docx']);
279
+ }
280
+
281
+ const zip = new AdmZip();
282
+ const projectName = config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'project';
283
+ const outputPath = options.output || `${projectName}-export.zip`;
284
+
285
+ const exclude = ['node_modules', '.git', '.DS_Store', '*.zip'];
286
+
287
+ const shouldInclude = (name: string): boolean => {
288
+ if (!options.includeOutput && (name.endsWith('.pdf') || name.endsWith('.docx'))) {
289
+ return false;
290
+ }
291
+ for (const pattern of exclude) {
292
+ if (name === pattern || name.includes(pattern.replace('*', ''))) return false;
293
+ }
294
+ return true;
295
+ };
296
+
297
+ addDirToZip(zip, '.', shouldInclude);
298
+
299
+ zip.writeZip(outputPath);
300
+ console.log(fmt.status('success', `Exported: ${outputPath}`));
301
+ });
302
+
303
+ // ==========================================================================
304
+ // CLEAN command - Remove generated files
305
+ // ==========================================================================
306
+
307
+ program
308
+ .command('clean')
309
+ .description('Remove generated files (paper.md, PDFs, DOCXs)')
310
+ .option('-n, --dry-run', 'Show what would be deleted without deleting')
311
+ .option('--all', 'Also remove backup and export zips')
312
+ .action((options: CleanOptions) => {
313
+ let config: Partial<BuildConfig> = {};
314
+ try {
315
+ config = loadBuildConfig('.') || {};
316
+ } catch {
317
+ // Not in a rev project, that's ok
318
+ }
319
+
320
+ const projectName = config.title?.toLowerCase().replace(/\s+/g, '-') || 'paper';
321
+
322
+ // Files to clean
323
+ const patterns = [
324
+ 'paper.md',
325
+ '*.pdf',
326
+ `${projectName}.docx`,
327
+ `${projectName}.pdf`,
328
+ `${projectName}.tex`,
329
+ '.paper-*.md', // Temp build files
330
+ ];
331
+
332
+ if (options.all) {
333
+ patterns.push('*.zip', 'backup-*.zip', '*-export.zip');
334
+ }
335
+
336
+ const toDelete: string[] = [];
337
+
338
+ for (const pattern of patterns) {
339
+ if (pattern.includes('*')) {
340
+ const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
341
+ const files = fs.readdirSync('.').filter(f => regex.test(f));
342
+ toDelete.push(...files);
343
+ } else if (fs.existsSync(pattern)) {
344
+ toDelete.push(pattern);
345
+ }
346
+ }
347
+
348
+ if (toDelete.length === 0) {
349
+ console.log(chalk.dim('No generated files to clean.'));
350
+ return;
351
+ }
352
+
353
+ console.log(fmt.header('Clean'));
354
+ console.log();
355
+
356
+ for (const file of toDelete) {
357
+ if (options.dryRun) {
358
+ console.log(chalk.dim(` Would delete: ${file}`));
359
+ } else {
360
+ fs.unlinkSync(file);
361
+ console.log(chalk.red(` Deleted: ${file}`));
362
+ }
363
+ }
364
+
365
+ console.log();
366
+ if (options.dryRun) {
367
+ console.log(chalk.dim(`Would delete ${toDelete.length} file(s). Run without --dry-run to delete.`));
368
+ } else {
369
+ console.log(fmt.status('success', `Cleaned ${toDelete.length} file(s)`));
370
+ }
371
+ });
372
+ }
@@ -10,24 +10,42 @@ import { register as registerCoreCommands } from './core.js';
10
10
  import { register as registerCommentCommands } from './comments.js';
11
11
  import { register as registerInitCommands } from './init.js';
12
12
  import { register as registerSectionCommands } from './sections.js';
13
+ import { register as registerSyncCommands } from './sync.js';
14
+ import { register as registerVerifyAnchorsCommands } from './verify-anchors.js';
15
+ import { register as registerMergeResolveCommands } from './merge-resolve.js';
13
16
  import { register as registerBuildCommands } from './build.js';
14
17
  import { register as registerResponseCommands } from './response.js';
15
18
  import { register as registerCitationCommands } from './citations.js';
16
19
  import { register as registerDoiCommands } from './doi.js';
17
20
  import { register as registerHistoryCommands } from './history.js';
18
21
  import { register as registerUtilityCommands } from './utilities.js';
22
+ import { register as registerProjectInfoCommands } from './project-info.js';
23
+ import { register as registerFileOpsCommands } from './file-ops.js';
24
+ import { register as registerPreviewCommands } from './preview.js';
25
+ import { register as registerQualityCommands } from './quality.js';
26
+ import { register as registerWordToolsCommands } from './word-tools.js';
27
+ import { register as registerTextOpsCommands } from './text-ops.js';
19
28
 
20
29
  export {
21
30
  registerCoreCommands,
22
31
  registerCommentCommands,
23
32
  registerInitCommands,
24
33
  registerSectionCommands,
34
+ registerSyncCommands,
35
+ registerVerifyAnchorsCommands,
36
+ registerMergeResolveCommands,
25
37
  registerBuildCommands,
26
38
  registerResponseCommands,
27
39
  registerCitationCommands,
28
40
  registerDoiCommands,
29
41
  registerHistoryCommands,
30
42
  registerUtilityCommands,
43
+ registerProjectInfoCommands,
44
+ registerFileOpsCommands,
45
+ registerPreviewCommands,
46
+ registerQualityCommands,
47
+ registerWordToolsCommands,
48
+ registerTextOpsCommands,
31
49
  };
32
50
 
33
51
  // Re-export context utilities for use by the main CLI
@@ -51,10 +69,19 @@ export function registerAllCommands(program: Command, pkg?: PackageJson): void {
51
69
  registerCommentCommands(program);
52
70
  registerInitCommands(program);
53
71
  registerSectionCommands(program);
72
+ registerSyncCommands(program);
73
+ registerVerifyAnchorsCommands(program);
74
+ registerMergeResolveCommands(program);
54
75
  registerBuildCommands(program, pkg || {});
55
76
  registerResponseCommands(program);
56
77
  registerCitationCommands(program);
57
78
  registerDoiCommands(program);
58
79
  registerHistoryCommands(program);
59
80
  registerUtilityCommands(program, pkg || {});
81
+ registerProjectInfoCommands(program);
82
+ registerFileOpsCommands(program);
83
+ registerPreviewCommands(program);
84
+ registerQualityCommands(program);
85
+ registerWordToolsCommands(program);
86
+ registerTextOpsCommands(program);
60
87
  }