docrev 0.6.13 → 0.7.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 +32 -0
- package/README.md +191 -133
- package/bin/rev.js +113 -5059
- package/completions/rev.ps1 +210 -0
- package/lib/annotations.js +41 -11
- package/lib/build.js +95 -8
- package/lib/commands/build.js +708 -0
- package/lib/commands/citations.js +497 -0
- package/lib/commands/comments.js +922 -0
- package/lib/commands/context.js +165 -0
- package/lib/commands/core.js +295 -0
- package/lib/commands/doi.js +419 -0
- package/lib/commands/history.js +307 -0
- package/lib/commands/index.js +56 -0
- package/lib/commands/init.js +247 -0
- package/lib/commands/response.js +374 -0
- package/lib/commands/sections.js +862 -0
- package/lib/commands/utilities.js +2272 -0
- package/lib/config.js +19 -0
- package/lib/crossref.js +17 -2
- package/lib/doi.js +279 -43
- package/lib/errors.js +338 -0
- package/lib/format.js +53 -6
- package/lib/git.js +92 -0
- package/lib/import.js +24 -3
- package/lib/journals.js +28 -4
- package/lib/orcid.js +149 -0
- package/lib/pdf-comments.js +217 -0
- package/lib/pdf-import.js +446 -0
- package/lib/plugins.js +285 -0
- package/lib/review.js +109 -0
- package/lib/schema.js +368 -0
- package/lib/sections.js +3 -8
- package/lib/templates.js +218 -0
- package/lib/tui.js +437 -0
- package/lib/undo.js +236 -0
- package/lib/wordcomments.js +15 -20
- package/package.json +5 -3
- package/skill/REFERENCE.md +76 -18
- package/skill/SKILL.md +122 -27
- package/.rev-dictionary +0 -4
package/lib/errors.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error handling utilities with actionable suggestions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format an error message with optional suggestions
|
|
11
|
+
* @param {string} message - Main error message
|
|
12
|
+
* @param {string[]} suggestions - Actionable suggestions
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function formatError(message, suggestions = []) {
|
|
16
|
+
const lines = [chalk.red(`Error: ${message}`)];
|
|
17
|
+
|
|
18
|
+
if (suggestions.length > 0) {
|
|
19
|
+
lines.push('');
|
|
20
|
+
for (const suggestion of suggestions) {
|
|
21
|
+
lines.push(chalk.dim(` ${suggestion}`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return lines.join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get actionable suggestions for file not found errors
|
|
30
|
+
* @param {string} filePath - The file path that wasn't found
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function getFileNotFoundSuggestions(filePath) {
|
|
34
|
+
const suggestions = [];
|
|
35
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
36
|
+
const dir = path.dirname(filePath);
|
|
37
|
+
const base = path.basename(filePath);
|
|
38
|
+
|
|
39
|
+
// Check if directory exists
|
|
40
|
+
if (!fs.existsSync(dir)) {
|
|
41
|
+
suggestions.push(`Directory does not exist: ${dir}`);
|
|
42
|
+
suggestions.push(`Create it with: mkdir -p "${dir}"`);
|
|
43
|
+
return suggestions;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Look for similar files
|
|
47
|
+
try {
|
|
48
|
+
const files = fs.readdirSync(dir);
|
|
49
|
+
const similar = findSimilarFiles(base, files, 3);
|
|
50
|
+
|
|
51
|
+
if (similar.length > 0) {
|
|
52
|
+
suggestions.push('Did you mean:');
|
|
53
|
+
for (const f of similar) {
|
|
54
|
+
suggestions.push(` ${path.join(dir, f)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Directory not readable
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Extension-specific suggestions
|
|
62
|
+
if (ext === '.md' || ext === '') {
|
|
63
|
+
suggestions.push('Run "rev status" to see files in the current project');
|
|
64
|
+
} else if (ext === '.docx') {
|
|
65
|
+
suggestions.push('Use "rev import <docx>" to import Word documents');
|
|
66
|
+
} else if (ext === '.bib') {
|
|
67
|
+
suggestions.push('Create a bibliography with "rev doi bib <doi>"');
|
|
68
|
+
suggestions.push('Or check references.bib in your project');
|
|
69
|
+
} else if (ext === '.pdf') {
|
|
70
|
+
suggestions.push('Build PDFs with "rev build pdf"');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return suggestions;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get actionable suggestions for dependency errors
|
|
78
|
+
* @param {string} dependency - The missing dependency
|
|
79
|
+
* @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
export function getDependencySuggestions(dependency) {
|
|
82
|
+
const suggestions = [];
|
|
83
|
+
const platform = process.platform;
|
|
84
|
+
|
|
85
|
+
switch (dependency.toLowerCase()) {
|
|
86
|
+
case 'pandoc':
|
|
87
|
+
suggestions.push('Pandoc is required for document conversion');
|
|
88
|
+
if (platform === 'darwin') {
|
|
89
|
+
suggestions.push('Install with: brew install pandoc');
|
|
90
|
+
} else if (platform === 'win32') {
|
|
91
|
+
suggestions.push('Install from: https://pandoc.org/installing.html');
|
|
92
|
+
suggestions.push('Or with: winget install --id JohnMacFarlane.Pandoc');
|
|
93
|
+
} else {
|
|
94
|
+
suggestions.push('Install with: sudo apt install pandoc');
|
|
95
|
+
suggestions.push('Or from: https://pandoc.org/installing.html');
|
|
96
|
+
}
|
|
97
|
+
suggestions.push('Run "rev install" to check all dependencies');
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case 'pdflatex':
|
|
101
|
+
case 'xelatex':
|
|
102
|
+
case 'latex':
|
|
103
|
+
suggestions.push('LaTeX is required for PDF generation');
|
|
104
|
+
if (platform === 'darwin') {
|
|
105
|
+
suggestions.push('Install with: brew install --cask mactex');
|
|
106
|
+
suggestions.push('Or minimal: brew install --cask basictex');
|
|
107
|
+
} else if (platform === 'win32') {
|
|
108
|
+
suggestions.push('Install MiKTeX from: https://miktex.org/download');
|
|
109
|
+
suggestions.push('Or TeX Live from: https://tug.org/texlive/');
|
|
110
|
+
} else {
|
|
111
|
+
suggestions.push('Install with: sudo apt install texlive-full');
|
|
112
|
+
suggestions.push('Or minimal: sudo apt install texlive-latex-base');
|
|
113
|
+
}
|
|
114
|
+
suggestions.push('Alternative: Use "rev build docx" for Word output');
|
|
115
|
+
break;
|
|
116
|
+
|
|
117
|
+
case 'pandoc-crossref':
|
|
118
|
+
suggestions.push('pandoc-crossref enables figure/table/equation numbering');
|
|
119
|
+
if (platform === 'darwin') {
|
|
120
|
+
suggestions.push('Install with: brew install pandoc-crossref');
|
|
121
|
+
} else if (platform === 'win32') {
|
|
122
|
+
suggestions.push('Download from: https://github.com/lierdakil/pandoc-crossref/releases');
|
|
123
|
+
} else {
|
|
124
|
+
suggestions.push('Install with: sudo apt install pandoc-crossref');
|
|
125
|
+
suggestions.push('Or from: https://github.com/lierdakil/pandoc-crossref/releases');
|
|
126
|
+
}
|
|
127
|
+
suggestions.push('Cross-references will work but wonʼt be numbered without it');
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return suggestions;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get actionable suggestions for configuration errors
|
|
136
|
+
* @param {string} field - The problematic config field
|
|
137
|
+
* @param {string} issue - What's wrong with it
|
|
138
|
+
* @returns {string[]}
|
|
139
|
+
*/
|
|
140
|
+
export function getConfigSuggestions(field, issue) {
|
|
141
|
+
const suggestions = [];
|
|
142
|
+
|
|
143
|
+
switch (field) {
|
|
144
|
+
case 'bibliography':
|
|
145
|
+
suggestions.push('Create a references.bib file in your project');
|
|
146
|
+
suggestions.push('Or set bibliography in rev.yaml:');
|
|
147
|
+
suggestions.push(' bibliography: path/to/refs.bib');
|
|
148
|
+
break;
|
|
149
|
+
|
|
150
|
+
case 'sections':
|
|
151
|
+
suggestions.push('List your sections in rev.yaml:');
|
|
152
|
+
suggestions.push(' sections:');
|
|
153
|
+
suggestions.push(' - introduction.md');
|
|
154
|
+
suggestions.push(' - methods.md');
|
|
155
|
+
suggestions.push('Or run "rev init" to auto-detect');
|
|
156
|
+
break;
|
|
157
|
+
|
|
158
|
+
case 'user':
|
|
159
|
+
suggestions.push('Set your name for comment attribution:');
|
|
160
|
+
suggestions.push(' rev config user "Your Name"');
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case 'csl':
|
|
164
|
+
suggestions.push('CSL styles control citation format');
|
|
165
|
+
suggestions.push('Download styles from: https://www.zotero.org/styles');
|
|
166
|
+
suggestions.push('Or use: citation-style: apa (common styles available)');
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
default:
|
|
170
|
+
suggestions.push(`Check rev.yaml for "${field}" configuration`);
|
|
171
|
+
suggestions.push('Run "rev help config" for configuration options');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (issue === 'typo') {
|
|
175
|
+
suggestions.unshift('This looks like a typo in rev.yaml');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return suggestions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get suggestions for comment/annotation errors
|
|
183
|
+
* @param {string} issue - The issue type
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
export function getAnnotationSuggestions(issue) {
|
|
187
|
+
const suggestions = [];
|
|
188
|
+
|
|
189
|
+
switch (issue) {
|
|
190
|
+
case 'no_comments':
|
|
191
|
+
suggestions.push('Comments use CriticMarkup syntax:');
|
|
192
|
+
suggestions.push(' {>>Author: Comment text<<}');
|
|
193
|
+
suggestions.push('Import from Word with: rev import <docx>');
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'no_changes':
|
|
197
|
+
suggestions.push('Track changes use CriticMarkup syntax:');
|
|
198
|
+
suggestions.push(' {++inserted text++}');
|
|
199
|
+
suggestions.push(' {--deleted text--}');
|
|
200
|
+
suggestions.push(' {~~old~>new~~}');
|
|
201
|
+
suggestions.push('Import from Word with: rev import <docx>');
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 'invalid_number':
|
|
205
|
+
suggestions.push('Use "rev comments <file>" to see comment numbers');
|
|
206
|
+
suggestions.push('Or "rev status <file>" for a summary');
|
|
207
|
+
break;
|
|
208
|
+
|
|
209
|
+
case 'no_author':
|
|
210
|
+
suggestions.push('Set your author name:');
|
|
211
|
+
suggestions.push(' rev config user "Your Name"');
|
|
212
|
+
suggestions.push('Or use --author "Name" flag');
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return suggestions;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get suggestions for build errors
|
|
221
|
+
* @param {string} issue - The build issue
|
|
222
|
+
* @param {object} context - Additional context
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
export function getBuildSuggestions(issue, context = {}) {
|
|
226
|
+
const suggestions = [];
|
|
227
|
+
|
|
228
|
+
switch (issue) {
|
|
229
|
+
case 'no_sections':
|
|
230
|
+
suggestions.push('No section files found to build');
|
|
231
|
+
suggestions.push('Create markdown files or run "rev new" to start a project');
|
|
232
|
+
suggestions.push('Or run "rev init" to auto-detect existing files');
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case 'missing_bib':
|
|
236
|
+
suggestions.push('Bibliography file not found');
|
|
237
|
+
if (context.bibPath) {
|
|
238
|
+
suggestions.push(`Expected: ${context.bibPath}`);
|
|
239
|
+
}
|
|
240
|
+
suggestions.push('Create references.bib or update rev.yaml');
|
|
241
|
+
suggestions.push('Add citations with: rev doi bib <doi>');
|
|
242
|
+
break;
|
|
243
|
+
|
|
244
|
+
case 'pandoc_failed':
|
|
245
|
+
suggestions.push('Pandoc conversion failed');
|
|
246
|
+
suggestions.push('Check for syntax errors in your markdown');
|
|
247
|
+
suggestions.push('Run "rev validate" to check document structure');
|
|
248
|
+
if (context.format === 'pdf') {
|
|
249
|
+
suggestions.push('Try "rev build docx" as an alternative');
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case 'latex_error':
|
|
254
|
+
suggestions.push('LaTeX compilation failed');
|
|
255
|
+
suggestions.push('Common issues:');
|
|
256
|
+
suggestions.push(' - Missing packages (run tlmgr to install)');
|
|
257
|
+
suggestions.push(' - Invalid characters in text');
|
|
258
|
+
suggestions.push(' - Math mode errors');
|
|
259
|
+
suggestions.push('Try "rev build docx" to bypass LaTeX');
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return suggestions;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Find similar filenames using Levenshtein distance
|
|
268
|
+
* @param {string} target - Target filename
|
|
269
|
+
* @param {string[]} candidates - Available filenames
|
|
270
|
+
* @param {number} limit - Max results
|
|
271
|
+
* @returns {string[]}
|
|
272
|
+
*/
|
|
273
|
+
function findSimilarFiles(target, candidates, limit = 3) {
|
|
274
|
+
const scored = candidates
|
|
275
|
+
.map(c => ({ name: c, distance: levenshtein(target.toLowerCase(), c.toLowerCase()) }))
|
|
276
|
+
.filter(c => c.distance <= 3) // Only reasonably similar
|
|
277
|
+
.sort((a, b) => a.distance - b.distance);
|
|
278
|
+
|
|
279
|
+
return scored.slice(0, limit).map(c => c.name);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Simple Levenshtein distance
|
|
284
|
+
*/
|
|
285
|
+
function levenshtein(a, b) {
|
|
286
|
+
if (a.length === 0) return b.length;
|
|
287
|
+
if (b.length === 0) return a.length;
|
|
288
|
+
|
|
289
|
+
const matrix = [];
|
|
290
|
+
|
|
291
|
+
for (let i = 0; i <= b.length; i++) {
|
|
292
|
+
matrix[i] = [i];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (let j = 0; j <= a.length; j++) {
|
|
296
|
+
matrix[0][j] = j;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (let i = 1; i <= b.length; i++) {
|
|
300
|
+
for (let j = 1; j <= a.length; j++) {
|
|
301
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
302
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
303
|
+
} else {
|
|
304
|
+
matrix[i][j] = Math.min(
|
|
305
|
+
matrix[i - 1][j - 1] + 1,
|
|
306
|
+
matrix[i][j - 1] + 1,
|
|
307
|
+
matrix[i - 1][j] + 1
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return matrix[b.length][a.length];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Print error and exit
|
|
318
|
+
* @param {string} message - Error message
|
|
319
|
+
* @param {string[]} suggestions - Suggestions
|
|
320
|
+
*/
|
|
321
|
+
export function exitWithError(message, suggestions = []) {
|
|
322
|
+
console.error(formatError(message, suggestions));
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Validate file exists with helpful error
|
|
328
|
+
* @param {string} filePath - File to check
|
|
329
|
+
* @param {string} fileType - Type description for error message
|
|
330
|
+
*/
|
|
331
|
+
export function requireFile(filePath, fileType = 'File') {
|
|
332
|
+
if (!fs.existsSync(filePath)) {
|
|
333
|
+
exitWithError(
|
|
334
|
+
`${fileType} not found: ${filePath}`,
|
|
335
|
+
getFileNotFoundSuggestions(filePath)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
package/lib/format.js
CHANGED
|
@@ -228,12 +228,23 @@ export function status(type, message) {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
*
|
|
231
|
+
* Pulsing star spinner frames (Claude-style)
|
|
232
|
+
* Cycles through star brightness using unicode stars
|
|
232
233
|
*/
|
|
233
|
-
const
|
|
234
|
+
const starFrames = ['✦', '✧', '✦', '✧', '⋆', '✧', '✦', '✧'];
|
|
235
|
+
const starColors = [
|
|
236
|
+
chalk.yellow,
|
|
237
|
+
chalk.yellow.dim,
|
|
238
|
+
chalk.white,
|
|
239
|
+
chalk.yellow.dim,
|
|
240
|
+
chalk.dim,
|
|
241
|
+
chalk.yellow.dim,
|
|
242
|
+
chalk.white,
|
|
243
|
+
chalk.yellow.dim,
|
|
244
|
+
];
|
|
234
245
|
|
|
235
246
|
/**
|
|
236
|
-
* Create a spinner for async operations
|
|
247
|
+
* Create a pulsing star spinner for async operations
|
|
237
248
|
*/
|
|
238
249
|
export function spinner(message) {
|
|
239
250
|
let frameIndex = 0;
|
|
@@ -243,10 +254,10 @@ export function spinner(message) {
|
|
|
243
254
|
start() {
|
|
244
255
|
process.stdout.write('\x1B[?25l'); // Hide cursor
|
|
245
256
|
interval = setInterval(() => {
|
|
246
|
-
const frame =
|
|
257
|
+
const frame = starColors[frameIndex](starFrames[frameIndex]);
|
|
247
258
|
process.stdout.write(`\r${frame} ${message}`);
|
|
248
|
-
frameIndex = (frameIndex + 1) %
|
|
249
|
-
},
|
|
259
|
+
frameIndex = (frameIndex + 1) % starFrames.length;
|
|
260
|
+
}, 120);
|
|
250
261
|
return spin;
|
|
251
262
|
},
|
|
252
263
|
stop(finalMessage = null) {
|
|
@@ -272,6 +283,42 @@ export function spinner(message) {
|
|
|
272
283
|
return spin;
|
|
273
284
|
}
|
|
274
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Create a progress bar for batch operations
|
|
288
|
+
* @param {number} total - Total number of items
|
|
289
|
+
* @param {string} label - Label for the progress bar
|
|
290
|
+
* @returns {object} Progress bar controller with update(), increment(), and done()
|
|
291
|
+
*/
|
|
292
|
+
export function progressBar(total, label = 'Progress') {
|
|
293
|
+
let current = 0;
|
|
294
|
+
const barWidth = 30;
|
|
295
|
+
|
|
296
|
+
const bar = {
|
|
297
|
+
update(n) {
|
|
298
|
+
current = Math.min(n, total);
|
|
299
|
+
const percent = Math.floor((current / total) * 100);
|
|
300
|
+
const filled = Math.floor((current / total) * barWidth);
|
|
301
|
+
const empty = barWidth - filled;
|
|
302
|
+
const filledBar = chalk.cyan('█'.repeat(filled));
|
|
303
|
+
const emptyBar = chalk.dim('░'.repeat(empty));
|
|
304
|
+
process.stdout.write(`\r${label} [${filledBar}${emptyBar}] ${percent}% (${current}/${total})`);
|
|
305
|
+
return bar;
|
|
306
|
+
},
|
|
307
|
+
increment() {
|
|
308
|
+
return bar.update(current + 1);
|
|
309
|
+
},
|
|
310
|
+
done(message) {
|
|
311
|
+
process.stdout.write('\r\x1B[K'); // Clear line
|
|
312
|
+
if (message) {
|
|
313
|
+
console.log(status('success', message));
|
|
314
|
+
}
|
|
315
|
+
return bar;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return bar;
|
|
320
|
+
}
|
|
321
|
+
|
|
275
322
|
/**
|
|
276
323
|
* Diff display with inline highlighting
|
|
277
324
|
*/
|
package/lib/git.js
CHANGED
|
@@ -236,3 +236,95 @@ export function getTags() {
|
|
|
236
236
|
return [];
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get blame information for a file
|
|
242
|
+
* Returns author and commit info for each line
|
|
243
|
+
* @param {string} filePath
|
|
244
|
+
* @returns {Array<{line: number, author: string, date: string, hash: string, content: string}>}
|
|
245
|
+
*/
|
|
246
|
+
export function getFileBlame(filePath) {
|
|
247
|
+
try {
|
|
248
|
+
const output = execSync(
|
|
249
|
+
`git blame --line-porcelain "${filePath}"`,
|
|
250
|
+
{ stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
|
|
251
|
+
).toString();
|
|
252
|
+
|
|
253
|
+
const lines = output.split('\n');
|
|
254
|
+
const result = [];
|
|
255
|
+
let current = {};
|
|
256
|
+
let lineNumber = 0;
|
|
257
|
+
|
|
258
|
+
for (const line of lines) {
|
|
259
|
+
if (/^[0-9a-f]{40}/.test(line)) {
|
|
260
|
+
// New blame entry: hash original-line final-line [count]
|
|
261
|
+
const parts = line.split(' ');
|
|
262
|
+
current.hash = parts[0].slice(0, 7);
|
|
263
|
+
lineNumber = parseInt(parts[2], 10);
|
|
264
|
+
} else if (line.startsWith('author ')) {
|
|
265
|
+
current.author = line.slice(7);
|
|
266
|
+
} else if (line.startsWith('author-time ')) {
|
|
267
|
+
const timestamp = parseInt(line.slice(12), 10);
|
|
268
|
+
current.date = new Date(timestamp * 1000).toISOString().slice(0, 10);
|
|
269
|
+
} else if (line.startsWith('\t')) {
|
|
270
|
+
// Actual content line (prefixed with tab)
|
|
271
|
+
current.content = line.slice(1);
|
|
272
|
+
current.line = lineNumber;
|
|
273
|
+
result.push({ ...current });
|
|
274
|
+
current = {};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return result;
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get author statistics for a file
|
|
286
|
+
* @param {string} filePath
|
|
287
|
+
* @returns {Object<string, {lines: number, percentage: number}>}
|
|
288
|
+
*/
|
|
289
|
+
export function getAuthorStats(filePath) {
|
|
290
|
+
const blame = getFileBlame(filePath);
|
|
291
|
+
if (blame.length === 0) return {};
|
|
292
|
+
|
|
293
|
+
const counts = {};
|
|
294
|
+
for (const entry of blame) {
|
|
295
|
+
counts[entry.author] = (counts[entry.author] || 0) + 1;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const total = blame.length;
|
|
299
|
+
const stats = {};
|
|
300
|
+
for (const [author, lines] of Object.entries(counts)) {
|
|
301
|
+
stats[author] = {
|
|
302
|
+
lines,
|
|
303
|
+
percentage: Math.round((lines / total) * 100),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return stats;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get contributors across multiple files
|
|
312
|
+
* @param {string[]} files
|
|
313
|
+
* @returns {Object<string, {lines: number, files: number}>}
|
|
314
|
+
*/
|
|
315
|
+
export function getContributors(files) {
|
|
316
|
+
const contributors = {};
|
|
317
|
+
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
const stats = getAuthorStats(file);
|
|
320
|
+
for (const [author, data] of Object.entries(stats)) {
|
|
321
|
+
if (!contributors[author]) {
|
|
322
|
+
contributors[author] = { lines: 0, files: 0 };
|
|
323
|
+
}
|
|
324
|
+
contributors[author].lines += data.lines;
|
|
325
|
+
contributors[author].files += 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return contributors;
|
|
330
|
+
}
|
package/lib/import.js
CHANGED
|
@@ -22,15 +22,32 @@ export async function extractWordComments(docxPath) {
|
|
|
22
22
|
|
|
23
23
|
const comments = [];
|
|
24
24
|
|
|
25
|
+
// Validate file exists
|
|
26
|
+
if (!fs.existsSync(docxPath)) {
|
|
27
|
+
throw new Error(`File not found: ${docxPath}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
try {
|
|
26
|
-
|
|
31
|
+
let zip;
|
|
32
|
+
try {
|
|
33
|
+
zip = new AdmZip(docxPath);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(`Invalid Word document (not a valid .docx file): ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
const commentsEntry = zip.getEntry('word/comments.xml');
|
|
28
39
|
|
|
29
40
|
if (!commentsEntry) {
|
|
30
41
|
return comments;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
|
|
44
|
+
let commentsXml;
|
|
45
|
+
try {
|
|
46
|
+
commentsXml = commentsEntry.getData().toString('utf8');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Failed to read comments from document: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
const parsed = await parseStringPromise(commentsXml, { explicitArray: false });
|
|
35
52
|
|
|
36
53
|
const ns = 'w:';
|
|
@@ -75,7 +92,11 @@ export async function extractWordComments(docxPath) {
|
|
|
75
92
|
comments.push({ id, author, date: date.slice(0, 10), text: text.trim() });
|
|
76
93
|
}
|
|
77
94
|
} catch (err) {
|
|
78
|
-
|
|
95
|
+
// Re-throw with more context if it's already an Error we created
|
|
96
|
+
if (err.message.includes('Invalid Word document') || err.message.includes('File not found')) {
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Error extracting comments from ${path.basename(docxPath)}: ${err.message}`);
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
return comments;
|
package/lib/journals.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
|
+
import { loadCustomProfiles } from './plugins.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Journal requirement profiles
|
|
@@ -317,15 +318,37 @@ export const JOURNAL_PROFILES = {
|
|
|
317
318
|
},
|
|
318
319
|
};
|
|
319
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Get all profiles (built-in + custom)
|
|
323
|
+
* Custom profiles override built-in ones with the same ID
|
|
324
|
+
* @returns {Object<string, Object>}
|
|
325
|
+
*/
|
|
326
|
+
function getAllProfiles() {
|
|
327
|
+
const customProfiles = loadCustomProfiles();
|
|
328
|
+
return { ...JOURNAL_PROFILES, ...customProfiles };
|
|
329
|
+
}
|
|
330
|
+
|
|
320
331
|
/**
|
|
321
332
|
* List all available journal profiles
|
|
322
|
-
* @
|
|
333
|
+
* @param {object} options
|
|
334
|
+
* @param {boolean} options.includeCustom - Include custom profiles (default: true)
|
|
335
|
+
* @param {boolean} options.customOnly - Only show custom profiles
|
|
336
|
+
* @returns {Array<{id: string, name: string, url: string, custom?: boolean}>}
|
|
323
337
|
*/
|
|
324
|
-
export function listJournals() {
|
|
325
|
-
|
|
338
|
+
export function listJournals(options = {}) {
|
|
339
|
+
const { includeCustom = true, customOnly = false } = options;
|
|
340
|
+
|
|
341
|
+
const profiles = customOnly
|
|
342
|
+
? loadCustomProfiles()
|
|
343
|
+
: includeCustom
|
|
344
|
+
? getAllProfiles()
|
|
345
|
+
: JOURNAL_PROFILES;
|
|
346
|
+
|
|
347
|
+
return Object.entries(profiles).map(([id, profile]) => ({
|
|
326
348
|
id,
|
|
327
349
|
name: profile.name,
|
|
328
350
|
url: profile.url,
|
|
351
|
+
custom: profile.custom || false,
|
|
329
352
|
}));
|
|
330
353
|
}
|
|
331
354
|
|
|
@@ -336,7 +359,8 @@ export function listJournals() {
|
|
|
336
359
|
*/
|
|
337
360
|
export function getJournalProfile(journalId) {
|
|
338
361
|
const normalized = journalId.toLowerCase().replace(/\s+/g, '-');
|
|
339
|
-
|
|
362
|
+
const profiles = getAllProfiles();
|
|
363
|
+
return profiles[normalized] || null;
|
|
340
364
|
}
|
|
341
365
|
|
|
342
366
|
/**
|