docrev 0.7.8 → 0.8.0
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/lib/build.js +3 -2
- package/lib/commands/build.js +15 -0
- package/lib/commands/sections.js +267 -51
- package/lib/crossref.js +60 -27
- package/lib/merge.js +342 -34
- package/package.json +1 -1
package/lib/build.js
CHANGED
|
@@ -213,7 +213,7 @@ export function combineSections(directory, config, options = {}) {
|
|
|
213
213
|
// Resolve forward references (refs that appear before their anchor definition)
|
|
214
214
|
// This fixes pandoc-crossref limitation with multi-file documents
|
|
215
215
|
if (hasPandocCrossref()) {
|
|
216
|
-
const registry = buildRegistry(directory);
|
|
216
|
+
const registry = buildRegistry(directory, sections);
|
|
217
217
|
const { text, resolved } = resolveForwardRefs(paperContent, registry);
|
|
218
218
|
if (resolved.length > 0) {
|
|
219
219
|
paperContent = text;
|
|
@@ -280,7 +280,8 @@ export function prepareForFormat(paperPath, format, config, options = {}) {
|
|
|
280
280
|
let content = fs.readFileSync(paperPath, 'utf-8');
|
|
281
281
|
|
|
282
282
|
// Build crossref registry for reference conversion
|
|
283
|
-
|
|
283
|
+
// Pass sections from config to ensure correct file ordering
|
|
284
|
+
const registry = buildRegistry(directory, config.sections);
|
|
284
285
|
|
|
285
286
|
if (format === 'pdf' || format === 'tex') {
|
|
286
287
|
// Strip all annotations for clean output
|
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/crossref.js
CHANGED
|
@@ -11,6 +11,49 @@ import * as fs from 'fs';
|
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Discover section files from a directory by reading config files
|
|
16
|
+
* Only returns files explicitly defined in rev.yaml or sections.yaml
|
|
17
|
+
* Returns empty array if no config found (caller should handle this)
|
|
18
|
+
*
|
|
19
|
+
* @param {string} directory
|
|
20
|
+
* @returns {string[]} Ordered list of section filenames, or empty if no config
|
|
21
|
+
*/
|
|
22
|
+
function discoverSectionFiles(directory) {
|
|
23
|
+
// Try rev.yaml first
|
|
24
|
+
const revYamlPath = path.join(directory, 'rev.yaml');
|
|
25
|
+
if (fs.existsSync(revYamlPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const config = YAML.parse(fs.readFileSync(revYamlPath, 'utf-8'));
|
|
28
|
+
if (config.sections && Array.isArray(config.sections) && config.sections.length > 0) {
|
|
29
|
+
return config.sections.filter(f => fs.existsSync(path.join(directory, f)));
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore yaml errors, try next option
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Try sections.yaml
|
|
37
|
+
const sectionsPath = path.join(directory, 'sections.yaml');
|
|
38
|
+
if (fs.existsSync(sectionsPath)) {
|
|
39
|
+
try {
|
|
40
|
+
const config = YAML.parse(fs.readFileSync(sectionsPath, 'utf-8'));
|
|
41
|
+
if (config.sections) {
|
|
42
|
+
const sectionOrder = Object.entries(config.sections)
|
|
43
|
+
.sort((a, b) => (a[1].order ?? 999) - (b[1].order ?? 999))
|
|
44
|
+
.map(([file]) => file);
|
|
45
|
+
return sectionOrder.filter(f => fs.existsSync(path.join(directory, f)));
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore yaml errors
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// No config found - return empty array
|
|
53
|
+
// Caller must handle this (either error or use explicit sections)
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
14
57
|
/**
|
|
15
58
|
* Patterns for detecting hardcoded references
|
|
16
59
|
* Matches complex patterns including:
|
|
@@ -208,8 +251,14 @@ export function parseReferenceList(listStr) {
|
|
|
208
251
|
* Build a registry of figure/table labels from .md files
|
|
209
252
|
* Scans for {#fig:label} and {#tbl:label} anchors
|
|
210
253
|
*
|
|
254
|
+
* IMPORTANT: This function requires either explicit sections or a rev.yaml/sections.yaml config.
|
|
255
|
+
* It will NOT guess by scanning all .md files, as this leads to incorrect numbering
|
|
256
|
+
* when temporary files (paper_clean.md, etc.) exist in the directory.
|
|
257
|
+
*
|
|
211
258
|
* @param {string} directory - Directory containing .md files
|
|
212
|
-
* @param {string[]} [
|
|
259
|
+
* @param {string[]} [sections] - Array of section filenames to scan (recommended).
|
|
260
|
+
* If not provided, reads from rev.yaml or sections.yaml.
|
|
261
|
+
* Returns empty registry if no sections can be determined.
|
|
213
262
|
* @returns {{
|
|
214
263
|
* figures: Map<string, {label: string, num: number, isSupp: boolean, file: string}>,
|
|
215
264
|
* tables: Map<string, {label: string, num: number, isSupp: boolean, file: string}>,
|
|
@@ -217,7 +266,7 @@ export function parseReferenceList(listStr) {
|
|
|
217
266
|
* byNumber: {fig: Map<string, string>, tbl: Map<string, string>, eq: Map<string, string>}
|
|
218
267
|
* }}
|
|
219
268
|
*/
|
|
220
|
-
export function buildRegistry(directory,
|
|
269
|
+
export function buildRegistry(directory, sections) {
|
|
221
270
|
const figures = new Map();
|
|
222
271
|
const tables = new Map();
|
|
223
272
|
const equations = new Map();
|
|
@@ -229,32 +278,16 @@ export function buildRegistry(directory, excludeFiles = ['paper.md', 'README.md'
|
|
|
229
278
|
let tblSuppNum = 0;
|
|
230
279
|
let eqNum = 0;
|
|
231
280
|
|
|
232
|
-
|
|
233
|
-
const files = fs.readdirSync(directory).filter((f) => {
|
|
234
|
-
if (!f.endsWith('.md')) return false;
|
|
235
|
-
if (excludeFiles.some((e) => f.toLowerCase() === e.toLowerCase())) return false;
|
|
236
|
-
return true;
|
|
237
|
-
});
|
|
281
|
+
let orderedFiles;
|
|
238
282
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
.sort((a, b) => (a[1].order ?? 999) - (b[1].order ?? 999))
|
|
248
|
-
.map(([file]) => file);
|
|
249
|
-
orderedFiles = sectionOrder.filter((f) => files.includes(f));
|
|
250
|
-
// Add any remaining files not in sections.yaml
|
|
251
|
-
for (const f of files) {
|
|
252
|
-
if (!orderedFiles.includes(f)) orderedFiles.push(f);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
} catch {
|
|
256
|
-
// Ignore yaml errors, use default order
|
|
257
|
-
}
|
|
283
|
+
if (Array.isArray(sections) && sections.length > 0) {
|
|
284
|
+
// Use explicitly provided section files - most reliable
|
|
285
|
+
orderedFiles = sections.filter(f => fs.existsSync(path.join(directory, f)));
|
|
286
|
+
} else {
|
|
287
|
+
// Try to determine sections from config files (rev.yaml or sections.yaml)
|
|
288
|
+
orderedFiles = discoverSectionFiles(directory);
|
|
289
|
+
// If no config found, return empty registry rather than guessing
|
|
290
|
+
// This prevents bugs from scanning wrong files
|
|
258
291
|
}
|
|
259
292
|
|
|
260
293
|
// Determine if a file is supplementary
|
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
|
+
|