docrev 0.9.13 → 0.9.15
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/.claude/settings.local.json +9 -9
- package/.gitattributes +1 -1
- package/CHANGELOG.md +149 -149
- package/PLAN-tables-and-postprocess.md +850 -850
- package/README.md +411 -391
- package/bin/rev.js +11 -11
- package/bin/rev.ts +145 -145
- package/completions/rev.bash +127 -127
- package/completions/rev.ps1 +210 -210
- package/completions/rev.zsh +207 -207
- package/dev_notes/stress2/build_adversarial.ts +186 -186
- package/dev_notes/stress2/drift_matcher.ts +62 -62
- package/dev_notes/stress2/probe_anchors.ts +35 -35
- package/dev_notes/stress2/project/discussion.before.md +3 -3
- package/dev_notes/stress2/project/discussion.md +3 -3
- package/dev_notes/stress2/project/methods.before.md +20 -20
- package/dev_notes/stress2/project/methods.md +20 -20
- package/dev_notes/stress2/project/rev.yaml +5 -5
- package/dev_notes/stress2/project/sections.yaml +4 -4
- package/dev_notes/stress2/sections.yaml +5 -5
- package/dev_notes/stress2/trace_placement.ts +50 -50
- package/dev_notes/stresstest_boundaries.ts +27 -27
- package/dev_notes/stresstest_drift_apply.ts +43 -43
- package/dev_notes/stresstest_drift_compare.ts +43 -43
- package/dev_notes/stresstest_drift_v2.ts +54 -54
- package/dev_notes/stresstest_inspect.ts +54 -54
- package/dev_notes/stresstest_pstyle.ts +55 -55
- package/dev_notes/stresstest_section_debug.ts +23 -23
- package/dev_notes/stresstest_split.ts +70 -70
- package/dev_notes/stresstest_trace.ts +19 -19
- package/dev_notes/stresstest_verify_no_overwrite.ts +40 -40
- package/dist/lib/build.d.ts +38 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +68 -30
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +38 -5
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/commands/utilities.js +164 -164
- package/dist/lib/commands/word-tools.js +8 -8
- package/dist/lib/grammar.js +3 -3
- package/dist/lib/pdf-comments.js +44 -44
- package/dist/lib/plugins.js +57 -57
- package/dist/lib/pptx-themes.js +115 -115
- package/dist/lib/spelling.js +2 -2
- package/dist/lib/templates.js +387 -387
- package/dist/lib/themes.js +51 -51
- package/eslint.config.js +27 -27
- package/lib/anchor-match.ts +276 -276
- package/lib/annotations.ts +644 -644
- package/lib/build.ts +1300 -1251
- package/lib/citations.ts +160 -160
- package/lib/commands/build.ts +833 -801
- package/lib/commands/citations.ts +515 -515
- package/lib/commands/comments.ts +1050 -1050
- package/lib/commands/context.ts +174 -174
- package/lib/commands/core.ts +309 -309
- package/lib/commands/doi.ts +435 -435
- package/lib/commands/file-ops.ts +372 -372
- package/lib/commands/history.ts +320 -320
- package/lib/commands/index.ts +87 -87
- package/lib/commands/init.ts +259 -259
- package/lib/commands/merge-resolve.ts +378 -378
- package/lib/commands/preview.ts +178 -178
- package/lib/commands/project-info.ts +244 -244
- package/lib/commands/quality.ts +517 -517
- package/lib/commands/response.ts +454 -454
- package/lib/commands/section-boundaries.ts +82 -82
- package/lib/commands/sections.ts +451 -451
- package/lib/commands/sync.ts +706 -706
- package/lib/commands/text-ops.ts +449 -449
- package/lib/commands/utilities.ts +448 -448
- package/lib/commands/verify-anchors.ts +272 -272
- package/lib/commands/word-tools.ts +340 -340
- package/lib/comment-realign.ts +517 -517
- package/lib/config.ts +84 -84
- package/lib/crossref.ts +781 -781
- package/lib/csl.ts +191 -191
- package/lib/dependencies.ts +98 -98
- package/lib/diff-engine.ts +465 -465
- package/lib/doi-cache.ts +115 -115
- package/lib/doi.ts +897 -897
- package/lib/equations.ts +506 -506
- package/lib/errors.ts +346 -346
- package/lib/format.ts +541 -541
- package/lib/git.ts +326 -326
- package/lib/grammar.ts +303 -303
- package/lib/image-registry.ts +180 -180
- package/lib/import.ts +911 -911
- package/lib/journals.ts +543 -543
- package/lib/merge.ts +633 -633
- package/lib/orcid.ts +144 -144
- package/lib/pdf-comments.ts +263 -263
- package/lib/pdf-import.ts +524 -524
- package/lib/plugins.ts +362 -362
- package/lib/postprocess.ts +188 -188
- package/lib/pptx-color-filter.lua +37 -37
- package/lib/pptx-template.ts +469 -469
- package/lib/pptx-themes.ts +483 -483
- package/lib/protect-restore.ts +520 -520
- package/lib/rate-limiter.ts +94 -94
- package/lib/response.ts +197 -197
- package/lib/restore-references.ts +240 -240
- package/lib/review.ts +327 -327
- package/lib/schema.ts +417 -417
- package/lib/scientific-words.ts +73 -73
- package/lib/sections.ts +335 -335
- package/lib/slides.ts +756 -756
- package/lib/spelling.ts +334 -334
- package/lib/templates.ts +526 -526
- package/lib/themes.ts +742 -742
- package/lib/trackchanges.ts +247 -247
- package/lib/tui.ts +450 -450
- package/lib/types.ts +550 -550
- package/lib/undo.ts +250 -250
- package/lib/utils.ts +69 -69
- package/lib/variables.ts +179 -179
- package/lib/word-extraction.ts +806 -806
- package/lib/word.ts +643 -643
- package/lib/wordcomments.ts +817 -817
- package/package.json +137 -137
- package/scripts/postbuild.js +28 -28
- package/skill/REFERENCE.md +473 -431
- package/skill/SKILL.md +274 -258
- package/tsconfig.json +26 -26
- package/types/index.d.ts +525 -525
package/lib/merge.ts
CHANGED
|
@@ -1,633 +1,633 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-reviewer merge utilities
|
|
3
|
-
* Combine feedback from multiple Word documents with conflict detection
|
|
4
|
-
*
|
|
5
|
-
* Supports true three-way merge: base document + multiple reviewer versions
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as fs from 'fs';
|
|
9
|
-
import * as path from 'path';
|
|
10
|
-
import * as crypto from 'crypto';
|
|
11
|
-
import { diffWords, diffSentences } from 'diff';
|
|
12
|
-
import { extractFromWord, extractWordComments } from './import.js';
|
|
13
|
-
import type { ReviewerChange, Conflict, MergeResult } from './types.js';
|
|
14
|
-
|
|
15
|
-
// =============================================================================
|
|
16
|
-
// Constants
|
|
17
|
-
// =============================================================================
|
|
18
|
-
|
|
19
|
-
/** Directory for revision tracking data */
|
|
20
|
-
const REV_DIR = '.rev';
|
|
21
|
-
|
|
22
|
-
/** Path to base document for three-way merge */
|
|
23
|
-
const BASE_FILE = '.rev/base.docx';
|
|
24
|
-
|
|
25
|
-
/** Path to conflict resolution state */
|
|
26
|
-
const CONFLICTS_FILE = '.rev/conflicts.json';
|
|
27
|
-
|
|
28
|
-
/** Minimum word length for similarity calculations */
|
|
29
|
-
const MIN_WORD_LENGTH = 2;
|
|
30
|
-
|
|
31
|
-
/** Similarity threshold below which changes are considered conflicts */
|
|
32
|
-
const CONFLICT_SIMILARITY_THRESHOLD = 0.8;
|
|
33
|
-
|
|
34
|
-
/** Characters of context for change attribution */
|
|
35
|
-
const CHANGE_CONTEXT_SIZE = 50;
|
|
36
|
-
|
|
37
|
-
// =============================================================================
|
|
38
|
-
// Interfaces
|
|
39
|
-
// =============================================================================
|
|
40
|
-
|
|
41
|
-
interface ReviewerDoc {
|
|
42
|
-
path: string;
|
|
43
|
-
name: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface ReviewerComment {
|
|
47
|
-
text: string;
|
|
48
|
-
reviewer: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface MergeOptions {
|
|
52
|
-
diffLevel?: 'sentence' | 'word';
|
|
53
|
-
autoResolve?: boolean;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface CheckMatchResult {
|
|
57
|
-
matches: boolean;
|
|
58
|
-
similarity: number;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface ConflictDetectionResult {
|
|
62
|
-
conflicts: Conflict[];
|
|
63
|
-
nonConflicting: ReviewerChange[];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface ConflictsData {
|
|
67
|
-
base: string;
|
|
68
|
-
merged: string;
|
|
69
|
-
conflicts: Conflict[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// =============================================================================
|
|
73
|
-
// Public API
|
|
74
|
-
// =============================================================================
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Initialize .rev directory for revision tracking
|
|
78
|
-
* @param projectDir - Project directory path
|
|
79
|
-
* @throws {TypeError} If projectDir is not a string
|
|
80
|
-
*/
|
|
81
|
-
export function initRevDir(projectDir: string): void {
|
|
82
|
-
if (typeof projectDir !== 'string') {
|
|
83
|
-
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const revDir = path.join(projectDir, REV_DIR);
|
|
87
|
-
if (!fs.existsSync(revDir)) {
|
|
88
|
-
fs.mkdirSync(revDir, { recursive: true });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Store the base document for three-way merge
|
|
94
|
-
* Overwrites any previous base document
|
|
95
|
-
* @param projectDir - Project directory path
|
|
96
|
-
* @param docxPath - Path to the built docx to store as base
|
|
97
|
-
* @throws {TypeError} If arguments are not strings
|
|
98
|
-
* @throws {Error} If docxPath does not exist
|
|
99
|
-
*/
|
|
100
|
-
export function storeBaseDocument(projectDir: string, docxPath: string): void {
|
|
101
|
-
if (typeof projectDir !== 'string') {
|
|
102
|
-
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
103
|
-
}
|
|
104
|
-
if (typeof docxPath !== 'string') {
|
|
105
|
-
throw new TypeError(`docxPath must be a string, got ${typeof docxPath}`);
|
|
106
|
-
}
|
|
107
|
-
if (!fs.existsSync(docxPath)) {
|
|
108
|
-
throw new Error(`Source document not found: ${docxPath}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
initRevDir(projectDir);
|
|
112
|
-
const basePath = path.join(projectDir, BASE_FILE);
|
|
113
|
-
fs.copyFileSync(docxPath, basePath);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Get the base document path if it exists
|
|
118
|
-
* @param projectDir - Project directory path
|
|
119
|
-
* @returns Path to base document or null if not found
|
|
120
|
-
* @throws {TypeError} If projectDir is not a string
|
|
121
|
-
*/
|
|
122
|
-
export function getBaseDocument(projectDir: string): string | null {
|
|
123
|
-
if (typeof projectDir !== 'string') {
|
|
124
|
-
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const basePath = path.join(projectDir, BASE_FILE);
|
|
128
|
-
if (fs.existsSync(basePath)) {
|
|
129
|
-
return basePath;
|
|
130
|
-
}
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Check if base document exists
|
|
136
|
-
* @param projectDir - Project directory path
|
|
137
|
-
* @returns True if base document exists
|
|
138
|
-
* @throws {TypeError} If projectDir is not a string
|
|
139
|
-
*/
|
|
140
|
-
export function hasBaseDocument(projectDir: string): boolean {
|
|
141
|
-
if (typeof projectDir !== 'string') {
|
|
142
|
-
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return fs.existsSync(path.join(projectDir, BASE_FILE));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Compute text similarity between two strings using Jaccard-like coefficient
|
|
150
|
-
* @param text1 - First text to compare
|
|
151
|
-
* @param text2 - Second text to compare
|
|
152
|
-
* @returns Similarity score 0-1 (0 = no similarity, 1 = identical)
|
|
153
|
-
*/
|
|
154
|
-
export function computeSimilarity(text1: string, text2: string): number {
|
|
155
|
-
if (typeof text1 !== 'string' || typeof text2 !== 'string') {
|
|
156
|
-
return 0;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const words1 = new Set(text1.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH));
|
|
160
|
-
const words2 = text2.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH);
|
|
161
|
-
if (words1.size === 0 || words2.length === 0) return 0;
|
|
162
|
-
const common = words2.filter(w => words1.has(w)).length;
|
|
163
|
-
return common / Math.max(words1.size, words2.length);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Check if base document matches reviewer document (similarity check)
|
|
168
|
-
*/
|
|
169
|
-
export async function checkBaseMatch(basePath: string, reviewerPath: string): Promise<CheckMatchResult> {
|
|
170
|
-
try {
|
|
171
|
-
const { text: baseText } = await extractFromWord(basePath);
|
|
172
|
-
const { text: reviewerText } = await extractFromWord(reviewerPath);
|
|
173
|
-
const similarity = computeSimilarity(baseText, reviewerText);
|
|
174
|
-
return { matches: similarity > 0.5, similarity };
|
|
175
|
-
} catch {
|
|
176
|
-
return { matches: false, similarity: 0 };
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Extract changes from diffs between original and modified text
|
|
182
|
-
* @param diffs - Array of diff changes
|
|
183
|
-
* @param reviewer - Reviewer identifier
|
|
184
|
-
*/
|
|
185
|
-
function extractChangesFromDiffs(
|
|
186
|
-
diffs: Array<{ added?: boolean; removed?: boolean; value: string }>,
|
|
187
|
-
reviewer: string,
|
|
188
|
-
): ReviewerChange[] {
|
|
189
|
-
const changes: ReviewerChange[] = [];
|
|
190
|
-
let originalPos = 0;
|
|
191
|
-
let i = 0;
|
|
192
|
-
|
|
193
|
-
while (i < diffs.length) {
|
|
194
|
-
const part = diffs[i];
|
|
195
|
-
if (!part) break;
|
|
196
|
-
|
|
197
|
-
if (!part.added && !part.removed) {
|
|
198
|
-
originalPos += part.value.length;
|
|
199
|
-
i++;
|
|
200
|
-
} else if (part.removed && diffs[i + 1]?.added) {
|
|
201
|
-
const nextPart = diffs[i + 1];
|
|
202
|
-
if (!nextPart) break;
|
|
203
|
-
changes.push({
|
|
204
|
-
reviewer,
|
|
205
|
-
type: 'replace',
|
|
206
|
-
start: originalPos,
|
|
207
|
-
end: originalPos + part.value.length,
|
|
208
|
-
oldText: part.value,
|
|
209
|
-
newText: nextPart.value,
|
|
210
|
-
});
|
|
211
|
-
originalPos += part.value.length;
|
|
212
|
-
i += 2;
|
|
213
|
-
} else if (part.removed) {
|
|
214
|
-
changes.push({
|
|
215
|
-
reviewer,
|
|
216
|
-
type: 'delete',
|
|
217
|
-
start: originalPos,
|
|
218
|
-
end: originalPos + part.value.length,
|
|
219
|
-
oldText: part.value,
|
|
220
|
-
newText: '',
|
|
221
|
-
});
|
|
222
|
-
originalPos += part.value.length;
|
|
223
|
-
i++;
|
|
224
|
-
} else if (part.added) {
|
|
225
|
-
changes.push({
|
|
226
|
-
reviewer,
|
|
227
|
-
type: 'insert',
|
|
228
|
-
start: originalPos,
|
|
229
|
-
end: originalPos,
|
|
230
|
-
oldText: '',
|
|
231
|
-
newText: part.value,
|
|
232
|
-
});
|
|
233
|
-
i++;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return changes;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Extract changes from a Word document compared to original
|
|
242
|
-
* Uses sentence-level diffing for better conflict detection
|
|
243
|
-
* @param originalText - Original text (from base document)
|
|
244
|
-
* @param wordText - Text extracted from reviewer's Word doc
|
|
245
|
-
* @param reviewer - Reviewer identifier
|
|
246
|
-
*/
|
|
247
|
-
export function extractChanges(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
|
|
248
|
-
return extractChangesFromDiffs(diffSentences(originalText, wordText), reviewer);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Extract changes using word-level diff (more fine-grained)
|
|
253
|
-
*/
|
|
254
|
-
export function extractChangesWordLevel(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
|
|
255
|
-
return extractChangesFromDiffs(diffWords(originalText, wordText), reviewer);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Check if two changes overlap
|
|
260
|
-
*/
|
|
261
|
-
function changesOverlap(a: ReviewerChange, b: ReviewerChange): boolean {
|
|
262
|
-
// Insertions at same point conflict
|
|
263
|
-
if (a.type === 'insert' && b.type === 'insert' && a.start === b.start) {
|
|
264
|
-
return a.newText !== b.newText; // Same insertion is not a conflict
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Check range overlap
|
|
268
|
-
const aStart = a.start;
|
|
269
|
-
const aEnd = a.type === 'insert' ? a.start : a.end;
|
|
270
|
-
const bStart = b.start;
|
|
271
|
-
const bEnd = b.type === 'insert' ? b.start : b.end;
|
|
272
|
-
|
|
273
|
-
// Ranges overlap if neither ends before the other starts
|
|
274
|
-
if (aEnd <= bStart || bEnd <= aStart) {
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// They overlap - but is it a conflict?
|
|
279
|
-
// Same change from different reviewers is not a conflict
|
|
280
|
-
if (a.type === b.type && a.oldText === b.oldText && a.newText === b.newText) {
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Detect conflicts between changes from multiple reviewers
|
|
289
|
-
* @param allChanges - Array of change arrays, one per reviewer
|
|
290
|
-
*/
|
|
291
|
-
export function detectConflicts(allChanges: ReviewerChange[][]): ConflictDetectionResult {
|
|
292
|
-
// Flatten and sort all changes by position
|
|
293
|
-
const flat = allChanges.flat().sort((a, b) => a.start - b.start || a.end - b.end);
|
|
294
|
-
|
|
295
|
-
const conflicts: Conflict[] = [];
|
|
296
|
-
const nonConflicting: ReviewerChange[] = [];
|
|
297
|
-
const usedIndices = new Set<number>();
|
|
298
|
-
let conflictId = 0;
|
|
299
|
-
|
|
300
|
-
for (let i = 0; i < flat.length; i++) {
|
|
301
|
-
if (usedIndices.has(i)) continue;
|
|
302
|
-
|
|
303
|
-
const change = flat[i];
|
|
304
|
-
if (!change) continue;
|
|
305
|
-
const conflictingChanges = [change];
|
|
306
|
-
|
|
307
|
-
// Find all changes that conflict with this one
|
|
308
|
-
for (let j = i + 1; j < flat.length; j++) {
|
|
309
|
-
if (usedIndices.has(j)) continue;
|
|
310
|
-
|
|
311
|
-
const other = flat[j];
|
|
312
|
-
if (!other) continue;
|
|
313
|
-
|
|
314
|
-
// Stop if we're past the range
|
|
315
|
-
if (other.start > change.end && change.type !== 'insert') break;
|
|
316
|
-
|
|
317
|
-
if (changesOverlap(change, other)) {
|
|
318
|
-
conflictingChanges.push(other);
|
|
319
|
-
usedIndices.add(j);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (conflictingChanges.length > 1) {
|
|
324
|
-
// Multiple reviewers changed the same region
|
|
325
|
-
const start = Math.min(...conflictingChanges.map(c => c?.start ?? 0).filter(s => s !== undefined));
|
|
326
|
-
const end = Math.max(...conflictingChanges.map(c => c?.end ?? 0).filter(e => e !== undefined));
|
|
327
|
-
const firstChange = conflictingChanges[0];
|
|
328
|
-
|
|
329
|
-
conflicts.push({
|
|
330
|
-
id: `c${++conflictId}`,
|
|
331
|
-
start,
|
|
332
|
-
end,
|
|
333
|
-
original: firstChange?.oldText || '',
|
|
334
|
-
changes: conflictingChanges.filter((c): c is ReviewerChange => c !== undefined),
|
|
335
|
-
resolved: null,
|
|
336
|
-
});
|
|
337
|
-
usedIndices.add(i);
|
|
338
|
-
} else {
|
|
339
|
-
// No conflict
|
|
340
|
-
nonConflicting.push(change);
|
|
341
|
-
usedIndices.add(i);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Deduplicate identical non-conflicting changes
|
|
346
|
-
const seen = new Map<string, boolean>();
|
|
347
|
-
const dedupedNonConflicting: ReviewerChange[] = [];
|
|
348
|
-
|
|
349
|
-
for (const change of nonConflicting) {
|
|
350
|
-
const key = `${change.start}:${change.end}:${change.type}:${change.newText}`;
|
|
351
|
-
if (!seen.has(key)) {
|
|
352
|
-
seen.set(key, true);
|
|
353
|
-
dedupedNonConflicting.push(change);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return { conflicts, nonConflicting: dedupedNonConflicting };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Apply non-conflicting changes to text
|
|
362
|
-
* @param originalText
|
|
363
|
-
* @param changes - Must be sorted by position
|
|
364
|
-
*/
|
|
365
|
-
export function applyChanges(originalText: string, changes: ReviewerChange[]): string {
|
|
366
|
-
// Sort by position descending to apply from end to start
|
|
367
|
-
const sorted = [...changes].sort((a, b) => b.start - a.start);
|
|
368
|
-
|
|
369
|
-
let result = originalText;
|
|
370
|
-
|
|
371
|
-
for (const change of sorted) {
|
|
372
|
-
if (change.type === 'insert') {
|
|
373
|
-
result = result.slice(0, change.start) + change.newText + result.slice(change.start);
|
|
374
|
-
} else if (change.type === 'delete') {
|
|
375
|
-
result = result.slice(0, change.start) + result.slice(change.end);
|
|
376
|
-
} else if (change.type === 'replace') {
|
|
377
|
-
result = result.slice(0, change.start) + change.newText + result.slice(change.end);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return result;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Apply changes as CriticMarkup annotations
|
|
386
|
-
*/
|
|
387
|
-
export function applyChangesAsAnnotations(originalText: string, changes: ReviewerChange[]): string {
|
|
388
|
-
const sorted = [...changes].sort((a, b) => b.start - a.start);
|
|
389
|
-
|
|
390
|
-
let result = originalText;
|
|
391
|
-
|
|
392
|
-
for (const change of sorted) {
|
|
393
|
-
if (change.type === 'insert') {
|
|
394
|
-
const annotation = `{++${change.newText}++}`;
|
|
395
|
-
result = result.slice(0, change.start) + annotation + result.slice(change.start);
|
|
396
|
-
} else if (change.type === 'delete') {
|
|
397
|
-
const annotation = `{--${change.oldText}--}`;
|
|
398
|
-
result = result.slice(0, change.start) + annotation + result.slice(change.end);
|
|
399
|
-
} else if (change.type === 'replace') {
|
|
400
|
-
const annotation = `{~~${change.oldText}~>${change.newText}~~}`;
|
|
401
|
-
result = result.slice(0, change.start) + annotation + result.slice(change.end);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return result;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Apply changes as git-style conflict markers
|
|
410
|
-
*/
|
|
411
|
-
export function applyConflictMarkers(originalText: string, conflicts: Conflict[]): string {
|
|
412
|
-
// Sort by position descending
|
|
413
|
-
const sorted = [...conflicts].sort((a, b) => b.start - a.start);
|
|
414
|
-
|
|
415
|
-
let result = originalText;
|
|
416
|
-
|
|
417
|
-
for (const conflict of sorted) {
|
|
418
|
-
const markers: string[] = [];
|
|
419
|
-
markers.push(`<<<<<<< CONFLICT ${conflict.id}`);
|
|
420
|
-
|
|
421
|
-
for (const change of conflict.changes) {
|
|
422
|
-
markers.push(`======= ${change.reviewer}`);
|
|
423
|
-
if (change.type === 'delete') {
|
|
424
|
-
markers.push(`[DELETED: "${change.oldText}"]`);
|
|
425
|
-
} else if (change.type === 'insert') {
|
|
426
|
-
markers.push(change.newText);
|
|
427
|
-
} else {
|
|
428
|
-
markers.push(change.newText);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
markers.push(`>>>>>>> END ${conflict.id}`);
|
|
433
|
-
|
|
434
|
-
const markerText = markers.join('\n');
|
|
435
|
-
result = result.slice(0, conflict.start) + markerText + result.slice(conflict.end);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
return result;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Format a conflict for display
|
|
443
|
-
*/
|
|
444
|
-
export function formatConflict(conflict: Conflict, originalText: string): string {
|
|
445
|
-
const lines: string[] = [];
|
|
446
|
-
const context = 50;
|
|
447
|
-
|
|
448
|
-
// Show context
|
|
449
|
-
const beforeStart = Math.max(0, conflict.start - context);
|
|
450
|
-
const afterEnd = Math.min(originalText.length, conflict.end + context);
|
|
451
|
-
|
|
452
|
-
const before = originalText.slice(beforeStart, conflict.start).trim();
|
|
453
|
-
const original = originalText.slice(conflict.start, conflict.end);
|
|
454
|
-
const after = originalText.slice(conflict.end, afterEnd).trim();
|
|
455
|
-
|
|
456
|
-
if (before) {
|
|
457
|
-
lines.push(` ...${before}`);
|
|
458
|
-
}
|
|
459
|
-
lines.push(` [ORIGINAL]: "${original || '(insertion point)'}"`);
|
|
460
|
-
if (after) {
|
|
461
|
-
lines.push(` ${after}...`);
|
|
462
|
-
}
|
|
463
|
-
lines.push('');
|
|
464
|
-
lines.push(' Options:');
|
|
465
|
-
|
|
466
|
-
conflict.changes.forEach((change, i) => {
|
|
467
|
-
const label = change.type === 'insert'
|
|
468
|
-
? `Insert: "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`
|
|
469
|
-
: change.type === 'delete'
|
|
470
|
-
? `Delete: "${change.oldText.slice(0, 60)}${change.oldText.length > 60 ? '...' : ''}"`
|
|
471
|
-
: `Replace → "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`;
|
|
472
|
-
lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
return lines.join('\n');
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Save conflicts to file for later resolution
|
|
480
|
-
*/
|
|
481
|
-
export function saveConflicts(projectDir: string, conflicts: Conflict[], baseDoc: string): void {
|
|
482
|
-
const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
|
|
483
|
-
const data: ConflictsData = {
|
|
484
|
-
base: baseDoc,
|
|
485
|
-
merged: new Date().toISOString(),
|
|
486
|
-
conflicts,
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
// Ensure directory exists
|
|
490
|
-
const dir = path.dirname(conflictsPath);
|
|
491
|
-
if (!fs.existsSync(dir)) {
|
|
492
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
fs.writeFileSync(conflictsPath, JSON.stringify(data, null, 2));
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Load conflicts from file
|
|
500
|
-
*/
|
|
501
|
-
export function loadConflicts(projectDir: string): ConflictsData | null {
|
|
502
|
-
const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
|
|
503
|
-
if (!fs.existsSync(conflictsPath)) {
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
return JSON.parse(fs.readFileSync(conflictsPath, 'utf-8')) as ConflictsData;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Clear conflicts file after resolution
|
|
511
|
-
*/
|
|
512
|
-
export function clearConflicts(projectDir: string): void {
|
|
513
|
-
const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
|
|
514
|
-
if (fs.existsSync(conflictsPath)) {
|
|
515
|
-
fs.unlinkSync(conflictsPath);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Core merge logic: extract changes from reviewer docs, detect conflicts, apply annotations
|
|
521
|
-
*/
|
|
522
|
-
async function mergeReviewerDocsCore(
|
|
523
|
-
baseText: string,
|
|
524
|
-
reviewerDocs: ReviewerDoc[],
|
|
525
|
-
options: MergeOptions = {},
|
|
526
|
-
): Promise<MergeResult> {
|
|
527
|
-
const { diffLevel = 'sentence' } = options;
|
|
528
|
-
|
|
529
|
-
const allChanges: ReviewerChange[][] = [];
|
|
530
|
-
const allComments: ReviewerComment[] = [];
|
|
531
|
-
|
|
532
|
-
for (const doc of reviewerDocs) {
|
|
533
|
-
if (!fs.existsSync(doc.path)) {
|
|
534
|
-
throw new Error(`Reviewer file not found: ${doc.path}`);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const { text: wordText } = await extractFromWord(doc.path);
|
|
538
|
-
|
|
539
|
-
const changes = diffLevel === 'word'
|
|
540
|
-
? extractChangesWordLevel(baseText, wordText, doc.name)
|
|
541
|
-
: extractChanges(baseText, wordText, doc.name);
|
|
542
|
-
|
|
543
|
-
allChanges.push(changes);
|
|
544
|
-
|
|
545
|
-
try {
|
|
546
|
-
const comments = await extractWordComments(doc.path);
|
|
547
|
-
allComments.push(...comments.map(c => ({ ...c, reviewer: doc.name })));
|
|
548
|
-
} catch (e) {
|
|
549
|
-
if (process.env.DEBUG) {
|
|
550
|
-
const error = e as Error;
|
|
551
|
-
console.warn(`merge: Failed to extract comments:`, error.message);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const { conflicts, nonConflicting } = detectConflicts(allChanges);
|
|
557
|
-
|
|
558
|
-
let merged = applyChangesAsAnnotations(baseText, nonConflicting);
|
|
559
|
-
|
|
560
|
-
for (const comment of allComments) {
|
|
561
|
-
merged += `\n{>>${comment.reviewer}: ${comment.text}<<}`;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const stats = {
|
|
565
|
-
reviewers: reviewerDocs.length,
|
|
566
|
-
totalChanges: allChanges.flat().length,
|
|
567
|
-
nonConflicting: nonConflicting.length,
|
|
568
|
-
conflicts: conflicts.length,
|
|
569
|
-
comments: allComments.length,
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
return { merged, conflicts, stats, originalText: baseText };
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Merge multiple Word documents using three-way merge
|
|
577
|
-
*/
|
|
578
|
-
export async function mergeThreeWay(
|
|
579
|
-
basePath: string,
|
|
580
|
-
reviewerDocs: ReviewerDoc[],
|
|
581
|
-
options: MergeOptions = {}
|
|
582
|
-
): Promise<MergeResult & { baseText: string }> {
|
|
583
|
-
if (!fs.existsSync(basePath)) {
|
|
584
|
-
throw new Error(`Base document not found: ${basePath}`);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const { text: baseText } = await extractFromWord(basePath);
|
|
588
|
-
const result = await mergeReviewerDocsCore(baseText, reviewerDocs, options);
|
|
589
|
-
return { ...result, baseText };
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Merge multiple Word documents against an original markdown file
|
|
594
|
-
* Legacy function - use mergeThreeWay for proper three-way merge
|
|
595
|
-
*/
|
|
596
|
-
export async function mergeReviewerDocs(
|
|
597
|
-
originalPath: string,
|
|
598
|
-
reviewerDocs: ReviewerDoc[],
|
|
599
|
-
options: MergeOptions = {}
|
|
600
|
-
): Promise<MergeResult> {
|
|
601
|
-
if (!fs.existsSync(originalPath)) {
|
|
602
|
-
throw new Error(`Original file not found: ${originalPath}`);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const originalText = fs.readFileSync(originalPath, 'utf-8');
|
|
606
|
-
return mergeReviewerDocsCore(originalText, reviewerDocs, options);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Resolve a conflict by choosing one option
|
|
611
|
-
* @param conflict
|
|
612
|
-
* @param choice - Index of chosen change (0-based)
|
|
613
|
-
*/
|
|
614
|
-
export function resolveConflict(conflict: Conflict, choice: number): ReviewerChange {
|
|
615
|
-
if (choice < 0 || choice >= conflict.changes.length) {
|
|
616
|
-
throw new Error(`Invalid choice: ${choice}. Must be 0-${conflict.changes.length - 1}`);
|
|
617
|
-
}
|
|
618
|
-
const selectedChange = conflict.changes[choice];
|
|
619
|
-
if (!selectedChange) {
|
|
620
|
-
throw new Error(`Invalid choice: ${choice}. Change not found`);
|
|
621
|
-
}
|
|
622
|
-
conflict.resolved = selectedChange.reviewer;
|
|
623
|
-
return selectedChange;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Get list of unresolved conflicts
|
|
628
|
-
*/
|
|
629
|
-
export function getUnresolvedConflicts(projectDir: string): Conflict[] {
|
|
630
|
-
const data = loadConflicts(projectDir);
|
|
631
|
-
if (!data) return [];
|
|
632
|
-
return data.conflicts.filter(c => c.resolved === null);
|
|
633
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Multi-reviewer merge utilities
|
|
3
|
+
* Combine feedback from multiple Word documents with conflict detection
|
|
4
|
+
*
|
|
5
|
+
* Supports true three-way merge: base document + multiple reviewer versions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as crypto from 'crypto';
|
|
11
|
+
import { diffWords, diffSentences } from 'diff';
|
|
12
|
+
import { extractFromWord, extractWordComments } from './import.js';
|
|
13
|
+
import type { ReviewerChange, Conflict, MergeResult } from './types.js';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Constants
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
/** Directory for revision tracking data */
|
|
20
|
+
const REV_DIR = '.rev';
|
|
21
|
+
|
|
22
|
+
/** Path to base document for three-way merge */
|
|
23
|
+
const BASE_FILE = '.rev/base.docx';
|
|
24
|
+
|
|
25
|
+
/** Path to conflict resolution state */
|
|
26
|
+
const CONFLICTS_FILE = '.rev/conflicts.json';
|
|
27
|
+
|
|
28
|
+
/** Minimum word length for similarity calculations */
|
|
29
|
+
const MIN_WORD_LENGTH = 2;
|
|
30
|
+
|
|
31
|
+
/** Similarity threshold below which changes are considered conflicts */
|
|
32
|
+
const CONFLICT_SIMILARITY_THRESHOLD = 0.8;
|
|
33
|
+
|
|
34
|
+
/** Characters of context for change attribution */
|
|
35
|
+
const CHANGE_CONTEXT_SIZE = 50;
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Interfaces
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
interface ReviewerDoc {
|
|
42
|
+
path: string;
|
|
43
|
+
name: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ReviewerComment {
|
|
47
|
+
text: string;
|
|
48
|
+
reviewer: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MergeOptions {
|
|
52
|
+
diffLevel?: 'sentence' | 'word';
|
|
53
|
+
autoResolve?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CheckMatchResult {
|
|
57
|
+
matches: boolean;
|
|
58
|
+
similarity: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ConflictDetectionResult {
|
|
62
|
+
conflicts: Conflict[];
|
|
63
|
+
nonConflicting: ReviewerChange[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ConflictsData {
|
|
67
|
+
base: string;
|
|
68
|
+
merged: string;
|
|
69
|
+
conflicts: Conflict[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// Public API
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Initialize .rev directory for revision tracking
|
|
78
|
+
* @param projectDir - Project directory path
|
|
79
|
+
* @throws {TypeError} If projectDir is not a string
|
|
80
|
+
*/
|
|
81
|
+
export function initRevDir(projectDir: string): void {
|
|
82
|
+
if (typeof projectDir !== 'string') {
|
|
83
|
+
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const revDir = path.join(projectDir, REV_DIR);
|
|
87
|
+
if (!fs.existsSync(revDir)) {
|
|
88
|
+
fs.mkdirSync(revDir, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Store the base document for three-way merge
|
|
94
|
+
* Overwrites any previous base document
|
|
95
|
+
* @param projectDir - Project directory path
|
|
96
|
+
* @param docxPath - Path to the built docx to store as base
|
|
97
|
+
* @throws {TypeError} If arguments are not strings
|
|
98
|
+
* @throws {Error} If docxPath does not exist
|
|
99
|
+
*/
|
|
100
|
+
export function storeBaseDocument(projectDir: string, docxPath: string): void {
|
|
101
|
+
if (typeof projectDir !== 'string') {
|
|
102
|
+
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
103
|
+
}
|
|
104
|
+
if (typeof docxPath !== 'string') {
|
|
105
|
+
throw new TypeError(`docxPath must be a string, got ${typeof docxPath}`);
|
|
106
|
+
}
|
|
107
|
+
if (!fs.existsSync(docxPath)) {
|
|
108
|
+
throw new Error(`Source document not found: ${docxPath}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
initRevDir(projectDir);
|
|
112
|
+
const basePath = path.join(projectDir, BASE_FILE);
|
|
113
|
+
fs.copyFileSync(docxPath, basePath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the base document path if it exists
|
|
118
|
+
* @param projectDir - Project directory path
|
|
119
|
+
* @returns Path to base document or null if not found
|
|
120
|
+
* @throws {TypeError} If projectDir is not a string
|
|
121
|
+
*/
|
|
122
|
+
export function getBaseDocument(projectDir: string): string | null {
|
|
123
|
+
if (typeof projectDir !== 'string') {
|
|
124
|
+
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const basePath = path.join(projectDir, BASE_FILE);
|
|
128
|
+
if (fs.existsSync(basePath)) {
|
|
129
|
+
return basePath;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if base document exists
|
|
136
|
+
* @param projectDir - Project directory path
|
|
137
|
+
* @returns True if base document exists
|
|
138
|
+
* @throws {TypeError} If projectDir is not a string
|
|
139
|
+
*/
|
|
140
|
+
export function hasBaseDocument(projectDir: string): boolean {
|
|
141
|
+
if (typeof projectDir !== 'string') {
|
|
142
|
+
throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return fs.existsSync(path.join(projectDir, BASE_FILE));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compute text similarity between two strings using Jaccard-like coefficient
|
|
150
|
+
* @param text1 - First text to compare
|
|
151
|
+
* @param text2 - Second text to compare
|
|
152
|
+
* @returns Similarity score 0-1 (0 = no similarity, 1 = identical)
|
|
153
|
+
*/
|
|
154
|
+
export function computeSimilarity(text1: string, text2: string): number {
|
|
155
|
+
if (typeof text1 !== 'string' || typeof text2 !== 'string') {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const words1 = new Set(text1.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH));
|
|
160
|
+
const words2 = text2.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH);
|
|
161
|
+
if (words1.size === 0 || words2.length === 0) return 0;
|
|
162
|
+
const common = words2.filter(w => words1.has(w)).length;
|
|
163
|
+
return common / Math.max(words1.size, words2.length);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if base document matches reviewer document (similarity check)
|
|
168
|
+
*/
|
|
169
|
+
export async function checkBaseMatch(basePath: string, reviewerPath: string): Promise<CheckMatchResult> {
|
|
170
|
+
try {
|
|
171
|
+
const { text: baseText } = await extractFromWord(basePath);
|
|
172
|
+
const { text: reviewerText } = await extractFromWord(reviewerPath);
|
|
173
|
+
const similarity = computeSimilarity(baseText, reviewerText);
|
|
174
|
+
return { matches: similarity > 0.5, similarity };
|
|
175
|
+
} catch {
|
|
176
|
+
return { matches: false, similarity: 0 };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract changes from diffs between original and modified text
|
|
182
|
+
* @param diffs - Array of diff changes
|
|
183
|
+
* @param reviewer - Reviewer identifier
|
|
184
|
+
*/
|
|
185
|
+
function extractChangesFromDiffs(
|
|
186
|
+
diffs: Array<{ added?: boolean; removed?: boolean; value: string }>,
|
|
187
|
+
reviewer: string,
|
|
188
|
+
): ReviewerChange[] {
|
|
189
|
+
const changes: ReviewerChange[] = [];
|
|
190
|
+
let originalPos = 0;
|
|
191
|
+
let i = 0;
|
|
192
|
+
|
|
193
|
+
while (i < diffs.length) {
|
|
194
|
+
const part = diffs[i];
|
|
195
|
+
if (!part) break;
|
|
196
|
+
|
|
197
|
+
if (!part.added && !part.removed) {
|
|
198
|
+
originalPos += part.value.length;
|
|
199
|
+
i++;
|
|
200
|
+
} else if (part.removed && diffs[i + 1]?.added) {
|
|
201
|
+
const nextPart = diffs[i + 1];
|
|
202
|
+
if (!nextPart) break;
|
|
203
|
+
changes.push({
|
|
204
|
+
reviewer,
|
|
205
|
+
type: 'replace',
|
|
206
|
+
start: originalPos,
|
|
207
|
+
end: originalPos + part.value.length,
|
|
208
|
+
oldText: part.value,
|
|
209
|
+
newText: nextPart.value,
|
|
210
|
+
});
|
|
211
|
+
originalPos += part.value.length;
|
|
212
|
+
i += 2;
|
|
213
|
+
} else if (part.removed) {
|
|
214
|
+
changes.push({
|
|
215
|
+
reviewer,
|
|
216
|
+
type: 'delete',
|
|
217
|
+
start: originalPos,
|
|
218
|
+
end: originalPos + part.value.length,
|
|
219
|
+
oldText: part.value,
|
|
220
|
+
newText: '',
|
|
221
|
+
});
|
|
222
|
+
originalPos += part.value.length;
|
|
223
|
+
i++;
|
|
224
|
+
} else if (part.added) {
|
|
225
|
+
changes.push({
|
|
226
|
+
reviewer,
|
|
227
|
+
type: 'insert',
|
|
228
|
+
start: originalPos,
|
|
229
|
+
end: originalPos,
|
|
230
|
+
oldText: '',
|
|
231
|
+
newText: part.value,
|
|
232
|
+
});
|
|
233
|
+
i++;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return changes;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Extract changes from a Word document compared to original
|
|
242
|
+
* Uses sentence-level diffing for better conflict detection
|
|
243
|
+
* @param originalText - Original text (from base document)
|
|
244
|
+
* @param wordText - Text extracted from reviewer's Word doc
|
|
245
|
+
* @param reviewer - Reviewer identifier
|
|
246
|
+
*/
|
|
247
|
+
export function extractChanges(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
|
|
248
|
+
return extractChangesFromDiffs(diffSentences(originalText, wordText), reviewer);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract changes using word-level diff (more fine-grained)
|
|
253
|
+
*/
|
|
254
|
+
export function extractChangesWordLevel(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
|
|
255
|
+
return extractChangesFromDiffs(diffWords(originalText, wordText), reviewer);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Check if two changes overlap
|
|
260
|
+
*/
|
|
261
|
+
function changesOverlap(a: ReviewerChange, b: ReviewerChange): boolean {
|
|
262
|
+
// Insertions at same point conflict
|
|
263
|
+
if (a.type === 'insert' && b.type === 'insert' && a.start === b.start) {
|
|
264
|
+
return a.newText !== b.newText; // Same insertion is not a conflict
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Check range overlap
|
|
268
|
+
const aStart = a.start;
|
|
269
|
+
const aEnd = a.type === 'insert' ? a.start : a.end;
|
|
270
|
+
const bStart = b.start;
|
|
271
|
+
const bEnd = b.type === 'insert' ? b.start : b.end;
|
|
272
|
+
|
|
273
|
+
// Ranges overlap if neither ends before the other starts
|
|
274
|
+
if (aEnd <= bStart || bEnd <= aStart) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// They overlap - but is it a conflict?
|
|
279
|
+
// Same change from different reviewers is not a conflict
|
|
280
|
+
if (a.type === b.type && a.oldText === b.oldText && a.newText === b.newText) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Detect conflicts between changes from multiple reviewers
|
|
289
|
+
* @param allChanges - Array of change arrays, one per reviewer
|
|
290
|
+
*/
|
|
291
|
+
export function detectConflicts(allChanges: ReviewerChange[][]): ConflictDetectionResult {
|
|
292
|
+
// Flatten and sort all changes by position
|
|
293
|
+
const flat = allChanges.flat().sort((a, b) => a.start - b.start || a.end - b.end);
|
|
294
|
+
|
|
295
|
+
const conflicts: Conflict[] = [];
|
|
296
|
+
const nonConflicting: ReviewerChange[] = [];
|
|
297
|
+
const usedIndices = new Set<number>();
|
|
298
|
+
let conflictId = 0;
|
|
299
|
+
|
|
300
|
+
for (let i = 0; i < flat.length; i++) {
|
|
301
|
+
if (usedIndices.has(i)) continue;
|
|
302
|
+
|
|
303
|
+
const change = flat[i];
|
|
304
|
+
if (!change) continue;
|
|
305
|
+
const conflictingChanges = [change];
|
|
306
|
+
|
|
307
|
+
// Find all changes that conflict with this one
|
|
308
|
+
for (let j = i + 1; j < flat.length; j++) {
|
|
309
|
+
if (usedIndices.has(j)) continue;
|
|
310
|
+
|
|
311
|
+
const other = flat[j];
|
|
312
|
+
if (!other) continue;
|
|
313
|
+
|
|
314
|
+
// Stop if we're past the range
|
|
315
|
+
if (other.start > change.end && change.type !== 'insert') break;
|
|
316
|
+
|
|
317
|
+
if (changesOverlap(change, other)) {
|
|
318
|
+
conflictingChanges.push(other);
|
|
319
|
+
usedIndices.add(j);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (conflictingChanges.length > 1) {
|
|
324
|
+
// Multiple reviewers changed the same region
|
|
325
|
+
const start = Math.min(...conflictingChanges.map(c => c?.start ?? 0).filter(s => s !== undefined));
|
|
326
|
+
const end = Math.max(...conflictingChanges.map(c => c?.end ?? 0).filter(e => e !== undefined));
|
|
327
|
+
const firstChange = conflictingChanges[0];
|
|
328
|
+
|
|
329
|
+
conflicts.push({
|
|
330
|
+
id: `c${++conflictId}`,
|
|
331
|
+
start,
|
|
332
|
+
end,
|
|
333
|
+
original: firstChange?.oldText || '',
|
|
334
|
+
changes: conflictingChanges.filter((c): c is ReviewerChange => c !== undefined),
|
|
335
|
+
resolved: null,
|
|
336
|
+
});
|
|
337
|
+
usedIndices.add(i);
|
|
338
|
+
} else {
|
|
339
|
+
// No conflict
|
|
340
|
+
nonConflicting.push(change);
|
|
341
|
+
usedIndices.add(i);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Deduplicate identical non-conflicting changes
|
|
346
|
+
const seen = new Map<string, boolean>();
|
|
347
|
+
const dedupedNonConflicting: ReviewerChange[] = [];
|
|
348
|
+
|
|
349
|
+
for (const change of nonConflicting) {
|
|
350
|
+
const key = `${change.start}:${change.end}:${change.type}:${change.newText}`;
|
|
351
|
+
if (!seen.has(key)) {
|
|
352
|
+
seen.set(key, true);
|
|
353
|
+
dedupedNonConflicting.push(change);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { conflicts, nonConflicting: dedupedNonConflicting };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Apply non-conflicting changes to text
|
|
362
|
+
* @param originalText
|
|
363
|
+
* @param changes - Must be sorted by position
|
|
364
|
+
*/
|
|
365
|
+
export function applyChanges(originalText: string, changes: ReviewerChange[]): string {
|
|
366
|
+
// Sort by position descending to apply from end to start
|
|
367
|
+
const sorted = [...changes].sort((a, b) => b.start - a.start);
|
|
368
|
+
|
|
369
|
+
let result = originalText;
|
|
370
|
+
|
|
371
|
+
for (const change of sorted) {
|
|
372
|
+
if (change.type === 'insert') {
|
|
373
|
+
result = result.slice(0, change.start) + change.newText + result.slice(change.start);
|
|
374
|
+
} else if (change.type === 'delete') {
|
|
375
|
+
result = result.slice(0, change.start) + result.slice(change.end);
|
|
376
|
+
} else if (change.type === 'replace') {
|
|
377
|
+
result = result.slice(0, change.start) + change.newText + result.slice(change.end);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Apply changes as CriticMarkup annotations
|
|
386
|
+
*/
|
|
387
|
+
export function applyChangesAsAnnotations(originalText: string, changes: ReviewerChange[]): string {
|
|
388
|
+
const sorted = [...changes].sort((a, b) => b.start - a.start);
|
|
389
|
+
|
|
390
|
+
let result = originalText;
|
|
391
|
+
|
|
392
|
+
for (const change of sorted) {
|
|
393
|
+
if (change.type === 'insert') {
|
|
394
|
+
const annotation = `{++${change.newText}++}`;
|
|
395
|
+
result = result.slice(0, change.start) + annotation + result.slice(change.start);
|
|
396
|
+
} else if (change.type === 'delete') {
|
|
397
|
+
const annotation = `{--${change.oldText}--}`;
|
|
398
|
+
result = result.slice(0, change.start) + annotation + result.slice(change.end);
|
|
399
|
+
} else if (change.type === 'replace') {
|
|
400
|
+
const annotation = `{~~${change.oldText}~>${change.newText}~~}`;
|
|
401
|
+
result = result.slice(0, change.start) + annotation + result.slice(change.end);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Apply changes as git-style conflict markers
|
|
410
|
+
*/
|
|
411
|
+
export function applyConflictMarkers(originalText: string, conflicts: Conflict[]): string {
|
|
412
|
+
// Sort by position descending
|
|
413
|
+
const sorted = [...conflicts].sort((a, b) => b.start - a.start);
|
|
414
|
+
|
|
415
|
+
let result = originalText;
|
|
416
|
+
|
|
417
|
+
for (const conflict of sorted) {
|
|
418
|
+
const markers: string[] = [];
|
|
419
|
+
markers.push(`<<<<<<< CONFLICT ${conflict.id}`);
|
|
420
|
+
|
|
421
|
+
for (const change of conflict.changes) {
|
|
422
|
+
markers.push(`======= ${change.reviewer}`);
|
|
423
|
+
if (change.type === 'delete') {
|
|
424
|
+
markers.push(`[DELETED: "${change.oldText}"]`);
|
|
425
|
+
} else if (change.type === 'insert') {
|
|
426
|
+
markers.push(change.newText);
|
|
427
|
+
} else {
|
|
428
|
+
markers.push(change.newText);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
markers.push(`>>>>>>> END ${conflict.id}`);
|
|
433
|
+
|
|
434
|
+
const markerText = markers.join('\n');
|
|
435
|
+
result = result.slice(0, conflict.start) + markerText + result.slice(conflict.end);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Format a conflict for display
|
|
443
|
+
*/
|
|
444
|
+
export function formatConflict(conflict: Conflict, originalText: string): string {
|
|
445
|
+
const lines: string[] = [];
|
|
446
|
+
const context = 50;
|
|
447
|
+
|
|
448
|
+
// Show context
|
|
449
|
+
const beforeStart = Math.max(0, conflict.start - context);
|
|
450
|
+
const afterEnd = Math.min(originalText.length, conflict.end + context);
|
|
451
|
+
|
|
452
|
+
const before = originalText.slice(beforeStart, conflict.start).trim();
|
|
453
|
+
const original = originalText.slice(conflict.start, conflict.end);
|
|
454
|
+
const after = originalText.slice(conflict.end, afterEnd).trim();
|
|
455
|
+
|
|
456
|
+
if (before) {
|
|
457
|
+
lines.push(` ...${before}`);
|
|
458
|
+
}
|
|
459
|
+
lines.push(` [ORIGINAL]: "${original || '(insertion point)'}"`);
|
|
460
|
+
if (after) {
|
|
461
|
+
lines.push(` ${after}...`);
|
|
462
|
+
}
|
|
463
|
+
lines.push('');
|
|
464
|
+
lines.push(' Options:');
|
|
465
|
+
|
|
466
|
+
conflict.changes.forEach((change, i) => {
|
|
467
|
+
const label = change.type === 'insert'
|
|
468
|
+
? `Insert: "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`
|
|
469
|
+
: change.type === 'delete'
|
|
470
|
+
? `Delete: "${change.oldText.slice(0, 60)}${change.oldText.length > 60 ? '...' : ''}"`
|
|
471
|
+
: `Replace → "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`;
|
|
472
|
+
lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
return lines.join('\n');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Save conflicts to file for later resolution
|
|
480
|
+
*/
|
|
481
|
+
export function saveConflicts(projectDir: string, conflicts: Conflict[], baseDoc: string): void {
|
|
482
|
+
const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
|
|
483
|
+
const data: ConflictsData = {
|
|
484
|
+
base: baseDoc,
|
|
485
|
+
merged: new Date().toISOString(),
|
|
486
|
+
conflicts,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// Ensure directory exists
|
|
490
|
+
const dir = path.dirname(conflictsPath);
|
|
491
|
+
if (!fs.existsSync(dir)) {
|
|
492
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
fs.writeFileSync(conflictsPath, JSON.stringify(data, null, 2));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Load conflicts from file
|
|
500
|
+
*/
|
|
501
|
+
export function loadConflicts(projectDir: string): ConflictsData | null {
|
|
502
|
+
const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
|
|
503
|
+
if (!fs.existsSync(conflictsPath)) {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
return JSON.parse(fs.readFileSync(conflictsPath, 'utf-8')) as ConflictsData;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Clear conflicts file after resolution
|
|
511
|
+
*/
|
|
512
|
+
export function clearConflicts(projectDir: string): void {
|
|
513
|
+
const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
|
|
514
|
+
if (fs.existsSync(conflictsPath)) {
|
|
515
|
+
fs.unlinkSync(conflictsPath);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Core merge logic: extract changes from reviewer docs, detect conflicts, apply annotations
|
|
521
|
+
*/
|
|
522
|
+
async function mergeReviewerDocsCore(
|
|
523
|
+
baseText: string,
|
|
524
|
+
reviewerDocs: ReviewerDoc[],
|
|
525
|
+
options: MergeOptions = {},
|
|
526
|
+
): Promise<MergeResult> {
|
|
527
|
+
const { diffLevel = 'sentence' } = options;
|
|
528
|
+
|
|
529
|
+
const allChanges: ReviewerChange[][] = [];
|
|
530
|
+
const allComments: ReviewerComment[] = [];
|
|
531
|
+
|
|
532
|
+
for (const doc of reviewerDocs) {
|
|
533
|
+
if (!fs.existsSync(doc.path)) {
|
|
534
|
+
throw new Error(`Reviewer file not found: ${doc.path}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const { text: wordText } = await extractFromWord(doc.path);
|
|
538
|
+
|
|
539
|
+
const changes = diffLevel === 'word'
|
|
540
|
+
? extractChangesWordLevel(baseText, wordText, doc.name)
|
|
541
|
+
: extractChanges(baseText, wordText, doc.name);
|
|
542
|
+
|
|
543
|
+
allChanges.push(changes);
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const comments = await extractWordComments(doc.path);
|
|
547
|
+
allComments.push(...comments.map(c => ({ ...c, reviewer: doc.name })));
|
|
548
|
+
} catch (e) {
|
|
549
|
+
if (process.env.DEBUG) {
|
|
550
|
+
const error = e as Error;
|
|
551
|
+
console.warn(`merge: Failed to extract comments:`, error.message);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const { conflicts, nonConflicting } = detectConflicts(allChanges);
|
|
557
|
+
|
|
558
|
+
let merged = applyChangesAsAnnotations(baseText, nonConflicting);
|
|
559
|
+
|
|
560
|
+
for (const comment of allComments) {
|
|
561
|
+
merged += `\n{>>${comment.reviewer}: ${comment.text}<<}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const stats = {
|
|
565
|
+
reviewers: reviewerDocs.length,
|
|
566
|
+
totalChanges: allChanges.flat().length,
|
|
567
|
+
nonConflicting: nonConflicting.length,
|
|
568
|
+
conflicts: conflicts.length,
|
|
569
|
+
comments: allComments.length,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
return { merged, conflicts, stats, originalText: baseText };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Merge multiple Word documents using three-way merge
|
|
577
|
+
*/
|
|
578
|
+
export async function mergeThreeWay(
|
|
579
|
+
basePath: string,
|
|
580
|
+
reviewerDocs: ReviewerDoc[],
|
|
581
|
+
options: MergeOptions = {}
|
|
582
|
+
): Promise<MergeResult & { baseText: string }> {
|
|
583
|
+
if (!fs.existsSync(basePath)) {
|
|
584
|
+
throw new Error(`Base document not found: ${basePath}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const { text: baseText } = await extractFromWord(basePath);
|
|
588
|
+
const result = await mergeReviewerDocsCore(baseText, reviewerDocs, options);
|
|
589
|
+
return { ...result, baseText };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Merge multiple Word documents against an original markdown file
|
|
594
|
+
* Legacy function - use mergeThreeWay for proper three-way merge
|
|
595
|
+
*/
|
|
596
|
+
export async function mergeReviewerDocs(
|
|
597
|
+
originalPath: string,
|
|
598
|
+
reviewerDocs: ReviewerDoc[],
|
|
599
|
+
options: MergeOptions = {}
|
|
600
|
+
): Promise<MergeResult> {
|
|
601
|
+
if (!fs.existsSync(originalPath)) {
|
|
602
|
+
throw new Error(`Original file not found: ${originalPath}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const originalText = fs.readFileSync(originalPath, 'utf-8');
|
|
606
|
+
return mergeReviewerDocsCore(originalText, reviewerDocs, options);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Resolve a conflict by choosing one option
|
|
611
|
+
* @param conflict
|
|
612
|
+
* @param choice - Index of chosen change (0-based)
|
|
613
|
+
*/
|
|
614
|
+
export function resolveConflict(conflict: Conflict, choice: number): ReviewerChange {
|
|
615
|
+
if (choice < 0 || choice >= conflict.changes.length) {
|
|
616
|
+
throw new Error(`Invalid choice: ${choice}. Must be 0-${conflict.changes.length - 1}`);
|
|
617
|
+
}
|
|
618
|
+
const selectedChange = conflict.changes[choice];
|
|
619
|
+
if (!selectedChange) {
|
|
620
|
+
throw new Error(`Invalid choice: ${choice}. Change not found`);
|
|
621
|
+
}
|
|
622
|
+
conflict.resolved = selectedChange.reviewer;
|
|
623
|
+
return selectedChange;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Get list of unresolved conflicts
|
|
628
|
+
*/
|
|
629
|
+
export function getUnresolvedConflicts(projectDir: string): Conflict[] {
|
|
630
|
+
const data = loadConflicts(projectDir);
|
|
631
|
+
if (!data) return [];
|
|
632
|
+
return data.conflicts.filter(c => c.resolved === null);
|
|
633
|
+
}
|