docrev 0.3.0 → 0.5.1

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.
@@ -1,37 +1,19 @@
1
1
  /**
2
- * Track Changes export utilities
3
- * Convert CriticMarkup annotations to Word track changes format
2
+ * Track changes module - Apply markdown annotations as Word track changes
3
+ *
4
+ * Converts CriticMarkup annotations to Word OOXML track changes format.
4
5
  */
5
6
 
6
7
  import * as fs from 'fs';
7
8
  import * as path from 'path';
9
+ import { execSync } from 'child_process';
8
10
  import AdmZip from 'adm-zip';
9
- import { parseAnnotations } from './annotations.js';
10
-
11
- /**
12
- * Generate a unique revision ID
13
- * @returns {number}
14
- */
15
- let revisionId = 0;
16
- function getNextRevId() {
17
- return revisionId++;
18
- }
19
-
20
- /**
21
- * Format date for Word revision
22
- * @returns {string}
23
- */
24
- function getRevisionDate() {
25
- return new Date().toISOString().replace('Z', '');
26
- }
27
11
 
28
12
  /**
29
13
  * Escape XML special characters
30
- * @param {string} text
31
- * @returns {string}
32
14
  */
33
- function escapeXml(text) {
34
- return text
15
+ function escapeXml(str) {
16
+ return str
35
17
  .replace(/&/g, '&')
36
18
  .replace(/</g, '&lt;')
37
19
  .replace(/>/g, '&gt;')
@@ -40,71 +22,87 @@ function escapeXml(text) {
40
22
  }
41
23
 
42
24
  /**
43
- * Create Word insertion markup
44
- * @param {string} text - Text to insert
45
- * @param {string} author - Author name
46
- * @returns {string}
47
- */
48
- function createInsertionXml(text, author = 'Author') {
49
- const id = getNextRevId();
50
- const date = getRevisionDate();
51
-
52
- return `<w:ins w:id="${id}" w:author="${escapeXml(author)}" w:date="${date}"><w:r><w:t>${escapeXml(text)}</w:t></w:r></w:ins>`;
53
- }
54
-
55
- /**
56
- * Create Word deletion markup
57
- * @param {string} text - Text to delete
58
- * @param {string} author - Author name
59
- * @returns {string}
60
- */
61
- function createDeletionXml(text, author = 'Author') {
62
- const id = getNextRevId();
63
- const date = getRevisionDate();
64
-
65
- return `<w:del w:id="${id}" w:author="${escapeXml(author)}" w:date="${date}"><w:r><w:delText>${escapeXml(text)}</w:delText></w:r></w:del>`;
66
- }
67
-
68
- /**
69
- * Convert CriticMarkup to Word track changes in markdown
70
- * This creates a special markdown format that can be processed after pandoc
25
+ * Prepare text with CriticMarkup annotations for track changes
26
+ * Replaces annotations with markers that can be processed in DOCX
71
27
  *
72
- * @param {string} text - Markdown with CriticMarkup
73
- * @returns {{text: string, annotations: Array}}
28
+ * @param {string} text - Text with CriticMarkup annotations
29
+ * @param {object} options - Options
30
+ * @param {string} options.author - Default author for track changes
31
+ * @returns {{text: string, markers: Array}} Processed text and marker info
74
32
  */
75
- export function prepareForTrackChanges(text) {
76
- const annotations = parseAnnotations(text);
33
+ export function prepareForTrackChanges(text, options = {}) {
34
+ const { author = 'Reviewer' } = options;
77
35
  const markers = [];
78
-
79
- // Sort by position descending to replace from end
80
- const sorted = [...annotations].sort((a, b) => b.position - a.position);
36
+ let markerId = 0;
81
37
 
82
38
  let result = text;
83
39
 
84
- for (const ann of sorted) {
85
- const marker = `{{TC_${markers.length}}}`;
40
+ // Process insertions: {++text++}
41
+ result = result.replace(/\{\+\+(.+?)\+\+\}/gs, (match, content) => {
42
+ const id = markerId++;
43
+ markers.push({
44
+ id,
45
+ type: 'insert',
46
+ content,
47
+ author,
48
+ });
49
+ return `{{TC_${id}}}`;
50
+ });
86
51
 
52
+ // Process deletions: {--text--}
53
+ result = result.replace(/\{--(.+?)--\}/gs, (match, content) => {
54
+ const id = markerId++;
87
55
  markers.push({
88
- id: markers.length,
89
- type: ann.type,
90
- content: ann.content,
91
- replacement: ann.replacement,
92
- author: ann.author || 'Reviewer',
56
+ id,
57
+ type: 'delete',
58
+ content,
59
+ author,
93
60
  });
61
+ return `{{TC_${id}}}`;
62
+ });
94
63
 
95
- // Replace annotation with marker
96
- result = result.slice(0, ann.position) + marker + result.slice(ann.position + ann.match.length);
97
- }
64
+ // Process substitutions: {~~old~>new~~}
65
+ result = result.replace(/\{~~(.+?)~>(.+?)~~\}/gs, (match, old, replacement) => {
66
+ const id = markerId++;
67
+ markers.push({
68
+ id,
69
+ type: 'substitute',
70
+ content: old,
71
+ replacement,
72
+ author,
73
+ });
74
+ return `{{TC_${id}}}`;
75
+ });
76
+
77
+ // Process comments: {>>Author: comment<<}
78
+ result = result.replace(/\{>>(.+?)<<\}/gs, (match, content) => {
79
+ const id = markerId++;
80
+ // Extract author if present (format: "Author: comment")
81
+ const colonIdx = content.indexOf(':');
82
+ let commentAuthor = author;
83
+ let commentText = content;
84
+ if (colonIdx > 0 && colonIdx < 30) {
85
+ commentAuthor = content.slice(0, colonIdx).trim();
86
+ commentText = content.slice(colonIdx + 1).trim();
87
+ }
88
+ markers.push({
89
+ id,
90
+ type: 'comment',
91
+ content: commentText,
92
+ author: commentAuthor,
93
+ });
94
+ return `{{TC_${id}}}`;
95
+ });
98
96
 
99
97
  return { text: result, markers };
100
98
  }
101
99
 
102
100
  /**
103
- * Post-process a DOCX file to convert markers to track changes
101
+ * Apply track changes markers to a Word document
104
102
  *
105
- * @param {string} docxPath - Path to DOCX file
103
+ * @param {string} docxPath - Path to input DOCX file
106
104
  * @param {Array} markers - Markers from prepareForTrackChanges
107
- * @param {string} outputPath - Output path
105
+ * @param {string} outputPath - Path for output DOCX file
108
106
  * @returns {Promise<{success: boolean, message: string}>}
109
107
  */
110
108
  export async function applyTrackChangesToDocx(docxPath, markers, outputPath) {
@@ -112,162 +110,120 @@ export async function applyTrackChangesToDocx(docxPath, markers, outputPath) {
112
110
  return { success: false, message: `File not found: ${docxPath}` };
113
111
  }
114
112
 
113
+ let zip;
115
114
  try {
116
- const zip = new AdmZip(docxPath);
117
- const documentEntry = zip.getEntry('word/document.xml');
115
+ zip = new AdmZip(docxPath);
116
+ } catch (err) {
117
+ return { success: false, message: `Invalid DOCX file: ${err.message}` };
118
+ }
118
119
 
119
- if (!documentEntry) {
120
- return { success: false, message: 'Invalid DOCX: no document.xml' };
121
- }
120
+ // Read document.xml
121
+ const docEntry = zip.getEntry('word/document.xml');
122
+ if (!docEntry) {
123
+ return { success: false, message: 'Invalid DOCX: no document.xml' };
124
+ }
122
125
 
123
- let documentXml = zip.readAsText(documentEntry);
124
-
125
- // Enable track changes in settings
126
- const settingsEntry = zip.getEntry('word/settings.xml');
127
- if (settingsEntry) {
128
- let settingsXml = zip.readAsText(settingsEntry);
129
- // Add trackRevisions setting if not present
130
- if (!settingsXml.includes('w:trackRevisions')) {
131
- settingsXml = settingsXml.replace(
132
- '</w:settings>',
133
- '<w:trackRevisions/></w:settings>'
134
- );
135
- zip.updateFile('word/settings.xml', Buffer.from(settingsXml, 'utf-8'));
136
- }
137
- }
126
+ let documentXml = zip.readAsText(docEntry);
138
127
 
139
- // Replace markers with track changes XML
140
- for (const marker of markers) {
141
- const markerText = `{{TC_${marker.id}}}`;
142
-
143
- // Find the marker in document.xml (may be split across runs)
144
- // First try simple replacement
145
- if (documentXml.includes(markerText)) {
146
- let replacement;
147
-
148
- switch (marker.type) {
149
- case 'insert':
150
- replacement = createInsertionXml(marker.content, marker.author);
151
- break;
152
- case 'delete':
153
- replacement = createDeletionXml(marker.content, marker.author);
154
- break;
155
- case 'substitute':
156
- // Substitution = deletion + insertion
157
- replacement =
158
- createDeletionXml(marker.content, marker.author) +
159
- createInsertionXml(marker.replacement, marker.author);
160
- break;
161
- case 'comment':
162
- // Comments are handled differently - skip for track changes
163
- replacement = '';
164
- break;
165
- default:
166
- replacement = '';
167
- }
168
-
169
- documentXml = documentXml.replace(markerText, replacement);
170
- } else {
171
- // Marker might be split across <w:t> elements
172
- // Try to find and reconstruct
173
- const markerPattern = markerText.split('').join('(?:</w:t></w:r><w:r><w:t>)?');
174
- const regex = new RegExp(markerPattern, 'g');
175
-
176
- if (regex.test(documentXml)) {
177
- let replacement;
178
-
179
- switch (marker.type) {
180
- case 'insert':
181
- replacement = `</w:t></w:r>${createInsertionXml(marker.content, marker.author)}<w:r><w:t>`;
182
- break;
183
- case 'delete':
184
- replacement = `</w:t></w:r>${createDeletionXml(marker.content, marker.author)}<w:r><w:t>`;
185
- break;
186
- case 'substitute':
187
- replacement =
188
- `</w:t></w:r>${createDeletionXml(marker.content, marker.author)}` +
189
- `${createInsertionXml(marker.replacement, marker.author)}<w:r><w:t>`;
190
- break;
191
- default:
192
- replacement = '';
193
- }
194
-
195
- documentXml = documentXml.replace(regex, replacement);
196
- }
197
- }
198
- }
128
+ // Generate ISO date for track changes
129
+ const now = new Date().toISOString();
199
130
 
200
- // Clean up empty runs created by replacements
201
- documentXml = documentXml.replace(/<w:r><w:t><\/w:t><\/w:r>/g, '');
131
+ // Replace markers with track change XML
132
+ for (const marker of markers) {
133
+ const placeholder = `{{TC_${marker.id}}}`;
134
+ let replacement = '';
135
+
136
+ const escapedContent = escapeXml(marker.content);
137
+ const escapedAuthor = escapeXml(marker.author);
138
+
139
+ if (marker.type === 'insert') {
140
+ replacement = `<w:ins w:id="${marker.id}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:t>${escapedContent}</w:t></w:r></w:ins>`;
141
+ } else if (marker.type === 'delete') {
142
+ replacement = `<w:del w:id="${marker.id}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:delText>${escapedContent}</w:delText></w:r></w:del>`;
143
+ } else if (marker.type === 'substitute') {
144
+ const escapedReplacement = escapeXml(marker.replacement);
145
+ replacement = `<w:del w:id="${marker.id}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:delText>${escapedContent}</w:delText></w:r></w:del><w:ins w:id="${marker.id + 1000}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:t>${escapedReplacement}</w:t></w:r></w:ins>`;
146
+ }
202
147
 
203
- zip.updateFile('word/document.xml', Buffer.from(documentXml, 'utf-8'));
204
- zip.writeZip(outputPath);
148
+ documentXml = documentXml.replace(placeholder, replacement);
149
+ }
205
150
 
206
- return { success: true, message: `Created ${outputPath} with track changes` };
207
- } catch (err) {
208
- return { success: false, message: err.message };
151
+ // Update document.xml
152
+ zip.updateFile('word/document.xml', Buffer.from(documentXml));
153
+
154
+ // Enable track revisions in settings.xml
155
+ const settingsEntry = zip.getEntry('word/settings.xml');
156
+ if (settingsEntry) {
157
+ let settingsXml = zip.readAsText(settingsEntry);
158
+ if (!settingsXml.includes('w:trackRevisions')) {
159
+ settingsXml = settingsXml.replace(
160
+ '</w:settings>',
161
+ '<w:trackRevisions/></w:settings>'
162
+ );
163
+ zip.updateFile('word/settings.xml', Buffer.from(settingsXml));
164
+ }
209
165
  }
166
+
167
+ // Write output
168
+ zip.writeZip(outputPath);
169
+
170
+ return { success: true, message: `Created ${outputPath} with track changes` };
210
171
  }
211
172
 
212
173
  /**
213
- * Build DOCX with track changes visible
214
- * This is the main entry point for the audit export feature
174
+ * Build a Word document with track changes from annotated markdown
215
175
  *
216
- * @param {string} markdownPath - Path to markdown with annotations
217
- * @param {string} outputPath - Output DOCX path
218
- * @param {Object} options
219
- * @returns {Promise<{success: boolean, message: string, stats: Object}>}
176
+ * @param {string} mdPath - Path to markdown file with CriticMarkup
177
+ * @param {string} docxPath - Output path for Word document
178
+ * @param {object} options - Options
179
+ * @param {string} options.author - Author name for track changes
180
+ * @returns {Promise<{success: boolean, message: string}>}
220
181
  */
221
- export async function buildWithTrackChanges(markdownPath, outputPath, options = {}) {
222
- const { author = 'Reviewer' } = options;
182
+ export async function buildWithTrackChanges(mdPath, docxPath, options = {}) {
183
+ const { author = 'Author' } = options;
223
184
 
224
- if (!fs.existsSync(markdownPath)) {
225
- return { success: false, message: `File not found: ${markdownPath}`, stats: null };
185
+ if (!fs.existsSync(mdPath)) {
186
+ return { success: false, message: `File not found: ${mdPath}` };
226
187
  }
227
188
 
228
- const text = fs.readFileSync(markdownPath, 'utf-8');
229
- const { text: preparedText, markers } = prepareForTrackChanges(text);
189
+ const content = fs.readFileSync(mdPath, 'utf-8');
230
190
 
231
- // Assign author to markers that don't have one
232
- for (const marker of markers) {
233
- if (!marker.author || marker.author === 'Reviewer') {
234
- marker.author = author;
191
+ // Prepare for track changes
192
+ const { text: prepared, markers } = prepareForTrackChanges(content, { author });
193
+
194
+ // If no annotations, just build normally
195
+ if (markers.length === 0) {
196
+ try {
197
+ execSync(`pandoc "${mdPath}" -o "${docxPath}"`, { encoding: 'utf-8' });
198
+ return { success: true, message: `Created ${docxPath}` };
199
+ } catch (err) {
200
+ return { success: false, message: err.message };
235
201
  }
236
202
  }
237
203
 
238
- // Write temporary markdown
239
- const tempMd = outputPath.replace('.docx', '.tmp.md');
240
- const tempDocx = outputPath.replace('.docx', '.tmp.docx');
241
-
242
- fs.writeFileSync(tempMd, preparedText, 'utf-8');
243
-
244
- // Run pandoc to create initial DOCX
245
- const { execSync } = await import('child_process');
204
+ // Write prepared content to temp file
205
+ const tempDir = path.dirname(mdPath);
206
+ const tempMd = path.join(tempDir, `.temp-${Date.now()}.md`);
207
+ const tempDocx = path.join(tempDir, `.temp-${Date.now()}.docx`);
246
208
 
247
209
  try {
248
- execSync(`pandoc "${tempMd}" -o "${tempDocx}"`, { stdio: 'pipe' });
249
- } catch (err) {
250
- fs.unlinkSync(tempMd);
251
- return { success: false, message: `Pandoc failed: ${err.message}`, stats: null };
252
- }
210
+ fs.writeFileSync(tempMd, prepared, 'utf-8');
253
211
 
254
- // Apply track changes
255
- const result = await applyTrackChangesToDocx(tempDocx, markers, outputPath);
212
+ // Build with pandoc
213
+ execSync(`pandoc "${tempMd}" -o "${tempDocx}"`, { encoding: 'utf-8' });
256
214
 
257
- // Cleanup
258
- try {
215
+ // Apply track changes
216
+ const result = await applyTrackChangesToDocx(tempDocx, markers, docxPath);
217
+
218
+ // Clean up temp files
259
219
  fs.unlinkSync(tempMd);
260
220
  fs.unlinkSync(tempDocx);
261
- } catch {
262
- // Ignore cleanup errors
263
- }
264
-
265
- const stats = {
266
- insertions: markers.filter(m => m.type === 'insert').length,
267
- deletions: markers.filter(m => m.type === 'delete').length,
268
- substitutions: markers.filter(m => m.type === 'substitute').length,
269
- total: markers.length,
270
- };
271
221
 
272
- return { ...result, stats };
222
+ return result;
223
+ } catch (err) {
224
+ // Clean up on error
225
+ if (fs.existsSync(tempMd)) fs.unlinkSync(tempMd);
226
+ if (fs.existsSync(tempDocx)) fs.unlinkSync(tempDocx);
227
+ return { success: false, message: err.message };
228
+ }
273
229
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Template variable substitution for rev
3
+ *
4
+ * Supported variables:
5
+ * {{date}} - Current date (YYYY-MM-DD)
6
+ * {{date:format}} - Custom date format (e.g., {{date:MMMM D, YYYY}})
7
+ * {{version}} - Version from rev.yaml
8
+ * {{word_count}} - Total word count
9
+ * {{author}} - First author name
10
+ * {{authors}} - All authors (comma-separated)
11
+ * {{title}} - Document title
12
+ * {{year}} - Current year
13
+ */
14
+
15
+ import * as fs from 'fs';
16
+
17
+ /**
18
+ * Format date with simple pattern
19
+ * @param {Date} date
20
+ * @param {string} format - Pattern (YYYY, MM, DD, MMMM, MMM, D)
21
+ * @returns {string}
22
+ */
23
+ function formatDate(date, format = 'YYYY-MM-DD') {
24
+ const months = [
25
+ 'January', 'February', 'March', 'April', 'May', 'June',
26
+ 'July', 'August', 'September', 'October', 'November', 'December'
27
+ ];
28
+ const monthsShort = [
29
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
30
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
31
+ ];
32
+
33
+ const year = date.getFullYear();
34
+ const month = date.getMonth();
35
+ const day = date.getDate();
36
+
37
+ // Use placeholders to avoid replacement conflicts (e.g., D in December)
38
+ return format
39
+ .replace('YYYY', '\x00YEAR\x00')
40
+ .replace('MMMM', '\x00MONTHFULL\x00')
41
+ .replace('MMM', '\x00MONTHSHORT\x00')
42
+ .replace('MM', '\x00MONTHNUM\x00')
43
+ .replace('DD', '\x00DAYPAD\x00')
44
+ .replace(/\bD\b/, '\x00DAY\x00')
45
+ .replace('\x00YEAR\x00', year.toString())
46
+ .replace('\x00MONTHFULL\x00', months[month])
47
+ .replace('\x00MONTHSHORT\x00', monthsShort[month])
48
+ .replace('\x00MONTHNUM\x00', (month + 1).toString().padStart(2, '0'))
49
+ .replace('\x00DAYPAD\x00', day.toString().padStart(2, '0'))
50
+ .replace('\x00DAY\x00', day.toString());
51
+ }
52
+
53
+ /**
54
+ * Count words in text (excluding markdown syntax)
55
+ * @param {string} text
56
+ * @returns {number}
57
+ */
58
+ function countWords(text) {
59
+ return text
60
+ .replace(/^---[\s\S]*?---/m, '') // Remove frontmatter
61
+ .replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
62
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Keep link text
63
+ .replace(/#+\s*/g, '') // Remove headers
64
+ .replace(/\*\*|__|[*_`]/g, '') // Remove formatting
65
+ .replace(/```[\s\S]*?```/g, '') // Remove code blocks
66
+ .replace(/\{[^}]+\}/g, '') // Remove annotations
67
+ .replace(/@\w+:\w+/g, '') // Remove refs
68
+ .replace(/@\w+/g, '') // Remove citations
69
+ .replace(/\|[^|]+\|/g, ' ') // Remove tables
70
+ .replace(/\n+/g, ' ')
71
+ .trim()
72
+ .split(/\s+/)
73
+ .filter(w => w.length > 0).length;
74
+ }
75
+
76
+ /**
77
+ * Get first author name from authors array
78
+ * @param {Array|string} authors
79
+ * @returns {string}
80
+ */
81
+ function getFirstAuthor(authors) {
82
+ if (!authors || authors.length === 0) return '';
83
+
84
+ const first = Array.isArray(authors) ? authors[0] : authors;
85
+
86
+ if (typeof first === 'string') return first;
87
+ if (first.name) return first.name;
88
+
89
+ return '';
90
+ }
91
+
92
+ /**
93
+ * Get all author names
94
+ * @param {Array|string} authors
95
+ * @returns {string}
96
+ */
97
+ function getAllAuthors(authors) {
98
+ if (!authors) return '';
99
+ if (typeof authors === 'string') return authors;
100
+
101
+ return authors
102
+ .map(a => typeof a === 'string' ? a : a.name)
103
+ .filter(Boolean)
104
+ .join(', ');
105
+ }
106
+
107
+ /**
108
+ * Process template variables in text
109
+ * @param {string} text - Text with {{variable}} placeholders
110
+ * @param {object} config - rev.yaml config
111
+ * @param {object} options - Additional options
112
+ * @param {string[]} options.sections - Section file contents for word count
113
+ * @returns {string} Text with variables replaced
114
+ */
115
+ export function processVariables(text, config = {}, options = {}) {
116
+ const now = new Date();
117
+ let result = text;
118
+
119
+ // Calculate word count from sections if provided
120
+ let wordCount = 0;
121
+ if (options.sectionContents) {
122
+ for (const content of options.sectionContents) {
123
+ wordCount += countWords(content);
124
+ }
125
+ }
126
+
127
+ // {{date}} - Current date
128
+ result = result.replace(/\{\{date\}\}/g, formatDate(now));
129
+
130
+ // {{date:format}} - Custom date format
131
+ result = result.replace(/\{\{date:([^}]+)\}\}/g, (match, format) => {
132
+ return formatDate(now, format);
133
+ });
134
+
135
+ // {{year}} - Current year
136
+ result = result.replace(/\{\{year\}\}/g, now.getFullYear().toString());
137
+
138
+ // {{version}} - From config
139
+ result = result.replace(/\{\{version\}\}/g, config.version || '');
140
+
141
+ // {{title}} - Document title
142
+ result = result.replace(/\{\{title\}\}/g, config.title || '');
143
+
144
+ // {{author}} - First author
145
+ result = result.replace(/\{\{author\}\}/g, getFirstAuthor(config.authors));
146
+
147
+ // {{authors}} - All authors
148
+ result = result.replace(/\{\{authors\}\}/g, getAllAuthors(config.authors));
149
+
150
+ // {{word_count}} - Total word count
151
+ result = result.replace(/\{\{word_count\}\}/g, wordCount.toLocaleString());
152
+
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Check if text contains any template variables
158
+ * @param {string} text
159
+ * @returns {boolean}
160
+ */
161
+ export function hasVariables(text) {
162
+ return /\{\{[^}]+\}\}/.test(text);
163
+ }
164
+
165
+ /**
166
+ * List all variables found in text
167
+ * @param {string} text
168
+ * @returns {string[]}
169
+ */
170
+ export function findVariables(text) {
171
+ const matches = text.match(/\{\{([^}]+)\}\}/g) || [];
172
+ return [...new Set(matches.map(m => m.slice(2, -2)))];
173
+ }