docrev 0.9.11 → 0.9.14

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.
Files changed (138) hide show
  1. package/.claude/settings.local.json +9 -9
  2. package/.gitattributes +1 -1
  3. package/CHANGELOG.md +149 -149
  4. package/PLAN-tables-and-postprocess.md +850 -850
  5. package/README.md +391 -391
  6. package/bin/rev.js +11 -11
  7. package/bin/rev.ts +145 -145
  8. package/completions/rev.bash +127 -127
  9. package/completions/rev.ps1 +210 -210
  10. package/completions/rev.zsh +207 -207
  11. package/dev_notes/stress2/build_adversarial.ts +186 -186
  12. package/dev_notes/stress2/drift_matcher.ts +62 -62
  13. package/dev_notes/stress2/probe_anchors.ts +35 -35
  14. package/dev_notes/stress2/project/discussion.before.md +3 -3
  15. package/dev_notes/stress2/project/discussion.md +3 -3
  16. package/dev_notes/stress2/project/methods.before.md +20 -20
  17. package/dev_notes/stress2/project/methods.md +20 -20
  18. package/dev_notes/stress2/project/rev.yaml +5 -5
  19. package/dev_notes/stress2/project/sections.yaml +4 -4
  20. package/dev_notes/stress2/sections.yaml +5 -5
  21. package/dev_notes/stress2/trace_placement.ts +50 -50
  22. package/dev_notes/stresstest_boundaries.ts +27 -27
  23. package/dev_notes/stresstest_drift_apply.ts +43 -43
  24. package/dev_notes/stresstest_drift_compare.ts +43 -43
  25. package/dev_notes/stresstest_drift_v2.ts +54 -54
  26. package/dev_notes/stresstest_inspect.ts +54 -54
  27. package/dev_notes/stresstest_pstyle.ts +55 -55
  28. package/dev_notes/stresstest_section_debug.ts +23 -23
  29. package/dev_notes/stresstest_split.ts +70 -70
  30. package/dev_notes/stresstest_trace.ts +19 -19
  31. package/dev_notes/stresstest_verify_no_overwrite.ts +40 -40
  32. package/dist/lib/build.d.ts +50 -1
  33. package/dist/lib/build.d.ts.map +1 -1
  34. package/dist/lib/build.js +80 -30
  35. package/dist/lib/build.js.map +1 -1
  36. package/dist/lib/commands/build.d.ts.map +1 -1
  37. package/dist/lib/commands/build.js +38 -5
  38. package/dist/lib/commands/build.js.map +1 -1
  39. package/dist/lib/commands/utilities.js +164 -164
  40. package/dist/lib/commands/word-tools.js +8 -8
  41. package/dist/lib/grammar.js +3 -3
  42. package/dist/lib/import.d.ts.map +1 -1
  43. package/dist/lib/import.js +146 -24
  44. package/dist/lib/import.js.map +1 -1
  45. package/dist/lib/pdf-comments.js +44 -44
  46. package/dist/lib/plugins.js +57 -57
  47. package/dist/lib/pptx-themes.js +115 -115
  48. package/dist/lib/spelling.js +2 -2
  49. package/dist/lib/templates.js +387 -387
  50. package/dist/lib/themes.js +51 -51
  51. package/dist/lib/types.d.ts +20 -0
  52. package/dist/lib/types.d.ts.map +1 -1
  53. package/dist/lib/word-extraction.d.ts +6 -0
  54. package/dist/lib/word-extraction.d.ts.map +1 -1
  55. package/dist/lib/word-extraction.js +46 -3
  56. package/dist/lib/word-extraction.js.map +1 -1
  57. package/dist/lib/wordcomments.d.ts.map +1 -1
  58. package/dist/lib/wordcomments.js +23 -5
  59. package/dist/lib/wordcomments.js.map +1 -1
  60. package/eslint.config.js +27 -27
  61. package/lib/anchor-match.ts +276 -276
  62. package/lib/annotations.ts +644 -644
  63. package/lib/build.ts +1300 -1227
  64. package/lib/citations.ts +160 -160
  65. package/lib/commands/build.ts +833 -801
  66. package/lib/commands/citations.ts +515 -515
  67. package/lib/commands/comments.ts +1050 -1050
  68. package/lib/commands/context.ts +174 -174
  69. package/lib/commands/core.ts +309 -309
  70. package/lib/commands/doi.ts +435 -435
  71. package/lib/commands/file-ops.ts +372 -372
  72. package/lib/commands/history.ts +320 -320
  73. package/lib/commands/index.ts +87 -87
  74. package/lib/commands/init.ts +259 -259
  75. package/lib/commands/merge-resolve.ts +378 -378
  76. package/lib/commands/preview.ts +178 -178
  77. package/lib/commands/project-info.ts +244 -244
  78. package/lib/commands/quality.ts +517 -517
  79. package/lib/commands/response.ts +454 -454
  80. package/lib/commands/section-boundaries.ts +82 -82
  81. package/lib/commands/sections.ts +451 -451
  82. package/lib/commands/sync.ts +706 -706
  83. package/lib/commands/text-ops.ts +449 -449
  84. package/lib/commands/utilities.ts +448 -448
  85. package/lib/commands/verify-anchors.ts +272 -272
  86. package/lib/commands/word-tools.ts +340 -340
  87. package/lib/comment-realign.ts +517 -517
  88. package/lib/config.ts +84 -84
  89. package/lib/crossref.ts +781 -781
  90. package/lib/csl.ts +191 -191
  91. package/lib/dependencies.ts +98 -98
  92. package/lib/diff-engine.ts +465 -465
  93. package/lib/doi-cache.ts +115 -115
  94. package/lib/doi.ts +897 -897
  95. package/lib/equations.ts +506 -506
  96. package/lib/errors.ts +346 -346
  97. package/lib/format.ts +541 -541
  98. package/lib/git.ts +326 -326
  99. package/lib/grammar.ts +303 -303
  100. package/lib/image-registry.ts +180 -180
  101. package/lib/import.ts +911 -792
  102. package/lib/journals.ts +543 -543
  103. package/lib/merge.ts +633 -633
  104. package/lib/orcid.ts +144 -144
  105. package/lib/pdf-comments.ts +263 -263
  106. package/lib/pdf-import.ts +524 -524
  107. package/lib/plugins.ts +362 -362
  108. package/lib/postprocess.ts +188 -188
  109. package/lib/pptx-color-filter.lua +37 -37
  110. package/lib/pptx-template.ts +469 -469
  111. package/lib/pptx-themes.ts +483 -483
  112. package/lib/protect-restore.ts +520 -520
  113. package/lib/rate-limiter.ts +94 -94
  114. package/lib/response.ts +197 -197
  115. package/lib/restore-references.ts +240 -240
  116. package/lib/review.ts +327 -327
  117. package/lib/schema.ts +417 -417
  118. package/lib/scientific-words.ts +73 -73
  119. package/lib/sections.ts +335 -335
  120. package/lib/slides.ts +756 -756
  121. package/lib/spelling.ts +334 -334
  122. package/lib/templates.ts +526 -526
  123. package/lib/themes.ts +742 -742
  124. package/lib/trackchanges.ts +247 -247
  125. package/lib/tui.ts +450 -450
  126. package/lib/types.ts +550 -530
  127. package/lib/undo.ts +250 -250
  128. package/lib/utils.ts +69 -69
  129. package/lib/variables.ts +179 -179
  130. package/lib/word-extraction.ts +806 -759
  131. package/lib/word.ts +643 -643
  132. package/lib/wordcomments.ts +817 -798
  133. package/package.json +137 -137
  134. package/scripts/postbuild.js +28 -28
  135. package/skill/REFERENCE.md +431 -431
  136. package/skill/SKILL.md +258 -258
  137. package/tsconfig.json +26 -26
  138. package/types/index.d.ts +525 -525
