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 +9 -0
- package/lib/commands/build.js +15 -0
- package/lib/commands/sections.js +267 -51
- package/lib/merge.js +342 -34
- package/package.json +1 -1
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` |
|
package/lib/commands/build.js
CHANGED
|
@@ -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();
|
package/lib/commands/sections.js
CHANGED
|
@@ -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('-
|
|
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('--
|
|
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
|
-
.
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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) =>
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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('
|
|
813
|
+
console.log(fmt.header('Three-Way Merge'));
|
|
773
814
|
console.log();
|
|
774
|
-
console.log(chalk.dim(`
|
|
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,
|
|
782
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
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
|
|
853
|
-
console.log(chalk.dim(' 2. rev 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
|
|
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
|
-
*
|
|
34
|
-
* @param {string}
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
266
|
-
|
|
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.
|
|
276
|
-
lines.push(`
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
349
|
-
* @returns {string}
|
|
653
|
+
* @returns {ReviewerChange}
|
|
350
654
|
*/
|
|
351
|
-
export function resolveConflict(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
+
|