docrev 0.7.9 → 0.8.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.
package/README.md CHANGED
@@ -117,6 +117,14 @@ rev sync annotated.pdf # extract PDF comments
117
117
  rev pdf-comments annotated.pdf --append methods.md
118
118
  ```
119
119
 
120
+ Multiple reviewers sending back separate files? Merge them:
121
+
122
+ ```bash
123
+ rev merge reviewer_A.docx reviewer_B.docx # three-way merge
124
+ ```
125
+
126
+ The merge command uses the original document (auto-saved in `.rev/base.docx` on build) to detect what each reviewer changed, identifies conflicts when reviewers edit the same text differently, and lets you resolve them interactively.
127
+
120
128
  Your entire revision cycle stays in the terminal. `final_v3_REAL_final.docx` is over.
121
129
 
122
130
  ## Getting Started
@@ -271,6 +279,7 @@ Cross-references: `@fig:label`, `@tbl:label`, `@eq:label` → "Figure 1", "Table
271
279
  | Resolve comment | `rev resolve file.md -n 1` |
272
280
  | Show contributors | `rev contributors` |
273
281
  | Lookup ORCID | `rev orcid 0000-0002-1825-0097` |
282
+ | Merge reviewer feedback | `rev merge reviewer_A.docx reviewer_B.docx` |
274
283
  | Archive reviewer files | `rev archive` |
275
284
  | Check DOIs | `rev doi check references.bib` |
276
285
  | Find missing DOIs | `rev doi lookup references.bib` |
@@ -704,6 +704,21 @@ export function register(program, pkg) {
704
704
  }
705
705
  }
706
706
 
707
+ // Store base document for three-way merge (only for DOCX, not dual)
708
+ const docxResult = results.find(r => r.format === 'docx' && r.success);
709
+ if (docxResult && !options.dual) {
710
+ try {
711
+ const { storeBaseDocument } = await import('../merge.js');
712
+ storeBaseDocument(dir, docxResult.outputPath);
713
+ console.log(chalk.dim(`\n Saved as .rev/base.docx for merge`));
714
+ } catch (err) {
715
+ // Non-fatal - just log if DEBUG
716
+ if (process.env.DEBUG) {
717
+ console.log(chalk.dim(`\n Could not store base document: ${err.message}`));
718
+ }
719
+ }
720
+ }
721
+
707
722
  console.log(chalk.green('\nBuild complete!'));
708
723
  } catch (err) {
709
724
  spin.stop();
@@ -727,26 +727,31 @@ export function register(program) {
727
727
  });
728
728
 
729
729
  // ==========================================================================
730
- // MERGE command - Combine feedback from multiple reviewers
730
+ // MERGE command - Combine feedback from multiple reviewers (three-way merge)
731
731
  // ==========================================================================
732
732
 
733
733
  program
734
734
  .command('merge')
735
- .description('Merge feedback from multiple Word documents')
736
- .argument('<original>', 'Original markdown file')
735
+ .description('Merge feedback from multiple Word documents using three-way merge')
737
736
  .argument('<docx...>', 'Word documents from reviewers')
738
- .option('-o, --output <file>', 'Output file (default: original-merged.md)')
737
+ .option('-b, --base <file>', 'Base document (original sent to reviewers). Auto-detected if not specified.')
738
+ .option('-o, --output <file>', 'Output file (default: writes to section files)')
739
739
  .option('--names <names>', 'Reviewer names (comma-separated, in order of docx files)')
740
- .option('--auto', 'Auto-resolve conflicts by taking first change')
740
+ .option('--strategy <strategy>', 'Conflict resolution: first, latest, or interactive (default)', 'interactive')
741
+ .option('--diff-level <level>', 'Diff granularity: sentence or word (default: sentence)', 'sentence')
741
742
  .option('--dry-run', 'Show conflicts without writing')
742
- .action(async (original, docxFiles, options) => {
743
- const { mergeReviewerDocs, formatConflict, resolveConflict } = await import('../merge.js');
744
-
745
- if (!fs.existsSync(original)) {
746
- console.error(fmt.status('error', `Original file not found: ${original}`));
747
- process.exit(1);
748
- }
749
-
743
+ .option('--sections', 'Split merged output back to section files')
744
+ .action(async (docxFiles, options) => {
745
+ const {
746
+ mergeThreeWay,
747
+ formatConflict,
748
+ resolveConflict,
749
+ getBaseDocument,
750
+ checkBaseMatch,
751
+ saveConflicts,
752
+ } = await import('../merge.js');
753
+
754
+ // Validate reviewer files exist
750
755
  for (const docx of docxFiles) {
751
756
  if (!fs.existsSync(docx)) {
752
757
  console.error(fmt.status('error', `Reviewer file not found: ${docx}`));
@@ -754,14 +759,50 @@ export function register(program) {
754
759
  }
755
760
  }
756
761
 
762
+ // Determine base document
763
+ let basePath = options.base;
764
+ let baseSource = 'specified';
765
+
766
+ if (!basePath) {
767
+ // Try to use .rev/base.docx
768
+ const projectDir = process.cwd();
769
+ basePath = getBaseDocument(projectDir);
770
+
771
+ if (basePath) {
772
+ baseSource = 'auto (.rev/base.docx)';
773
+ } else {
774
+ console.log(chalk.yellow('\n No base document found in .rev/base.docx'));
775
+ console.log(chalk.dim(' Tip: Run "rev build docx" to automatically save the base document.\n'));
776
+ console.error(fmt.status('error', 'Base document required. Use --base <file.docx>'));
777
+ process.exit(1);
778
+ }
779
+ }
780
+
781
+ if (!fs.existsSync(basePath)) {
782
+ console.error(fmt.status('error', `Base document not found: ${basePath}`));
783
+ process.exit(1);
784
+ }
785
+
786
+ // Check similarity between base and reviewer docs
787
+ const { matches, similarity } = await checkBaseMatch(basePath, docxFiles[0]);
788
+ if (!matches) {
789
+ console.log(chalk.yellow(`\n Warning: Base document may not match reviewer file (${Math.round(similarity * 100)}% similar)`));
790
+ console.log(chalk.dim(' If this is wrong, use --base to specify the correct original document.\n'));
791
+ }
792
+
793
+ // Parse reviewer names
757
794
  const names = options.names
758
795
  ? options.names.split(',').map(n => n.trim())
759
- : docxFiles.map((f, i) => `Reviewer ${i + 1}`);
796
+ : docxFiles.map((f, i) => {
797
+ // Try to extract name from filename (e.g., paper_reviewer_A.docx)
798
+ const basename = path.basename(f, '.docx');
799
+ const match = basename.match(/_([A-Za-z]+)$/);
800
+ return match ? match[1] : `Reviewer ${i + 1}`;
801
+ });
760
802
 
761
- if (names.length < docxFiles.length) {
762
- for (let i = names.length; i < docxFiles.length; i++) {
763
- names.push(`Reviewer ${i + 1}`);
764
- }
803
+ // Pad names if needed
804
+ while (names.length < docxFiles.length) {
805
+ names.push(`Reviewer ${names.length + 1}`);
765
806
  }
766
807
 
767
808
  const reviewerDocs = docxFiles.map((p, i) => ({
@@ -769,21 +810,23 @@ export function register(program) {
769
810
  name: names[i],
770
811
  }));
771
812
 
772
- console.log(fmt.header('Multi-Reviewer Merge'));
813
+ console.log(fmt.header('Three-Way Merge'));
773
814
  console.log();
774
- console.log(chalk.dim(` Original: ${original}`));
815
+ console.log(chalk.dim(` Base: ${path.basename(basePath)} (${baseSource})`));
775
816
  console.log(chalk.dim(` Reviewers: ${names.join(', ')}`));
817
+ console.log(chalk.dim(` Diff level: ${options.diffLevel}`));
776
818
  console.log();
777
819
 
778
820
  const spin = fmt.spinner('Analyzing changes...').start();
779
821
 
780
822
  try {
781
- const { merged, conflicts, stats, originalText } = await mergeReviewerDocs(original, reviewerDocs, {
782
- autoResolve: options.auto,
823
+ const { merged, conflicts, stats, baseText } = await mergeThreeWay(basePath, reviewerDocs, {
824
+ diffLevel: options.diffLevel,
783
825
  });
784
826
 
785
827
  spin.stop();
786
828
 
829
+ // Display stats
787
830
  console.log(fmt.table(['Metric', 'Count'], [
788
831
  ['Total changes', stats.totalChanges.toString()],
789
832
  ['Non-conflicting', stats.nonConflicting.toString()],
@@ -792,21 +835,33 @@ export function register(program) {
792
835
  ]));
793
836
  console.log();
794
837
 
838
+ let finalMerged = merged;
839
+
840
+ // Handle conflicts
795
841
  if (conflicts.length > 0) {
796
842
  console.log(chalk.yellow(`Found ${conflicts.length} conflict(s):\n`));
797
843
 
798
- let resolvedMerged = merged;
799
-
800
- for (let i = 0; i < conflicts.length; i++) {
801
- const conflict = conflicts[i];
802
- console.log(chalk.bold(`Conflict ${i + 1}/${conflicts.length}:`));
803
- console.log(formatConflict(conflict, originalText));
804
- console.log();
844
+ if (options.strategy === 'first') {
845
+ // Auto-resolve: take first reviewer's change
846
+ for (const conflict of conflicts) {
847
+ console.log(chalk.dim(` Conflict ${conflict.id}: using ${conflict.changes[0].reviewer}'s change`));
848
+ resolveConflict(conflict, 0);
849
+ }
850
+ } else if (options.strategy === 'latest') {
851
+ // Auto-resolve: take last reviewer's change
852
+ for (const conflict of conflicts) {
853
+ const lastIdx = conflict.changes.length - 1;
854
+ console.log(chalk.dim(` Conflict ${conflict.id}: using ${conflict.changes[lastIdx].reviewer}'s change`));
855
+ resolveConflict(conflict, lastIdx);
856
+ }
857
+ } else if (!options.dryRun) {
858
+ // Interactive resolution
859
+ for (let i = 0; i < conflicts.length; i++) {
860
+ const conflict = conflicts[i];
861
+ console.log(chalk.bold(`\nConflict ${i + 1}/${conflicts.length} (${conflict.id}):`));
862
+ console.log(formatConflict(conflict, baseText));
863
+ console.log();
805
864
 
806
- if (options.auto) {
807
- console.log(chalk.dim(` Auto-resolved: using ${conflict.changes[0].reviewer}'s change`));
808
- resolvedMerged = resolveConflict(resolvedMerged, conflict, 0, originalText);
809
- } else if (!options.dryRun) {
810
865
  const rl = await import('readline');
811
866
  const readline = rl.createInterface({
812
867
  input: process.stdin,
@@ -821,36 +876,50 @@ export function register(program) {
821
876
  if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
822
877
  const choice = parseInt(answer) - 1;
823
878
  if (choice >= 0 && choice < conflict.changes.length) {
824
- resolvedMerged = resolveConflict(resolvedMerged, conflict, choice, originalText);
825
- console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
879
+ resolveConflict(conflict, choice);
880
+ console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
826
881
  }
827
882
  } else {
828
- console.log(chalk.dim(' Skipped'));
883
+ console.log(chalk.dim(' Skipped (will need manual resolution)'));
829
884
  }
830
- console.log();
831
885
  }
832
886
  }
833
887
 
834
- if (!options.dryRun) {
835
- const outPath = options.output || original.replace(/\.md$/, '-merged.md');
836
- fs.writeFileSync(outPath, resolvedMerged, 'utf-8');
837
- console.log(fmt.status('success', `Merged output written to ${outPath}`));
888
+ // Save unresolved conflicts for later
889
+ const unresolved = conflicts.filter(c => c.resolved === null);
890
+ if (unresolved.length > 0) {
891
+ saveConflicts(process.cwd(), conflicts, basePath);
892
+ console.log(chalk.yellow(`\n ${unresolved.length} unresolved conflict(s) saved to .rev/conflicts.json`));
893
+ console.log(chalk.dim(' Run "rev conflicts" to view, "rev merge-resolve" to resolve'));
838
894
  }
839
- } else {
840
- if (!options.dryRun) {
841
- const outPath = options.output || original.replace(/\.md$/, '-merged.md');
842
- fs.writeFileSync(outPath, merged, 'utf-8');
843
- console.log(fmt.status('success', `Merged output written to ${outPath}`));
895
+ }
896
+
897
+ // Write output
898
+ if (!options.dryRun) {
899
+ if (options.output) {
900
+ // Write to single file
901
+ fs.writeFileSync(options.output, finalMerged, 'utf-8');
902
+ console.log(fmt.status('success', `Merged output written to ${options.output}`));
903
+ } else if (options.sections) {
904
+ // Split to section files (TODO: implement section splitting)
905
+ console.log(chalk.yellow(' Section splitting not yet implemented'));
906
+ console.log(chalk.dim(' Use -o to specify output file'));
844
907
  } else {
845
- console.log(fmt.status('info', 'Dry run - no output written'));
908
+ // Default: write to merged.md
909
+ const outPath = 'merged.md';
910
+ fs.writeFileSync(outPath, finalMerged, 'utf-8');
911
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
846
912
  }
847
- }
848
913
 
849
- if (!options.dryRun && stats.nonConflicting > 0) {
850
914
  console.log();
851
915
  console.log(chalk.dim('Next steps:'));
852
- console.log(chalk.dim(' 1. rev review <merged.md> - Review all changes'));
853
- console.log(chalk.dim(' 2. rev comments <merged.md> - Address comments'));
916
+ console.log(chalk.dim(' 1. rev review merged.md - Accept/reject changes'));
917
+ console.log(chalk.dim(' 2. rev comments merged.md - Address comments'));
918
+ if (conflicts.some(c => c.resolved === null)) {
919
+ console.log(chalk.dim(' 3. rev merge-resolve - Resolve remaining conflicts'));
920
+ }
921
+ } else {
922
+ console.log(fmt.status('info', 'Dry run - no output written'));
854
923
  }
855
924
  } catch (err) {
856
925
  spin.stop();
@@ -859,4 +928,151 @@ export function register(program) {
859
928
  process.exit(1);
860
929
  }
861
930
  });
931
+
932
+ // ==========================================================================
933
+ // CONFLICTS command - List unresolved conflicts
934
+ // ==========================================================================
935
+
936
+ program
937
+ .command('conflicts')
938
+ .description('List unresolved merge conflicts')
939
+ .action(async () => {
940
+ const { loadConflicts, formatConflict } = await import('../merge.js');
941
+ const projectDir = process.cwd();
942
+ const data = loadConflicts(projectDir);
943
+
944
+ if (!data) {
945
+ console.log(fmt.status('info', 'No conflicts file found'));
946
+ return;
947
+ }
948
+
949
+ const unresolved = data.conflicts.filter(c => c.resolved === null);
950
+
951
+ if (unresolved.length === 0) {
952
+ console.log(fmt.status('success', 'All conflicts resolved!'));
953
+ return;
954
+ }
955
+
956
+ console.log(fmt.header(`Unresolved Conflicts (${unresolved.length})`));
957
+ console.log();
958
+ console.log(chalk.dim(` Base: ${data.base}`));
959
+ console.log(chalk.dim(` Merged: ${data.merged}`));
960
+ console.log();
961
+
962
+ for (const conflict of unresolved) {
963
+ console.log(chalk.bold(`Conflict ${conflict.id}:`));
964
+ // Show abbreviated info
965
+ console.log(chalk.dim(` Original: "${conflict.original.slice(0, 50)}${conflict.original.length > 50 ? '...' : ''}"`));
966
+ console.log(chalk.dim(` Options: ${conflict.changes.map(c => c.reviewer).join(', ')}`));
967
+ console.log();
968
+ }
969
+
970
+ console.log(chalk.dim('Run "rev merge-resolve" to resolve conflicts interactively'));
971
+ });
972
+
973
+ // ==========================================================================
974
+ // MERGE-RESOLVE command - Interactively resolve merge conflicts
975
+ // ==========================================================================
976
+
977
+ program
978
+ .command('merge-resolve')
979
+ .alias('mresolve')
980
+ .description('Resolve merge conflicts interactively')
981
+ .option('--theirs', 'Accept all changes from last reviewer')
982
+ .option('--ours', 'Accept all changes from first reviewer')
983
+ .action(async (options) => {
984
+ const { loadConflicts, saveConflicts, clearConflicts, resolveConflict, formatConflict } = await import('../merge.js');
985
+ const projectDir = process.cwd();
986
+ const data = loadConflicts(projectDir);
987
+
988
+ if (!data) {
989
+ console.log(fmt.status('info', 'No conflicts to resolve'));
990
+ return;
991
+ }
992
+
993
+ const unresolved = data.conflicts.filter(c => c.resolved === null);
994
+
995
+ if (unresolved.length === 0) {
996
+ console.log(fmt.status('success', 'All conflicts already resolved!'));
997
+ clearConflicts(projectDir);
998
+ return;
999
+ }
1000
+
1001
+ console.log(fmt.header(`Resolving ${unresolved.length} Conflict(s)`));
1002
+ console.log();
1003
+
1004
+ if (options.theirs) {
1005
+ // Accept all from last reviewer
1006
+ for (const conflict of unresolved) {
1007
+ const lastIdx = conflict.changes.length - 1;
1008
+ resolveConflict(conflict, lastIdx);
1009
+ console.log(chalk.dim(` ${conflict.id}: accepted ${conflict.changes[lastIdx].reviewer}'s change`));
1010
+ }
1011
+ saveConflicts(projectDir, data.conflicts, data.base);
1012
+ console.log(fmt.status('success', `Resolved ${unresolved.length} conflicts (--theirs)`));
1013
+ } else if (options.ours) {
1014
+ // Accept all from first reviewer
1015
+ for (const conflict of unresolved) {
1016
+ resolveConflict(conflict, 0);
1017
+ console.log(chalk.dim(` ${conflict.id}: accepted ${conflict.changes[0].reviewer}'s change`));
1018
+ }
1019
+ saveConflicts(projectDir, data.conflicts, data.base);
1020
+ console.log(fmt.status('success', `Resolved ${unresolved.length} conflicts (--ours)`));
1021
+ } else {
1022
+ // Interactive resolution
1023
+ // Read base text for context display
1024
+ let baseText = '';
1025
+ try {
1026
+ const { extractFromWord } = await import('../import.js');
1027
+ const { text } = await extractFromWord(data.base);
1028
+ baseText = text;
1029
+ } catch {
1030
+ // Can't read base, show without context
1031
+ }
1032
+
1033
+ for (let i = 0; i < unresolved.length; i++) {
1034
+ const conflict = unresolved[i];
1035
+ console.log(chalk.bold(`\nConflict ${i + 1}/${unresolved.length} (${conflict.id}):`));
1036
+ console.log(formatConflict(conflict, baseText));
1037
+ console.log();
1038
+
1039
+ const rl = await import('readline');
1040
+ const readline = rl.createInterface({
1041
+ input: process.stdin,
1042
+ output: process.stdout,
1043
+ });
1044
+
1045
+ const answer = await new Promise((resolve) =>
1046
+ readline.question(chalk.cyan(` Choose (1-${conflict.changes.length}, s=skip, q=quit): `), resolve)
1047
+ );
1048
+ readline.close();
1049
+
1050
+ if (answer.toLowerCase() === 'q') {
1051
+ console.log(chalk.dim('\n Saving progress...'));
1052
+ break;
1053
+ }
1054
+
1055
+ if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
1056
+ const choice = parseInt(answer) - 1;
1057
+ if (choice >= 0 && choice < conflict.changes.length) {
1058
+ resolveConflict(conflict, choice);
1059
+ console.log(chalk.green(` ✓ Applied: ${conflict.changes[choice].reviewer}'s change`));
1060
+ }
1061
+ } else {
1062
+ console.log(chalk.dim(' Skipped'));
1063
+ }
1064
+ }
1065
+
1066
+ saveConflicts(projectDir, data.conflicts, data.base);
1067
+
1068
+ const remaining = data.conflicts.filter(c => c.resolved === null).length;
1069
+ if (remaining === 0) {
1070
+ console.log(fmt.status('success', '\nAll conflicts resolved!'));
1071
+ clearConflicts(projectDir);
1072
+ } else {
1073
+ console.log(chalk.yellow(`\n ${remaining} conflict(s) remaining`));
1074
+ }
1075
+ }
1076
+ });
1077
+
862
1078
  }
