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/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
- * Spinner frames for async operations
231
+ * Pulsing star spinner frames (Claude-style)
232
+ * Cycles through star brightness using unicode stars
232
233
  */
233
- const spinnerFrames = ['', '', '', '', '', '', '', '', '⠇', '⠏'];
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 = chalk.cyan(spinnerFrames[frameIndex]);
257
+ const frame = starColors[frameIndex](starFrames[frameIndex]);
247
258
  process.stdout.write(`\r${frame} ${message}`);
248
- frameIndex = (frameIndex + 1) % spinnerFrames.length;
249
- }, 80);
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
- const zip = new AdmZip(docxPath);
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
- const commentsXml = commentsEntry.getData().toString('utf8');
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
- console.error('Error extracting comments:', err.message);
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
- * @returns {Array<{id: string, name: string, url: string}>}
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
- return Object.entries(JOURNAL_PROFILES).map(([id, profile]) => ({
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
- return JOURNAL_PROFILES[normalized] || null;
362
+ const profiles = getAllProfiles();
363
+ return profiles[normalized] || null;
340
364
  }
341
365
 
342
366
  /**