@@ -229,179 +229,179 @@ export function register(program, pkg) {
229
229
  }
230
230
  // Helper functions for help text
231
231
  function showFullHelp(pkg) {
232
- console.log(`
233
- ${chalk.bold.cyan('rev')} ${chalk.dim(`v${pkg?.version || 'unknown'}`)} - Revision workflow for Word ↔ Markdown round-trips
234
-
235
- ${chalk.bold('DESCRIPTION')}
236
- Handle reviewer feedback when collaborating on academic papers.
237
- Import changes from Word, review them interactively, and preserve
238
- comments for discussion with Claude.
239
-
240
- ${chalk.bold('GLOBAL OPTIONS')}
241
-
242
- ${chalk.bold('--no-color')} Disable colored output
243
- ${chalk.bold('-q, --quiet')} Suppress non-essential output
244
- ${chalk.bold('--json')} Output in JSON format (for scripting)
245
-
246
- ${chalk.bold('TYPICAL WORKFLOW')}
247
-
248
- ${chalk.dim('1.')} Build and send: ${chalk.green('rev build docx')} ${chalk.dim('(or rev b docx)')}
249
- ${chalk.dim('2.')} Reviewers return ${chalk.yellow('reviewed.docx')} with edits and comments
250
- ${chalk.dim('3.')} Sync their feedback: ${chalk.green('rev sync reviewed.docx')}
251
- ${chalk.dim('4.')} Work through comments: ${chalk.green('rev next')} ${chalk.dim('(n)')} / ${chalk.green('rev todo')} ${chalk.dim('(t)')}
252
- ${chalk.dim('5.')} Accept/reject changes: ${chalk.green('rev accept -a')} ${chalk.dim('(a)')} or ${chalk.green('rev review')}
253
- ${chalk.dim('6.')} Rebuild: ${chalk.green('rev build docx')}
254
- ${chalk.dim('7.')} Archive old files: ${chalk.green('rev archive')}
255
-
256
- ${chalk.bold('MORE HELP')}
257
-
258
- rev help workflow Detailed workflow guide
259
- rev help syntax Annotation syntax reference
260
- rev help commands All commands with options
232
+ console.log(`
233
+ ${chalk.bold.cyan('rev')} ${chalk.dim(`v${pkg?.version || 'unknown'}`)} - Revision workflow for Word ↔ Markdown round-trips
234
+
235
+ ${chalk.bold('DESCRIPTION')}
236
+ Handle reviewer feedback when collaborating on academic papers.
237
+ Import changes from Word, review them interactively, and preserve
238
+ comments for discussion with Claude.
239
+
240
+ ${chalk.bold('GLOBAL OPTIONS')}
241
+
242
+ ${chalk.bold('--no-color')} Disable colored output
243
+ ${chalk.bold('-q, --quiet')} Suppress non-essential output
244
+ ${chalk.bold('--json')} Output in JSON format (for scripting)
245
+
246
+ ${chalk.bold('TYPICAL WORKFLOW')}
247
+
248
+ ${chalk.dim('1.')} Build and send: ${chalk.green('rev build docx')} ${chalk.dim('(or rev b docx)')}
249
+ ${chalk.dim('2.')} Reviewers return ${chalk.yellow('reviewed.docx')} with edits and comments
250
+ ${chalk.dim('3.')} Sync their feedback: ${chalk.green('rev sync reviewed.docx')}
251
+ ${chalk.dim('4.')} Work through comments: ${chalk.green('rev next')} ${chalk.dim('(n)')} / ${chalk.green('rev todo')} ${chalk.dim('(t)')}
252
+ ${chalk.dim('5.')} Accept/reject changes: ${chalk.green('rev accept -a')} ${chalk.dim('(a)')} or ${chalk.green('rev review')}
253
+ ${chalk.dim('6.')} Rebuild: ${chalk.green('rev build docx')}
254
+ ${chalk.dim('7.')} Archive old files: ${chalk.green('rev archive')}
255
+
256
+ ${chalk.bold('MORE HELP')}
257
+
258
+ rev help workflow Detailed workflow guide
259
+ rev help syntax Annotation syntax reference
260
+ rev help commands All commands with options
261
261
  `);
262
262
  }
263
263
  function showWorkflowHelp() {
264
- console.log(`
265
- ${chalk.bold.cyan('rev')} ${chalk.dim('- Workflow Guide')}
266
-
267
- ${chalk.bold('OVERVIEW')}
268
-
269
- The rev workflow solves a common problem: you write in Markdown,
270
- but collaborators review in Word. When they return edited documents,
271
- you need to merge their changes back into your source files.
272
-
273
- ${chalk.bold('STEP 1: BUILD & SEND')}
274
-
275
- ${chalk.green('rev build docx')}
276
- ${chalk.dim('# Send the .docx to reviewers')}
277
-
278
- ${chalk.bold('STEP 2: RECEIVE FEEDBACK')}
279
-
280
- Reviewers edit the document, adding:
281
- ${chalk.dim('•')} Track changes (insertions, deletions)
282
- ${chalk.dim('•')} Comments (questions, suggestions)
283
-
284
- ${chalk.bold('STEP 3: SYNC CHANGES')}
285
-
286
- ${chalk.green('rev sync reviewed.docx')}
287
- ${chalk.dim('# Or just: rev sync (auto-detects most recent .docx)')}
288
-
289
- Your markdown files now contain their feedback as annotations.
290
-
291
- ${chalk.bold('STEP 4: WORK THROUGH COMMENTS')}
292
-
293
- ${chalk.green('rev todo')} ${chalk.dim('# See all pending comments')}
294
- ${chalk.green('rev next')} ${chalk.dim('# Show next pending comment')}
295
- ${chalk.green('rev reply file.md -n 1 -m "Done"')}
296
- ${chalk.green('rev resolve file.md -n 1')}
297
-
298
- ${chalk.bold('STEP 5: ACCEPT/REJECT CHANGES')}
299
-
300
- ${chalk.green('rev accept file.md -a')} ${chalk.dim('# Accept all changes')}
301
- ${chalk.green('rev reject file.md -n 2')} ${chalk.dim('# Reject specific change')}
302
- ${chalk.dim('# Or use interactive mode:')}
303
- ${chalk.green('rev review file.md')}
304
-
305
- ${chalk.bold('STEP 6: REBUILD')}
306
-
307
- ${chalk.green('rev build docx')}
308
- ${chalk.green('rev build docx --dual')} ${chalk.dim('# Clean + comments version')}
309
-
310
- ${chalk.bold('STEP 7: ARCHIVE & REPEAT')}
311
-
312
- ${chalk.green('rev archive')} ${chalk.dim('# Move reviewer files to archive/')}
313
- ${chalk.dim('# Send new .docx, repeat cycle')}
264
+ console.log(`
265
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Workflow Guide')}
266
+
267
+ ${chalk.bold('OVERVIEW')}
268
+
269
+ The rev workflow solves a common problem: you write in Markdown,
270
+ but collaborators review in Word. When they return edited documents,
271
+ you need to merge their changes back into your source files.
272
+
273
+ ${chalk.bold('STEP 1: BUILD & SEND')}
274
+
275
+ ${chalk.green('rev build docx')}
276
+ ${chalk.dim('# Send the .docx to reviewers')}
277
+
278
+ ${chalk.bold('STEP 2: RECEIVE FEEDBACK')}
279
+
280
+ Reviewers edit the document, adding:
281
+ ${chalk.dim('•')} Track changes (insertions, deletions)
282
+ ${chalk.dim('•')} Comments (questions, suggestions)
283
+
284
+ ${chalk.bold('STEP 3: SYNC CHANGES')}
285
+
286
+ ${chalk.green('rev sync reviewed.docx')}
287
+ ${chalk.dim('# Or just: rev sync (auto-detects most recent .docx)')}
288
+
289
+ Your markdown files now contain their feedback as annotations.
290
+
291
+ ${chalk.bold('STEP 4: WORK THROUGH COMMENTS')}
292
+
293
+ ${chalk.green('rev todo')} ${chalk.dim('# See all pending comments')}
294
+ ${chalk.green('rev next')} ${chalk.dim('# Show next pending comment')}
295
+ ${chalk.green('rev reply file.md -n 1 -m "Done"')}
296
+ ${chalk.green('rev resolve file.md -n 1')}
297
+
298
+ ${chalk.bold('STEP 5: ACCEPT/REJECT CHANGES')}
299
+
300
+ ${chalk.green('rev accept file.md -a')} ${chalk.dim('# Accept all changes')}
301
+ ${chalk.green('rev reject file.md -n 2')} ${chalk.dim('# Reject specific change')}
302
+ ${chalk.dim('# Or use interactive mode:')}
303
+ ${chalk.green('rev review file.md')}
304
+
305
+ ${chalk.bold('STEP 6: REBUILD')}
306
+
307
+ ${chalk.green('rev build docx')}
308
+ ${chalk.green('rev build docx --dual')} ${chalk.dim('# Clean + comments version')}
309
+
310
+ ${chalk.bold('STEP 7: ARCHIVE & REPEAT')}
311
+
312
+ ${chalk.green('rev archive')} ${chalk.dim('# Move reviewer files to archive/')}
313
+ ${chalk.dim('# Send new .docx, repeat cycle')}
314
314
  `);
315
315
  }
316
316
  function showSyntaxHelp() {
317
- console.log(`
318
- ${chalk.bold.cyan('rev')} ${chalk.dim('- Annotation Syntax (CriticMarkup)')}
319
-
320
- ${chalk.bold('INSERTIONS')}
321
-
322
- Syntax: ${chalk.green('{++inserted text++}')}
323
- Meaning: This text was added by the reviewer
324
-
325
- Example:
326
- We ${chalk.green('{++specifically++}')} focused on neophytes.
327
- → Reviewer added the word "specifically"
328
-
329
- ${chalk.bold('DELETIONS')}
330
-
331
- Syntax: ${chalk.red('{--deleted text--}')}
332
- Meaning: This text was removed by the reviewer
333
-
334
- Example:
335
- We focused on ${chalk.red('{--recent--}')} neophytes.
336
- → Reviewer removed the word "recent"
337
-
338
- ${chalk.bold('SUBSTITUTIONS')}
339
-
340
- Syntax: ${chalk.yellow('{~~old text~>new text~~}')}
341
- Meaning: Text was changed from old to new
342
-
343
- Example:
344
- The effect was ${chalk.yellow('{~~significant~>substantial~~}')}.
345
- → Reviewer changed "significant" to "substantial"
346
-
347
- ${chalk.bold('COMMENTS')}
348
-
349
- Syntax: ${chalk.blue('{>>Author: comment text<<}')}
350
- Meaning: Reviewer left a comment at this location
351
-
352
- Example:
353
- The results were significant. ${chalk.blue('{>>Dr. Smith: Add p-value<<}')}
354
- → Dr. Smith commented asking for a p-value
355
-
356
- Comments are placed ${chalk.bold('after')} the text they reference.
317
+ console.log(`
318
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Annotation Syntax (CriticMarkup)')}
319
+
320
+ ${chalk.bold('INSERTIONS')}
321
+
322
+ Syntax: ${chalk.green('{++inserted text++}')}
323
+ Meaning: This text was added by the reviewer
324
+
325
+ Example:
326
+ We ${chalk.green('{++specifically++}')} focused on neophytes.
327
+ → Reviewer added the word "specifically"
328
+
329
+ ${chalk.bold('DELETIONS')}
330
+
331
+ Syntax: ${chalk.red('{--deleted text--}')}
332
+ Meaning: This text was removed by the reviewer
333
+
334
+ Example:
335
+ We focused on ${chalk.red('{--recent--}')} neophytes.
336
+ → Reviewer removed the word "recent"
337
+
338
+ ${chalk.bold('SUBSTITUTIONS')}
339
+
340
+ Syntax: ${chalk.yellow('{~~old text~>new text~~}')}
341
+ Meaning: Text was changed from old to new
342
+
343
+ Example:
344
+ The effect was ${chalk.yellow('{~~significant~>substantial~~}')}.
345
+ → Reviewer changed "significant" to "substantial"
346
+
347
+ ${chalk.bold('COMMENTS')}
348
+
349
+ Syntax: ${chalk.blue('{>>Author: comment text<<}')}
350
+ Meaning: Reviewer left a comment at this location
351
+
352
+ Example:
353
+ The results were significant. ${chalk.blue('{>>Dr. Smith: Add p-value<<}')}
354
+ → Dr. Smith commented asking for a p-value
355
+
356
+ Comments are placed ${chalk.bold('after')} the text they reference.
357
357
  `);
358
358
  }
359
359
  function showCommandsHelp() {
360
- console.log(`
361
- ${chalk.bold.cyan('rev')} ${chalk.dim('- Command Reference')}
362
-
363
- ${chalk.bold('rev import')} <docx> <original-md>
364
-
365
- Import changes from a Word document by comparing against your
366
- original Markdown source.
367
-
368
- ${chalk.bold('Arguments:')}
369
- docx Word document from reviewer
370
- original-md Your original Markdown file
371
-
372
- ${chalk.bold('Options:')}
373
- -o, --output <file> Write to different file (default: overwrites original)
374
- -a, --author <name> Author name for changes (default: "Reviewer")
375
- --dry-run Preview changes without saving
376
-
377
- ${chalk.bold('rev review')} <file>
378
-
379
- Interactively review and accept/reject track changes.
380
- Comments are preserved; only track changes are processed.
381
-
382
- ${chalk.bold('Keys:')}
383
- a Accept this change
384
- r Reject this change
385
- s Skip (decide later)
386
- A Accept all remaining changes
387
- L Reject all remaining changes
388
- q Quit without saving
389
-
390
- ${chalk.bold('rev strip')} <file>
391
-
392
- Remove annotations, outputting clean Markdown.
393
- Track changes are applied (insertions kept, deletions removed).
394
-
395
- ${chalk.bold('Options:')}
396
- -o, --output <file> Write to file (default: stdout)
397
- -c, --keep-comments Keep comment annotations
398
-
399
- ${chalk.bold('rev help')} [topic]
400
-
401
- Show help. Optional topics:
402
- workflow Step-by-step workflow guide
403
- syntax Annotation syntax reference
404
- commands This command reference
360
+ console.log(`
361
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Command Reference')}
362
+
363
+ ${chalk.bold('rev import')} <docx> <original-md>
364
+
365
+ Import changes from a Word document by comparing against your
366
+ original Markdown source.
367
+
368
+ ${chalk.bold('Arguments:')}
369
+ docx Word document from reviewer
370
+ original-md Your original Markdown file
371
+
372
+ ${chalk.bold('Options:')}
373
+ -o, --output <file> Write to different file (default: overwrites original)
374
+ -a, --author <name> Author name for changes (default: "Reviewer")
375
+ --dry-run Preview changes without saving
376
+
377
+ ${chalk.bold('rev review')} <file>
378
+
379
+ Interactively review and accept/reject track changes.
380
+ Comments are preserved; only track changes are processed.
381
+
382
+ ${chalk.bold('Keys:')}
383
+ a Accept this change
384
+ r Reject this change
385
+ s Skip (decide later)
386
+ A Accept all remaining changes
387
+ L Reject all remaining changes
388
+ q Quit without saving
389
+
390
+ ${chalk.bold('rev strip')} <file>
391
+
392
+ Remove annotations, outputting clean Markdown.
393
+ Track changes are applied (insertions kept, deletions removed).
394
+
395
+ ${chalk.bold('Options:')}
396
+ -o, --output <file> Write to file (default: stdout)
397
+ -c, --keep-comments Keep comment annotations
398
+
399
+ ${chalk.bold('rev help')} [topic]
400
+
401
+ Show help. Optional topics:
402
+ workflow Step-by-step workflow guide
403
+ syntax Annotation syntax reference
404
+ commands This command reference
405
405
  `);
406
406
  }
407
407
  //# sourceMappingURL=utilities.js.map
@@ -50,16 +50,16 @@ export function register(program) {
50
50
  }
51
51
  }
52
52
  else {
53
- commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
54
- <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
53
+ commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
54
+ <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
55
55
  </w:comments>`;
56
56
  }
57
57
  const author = options.author || getUserName() || 'Claude';
58
58
  const date = new Date().toISOString();
59
59
  const commentId = nextCommentId;
60
60
  // Add comment to comments.xml
61
- const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
62
- <w:p><w:r><w:t>${options.message}</w:t></w:r></w:p>
61
+ const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
62
+ <w:p><w:r><w:t>${options.message}</w:t></w:r></w:p>
63
63
  </w:comment>`;
64
64
  commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
65
65
  // Find text and add comment markers
@@ -200,15 +200,15 @@ export function register(program) {
200
200
  }
201
201
  }
202
202
  else {
203
- commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
204
- <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
203
+ commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
204
+ <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
205
205
  </w:comments>`;
206
206
  }
207
207
  const date = new Date().toISOString();
208
208
  const commentId = nextCommentId;
209
209
  // Add comment to comments.xml
210
- const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
211
- <w:p><w:r><w:t>${message}</w:t></w:r></w:p>
210
+ const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
211
+ <w:p><w:r><w:t>${message}</w:t></w:r></w:p>
212
212
  </w:comment>`;
213
213
  commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
214
214
  // Find text and add comment markers
@@ -125,9 +125,9 @@ export function loadDictionary(directory = '.') {
125
125
  */
126
126
  export function saveDictionary(words, directory = '.') {
127
127
  const dictPath = path.join(directory, DEFAULT_DICT_NAME);
128
- const header = `# Custom dictionary for docrev
129
- # Add one word per line
130
- # Lines starting with # are comments
128
+ const header = `# Custom dictionary for docrev
129
+ # Add one word per line
130
+ # Lines starting with # are comments
131
131
  `;
132
132
  const content = header + [...words].sort().join('\n') + '\n';
133
133
  fs.writeFileSync(dictPath, content, 'utf-8');
@@ -1 +1 @@
1
- {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../lib/import.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../lib/import.ts"],"names":[],"mappings":"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"}
@@ -103,6 +103,44 @@ function pushPastSectionHeading(text, pos) {
103
103
  }
104
104
  return after;
105
105
  }
106
+ /**
107
+ * Snap a position to the nearest whitespace boundary within ±50 chars so a
108
+ * proportional fallback insertion never lands mid-word.
109
+ */
110
+ function snapToWordBoundary(text, pos) {
111
+ if (pos <= 0)
112
+ return 0;
113
+ if (pos >= text.length)
114
+ return text.length;
115
+ if (/\s/.test(text[pos] ?? ''))
116
+ return pos;
117
+ for (let d = 1; d <= 50; d++) {
118
+ if (pos + d < text.length && /\s/.test(text[pos + d] ?? ''))
119
+ return pos + d;
120
+ if (pos - d >= 0 && /\s/.test(text[pos - d] ?? ''))
121
+ return pos - d;
122
+ }
123
+ return pos;
124
+ }
125
+ /**
126
+ * Final-resort placement when every text-matching strategy failed. The docx
127
+ * carries a real `<w:commentRangeStart w:id="N">` marker at a known offset
128
+ * inside its body text — that's a structural anchor, even if the anchored
129
+ * span itself is empty and the surrounding context drifted in the target.
130
+ *
131
+ * Map docPosition into the target markdown proportionally and snap to a word
132
+ * boundary. This is approximate when the document was heavily restructured,
133
+ * but it's strictly better than silently dropping a reviewer's comment: the
134
+ * comment lands in roughly the right neighborhood and the reviewer can
135
+ * relocate it during their next pass.
136
+ */
137
+ function proportionalFallback(anchorData, target) {
138
+ if (anchorData.docLength <= 0)
139
+ return null;
140
+ const proportion = Math.min(anchorData.docPosition / anchorData.docLength, 1.0);
141
+ const rawPos = Math.floor(proportion * target.length);
142
+ return pushPastSectionHeading(target, snapToWordBoundary(target, rawPos));
143
+ }
106
144
  /**
107
145
  * Insert comments into markdown text based on anchor texts with context
108
146
  */
@@ -113,10 +151,42 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
113
151
  let placedCount = 0;
114
152
  const duplicateWarnings = [];
115
153
  const usedPositions = new Set(); // For tie-breaking: track used positions
154
+ // Resolve threading: replies share their parent's anchor in Word, so they
155
+ // must inherit the parent's position and ride alongside it as one cluster.
156
+ // Letting each reply run through anchor scoring scatters the cluster (the
157
+ // same docPosition forces `usedPositions` to push later replies onto a
158
+ // different occurrence), which on re-build looks like independent comments
159
+ // and loses the paraIdParent threading. See gcol33/docrev issue #2.
160
+ const inputById = new Map();
161
+ for (const c of comments)
162
+ inputById.set(c.id, c);
163
+ function rootIdOf(c) {
164
+ let cur = c;
165
+ const seen = new Set();
166
+ while (cur.parentId && !seen.has(cur.id)) {
167
+ seen.add(cur.id);
168
+ const parent = inputById.get(cur.parentId);
169
+ if (!parent || parent === cur)
170
+ break;
171
+ cur = parent;
172
+ }
173
+ return cur.id;
174
+ }
175
+ const replyRootId = new Map();
176
+ for (const c of comments) {
177
+ const root = rootIdOf(c);
178
+ if (root !== c.id)
179
+ replyRootId.set(c.id, root);
180
+ }
116
181
  // Anchor matching primitives live in lib/anchor-match.ts so that
117
182
  // `rev verify-anchors` can use the same strategies for drift reporting.
118
- // Get all positions in order (for sequential tie-breaking)
183
+ // Get all positions in order (for sequential tie-breaking).
184
+ // Replies skip scoring entirely — they piggyback on their root's position
185
+ // in the emit pass below.
119
186
  const commentsWithPositions = comments.map((c) => {
187
+ if (replyRootId.has(c.id)) {
188
+ return { ...c, pos: -1, anchorText: null, strategy: 'reply' };
189
+ }
120
190
  const anchorData = anchors.get(c.id);
121
191
  if (!anchorData) {
122
192
  unmatchedCount++;
@@ -212,12 +282,28 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
212
282
  return { ...c, pos: occurrences[0], anchorText: null, isEmpty: true };
213
283
  }
214
284
  }
285
+ // Last resort: docx carried a structural marker at docPosition; map
286
+ // it proportionally into the target so the comment isn't dropped.
287
+ if (typeof anchorData === 'object') {
288
+ const fallback = proportionalFallback(anchorData, result);
289
+ if (fallback !== null) {
290
+ return { ...c, pos: fallback, anchorText: null, isEmpty: true, strategy: 'proportional-fallback' };
291
+ }
292
+ }
215
293
  unmatchedCount++;
216
294
  return { ...c, pos: -1, anchorText: null, isEmpty: true };
217
295
  }
218
296
  // Text-based matching strategies
219
297
  const { occurrences, matchedAnchor, strategy, stripped } = findAnchorInText(anchor, result, before, after);
220
298
  if (occurrences.length === 0) {
299
+ // Same last-resort as the empty-anchor path: anchor text is gone from
300
+ // the target, but the marker's text-offset survived extraction.
301
+ if (typeof anchorData === 'object') {
302
+ const fallback = proportionalFallback(anchorData, result);
303
+ if (fallback !== null) {
304
+ return { ...c, pos: fallback, anchorText: null, strategy: 'proportional-fallback' };
305
+ }
306
+ }
221
307
  unmatchedCount++;
222
308
  return { ...c, pos: -1, anchorText: null };
223
309
  }
@@ -244,51 +330,87 @@ export function insertCommentsIntoMarkdown(markdown, comments, anchors, options
244
330
  return { ...c, pos: finalIdx, anchorText: null };
245
331
  }
246
332
  });
247
- // Log any unmatched comments for debugging
248
- const unmatched = commentsWithPositions.filter((c) => c.pos < 0);
333
+ // Group comments into clusters (root + ordered replies). The root carries
334
+ // the resolved position; replies inherit it and ride along in input order
335
+ // so the rebuilt CriticMarkup looks like `{>>p<<}{>>r1<<}{>>r2<<}[anchor]`
336
+ // and adjacency-based reply detection picks the cluster up again.
337
+ const byId = new Map();
338
+ for (const cwp of commentsWithPositions)
339
+ byId.set(cwp.id, cwp);
340
+ const repliesByRoot = new Map();
341
+ for (const c of comments) {
342
+ const rootId = replyRootId.get(c.id);
343
+ if (!rootId)
344
+ continue;
345
+ const cwp = byId.get(c.id);
346
+ if (!cwp)
347
+ continue;
348
+ const list = repliesByRoot.get(rootId);
349
+ if (list)
350
+ list.push(cwp);
351
+ else
352
+ repliesByRoot.set(rootId, [cwp]);
353
+ }
354
+ // Replies whose root never resolved (parent missing from the input slice or
355
+ // parent unmatched) count as unmatched too — there's no position to attach
356
+ // them to.
357
+ for (const [rootId, replies] of repliesByRoot) {
358
+ const root = byId.get(rootId);
359
+ if (!root || root.pos < 0) {
360
+ unmatchedCount += replies.length;
361
+ }
362
+ }
363
+ // Roots only — replies attach during emission.
364
+ const rootsWithPos = commentsWithPositions.filter(c => !replyRootId.has(c.id));
365
+ // Log any unmatched roots for debugging
366
+ const unmatched = rootsWithPos.filter((c) => c.pos < 0);
249
367
  if (process.env.DEBUG) {
250
- console.log(`[DEBUG] insertComments: ${comments.length} input, ${commentsWithPositions.length} processed, ${unmatched.length} unmatched`);
368
+ console.log(`[DEBUG] insertComments: ${comments.length} input, ${rootsWithPos.length} roots, ${unmatched.length} unmatched roots, ${replyRootId.size} replies`);
251
369
  if (unmatched.length > 0) {
252
370
  unmatched.forEach(c => console.log(`[DEBUG] Unmatched ID=${c.id}: anchor="${(c.anchorText || 'none').slice(0, 30)}"`));
253
371
  }
254
372
  }
255
- const matched = commentsWithPositions.filter((c) => c.pos >= 0);
373
+ const matchedRoots = rootsWithPos.filter((c) => c.pos >= 0);
256
374
  // Sort by position descending (insert from end to avoid offset issues)
257
- matched.sort((a, b) => b.pos - a.pos);
258
- // Insert each comment. With `wrapAnchor` (the default), the anchor text
375
+ matchedRoots.sort((a, b) => b.pos - a.pos);
376
+ // Insert each cluster. With `wrapAnchor` (the default), the anchor text
259
377
  // gets wrapped in `[anchor]{.mark}` so the rebuilt docx restores the
260
378
  // original Word comment range. Without it, the comment block is inserted
261
379
  // adjacent to the anchor and prose stays untouched — required for
262
380
  // comments-only sync where multiple comments may share one anchor.
263
- // Skip insertion when an identical comment already lives near the target.
264
- // Re-running sync against the same docx would otherwise stack duplicate
265
- // CriticMarkup blocks (`{>>R1: ...<<}{>>R1: ...<<}...`) on each invocation.
266
- // A 200-char window catches both wrapped (`{>>...<<}[anchor]{.mark}`) and
267
- // bare (`{>>...<<}anchor`) forms while ignoring incidental matches farther
268
- // away.
381
+ // Skip insertion when the parent's CriticMarkup already lives near the
382
+ // target — re-running sync against the same docx would otherwise stack
383
+ // duplicates. A 200-char window catches both wrapped
384
+ // (`{>>...<<}[anchor]{.mark}`) and bare (`{>>...<<}anchor`) forms while
385
+ // ignoring incidental matches farther away.
269
386
  let dedupedCount = 0;
270
- for (const c of matched) {
271
- const comment = `{>>${c.author}: ${c.text}<<}`;
387
+ for (const c of matchedRoots) {
388
+ const parentBlock = `{>>${c.author}: ${c.text}<<}`;
389
+ const replies = repliesByRoot.get(c.id) ?? [];
272
390
  const windowStart = Math.max(0, c.pos - 200);
273
391
  const windowEnd = Math.min(result.length, c.pos + 200);
274
- if (result.slice(windowStart, windowEnd).includes(comment)) {
275
- dedupedCount++;
392
+ if (result.slice(windowStart, windowEnd).includes(parentBlock)) {
393
+ // Cluster already synced; treat all members as deduped.
394
+ dedupedCount += 1 + replies.length;
276
395
  continue;
277
396
  }
397
+ // Replies carry an explicit `↪ ` author prefix so the round-trip does not
398
+ // depend on positional adjacency in the markdown. On dense reviewer docs
399
+ // distinct clusters frequently land at the same anchor position; without
400
+ // the prefix the re-parse would misthread them. The injection side strips
401
+ // `↪ ` back off the author so Word renders the original name.
402
+ const replyBlocks = replies.map(r => `{>>↪ ${r.author}: ${r.text}<<}`);
403
+ const combined = parentBlock + replyBlocks.join('');
278
404
  if (wrapAnchor && c.anchorText && c.anchorEnd) {
279
405
  const before = result.slice(0, c.pos);
280
406
  const anchor = result.slice(c.pos, c.anchorEnd);
281
407
  const after = result.slice(c.anchorEnd);
282
- result = before + comment + `[${anchor}]{.mark}` + after;
408
+ result = before + combined + `[${anchor}]{.mark}` + after;
283
409
  }
284
410
  else {
285
- // Insert comment at the anchor position with no surrounding whitespace
286
- // tweaks; CriticMarkup blocks are invisible to readers, and adding a
287
- // leading space would shift prose byte-for-byte (relevant when callers
288
- // verify that --comments-only didn't touch the original).
289
- result = result.slice(0, c.pos) + comment + result.slice(c.pos);
411
+ result = result.slice(0, c.pos) + combined + result.slice(c.pos);
290
412
  }
291
- placedCount++;
413
+ placedCount += 1 + replies.length;
292
414
  }
293
415
  if (outStats) {
294
416
  outStats.placed = placedCount;