package/lib/merge.js CHANGED
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Multi-reviewer merge utilities
3
3
  * Combine feedback from multiple Word documents with conflict detection
4
+ *
5
+ * Supports true three-way merge: base document + multiple reviewer versions
4
6
  */
5
7
 
6
8
  import * as fs from 'fs';
7
9
  import * as path from 'path';
8
- import { diffWords } from 'diff';
10
+ import * as crypto from 'crypto';
11
+ import { diffWords, diffSentences } from 'diff';
9
12
  import { extractFromWord, extractWordComments } from './import.js';
10
13
 
14
+ // Single base document for three-way merge
15
+ const REV_DIR = '.rev';
16
+ const BASE_FILE = '.rev/base.docx';
17
+ const CONFLICTS_FILE = '.rev/conflicts.json';
18
+
11
19
  /**
12
20
  * Represents a change from a reviewer
13
21
  * @typedef {Object} ReviewerChange
@@ -17,27 +25,111 @@ import { extractFromWord, extractWordComments } from './import.js';
17
25
  * @property {number} end - End position in original text
18
26
  * @property {string} oldText - Original text (for delete/replace)
19
27
  * @property {string} newText - New text (for insert/replace)
28
+ * @property {string} [date] - Date of change (from Word track changes)
20
29
  */
