docrev 0.6.6 → 0.6.13
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/README.md +166 -94
- package/bin/rev.js +8 -4
- package/lib/import.js +17 -6
- package/lib/wordcomments.js +88 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
# docrev
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/docrev)
|
|
4
|
+
[](https://www.npmjs.com/package/docrev)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/gcol33/docrev/actions/workflows/ci.yml)
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
A CLI for writing scientific papers in Markdown while collaborating with Word users.
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Scientific papers go through many revision cycles. You send a Word document to collaborators, they add comments and track changes, you address the feedback and send it back. Then journal submission, reviewer comments, more revisions. After a few rounds, track changes become unreadable, you have fifteen versions of the same file, and nobody knows which one is current. Equations break when copying between documents. Figures get embedded at the wrong resolution or disappear entirely.
|
|
10
|
-
|
|
11
|
-
docrev takes a different approach. You write in plain text using Markdown, a simple formatting syntax that takes ten minutes to learn. When you need to share with collaborators or submit to a journal, docrev generates a Word document or PDF. Your collaborators review and comment in Word as usual. When they send it back, docrev imports their feedback into your Markdown files. You address the comments, rebuild the document, and send it back. The cycle continues, but your source files stay clean and under version control.
|
|
12
|
-
|
|
13
|
-
Your collaborators do not need to install anything or change how they work. They keep using Word. You handle the conversion on your end. The result is proper version history, equations that never break, figures that stay linked rather than embedded, and automated citation formatting.
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
Markdown files --> docrev --> Word/PDF
|
|
17
|
-
^ |
|
|
18
|
-
| v
|
|
19
|
-
+---- docrev <---- reviewer feedback
|
|
20
|
-
```
|
|
11
|
+
You write in Markdown under version control. Your collaborators use Word. docrev converts between the two, preserving track changes, comments, equations, and cross-references.
|
|
21
12
|
|
|
22
13
|
## Install
|
|
23
14
|
|
|
@@ -26,20 +17,20 @@ npm install -g docrev
|
|
|
26
17
|
brew install pandoc
|
|
27
18
|
```
|
|
28
19
|
|
|
29
|
-
|
|
20
|
+
Pandoc is required for document conversion. On Windows use `winget install JohnMacFarlane.Pandoc`, on Linux use `apt install pandoc`.
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
## Getting Started
|
|
23
|
+
|
|
24
|
+
### Starting from a Word Document
|
|
25
|
+
|
|
26
|
+
If you have an existing manuscript in Word:
|
|
36
27
|
|
|
37
|
-
From existing Word document:
|
|
38
28
|
```bash
|
|
39
29
|
rev import manuscript.docx
|
|
40
30
|
```
|
|
41
31
|
|
|
42
|
-
|
|
32
|
+
This converts your document to markdown, splitting it into sections:
|
|
33
|
+
|
|
43
34
|
```
|
|
44
35
|
my-paper/
|
|
45
36
|
├── introduction.md
|
|
@@ -50,130 +41,211 @@ my-paper/
|
|
|
50
41
|
└── rev.yaml
|
|
51
42
|
```
|
|
52
43
|
|
|
53
|
-
|
|
44
|
+
Track changes and comments from the Word document are preserved as annotations in the markdown files (see below).
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
# Heading
|
|
46
|
+
### Starting from Scratch
|
|
57
47
|
|
|
58
|
-
|
|
48
|
+
To start a new paper in markdown:
|
|
59
49
|
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
```bash
|
|
51
|
+
rev new my-paper
|
|
52
|
+
cd my-paper
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This creates the same project structure with empty section files. Write your paper in the markdown files, then build Word documents to share with collaborators.
|
|
56
|
+
|
|
57
|
+
## The Revision Cycle
|
|
58
|
+
|
|
59
|
+
### 1. Build and Share
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
Generate a Word document from your markdown:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
rev build docx
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Send this to your collaborators. They review it in Word, adding comments and track changes as usual.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
year = {2020}
|
|
76
|
-
}
|
|
69
|
+
### 2. Import Feedback
|
|
70
|
+
|
|
71
|
+
When collaborators return the reviewed document, import their feedback:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
rev sections reviewed.docx
|
|
77
75
|
```
|
|
78
76
|
|
|
79
|
-
|
|
77
|
+
This updates your markdown files with their comments and track changes, converted to inline annotations.
|
|
78
|
+
|
|
79
|
+
### 3. Review Track Changes
|
|
80
|
+
|
|
81
|
+
Track changes appear as inline annotations in your markdown:
|
|
82
|
+
|
|
80
83
|
```markdown
|
|
81
|
-
|
|
84
|
+
The sample size was {--100--}{++150++} individuals.
|
|
85
|
+
We collected data {~~monthly~>weekly~~} from each site.
|
|
82
86
|
```
|
|
83
87
|
|
|
84
|
-
|
|
88
|
+
- `{++text++}` — inserted text
|
|
89
|
+
- `{--text--}` — deleted text
|
|
90
|
+
- `{~~old~>new~~}` — substitution
|
|
85
91
|
|
|
86
|
-
|
|
92
|
+
To accept a change, keep the new text and delete the markup. To reject it, keep the old text. When you're done, the file is clean markdown.
|
|
93
|
+
|
|
94
|
+
### 4. Respond to Comments
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
Comments appear inline in your markdown:
|
|
89
97
|
|
|
90
|
-
Display:
|
|
91
98
|
```markdown
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
$$
|
|
99
|
+
We used a random sampling approach.
|
|
100
|
+
{>>Reviewer 2: Please clarify the sampling method.<<}
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
List all comments in a file:
|
|
98
104
|
|
|
99
|
-
```
|
|
100
|
-
|
|
105
|
+
```bash
|
|
106
|
+
rev comments methods.md
|
|
107
|
+
```
|
|
101
108
|
|
|
102
|
-
|
|
109
|
+
Reply from the command line:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
rev config user "Your Name" # one-time setup
|
|
113
|
+
rev reply methods.md -n 1 -m "Added clarification in paragraph 2"
|
|
103
114
|
```
|
|
104
115
|
|
|
105
|
-
|
|
116
|
+
Your reply threads beneath the original:
|
|
106
117
|
|
|
107
|
-
|
|
118
|
+
```markdown
|
|
119
|
+
We used a random sampling approach.
|
|
120
|
+
{>>Reviewer 2: Please clarify the sampling method.<<}
|
|
121
|
+
{>>Your Name: Added clarification in paragraph 2.<<}
|
|
122
|
+
```
|
|
108
123
|
|
|
109
|
-
|
|
124
|
+
Mark comments as resolved:
|
|
110
125
|
|
|
111
126
|
```bash
|
|
112
|
-
rev
|
|
113
|
-
rev build pdf # PDF
|
|
114
|
-
rev build --dual # Clean + comments versions
|
|
115
|
-
rev watch docx # Auto-rebuild on save
|
|
127
|
+
rev resolve methods.md -n 1
|
|
116
128
|
```
|
|
117
129
|
|
|
118
|
-
|
|
130
|
+
### 5. Rebuild with Comment Threads
|
|
131
|
+
|
|
132
|
+
Generate both a clean version and one showing the comment threads:
|
|
119
133
|
|
|
120
|
-
Import reviewed document:
|
|
121
134
|
```bash
|
|
122
|
-
rev
|
|
135
|
+
rev build --dual
|
|
123
136
|
```
|
|
124
137
|
|
|
125
|
-
|
|
138
|
+
This produces:
|
|
139
|
+
- `paper.docx` — clean, for submission
|
|
140
|
+
- `paper_comments.docx` — includes comment threads as Word comments
|
|
141
|
+
|
|
142
|
+
Your collaborators see the full conversation in the comments pane.
|
|
143
|
+
|
|
144
|
+
### 6. Repeat
|
|
145
|
+
|
|
146
|
+
Send the updated Word document. Import new feedback with `rev sections`. Continue until done.
|
|
147
|
+
|
|
148
|
+
## Before Submission
|
|
149
|
+
|
|
150
|
+
### Validate Your Bibliography
|
|
151
|
+
|
|
152
|
+
Check that DOIs in your bibliography resolve correctly:
|
|
153
|
+
|
|
126
154
|
```bash
|
|
127
|
-
rev
|
|
155
|
+
rev doi check references.bib
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Find DOIs for entries missing them:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
rev doi lookup references.bib
|
|
128
162
|
```
|
|
129
163
|
|
|
130
|
-
|
|
164
|
+
Add a citation directly from a DOI:
|
|
165
|
+
|
|
131
166
|
```bash
|
|
132
|
-
rev
|
|
133
|
-
rev reply methods.md -n 1 -m "Clarified sampling methodology"
|
|
167
|
+
rev doi add 10.1038/s41586-020-2649-2
|
|
134
168
|
```
|
|
135
169
|
|
|
136
|
-
|
|
170
|
+
### Run Pre-Submission Checks
|
|
171
|
+
|
|
172
|
+
Check for broken references, missing citations, and common issues:
|
|
173
|
+
|
|
137
174
|
```bash
|
|
138
|
-
rev
|
|
175
|
+
rev check
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Writing in Markdown
|
|
179
|
+
|
|
180
|
+
### Citations
|
|
181
|
+
|
|
182
|
+
Add references to `references.bib`:
|
|
183
|
+
|
|
184
|
+
```bibtex
|
|
185
|
+
@article{Smith2020,
|
|
186
|
+
author = {Smith, Jane},
|
|
187
|
+
title = {Paper Title},
|
|
188
|
+
journal = {Nature},
|
|
189
|
+
year = {2020},
|
|
190
|
+
doi = {10.1038/example}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Cite in text:
|
|
195
|
+
|
|
196
|
+
```markdown
|
|
197
|
+
Previous work [@Smith2020] established this relationship.
|
|
198
|
+
Multiple sources support this [@Smith2020; @Jones2021].
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Equations
|
|
202
|
+
|
|
203
|
+
Inline equations use single dollar signs: `$E = mc^2$`
|
|
204
|
+
|
|
205
|
+
Display equations use double dollar signs:
|
|
206
|
+
|
|
207
|
+
```markdown
|
|
208
|
+
$$
|
|
209
|
+
\bar{x} = \frac{1}{n} \sum_{i=1}^{n} x_i
|
|
210
|
+
$$
|
|
139
211
|
```
|
|
140
212
|
|
|
141
|
-
|
|
142
|
-
- `paper.docx` (clean)
|
|
143
|
-
- `paper_comments.docx` (with comment threads)
|
|
213
|
+
### Figures and Cross-References
|
|
144
214
|
|
|
145
|
-
|
|
215
|
+
```markdown
|
|
216
|
+
{#fig:map}
|
|
217
|
+
|
|
218
|
+
Results are shown in @fig:map.
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The reference `@fig:map` becomes "Figure 1" in the output. Numbers update automatically when figures are reordered.
|
|
222
|
+
|
|
223
|
+
Tables and equations work the same way with `@tbl:label` and `@eq:label`.
|
|
224
|
+
|
|
225
|
+
## Useful Commands
|
|
146
226
|
|
|
147
227
|
| Task | Command |
|
|
148
228
|
|------|---------|
|
|
149
|
-
|
|
|
150
|
-
| Import Word | `rev import manuscript.docx` |
|
|
229
|
+
| Start new project | `rev new my-paper` |
|
|
230
|
+
| Import Word document | `rev import manuscript.docx` |
|
|
231
|
+
| Import feedback | `rev sections reviewed.docx` |
|
|
232
|
+
| List comments | `rev comments methods.md` |
|
|
233
|
+
| Reply to comment | `rev reply methods.md -n 1 -m "response"` |
|
|
151
234
|
| Build Word | `rev build docx` |
|
|
152
235
|
| Build PDF | `rev build pdf` |
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
236
|
+
| Build both clean and annotated | `rev build --dual` |
|
|
237
|
+
| Check DOIs | `rev doi check references.bib` |
|
|
238
|
+
| Find missing DOIs | `rev doi lookup references.bib` |
|
|
156
239
|
| Word count | `rev word-count` |
|
|
157
|
-
|
|
|
158
|
-
|
|
|
240
|
+
| Pre-submission check | `rev check` |
|
|
241
|
+
| Watch for changes | `rev watch docx` |
|
|
159
242
|
|
|
160
|
-
Full reference: [docs/commands.md](docs/commands.md)
|
|
243
|
+
Full command reference: [docs/commands.md](docs/commands.md)
|
|
161
244
|
|
|
162
245
|
## Requirements
|
|
163
246
|
|
|
164
247
|
- Node.js 18+
|
|
165
|
-
- Pandoc
|
|
166
|
-
|
|
167
|
-
```bash
|
|
168
|
-
# macOS
|
|
169
|
-
brew install pandoc
|
|
170
|
-
|
|
171
|
-
# Windows
|
|
172
|
-
winget install JohnMacFarlane.Pandoc
|
|
173
|
-
|
|
174
|
-
# Linux
|
|
175
|
-
sudo apt install pandoc
|
|
176
|
-
```
|
|
248
|
+
- Pandoc 2.11+
|
|
177
249
|
|
|
178
250
|
## License
|
|
179
251
|
|
package/bin/rev.js
CHANGED
|
@@ -1683,7 +1683,11 @@ program
|
|
|
1683
1683
|
}
|
|
1684
1684
|
}
|
|
1685
1685
|
|
|
1686
|
-
// Step 1:
|
|
1686
|
+
// Step 1: Strip track changes but keep comments for Word conversion
|
|
1687
|
+
// This applies {++insertions++}, removes {--deletions--}, keeps {>>comments<<}
|
|
1688
|
+
markdown = stripAnnotations(markdown, { keepComments: true });
|
|
1689
|
+
|
|
1690
|
+
// Step 2: Replace comments with markers
|
|
1687
1691
|
const spinMarkers = fmt.spinner('Preparing markers...').start();
|
|
1688
1692
|
const { markedMarkdown, comments } = prepareMarkdownWithMarkers(markdown);
|
|
1689
1693
|
spinMarkers.stop();
|
|
@@ -1691,11 +1695,11 @@ program
|
|
|
1691
1695
|
if (comments.length === 0) {
|
|
1692
1696
|
console.log(chalk.yellow('\nNo comments found - skipping comments DOCX'));
|
|
1693
1697
|
} else {
|
|
1694
|
-
// Step
|
|
1698
|
+
// Step 3: Write marked markdown to temp file
|
|
1695
1699
|
const markedPath = path.join(dir, '.paper-marked.md');
|
|
1696
1700
|
fs.writeFileSync(markedPath, markedMarkdown, 'utf-8');
|
|
1697
1701
|
|
|
1698
|
-
// Step
|
|
1702
|
+
// Step 4: Build DOCX from marked markdown using pandoc
|
|
1699
1703
|
const spinBuild = fmt.spinner('Building marked DOCX...').start();
|
|
1700
1704
|
const markedDocxPath = path.join(dir, '.paper-marked.docx');
|
|
1701
1705
|
const pandocResult = await runPandoc(markedPath, 'docx', config, { ...options, outputPath: markedDocxPath });
|
|
@@ -1704,7 +1708,7 @@ program
|
|
|
1704
1708
|
if (!pandocResult.success) {
|
|
1705
1709
|
console.error(chalk.yellow(`\nWarning: Could not build marked DOCX: ${pandocResult.error}`));
|
|
1706
1710
|
} else {
|
|
1707
|
-
// Step
|
|
1711
|
+
// Step 5: Replace markers with comment ranges
|
|
1708
1712
|
const commentsDocxPath = docxResult.outputPath.replace(/\.docx$/, '_comments.docx');
|
|
1709
1713
|
const spinInject = fmt.spinner('Injecting comments at markers...').start();
|
|
1710
1714
|
const commentResult = await injectCommentsAtMarkers(markedDocxPath, comments, commentsDocxPath);
|
package/lib/import.js
CHANGED
|
@@ -268,7 +268,8 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
|
|
|
268
268
|
|
|
269
269
|
if (occurrences.length === 1) {
|
|
270
270
|
// Unique match - easy case
|
|
271
|
-
|
|
271
|
+
// Position at START of anchor (comment goes before, anchor gets marked)
|
|
272
|
+
return { ...c, pos: occurrences[0], anchorText: anchor, anchorEnd: occurrences[0] + anchor.length };
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
// Multiple occurrences - use context for disambiguation
|
|
@@ -321,16 +322,26 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
|
|
|
321
322
|
// Mark this position as used for tie-breaking subsequent comments
|
|
322
323
|
usedPositions.add(bestIdx);
|
|
323
324
|
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
// Position at START of anchor (comment goes before, anchor gets marked)
|
|
326
|
+
return { ...c, pos: bestIdx, anchorText: anchor, anchorEnd: bestIdx + anchor.length };
|
|
327
|
+
}).filter((c) => c.pos >= 0);
|
|
326
328
|
|
|
327
329
|
// Sort by position descending (insert from end to avoid offset issues)
|
|
328
330
|
commentsWithPositions.sort((a, b) => b.pos - a.pos);
|
|
329
331
|
|
|
330
|
-
// Insert each comment
|
|
332
|
+
// Insert each comment with anchor marking
|
|
331
333
|
for (const c of commentsWithPositions) {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
+
const comment = `{>>${c.author}: ${c.text}<<}`;
|
|
335
|
+
if (c.anchorText && c.anchorEnd) {
|
|
336
|
+
// Replace anchor text with: {>>comment<<}[anchor]{.mark}
|
|
337
|
+
const before = result.slice(0, c.pos);
|
|
338
|
+
const anchor = result.slice(c.pos, c.anchorEnd);
|
|
339
|
+
const after = result.slice(c.anchorEnd);
|
|
340
|
+
result = before + comment + `[${anchor}]{.mark}` + after;
|
|
341
|
+
} else {
|
|
342
|
+
// No anchor - just insert comment at position
|
|
343
|
+
result = result.slice(0, c.pos) + ` ${comment}` + result.slice(c.pos);
|
|
344
|
+
}
|
|
334
345
|
}
|
|
335
346
|
|
|
336
347
|
// Log warnings unless quiet mode
|
package/lib/wordcomments.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Word comment injection with reply threading
|
|
3
3
|
*
|
|
4
4
|
* Flow:
|
|
5
|
-
* 1. prepareMarkdownWithMarkers() - Parse comments, detect
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
5
|
+
* 1. prepareMarkdownWithMarkers() - Parse comments, detect reply relationships
|
|
6
|
+
* - First comment in a cluster = parent (gets markers: ⟦CMS:n⟧anchor⟦CME:n⟧)
|
|
7
|
+
* - Subsequent adjacent comments = replies (no markers, attach to parent)
|
|
8
8
|
* 2. Pandoc converts to DOCX
|
|
9
9
|
* 3. injectCommentsAtMarkers() - Insert comment ranges for parents only
|
|
10
10
|
* - Replies go in comments.xml with parent reference in commentsExtended.xml
|
|
@@ -71,50 +71,41 @@ export function prepareMarkdownWithMarkers(markdown) {
|
|
|
71
71
|
return { markedMarkdown: markdown, comments: [] };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// Detect reply relationships
|
|
74
|
+
// Detect reply relationships based on adjacency
|
|
75
|
+
// First comment in a cluster = parent, all subsequent = replies to that parent
|
|
75
76
|
// Comments are "adjacent" if there's only whitespace between them (< 50 chars)
|
|
76
77
|
const ADJACENT_THRESHOLD = 50;
|
|
77
78
|
const comments = [];
|
|
78
|
-
let
|
|
79
|
+
let clusterParentIdx = -1; // Index of first comment in current cluster
|
|
79
80
|
let lastCommentEnd = -1;
|
|
80
81
|
|
|
81
82
|
for (let i = 0; i < rawMatches.length; i++) {
|
|
82
83
|
const m = rawMatches[i];
|
|
83
|
-
const isGuy = m.author === 'Guy Colling';
|
|
84
|
-
const isGilles = m.author === 'Gilles Colling';
|
|
85
84
|
|
|
86
85
|
// Check if this comment is adjacent to the previous one
|
|
87
86
|
const gap = lastCommentEnd >= 0 ? m.start - lastCommentEnd : Infinity;
|
|
88
87
|
const isAdjacent = gap < ADJACENT_THRESHOLD;
|
|
89
88
|
|
|
90
|
-
// Reset
|
|
89
|
+
// Reset cluster if there's a gap (comments not in same cluster)
|
|
91
90
|
if (!isAdjacent) {
|
|
92
|
-
|
|
91
|
+
clusterParentIdx = -1;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
if (
|
|
94
|
+
if (clusterParentIdx === -1) {
|
|
95
|
+
// First comment in cluster = parent (regardless of author)
|
|
96
96
|
comments.push({
|
|
97
97
|
...m,
|
|
98
98
|
isReply: false,
|
|
99
99
|
parentIdx: null,
|
|
100
100
|
commentIdx: comments.length
|
|
101
101
|
});
|
|
102
|
-
|
|
103
|
-
} else if (isGilles && lastGuyIdx >= 0 && isAdjacent) {
|
|
104
|
-
// Gilles immediately following Guy (same cluster) = reply
|
|
105
|
-
comments.push({
|
|
106
|
-
...m,
|
|
107
|
-
isReply: true,
|
|
108
|
-
parentIdx: lastGuyIdx,
|
|
109
|
-
commentIdx: comments.length
|
|
110
|
-
});
|
|
111
|
-
// Don't reset lastGuyIdx - multiple replies could follow
|
|
102
|
+
clusterParentIdx = comments.length - 1;
|
|
112
103
|
} else {
|
|
113
|
-
//
|
|
104
|
+
// Subsequent comment in cluster = reply to first comment
|
|
114
105
|
comments.push({
|
|
115
106
|
...m,
|
|
116
|
-
isReply:
|
|
117
|
-
parentIdx:
|
|
107
|
+
isReply: true,
|
|
108
|
+
parentIdx: clusterParentIdx,
|
|
118
109
|
commentIdx: comments.length
|
|
119
110
|
});
|
|
120
111
|
}
|
|
@@ -122,6 +113,21 @@ export function prepareMarkdownWithMarkers(markdown) {
|
|
|
122
113
|
lastCommentEnd = m.end;
|
|
123
114
|
}
|
|
124
115
|
|
|
116
|
+
// Propagate anchors from replies to parents
|
|
117
|
+
// If a reply has an anchor but its parent doesn't, move the anchor to the parent
|
|
118
|
+
// Track flags for special handling during marker generation
|
|
119
|
+
for (const c of comments) {
|
|
120
|
+
if (c.isReply && c.anchor && c.parentIdx !== null) {
|
|
121
|
+
const parent = comments[c.parentIdx];
|
|
122
|
+
if (!parent.anchor) {
|
|
123
|
+
parent.anchor = c.anchor;
|
|
124
|
+
parent.anchorFromReply = true; // Parent's anchor came from a reply (markers placed by reply)
|
|
125
|
+
c.placesParentMarkers = true; // This reply should place the parent's markers
|
|
126
|
+
c.anchor = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
// Build marked markdown - only parent comments get markers
|
|
126
132
|
// Process from end to start to preserve positions
|
|
127
133
|
let markedMarkdown = markdown;
|
|
@@ -131,12 +137,57 @@ export function prepareMarkdownWithMarkers(markdown) {
|
|
|
131
137
|
|
|
132
138
|
if (c.isReply) {
|
|
133
139
|
// Reply: remove from document entirely (will be in comments.xml only)
|
|
134
|
-
|
|
140
|
+
// Also consume leading whitespace to avoid double spaces
|
|
141
|
+
let removeStart = c.start;
|
|
142
|
+
while (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1])) {
|
|
143
|
+
removeStart--;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// If this reply places parent's markers (anchor was propagated)
|
|
147
|
+
if (c.placesParentMarkers && c.parentIdx !== null) {
|
|
148
|
+
// Extract anchor text from the original match
|
|
149
|
+
const anchorMatch = c.fullMatch.match(/\[([^\]]+)\]\{\.mark\}$/);
|
|
150
|
+
if (anchorMatch) {
|
|
151
|
+
const anchorText = anchorMatch[1];
|
|
152
|
+
// Output markers with PARENT's index around the anchor text
|
|
153
|
+
const parentIdx = c.parentIdx;
|
|
154
|
+
const replacement = `${MARKER_START_PREFIX}${parentIdx}${MARKER_SUFFIX}${anchorText}${MARKER_END_PREFIX}${parentIdx}${MARKER_SUFFIX}`;
|
|
155
|
+
markedMarkdown = markedMarkdown.slice(0, removeStart) + replacement + markedMarkdown.slice(c.end);
|
|
156
|
+
} else {
|
|
157
|
+
markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
|
|
161
|
+
}
|
|
135
162
|
} else {
|
|
136
|
-
// Parent comment
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
// Parent comment
|
|
164
|
+
if (c.anchorFromReply) {
|
|
165
|
+
// Anchor markers are placed by the reply, just remove this comment
|
|
166
|
+
let removeStart = c.start;
|
|
167
|
+
while (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1])) {
|
|
168
|
+
removeStart--;
|
|
169
|
+
}
|
|
170
|
+
markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
|
|
171
|
+
} else {
|
|
172
|
+
// Normal case: replace with markers
|
|
173
|
+
let anchor = c.anchor || '';
|
|
174
|
+
|
|
175
|
+
// If no anchor, try to use the next word as fallback to avoid empty ranges
|
|
176
|
+
if (!anchor) {
|
|
177
|
+
const afterComment = markedMarkdown.slice(c.end);
|
|
178
|
+
// Match the next word (skip leading whitespace/punctuation)
|
|
179
|
+
const nextWordMatch = afterComment.match(/^\s*([a-zA-Z0-9]+)/);
|
|
180
|
+
if (nextWordMatch) {
|
|
181
|
+
anchor = nextWordMatch[1];
|
|
182
|
+
// Also need to consume the matched text from afterComment
|
|
183
|
+
c.consumeAfter = nextWordMatch[0].length;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`;
|
|
188
|
+
const endPos = c.end + (c.consumeAfter || 0);
|
|
189
|
+
markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(endPos);
|
|
190
|
+
}
|
|
140
191
|
}
|
|
141
192
|
}
|
|
142
193
|
|
|
@@ -239,11 +290,7 @@ function createCommentsExtensibleXml(comments) {
|
|
|
239
290
|
return xml;
|
|
240
291
|
}
|
|
241
292
|
|
|
242
|
-
//
|
|
243
|
-
const AUTHOR_USER_IDS = {
|
|
244
|
-
'Guy Colling': '9ff4d97962428673',
|
|
245
|
-
'Gilles Colling': '46e930a4c4b85dfd',
|
|
246
|
-
};
|
|
293
|
+
// Generate deterministic user IDs for authors (no hardcoded personal data)
|
|
247
294
|
|
|
248
295
|
function createPeopleXml(comments) {
|
|
249
296
|
// Extract unique authors
|
|
@@ -265,7 +312,7 @@ function createPeopleXml(comments) {
|
|
|
265
312
|
xml += 'mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh">';
|
|
266
313
|
|
|
267
314
|
for (const author of authors) {
|
|
268
|
-
const userId =
|
|
315
|
+
const userId = generateUserId(author);
|
|
269
316
|
xml += `<w15:person w15:author="${escapeXml(author)}">`;
|
|
270
317
|
xml += `<w15:presenceInfo w15:providerId="Windows Live" w15:userId="${userId}"/>`;
|
|
271
318
|
xml += `</w15:person>`;
|
|
@@ -366,9 +413,14 @@ export async function injectCommentsAtMarkers(docxPath, comments, outputPath) {
|
|
|
366
413
|
const endInText = fullText.indexOf(endMarker);
|
|
367
414
|
if (startInText === -1 || endInText === -1) continue;
|
|
368
415
|
|
|
369
|
-
|
|
416
|
+
let textBefore = fullText.slice(0, startInText);
|
|
370
417
|
const anchorText = fullText.slice(startInText + startMarker.length, endInText);
|
|
371
|
-
|
|
418
|
+
let textAfter = fullText.slice(endInText + endMarker.length);
|
|
419
|
+
|
|
420
|
+
// When anchor is empty, normalize double spaces to single space
|
|
421
|
+
if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) {
|
|
422
|
+
textAfter = textAfter.slice(1); // Remove leading space from textAfter
|
|
423
|
+
}
|
|
372
424
|
|
|
373
425
|
// Build replacement
|
|
374
426
|
let replacement = '';
|