docrev 0.6.7 → 0.7.6

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/plugins.js ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Plugin system for custom journal profiles and export formats
3
+ *
4
+ * Users can add custom profiles in:
5
+ * - Project: .rev/profiles/*.yaml
6
+ * - User: ~/.rev/profiles/*.yaml
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+ import * as yaml from 'yaml';
13
+
14
+ // Plugin directories
15
+ const USER_PLUGINS_DIR = path.join(os.homedir(), '.rev', 'profiles');
16
+ const PROJECT_PLUGINS_DIR = path.join(process.cwd(), '.rev', 'profiles');
17
+
18
+ /**
19
+ * Load all custom journal profiles
20
+ * @returns {Object<string, Object>}
21
+ */
22
+ export function loadCustomProfiles() {
23
+ const profiles = {};
24
+
25
+ // Load user profiles first (lower priority)
26
+ const userProfiles = loadProfilesFromDir(USER_PLUGINS_DIR);
27
+ Object.assign(profiles, userProfiles);
28
+
29
+ // Load project profiles (higher priority, can override)
30
+ const projectProfiles = loadProfilesFromDir(PROJECT_PLUGINS_DIR);
31
+ Object.assign(profiles, projectProfiles);
32
+
33
+ return profiles;
34
+ }
35
+
36
+ /**
37
+ * Load profiles from a directory
38
+ * @param {string} dir
39
+ * @returns {Object<string, Object>}
40
+ */
41
+ function loadProfilesFromDir(dir) {
42
+ const profiles = {};
43
+
44
+ if (!fs.existsSync(dir)) {
45
+ return profiles;
46
+ }
47
+
48
+ try {
49
+ const files = fs.readdirSync(dir).filter(f =>
50
+ f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json')
51
+ );
52
+
53
+ for (const file of files) {
54
+ try {
55
+ const filePath = path.join(dir, file);
56
+ const content = fs.readFileSync(filePath, 'utf-8');
57
+ const profile = file.endsWith('.json')
58
+ ? JSON.parse(content)
59
+ : yaml.parse(content);
60
+
61
+ if (validateProfile(profile)) {
62
+ const id = profile.id || path.basename(file, path.extname(file));
63
+ profiles[id] = normalizeProfile(profile);
64
+ }
65
+ } catch (err) {
66
+ console.error(`Warning: Failed to load profile ${file}: ${err.message}`);
67
+ }
68
+ }
69
+ } catch {
70
+ // Directory not readable
71
+ }
72
+
73
+ return profiles;
74
+ }
75
+
76
+ /**
77
+ * Validate a profile structure
78
+ * @param {Object} profile
79
+ * @returns {boolean}
80
+ */
81
+ function validateProfile(profile) {
82
+ if (!profile || typeof profile !== 'object') {
83
+ return false;
84
+ }
85
+
86
+ // Must have a name
87
+ if (!profile.name || typeof profile.name !== 'string') {
88
+ return false;
89
+ }
90
+
91
+ // Requirements must be an object if present
92
+ if (profile.requirements && typeof profile.requirements !== 'object') {
93
+ return false;
94
+ }
95
+
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Normalize profile to standard structure
101
+ * @param {Object} profile
102
+ * @returns {Object}
103
+ */
104
+ function normalizeProfile(profile) {
105
+ return {
106
+ name: profile.name,
107
+ url: profile.url || null,
108
+ custom: true,
109
+ requirements: {
110
+ wordLimit: profile.requirements?.wordLimit || profile.wordLimit || {},
111
+ references: profile.requirements?.references || profile.references || {},
112
+ figures: profile.requirements?.figures || profile.figures || {},
113
+ sections: profile.requirements?.sections || profile.sections || {},
114
+ authors: profile.requirements?.authors || profile.authors || {},
115
+ keywords: profile.requirements?.keywords || profile.keywords || null,
116
+ dataAvailability: profile.requirements?.dataAvailability || profile.dataAvailability || false,
117
+ ...profile.requirements,
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Initialize plugin directories
124
+ * @param {boolean} project - Create project directory instead of user
125
+ * @returns {string} Created directory path
126
+ */
127
+ export function initPluginDir(project = false) {
128
+ const dir = project ? PROJECT_PLUGINS_DIR : USER_PLUGINS_DIR;
129
+
130
+ if (!fs.existsSync(dir)) {
131
+ fs.mkdirSync(dir, { recursive: true });
132
+ }
133
+
134
+ return dir;
135
+ }
136
+
137
+ /**
138
+ * Get plugin directories info
139
+ * @returns {{user: string, project: string, userExists: boolean, projectExists: boolean}}
140
+ */
141
+ export function getPluginDirs() {
142
+ return {
143
+ user: USER_PLUGINS_DIR,
144
+ project: PROJECT_PLUGINS_DIR,
145
+ userExists: fs.existsSync(USER_PLUGINS_DIR),
146
+ projectExists: fs.existsSync(PROJECT_PLUGINS_DIR),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Create a sample profile template
152
+ * @param {string} journalName
153
+ * @returns {string} YAML content
154
+ */
155
+ export function createProfileTemplate(journalName) {
156
+ const id = journalName.toLowerCase().replace(/\s+/g, '-');
157
+
158
+ return `# Custom journal profile for ${journalName}
159
+ # Save as: ~/.rev/profiles/${id}.yaml (user-wide)
160
+ # Or: .rev/profiles/${id}.yaml (project-specific)
161
+
162
+ id: ${id}
163
+ name: "${journalName}"
164
+ url: "https://journal-website.com/author-guidelines"
165
+
166
+ # Word count limits
167
+ wordLimit:
168
+ main: 8000 # null for no limit
169
+ abstract: 300
170
+ title: null # characters
171
+
172
+ # Reference requirements
173
+ references:
174
+ max: null # null for no limit
175
+ doiRequired: true
176
+
177
+ # Figure/table limits
178
+ figures:
179
+ max: 8
180
+ combinedWithTables: false
181
+
182
+ # Required sections
183
+ sections:
184
+ required:
185
+ - Abstract
186
+ - Introduction
187
+ - Methods
188
+ - Results
189
+ - Discussion
190
+ methodsPosition: null # 'end' or 'before-results'
191
+
192
+ # Keywords
193
+ keywords:
194
+ min: 4
195
+ max: 8
196
+
197
+ # Other requirements
198
+ dataAvailability: true
199
+ highlights: false
200
+ graphicalAbstract: false
201
+ `;
202
+ }
203
+
204
+ /**
205
+ * Save a profile template
206
+ * @param {string} journalName
207
+ * @param {boolean} project - Save to project directory
208
+ * @returns {string} Saved file path
209
+ */
210
+ export function saveProfileTemplate(journalName, project = false) {
211
+ const dir = initPluginDir(project);
212
+ const id = journalName.toLowerCase().replace(/\s+/g, '-');
213
+ const filePath = path.join(dir, `${id}.yaml`);
214
+
215
+ if (fs.existsSync(filePath)) {
216
+ throw new Error(`Profile already exists: ${filePath}`);
217
+ }
218
+
219
+ const content = createProfileTemplate(journalName);
220
+ fs.writeFileSync(filePath, content, 'utf-8');
221
+
222
+ return filePath;
223
+ }
224
+
225
+ /**
226
+ * List all custom profiles
227
+ * @returns {Array<{id: string, name: string, source: string, path: string}>}
228
+ */
229
+ export function listCustomProfiles() {
230
+ const result = [];
231
+
232
+ // User profiles
233
+ if (fs.existsSync(USER_PLUGINS_DIR)) {
234
+ const files = fs.readdirSync(USER_PLUGINS_DIR).filter(f =>
235
+ f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json')
236
+ );
237
+
238
+ for (const file of files) {
239
+ try {
240
+ const filePath = path.join(USER_PLUGINS_DIR, file);
241
+ const content = fs.readFileSync(filePath, 'utf-8');
242
+ const profile = file.endsWith('.json') ? JSON.parse(content) : yaml.parse(content);
243
+
244
+ if (validateProfile(profile)) {
245
+ result.push({
246
+ id: profile.id || path.basename(file, path.extname(file)),
247
+ name: profile.name,
248
+ source: 'user',
249
+ path: filePath,
250
+ });
251
+ }
252
+ } catch {
253
+ // Skip invalid profiles
254
+ }
255
+ }
256
+ }
257
+
258
+ // Project profiles
259
+ if (fs.existsSync(PROJECT_PLUGINS_DIR)) {
260
+ const files = fs.readdirSync(PROJECT_PLUGINS_DIR).filter(f =>
261
+ f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json')
262
+ );
263
+
264
+ for (const file of files) {
265
+ try {
266
+ const filePath = path.join(PROJECT_PLUGINS_DIR, file);
267
+ const content = fs.readFileSync(filePath, 'utf-8');
268
+ const profile = file.endsWith('.json') ? JSON.parse(content) : yaml.parse(content);
269
+
270
+ if (validateProfile(profile)) {
271
+ result.push({
272
+ id: profile.id || path.basename(file, path.extname(file)),
273
+ name: profile.name,
274
+ source: 'project',
275
+ path: filePath,
276
+ });
277
+ }
278
+ } catch {
279
+ // Skip invalid profiles
280
+ }
281
+ }
282
+ }
283
+
284
+ return result;
285
+ }
package/lib/review.js CHANGED
@@ -206,3 +206,112 @@ export function listComments(text) {
206
206
  console.log();
207
207
  }
208
208
  }
209
+
210
+ /**
211
+ * Format a comment for interactive display
212
+ * @param {object} comment
213
+ * @param {number} index
214
+ * @param {number} total
215
+ * @returns {string}
216
+ */
217
+ function formatComment(comment, index, total) {
218
+ const statusIcon = comment.resolved ? chalk.green('✓') : chalk.yellow('○');
219
+ const author = comment.author || 'Anonymous';
220
+ const header = chalk.dim(`─── Comment ${index + 1}/${total} (line ${comment.line}) ───`);
221
+
222
+ const authorLine = chalk.blue(`${author}`) + ` ${statusIcon}`;
223
+ const context = chalk.dim(`Context: "...${(comment.before || '').slice(-40)}"`);
224
+
225
+ return `\n${header}\n\n ${authorLine}\n ${comment.content}\n\n ${context}\n`;
226
+ }
227
+
228
+ /**
229
+ * Run interactive comment review session
230
+ * @param {string} text
231
+ * @param {object} options
232
+ * @param {string} options.author - Author name for replies
233
+ * @param {Function} options.addReply - Function to add reply to comment
234
+ * @param {Function} options.setCommentStatus - Function to set comment status
235
+ * @returns {Promise<{text: string, resolved: number, replied: number, skipped: number}>}
236
+ */
237
+ export async function interactiveCommentReview(text, options = {}) {
238
+ const { author = 'Author', addReply, setCommentStatus } = options;
239
+ const comments = getComments(text, { pendingOnly: true });
240
+
241
+ if (comments.length === 0) {
242
+ console.log(chalk.green('No pending comments found.'));
243
+ return { text, resolved: 0, replied: 0, skipped: 0 };
244
+ }
245
+
246
+ console.log(chalk.cyan(`\nReviewing ${comments.length} pending comment(s) as ${chalk.bold(author)}\n`));
247
+
248
+ let resolved = 0;
249
+ let replied = 0;
250
+ let skipped = 0;
251
+ let currentText = text;
252
+
253
+ for (let i = 0; i < comments.length; i++) {
254
+ const comment = comments[i];
255
+ console.log(formatComment(comment, i, comments.length));
256
+
257
+ const prompt = chalk.dim('[r]eply [m]ark resolved [s]kip | resolve [A]ll [q]uit: ');
258
+ const choice = await promptKey(prompt, ['r', 'm', 's', 'A', 'q']);
259
+
260
+ switch (choice) {
261
+ case 'q':
262
+ console.log(chalk.yellow('\nAborted.'));
263
+ return { text: currentText, resolved, replied, skipped: comments.length - i };
264
+
265
+ case 'A':
266
+ // Resolve all remaining
267
+ for (let j = i; j < comments.length; j++) {
268
+ if (setCommentStatus) {
269
+ currentText = setCommentStatus(currentText, comments[j], true);
270
+ }
271
+ }
272
+ resolved += comments.length - i;
273
+ console.log(chalk.green(`\nResolved all ${comments.length - i} remaining comments.`));
274
+ i = comments.length;
275
+ break;
276
+
277
+ case 'm':
278
+ if (setCommentStatus) {
279
+ currentText = setCommentStatus(currentText, comment, true);
280
+ }
281
+ resolved++;
282
+ console.log(chalk.green(' Marked as resolved'));
283
+ break;
284
+
285
+ case 'r':
286
+ // Get reply text
287
+ const rl = readline.createInterface({
288
+ input: process.stdin,
289
+ output: process.stdout,
290
+ });
291
+ const replyText = await new Promise((resolve) => {
292
+ rl.question(chalk.cyan(' Reply: '), resolve);
293
+ });
294
+ rl.close();
295
+
296
+ if (replyText.trim() && addReply) {
297
+ currentText = addReply(currentText, comment, author, replyText.trim());
298
+ replied++;
299
+ console.log(chalk.green(' Reply added'));
300
+ } else {
301
+ console.log(chalk.dim(' No reply added'));
302
+ }
303
+ break;
304
+
305
+ case 's':
306
+ skipped++;
307
+ break;
308
+ }
309
+ }
310
+
311
+ console.log(chalk.cyan('\n─── Summary ───'));
312
+ console.log(chalk.green(`Resolved: ${resolved}`));
313
+ console.log(chalk.blue(`Replied: ${replied}`));
314
+ console.log(chalk.yellow(`Skipped: ${skipped}`));
315
+
316
+ return { text: currentText, resolved, replied, skipped };
317
+ }