21
30
 
22
31
  /**
23
32
  * Represents a conflict between reviewers
24
33
  * @typedef {Object} Conflict
34
+ * @property {string} id - Unique conflict ID
25
35
  * @property {number} start - Start position in original
26
36
  * @property {number} end - End position in original
27
37
  * @property {string} original - Original text
28
38
  * @property {ReviewerChange[]} changes - Conflicting changes from different reviewers
39
+ * @property {string} [section] - Section file this conflict belongs to
40
+ * @property {number} [line] - Line number in section
41
+ * @property {string|null} resolved - Resolution choice or null if unresolved
42
+ */
43
+
44
+ /**
45
+ * Initialize .rev directory
46
+ * @param {string} projectDir
47
+ */
48
+ export function initRevDir(projectDir) {
49
+ const revDir = path.join(projectDir, REV_DIR);
50
+ if (!fs.existsSync(revDir)) {
51
+ fs.mkdirSync(revDir, { recursive: true });
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Store the base document for three-way merge
57
+ * Overwrites any previous base document
58
+ * @param {string} projectDir
59
+ * @param {string} docxPath - Path to the built docx
29
60
  */
61
+ export function storeBaseDocument(projectDir, docxPath) {
62
+ initRevDir(projectDir);
63
+ const basePath = path.join(projectDir, BASE_FILE);
64
+ fs.copyFileSync(docxPath, basePath);
65
+ }
66
+
67
+ /**
68
+ * Get the base document path if it exists
69
+ * @param {string} projectDir
70
+ * @returns {string|null}
71
+ */
72
+ export function getBaseDocument(projectDir) {
73
+ const basePath = path.join(projectDir, BASE_FILE);
74
+ if (fs.existsSync(basePath)) {
75
+ return basePath;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Check if base document exists
82
+ * @param {string} projectDir
83
+ * @returns {boolean}
84
+ */
85
+ export function hasBaseDocument(projectDir) {
86
+ return fs.existsSync(path.join(projectDir, BASE_FILE));
87
+ }
88
+
89
+ /**
90
+ * Compute text similarity between two strings
91
+ * @param {string} text1
92
+ * @param {string} text2
93
+ * @returns {number} Similarity score 0-1
94
+ */
95
+ export function computeSimilarity(text1, text2) {
96
+ const words1 = new Set(text1.toLowerCase().split(/\s+/).filter(w => w.length > 2));
97
+ const words2 = text2.toLowerCase().split(/\s+/).filter(w => w.length > 2);
98
+ if (words1.size === 0 || words2.length === 0) return 0;
99
+ const common = words2.filter(w => words1.has(w)).length;
100
+ return common / Math.max(words1.size, words2.length);
101
+ }
102
+
103
+ /**
104
+ * Check if base document matches reviewer document (similarity check)
105
+ * @param {string} basePath
106
+ * @param {string} reviewerPath
107
+ * @returns {Promise<{matches: boolean, similarity: number}>}
108
+ */
109
+ export async function checkBaseMatch(basePath, reviewerPath) {
110
+ try {
111
+ const { text: baseText } = await extractFromWord(basePath);
112
+ const { text: reviewerText } = await extractFromWord(reviewerPath);
113
+ const similarity = computeSimilarity(baseText, reviewerText);
114
+ return { matches: similarity > 0.5, similarity };
115
+ } catch {
116
+ return { matches: false, similarity: 0 };
117
+ }
118
+ }
30
119
 
31
120
  /**
32
121
  * Extract changes from a Word document compared to original
33
- * @param {string} originalText - Original markdown text
34
- * @param {string} wordText - Text extracted from Word
122
+ * Uses sentence-level diffing for better conflict detection
123
+ * @param {string} originalText - Original text (from base document)
124
+ * @param {string} wordText - Text extracted from reviewer's Word doc
35
125
  * @param {string} reviewer - Reviewer identifier
36
126
  * @returns {ReviewerChange[]}
37
127
  */
38
128
  export function extractChanges(originalText, wordText, reviewer) {
39
129
  const changes = [];
40
- const diffs = diffWords(originalText, wordText);
130
+
131
+ // Use sentence-level diff for better granularity
132
+ const diffs = diffSentences(originalText, wordText);
41
133
 
42
134
  let originalPos = 0;
43
135
  let i = 0;
@@ -90,6 +182,64 @@ export function extractChanges(originalText, wordText, reviewer) {
90
182
  return changes;
91
183
  }
92
184
 
185
+ /**
186
+ * Extract changes using word-level diff (more fine-grained)
187
+ * @param {string} originalText
188
+ * @param {string} wordText
189
+ * @param {string} reviewer
190
+ * @returns {ReviewerChange[]}
191
+ */
192
+ export function extractChangesWordLevel(originalText, wordText, reviewer) {
193
+ const changes = [];
194
+ const diffs = diffWords(originalText, wordText);
195
+
196
+ let originalPos = 0;
197
+ let i = 0;
198
+
199
+ while (i < diffs.length) {
200
+ const part = diffs[i];
201
+
202
+ if (!part.added && !part.removed) {
203
+ originalPos += part.value.length;
204
+ i++;
205
+ } else if (part.removed && diffs[i + 1]?.added) {
206
+ changes.push({
207
+ reviewer,
208
+ type: 'replace',
209
+ start: originalPos,
210
+ end: originalPos + part.value.length,
211
+ oldText: part.value,
212
+ newText: diffs[i + 1].value,
213
+ });
214
+ originalPos += part.value.length;
215
+ i += 2;
216
+ } else if (part.removed) {
217
+ changes.push({
218
+ reviewer,
219
+ type: 'delete',
220
+ start: originalPos,
221
+ end: originalPos + part.value.length,
222
+ oldText: part.value,
223
+ newText: '',
224
+ });
225
+ originalPos += part.value.length;
226
+ i++;
227
+ } else if (part.added) {
228
+ changes.push({
229
+ reviewer,
230
+ type: 'insert',
231
+ start: originalPos,
232
+ end: originalPos,
233
+ oldText: '',
234
+ newText: part.value,
235
+ });
236
+ i++;
237
+ }
238
+ }
239
+
240
+ return changes;
241
+ }
242
+
93
243
  /**
94
244
  * Check if two changes overlap
95
245
  * @param {ReviewerChange} a
@@ -134,6 +284,7 @@ export function detectConflicts(allChanges) {
134
284
  const conflicts = [];
135
285
  const nonConflicting = [];
136
286
  const usedIndices = new Set();
287
+ let conflictId = 0;
137
288
 
138
289
  for (let i = 0; i < flat.length; i++) {
139
290
  if (usedIndices.has(i)) continue;
@@ -162,10 +313,12 @@ export function detectConflicts(allChanges) {
162
313
  const end = Math.max(...conflictingChanges.map(c => c.end));
163
314
 
164
315
  conflicts.push({
316
+ id: `c${++conflictId}`,
165
317
  start,
166
318
  end,
167
319
  original: conflictingChanges[0].oldText || '',
168
320
  changes: conflictingChanges,
321
+ resolved: null,
169
322
  });
170
323
  usedIndices.add(i);
171
324
  } else {
@@ -227,8 +380,6 @@ export function applyChangesAsAnnotations(originalText, changes) {
227
380
  let result = originalText;
228
381
 
229
382
  for (const change of sorted) {
230
- const reviewer = change.reviewer;
231
-
232
383
  if (change.type === 'insert') {
233
384
  const annotation = `{++${change.newText}++}`;
234
385
  result = result.slice(0, change.start) + annotation + result.slice(change.start);
@@ -244,6 +395,42 @@ export function applyChangesAsAnnotations(originalText, changes) {
244
395
  return result;
245
396
  }
246
397
 
398
+ /**
399
+ * Apply changes as git-style conflict markers
400
+ * @param {string} originalText
401
+ * @param {Conflict[]} conflicts
402
+ * @returns {string}
403
+ */
404
+ export function applyConflictMarkers(originalText, conflicts) {
405
+ // Sort by position descending
406
+ const sorted = [...conflicts].sort((a, b) => b.start - a.start);
407
+
408
+ let result = originalText;
409
+
410
+ for (const conflict of sorted) {
411
+ const markers = [];
412
+ markers.push(`<<<<<<< CONFLICT ${conflict.id}`);
413
+
414
+ for (const change of conflict.changes) {
415
+ markers.push(`======= ${change.reviewer}`);
416
+ if (change.type === 'delete') {
417
+ markers.push(`[DELETED: "${change.oldText}"]`);
418
+ } else if (change.type === 'insert') {
419
+ markers.push(change.newText);
420
+ } else {
421
+ markers.push(change.newText);
422
+ }
423
+ }
424
+
425
+ markers.push(`>>>>>>> END ${conflict.id}`);
426
+
427
+ const markerText = markers.join('\n');
428
+ result = result.slice(0, conflict.start) + markerText + result.slice(conflict.end);
429
+ }
430
+
431
+ return result;
432
+ }
433
+
247
434
  /**
248
435
  * Format a conflict for display
249
436
  * @param {Conflict} conflict
@@ -252,35 +439,154 @@ export function applyChangesAsAnnotations(originalText, changes) {
252
439
  */
253
440
  export function formatConflict(conflict, originalText) {
254
441
  const lines = [];
255
- const context = 30;
442
+ const context = 50;
256
443
 
257
444
  // Show context
258
445
  const beforeStart = Math.max(0, conflict.start - context);
259
446
  const afterEnd = Math.min(originalText.length, conflict.end + context);
260
447
 
261
- const before = originalText.slice(beforeStart, conflict.start);
448
+ const before = originalText.slice(beforeStart, conflict.start).trim();
262
449
  const original = originalText.slice(conflict.start, conflict.end);
263
- const after = originalText.slice(conflict.end, afterEnd);
450
+ const after = originalText.slice(conflict.end, afterEnd).trim();
264
451
 
265
- lines.push(`Context: ...${before}[CONFLICT]${after}...`);
266
- lines.push(`Original: "${original || '(insertion point)'}"`);
452
+ if (before) {
453
+ lines.push(` ...${before}`);
454
+ }
455
+ lines.push(` [ORIGINAL]: "${original || '(insertion point)'}"`);
456
+ if (after) {
457
+ lines.push(` ${after}...`);
458
+ }
267
459
  lines.push('');
268
- lines.push('Options:');
460
+ lines.push(' Options:');
269
461
 
270
462
  conflict.changes.forEach((change, i) => {
271
463
  const label = change.type === 'insert'
272
- ? `Insert: "${change.newText}"`
464
+ ? `Insert: "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`
273
465
  : change.type === 'delete'
274
- ? `Delete: "${change.oldText}"`
275
- : `Replace "${change.oldText}" → "${change.newText}"`;
276
- lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
466
+ ? `Delete: "${change.oldText.slice(0, 60)}${change.oldText.length > 60 ? '...' : ''}"`
467
+ : `Replace "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`;
468
+ lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
277
469
  });
278
470
 
279
471
  return lines.join('\n');
280
472
  }
281
473
 
282
474
  /**
283
- * Merge multiple Word documents against an original
475
+ * Save conflicts to file for later resolution
476
+ * @param {string} projectDir
477
+ * @param {Conflict[]} conflicts
478
+ * @param {string} baseDoc - Base document path
479
+ */
480
+ export function saveConflicts(projectDir, conflicts, baseDoc) {
481
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
482
+ const data = {
483
+ base: baseDoc,
484
+ merged: new Date().toISOString(),
485
+ conflicts,
486
+ };
487
+
488
+ // Ensure directory exists
489
+ const dir = path.dirname(conflictsPath);
490
+ if (!fs.existsSync(dir)) {
491
+ fs.mkdirSync(dir, { recursive: true });
492
+ }
493
+
494
+ fs.writeFileSync(conflictsPath, JSON.stringify(data, null, 2));
495
+ }
496
+
497
+ /**
498
+ * Load conflicts from file
499
+ * @param {string} projectDir
500
+ * @returns {{base: string, merged: string, conflicts: Conflict[]}|null}
501
+ */
502
+ export function loadConflicts(projectDir) {
503
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
504
+ if (!fs.existsSync(conflictsPath)) {
505
+ return null;
506
+ }
507
+ return JSON.parse(fs.readFileSync(conflictsPath, 'utf-8'));
508
+ }
509
+
510
+ /**
511
+ * Clear conflicts file after resolution
512
+ * @param {string} projectDir
513
+ */
514
+ export function clearConflicts(projectDir) {
515
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
516
+ if (fs.existsSync(conflictsPath)) {
517
+ fs.unlinkSync(conflictsPath);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Merge multiple Word documents using three-way merge
523
+ * @param {string} basePath - Path to base document (original sent to reviewers)
524
+ * @param {Array<{path: string, name: string}>} reviewerDocs - Reviewer Word docs
525
+ * @param {Object} options
526
+ * @returns {Promise<{merged: string, conflicts: Conflict[], stats: Object, baseText: string}>}
527
+ */
528
+ export async function mergeThreeWay(basePath, reviewerDocs, options = {}) {
529
+ const { diffLevel = 'sentence' } = options;
530
+
531
+ if (!fs.existsSync(basePath)) {
532
+ throw new Error(`Base document not found: ${basePath}`);
533
+ }
534
+
535
+ // Extract text from base document
536
+ const { text: baseText } = await extractFromWord(basePath);
537
+
538
+ // Extract changes from each reviewer relative to base
539
+ const allChanges = [];
540
+ const allComments = [];
541
+
542
+ for (const doc of reviewerDocs) {
543
+ if (!fs.existsSync(doc.path)) {
544
+ throw new Error(`Reviewer file not found: ${doc.path}`);
545
+ }
546
+
547
+ const { text: wordText } = await extractFromWord(doc.path);
548
+
549
+ // Choose diff level
550
+ const changes = diffLevel === 'word'
551
+ ? extractChangesWordLevel(baseText, wordText, doc.name)
552
+ : extractChanges(baseText, wordText, doc.name);
553
+
554
+ allChanges.push(changes);
555
+
556
+ // Also extract comments
557
+ try {
558
+ const comments = await extractWordComments(doc.path);
559
+ allComments.push(...comments.map(c => ({ ...c, reviewer: doc.name })));
560
+ } catch {
561
+ // Comments extraction failed, continue without
562
+ }
563
+ }
564
+
565
+ // Detect conflicts
566
+ const { conflicts, nonConflicting } = detectConflicts(allChanges);
567
+
568
+ // Apply non-conflicting changes as annotations
569
+ let merged = applyChangesAsAnnotations(baseText, nonConflicting);
570
+
571
+ // Add comments with reviewer attribution
572
+ for (const comment of allComments) {
573
+ merged += `\n{>>${comment.reviewer}: ${comment.text}<<}`;
574
+ }
575
+
576
+ const stats = {
577
+ reviewers: reviewerDocs.length,
578
+ totalChanges: allChanges.flat().length,
579
+ nonConflicting: nonConflicting.length,
580
+ conflicts: conflicts.length,
581
+ comments: allComments.length,
582
+ };
583
+
584
+ return { merged, conflicts, stats, baseText };
585
+ }
586
+
587
+ /**
588
+ * Merge multiple Word documents against an original markdown file
589
+ * Legacy function - use mergeThreeWay for proper three-way merge
284
590
  * @param {string} originalPath - Path to original markdown
285
591
  * @param {Array<{path: string, name: string}>} reviewerDocs - Reviewer Word docs
286
592
  * @param {Object} options
@@ -342,24 +648,26 @@ export async function mergeReviewerDocs(originalPath, reviewerDocs, options = {}
342
648
 
343
649
  /**
344
650
  * Resolve a conflict by choosing one option
345
- * @param {string} text - Current merged text
346
651
  * @param {Conflict} conflict
347
652
  * @param {number} choice - Index of chosen change (0-based)
348
- * @param {string} originalText - Original text for position reference
349
- * @returns {string}
653
+ * @returns {ReviewerChange}
350
654
  */
351
- export function resolveConflict(text, conflict, choice, originalText) {
352
- const chosen = conflict.changes[choice];
353
-
354
- // Find the conflict region in the current text
355
- // This is simplified - real implementation would track positions
356
- const annotation = chosen.type === 'insert'
357
- ? `{++${chosen.newText}++}`
358
- : chosen.type === 'delete'
359
- ? `{--${chosen.oldText}--}`
360
- : `{~~${chosen.oldText}~>${chosen.newText}~~}`;
361
-
362
- // For now, append resolved conflicts at marker position
363
- // A more sophisticated approach would track exact positions
364
- return text + `\n<!-- Resolved: ${annotation} -->`;
655
+ export function resolveConflict(conflict, choice) {
656
+ if (choice < 0 || choice >= conflict.changes.length) {
657
+ throw new Error(`Invalid choice: ${choice}. Must be 0-${conflict.changes.length - 1}`);
658
+ }
659
+ conflict.resolved = conflict.changes[choice].reviewer;
660
+ return conflict.changes[choice];
365
661
  }
662
+
663
+ /**
664
+ * Get list of unresolved conflicts
665
+ * @param {string} projectDir
666
+ * @returns {Conflict[]}
667
+ */
668
+ export function getUnresolvedConflicts(projectDir) {
669
+ const data = loadConflicts(projectDir);
670
+ if (!data) return [];
671
+ return data.conflicts.filter(c => c.resolved === null);
672
+ }
673
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docrev",
3
- "version": "0.7.9",
3
+ "version": "0.8.1",
4
4
  "description": "Academic paper revision workflow: Word ↔ Markdown round-trips, DOI validation, reviewer comments",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",