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.
- package/CHANGELOG.md +20 -0
- package/dev_notes/bug_repro_comment_parser.md +71 -0
- package/dist/lib/anchor-match.d.ts +41 -0
- package/dist/lib/anchor-match.d.ts.map +1 -0
- package/dist/lib/anchor-match.js +192 -0
- package/dist/lib/anchor-match.js.map +1 -0
- package/dist/lib/annotations.d.ts.map +1 -1
- package/dist/lib/annotations.js +8 -5
- package/dist/lib/annotations.js.map +1 -1
- package/dist/lib/commands/file-ops.d.ts +11 -0
- package/dist/lib/commands/file-ops.d.ts.map +1 -0
- package/dist/lib/commands/file-ops.js +301 -0
- package/dist/lib/commands/file-ops.js.map +1 -0
- package/dist/lib/commands/index.d.ts +10 -1
- package/dist/lib/commands/index.d.ts.map +1 -1
- package/dist/lib/commands/index.js +19 -1
- package/dist/lib/commands/index.js.map +1 -1
- package/dist/lib/commands/merge-resolve.d.ts +12 -0
- package/dist/lib/commands/merge-resolve.d.ts.map +1 -0
- package/dist/lib/commands/merge-resolve.js +318 -0
- package/dist/lib/commands/merge-resolve.js.map +1 -0
- package/dist/lib/commands/preview.d.ts +11 -0
- package/dist/lib/commands/preview.d.ts.map +1 -0
- package/dist/lib/commands/preview.js +138 -0
- package/dist/lib/commands/preview.js.map +1 -0
- package/dist/lib/commands/project-info.d.ts +11 -0
- package/dist/lib/commands/project-info.d.ts.map +1 -0
- package/dist/lib/commands/project-info.js +187 -0
- package/dist/lib/commands/project-info.js.map +1 -0
- package/dist/lib/commands/quality.d.ts +11 -0
- package/dist/lib/commands/quality.d.ts.map +1 -0
- package/dist/lib/commands/quality.js +384 -0
- package/dist/lib/commands/quality.js.map +1 -0
- package/dist/lib/commands/section-boundaries.d.ts +22 -0
- package/dist/lib/commands/section-boundaries.d.ts.map +1 -0
- package/dist/lib/commands/section-boundaries.js +53 -0
- package/dist/lib/commands/section-boundaries.js.map +1 -0
- package/dist/lib/commands/sections.d.ts +3 -2
- package/dist/lib/commands/sections.d.ts.map +1 -1
- package/dist/lib/commands/sections.js +4 -736
- package/dist/lib/commands/sections.js.map +1 -1
- package/dist/lib/commands/sync.d.ts +11 -0
- package/dist/lib/commands/sync.d.ts.map +1 -0
- package/dist/lib/commands/sync.js +576 -0
- package/dist/lib/commands/sync.js.map +1 -0
- package/dist/lib/commands/text-ops.d.ts +11 -0
- package/dist/lib/commands/text-ops.d.ts.map +1 -0
- package/dist/lib/commands/text-ops.js +357 -0
- package/dist/lib/commands/text-ops.js.map +1 -0
- package/dist/lib/commands/utilities.d.ts +2 -4
- package/dist/lib/commands/utilities.d.ts.map +1 -1
- package/dist/lib/commands/utilities.js +3 -1572
- package/dist/lib/commands/utilities.js.map +1 -1
- package/dist/lib/commands/verify-anchors.d.ts +17 -0
- package/dist/lib/commands/verify-anchors.d.ts.map +1 -0
- package/dist/lib/commands/verify-anchors.js +215 -0
- package/dist/lib/commands/verify-anchors.js.map +1 -0
- package/dist/lib/commands/word-tools.d.ts +11 -0
- package/dist/lib/commands/word-tools.d.ts.map +1 -0
- package/dist/lib/commands/word-tools.js +272 -0
- package/dist/lib/commands/word-tools.js.map +1 -0
- package/dist/lib/diff-engine.d.ts +25 -0
- package/dist/lib/diff-engine.d.ts.map +1 -0
- package/dist/lib/diff-engine.js +354 -0
- package/dist/lib/diff-engine.js.map +1 -0
- package/dist/lib/import.d.ts +44 -118
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/import.js +25 -1173
- package/dist/lib/import.js.map +1 -1
- package/dist/lib/restore-references.d.ts +35 -0
- package/dist/lib/restore-references.d.ts.map +1 -0
- package/dist/lib/restore-references.js +188 -0
- package/dist/lib/restore-references.js.map +1 -0
- package/dist/lib/word-extraction.d.ts +100 -0
- package/dist/lib/word-extraction.d.ts.map +1 -0
- package/dist/lib/word-extraction.js +594 -0
- package/dist/lib/word-extraction.js.map +1 -0
- package/lib/anchor-match.ts +238 -0
- package/lib/annotations.ts +9 -5
- package/lib/commands/file-ops.ts +372 -0
- package/lib/commands/index.ts +27 -0
- package/lib/commands/merge-resolve.ts +378 -0
- package/lib/commands/preview.ts +178 -0
- package/lib/commands/project-info.ts +244 -0
- package/lib/commands/quality.ts +517 -0
- package/lib/commands/section-boundaries.ts +72 -0
- package/lib/commands/sections.ts +3 -870
- package/lib/commands/sync.ts +701 -0
- package/lib/commands/text-ops.ts +449 -0
- package/lib/commands/utilities.ts +62 -2043
- package/lib/commands/verify-anchors.ts +261 -0
- package/lib/commands/word-tools.ts +340 -0
- package/lib/diff-engine.ts +465 -0
- package/lib/import.ts +108 -1504
- package/lib/restore-references.ts +240 -0
- package/lib/word-extraction.ts +759 -0
- package/package.json +1 -1
- package/skill/REFERENCE.md +29 -2
- 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
|
+
}
|
package/lib/annotations.ts
CHANGED
|
@@ -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
|
-
//
|
|
95
|
-
|
|
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
|
+
}
|
package/lib/commands/index.ts
CHANGED
|
@@ -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
|
}
|