const express = require('express');
const multer = require('multer');
const mammoth = require('mammoth');
const { diffWords, diffWordsWithSpace, diffLines, diffArrays } = require('diff');
// Helpers: tokenization and robust token diff (handles CJK without spaces)
function tokenizeForDiff(s = '') {
const tokens = [];
const len = s.length;
for (let i = 0; i < len; i++) {
const ch = s[i];
const code = ch.codePointAt(0);
// Newlines preserved as dedicated tokens
if (ch === '\n') { tokens.push('\n'); continue; }
// CR ignored (handled by \r?\n upstream)
if (ch === '\r') continue;
// Han/CJK chars as individual tokens (basic + ext A + CJK compat)
if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3400 && code <= 0x4DBF) || (code >= 0xF900 && code <= 0xFAFF)) {
tokens.push(ch);
continue;
}
// Latin words/numbers accumulate
if (/[A-Za-z0-9]/.test(ch)) {
let j = i + 1;
while (j < len && /[A-Za-z0-9]/.test(s[j])) j++;
tokens.push(s.slice(i, j));
i = j - 1;
continue;
}
// Spaces accumulate
if (ch === ' ') {
let j = i + 1;
while (j < len && s[j] === ' ') j++;
tokens.push(s.slice(i, j));
i = j - 1;
continue;
}
// Punctuation as single token
tokens.push(ch);
}
return tokens;
}
function safeDiffTokens(a, b) {
try { return diffArrays(tokenizeForDiff(a), tokenizeForDiff(b)); }
catch {
try { return diffWordsWithSpace(a, b); } // fallback
catch { return [{ value: String(b || '') }]; }
}
}
const { Document, Packer, Paragraph, TextRun, CommentRangeStart, CommentRangeEnd, CommentReference, Comments } = require('docx');
const JSZip = require('jszip');
const router = express.Router();
const RefinityTask = require('../models/RefinityTask');
const RefinityVersion = require('../models/RefinityVersion');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 } });
const RefinityAnnotation = require('../models/RefinityAnnotation');
// Diagnostic endpoint (helps verify which backend build HF is serving)
router.get('/test', (req, res) => {
res.json({
message: 'refinity route is working',
compareCommentsImpl: 'tutorial-refinity-copied',
timestamp: new Date().toISOString(),
});
});
// ---- Helpers ----
function encodeRFC5987ValueChars(str) {
return encodeURIComponent(str)
.replace(/['()]/g, escape) // i.e., %27 %28 %29
.replace(/\*/g, '%2A')
.replace(/%(7C|60|5E)/g, '%25$1'); // encode RFC5987 attr-chars
}
function setDownloadHeaders(res, filename) {
// Fallback ASCII-only filename
const asciiName = String(filename || 'download.docx').replace(/[^\x20-\x7E]/g, '_');
const utf8Name = encodeRFC5987ValueChars(String(filename || 'download.docx'));
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
res.setHeader('Content-Disposition', `attachment; filename="${asciiName}"; filename*=UTF-8''${utf8Name}`);
}
function clampRange(start, end, textLength) {
const s = Math.max(0, Math.min(start, textLength));
const e = Math.max(s, Math.min(end, textLength));
return { start: s, end: e };
}
function pushTextRuns(children, text) {
const parts = String(text || '').split(/\r?\n/);
parts.forEach((part, idx) => {
if (idx > 0) {
children.push(new TextRun({ text: '', break: 1 }));
}
if (part) {
children.push(new TextRun({ text: part }));
}
});
}
// Parse .docx or .doc (best effort: .doc handled via mammoth may fail depending on content)
router.post('/parse', upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file provided' });
const buffer = req.file.buffer;
const result = await mammoth.extractRawText({ buffer }).catch(() => ({ value: '' }));
const text = (result && result.value) || '';
res.json({ text });
} catch (e) {
res.status(500).json({ error: 'Failed to parse document' });
}
});
// Compute HTML diff between prev and current
router.post('/diff', async (req, res) => {
try {
const prev = String(req.body?.prev || '');
const current = String(req.body?.current || '');
const parts = safeDiffTokens(prev, current);
// Preserve layout by converting newlines to
so paragraphs and blank lines are kept
const toHtml = (s) => escapeHtml(s).replace(/\r?\n/g, '
');
const html = parts.map(p => {
const text = Array.isArray(p.value) ? p.value.join('') : String(p.value || '');
if (p.added) return `${toHtml(text)}`;
if (p.removed) return `${toHtml(text)}`;
return `${toHtml(text)}`;
}).join('');
res.json({ html });
} catch (e) {
res.status(500).json({ error: 'Diff failed' });
}
});
// Generate .docx with Track Changes-like markup (approximation)
router.post('/track-changes', async (req, res) => {
try {
const prev = String(req.body?.prev || '');
const current = String(req.body?.current || '');
const outName = String(req.body?.filename || 'refinity-tracked.docx').replace(/\/+|\\+/g,'_');
// Inline-styled diff matching Show Diff (token-level; preserves newlines into paragraphs)
const parts = safeDiffTokens(prev, current);
const addedColor = '065F46';
const removedColor = '991B1B';
const paragraphs = [];
let children = [];
const pushParagraph = () => { paragraphs.push(new Paragraph({ children: children.length ? children : [ new TextRun('') ] })); children = []; };
parts.forEach(p => {
const arr = Array.isArray(p.value) ? p.value : String(p.value || '').split(/(?<=)/);
arr.forEach(tok => {
if (tok === '\n') { pushParagraph(); return; }
if (!tok) return;
if (p.added) {
// Subtle background akin to Show Diff (green-100) with dark teal text
const opt = { text: tok, color: addedColor };
try {
Object.assign(opt, { shading: { fill: 'DCFCE7', color: 'auto', type: 'clear' } });
} catch {}
children.push(new TextRun(opt));
} else if (p.removed) {
// Subtle background akin to Show Diff (red-100) with dark red text + strike
const opt = { text: tok, color: removedColor, strike: true };
try {
Object.assign(opt, { shading: { fill: 'FEE2E2', color: 'auto', type: 'clear' } });
} catch {}
children.push(new TextRun(opt));
} else {
children.push(new TextRun({ text: tok }));
}
});
});
if (children.length || paragraphs.length === 0) pushParagraph();
const doc = new Document({ sections: [ { properties: {}, children: paragraphs } ] });
const buffer = await Packer.toBuffer(doc);
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
} catch (e) {
// Fallback: plain export to avoid 500s
try {
const current = String(req.body?.current || '');
const paragraphs = String(current).split(/\r?\n/).map(line => new Paragraph({ children: [ new TextRun({ text: line }) ] }));
const doc = new Document({ sections: [ { properties: {}, children: paragraphs.length ? paragraphs : [ new Paragraph('') ] } ] });
const buffer = await Packer.toBuffer(doc);
const outName = String(req.body?.filename || 'refinity-fallback.docx').replace(/[\\/]+/g,'_');
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
} catch (e2) {
res.status(500).json({ error: 'Failed to generate document' });
}
}
});
// Generate .docx with comments in the sidebar describing changes (Word/WPS comment balloons)
router.post('/track-changes-comments', async (req, res) => {
try {
const prev = String(req.body?.prev || '');
const current = String(req.body?.current || '');
const outName = String(req.body?.filename || 'refinity-tracked-comments.docx').replace(/[\\/]+/g,'_');
try {
// Attach comment ranges for added/removed spans
const authorName = String(req.body?.authorName || 'Refinity');
const authorInitials = String(req.body?.authorInitials || (authorName.split(/\s+/).map(s=>s[0]||'').join('').slice(0,3).toUpperCase()) || 'RF');
const comments = new Comments();
let nextCommentId = 0;
const children = [];
const parts = safeDiffWords(prev, current);
parts.forEach(p => {
const value = p.value || '';
if (!value) return;
if (p.added) {
const cid = nextCommentId++;
comments.create(cid, authorName, authorInitials, new Date(), [
new Paragraph({ children: [ new TextRun({ text: 'Added: ', bold: true }), new TextRun({ text: value }) ] })
]);
children.push(new CommentRangeStart(cid));
children.push(new TextRun({ text: value }));
children.push(new CommentRangeEnd(cid));
children.push(new CommentReference(cid));
} else if (p.removed) {
const cid = nextCommentId++;
comments.create(cid, authorName, authorInitials, new Date(), [
new Paragraph({ children: [ new TextRun({ text: 'Removed: ', bold: true }), new TextRun({ text: value }) ] })
]);
children.push(new CommentRangeStart(cid));
children.push(new TextRun({ text: value }));
children.push(new CommentRangeEnd(cid));
children.push(new CommentReference(cid));
} else {
children.push(new TextRun({ text: value }));
}
});
const doc = new Document({ sections: [ { properties: {}, children: [ new Paragraph({ children }) ] } ], comments });
const buffer = await Packer.toBuffer(doc);
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
return;
} catch (e1) {
// Fallback: plain text with minimal markers (still distinct from inline variant)
const lines = String(current || '').split(/\r?\n/);
const paragraphs = lines.map(ln => new Paragraph({ children: [ new TextRun({ text: ln }) ] }));
const doc = new Document({ sections: [ { properties: {}, children: paragraphs } ] });
const buffer = await Packer.toBuffer(doc);
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
return;
}
} catch (e) {
// Fallback: plain export to avoid 500s
try {
const current = String(req.body?.current || '');
const paragraphs = String(current).split(/\r?\n/).map(line => new Paragraph({ children: [ new TextRun({ text: line }) ] }));
const doc = new Document({ sections: [ { properties: {}, children: paragraphs.length ? paragraphs : [ new Paragraph('') ] } ] });
const buffer = await Packer.toBuffer(doc);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
const outName = String(req.body?.filename || 'refinity-fallback-comments.docx').replace(/[\\/]+/g,'_');
res.setHeader('Content-Disposition', `attachment; filename="${outName}"`);
res.send(Buffer.from(buffer));
} catch (e2) {
res.status(500).json({ error: 'Failed to generate document with comments' });
}
}
});
// Generate .docx where each annotated change on the older version is surfaced as a Word comment.
// - Body text: older version's translation (prev), with natural paragraphs preserved
// - For each annotation on that version: wrap the original span in a comment range whose
// corresponding sidebar comment contains "original → correction [Error category]".
router.post('/compare-comments-with-corrections', async (req, res) => {
try {
const prev = String(req.body?.prev || '');
const current = String(req.body?.current || '');
const outName = String(req.body?.filename || 'refinity-compare-comments.docx').replace(/[\\/]+/g,'_');
const annotationVersionId = req.body?.annotationVersionId;
const authorName = String(req.body?.authorName || 'Refinity');
const authorInitials = String(req.body?.authorInitials || (authorName.split(/\s+/).map(s=>s[0]||'').join('').slice(0,3).toUpperCase()) || 'RF');
const requester = String(req.headers['x-user-name'] || req.headers['x-user-email'] || '').toLowerCase();
const roleHdr = String(req.headers['x-user-role'] || req.headers['user-role'] || '').toLowerCase();
const isAdmin = roleHdr === 'admin';
if (!annotationVersionId) {
return res.status(400).json({ error: 'annotationVersionId is required' });
}
// Get the older version to find taskId and versionNumber
const olderVersion = await RefinityVersion.findById(annotationVersionId).lean();
if (!olderVersion) {
return res.status(404).json({ error: 'Version not found' });
}
// Find all versions in the same task that come after the older version
// (kept for compatibility / potential future use)
const laterVersions = await RefinityVersion.find({
taskId: olderVersion.taskId,
versionNumber: { $gt: olderVersion.versionNumber }
}).sort({ versionNumber: 1 }).lean();
// Match Tutorial DR compare-sidebar behavior:
// include annotations from the older version AND all later versions in the same task.
// This allows compare(v1, v4) to surface annotations added while revising v2/v3 as well.
const versionIds = [annotationVersionId, ...laterVersions.map(v => v._id)];
const annQuery = { versionId: { $in: versionIds } };
// Toolkit DR is shared across users; avoid mixing other users' highlights/corrections in exports.
// Non-admin: export the requester's annotations, but ALSO include legacy annotations that have no createdBy
// so we don't "miss edits" when older HF data lacks creator attribution.
if (requester && !isAdmin) {
annQuery.$or = [
{ createdBy: requester },
{ createdBy: { $exists: false } },
{ createdBy: null },
{ createdBy: '' },
];
}
const allAnns = await RefinityAnnotation.find(annQuery).sort({ start: 1, end: 1 }).lean();
// Build a map of annotations by their text content for matching
// Key: normalized text content, Value: annotation with error type, correction, and positions
const annByText = new Map();
const annByPosition = []; // Array of annotations sorted by position for range matching
for (const ann of allAnns) {
const version = ann.versionId.toString() === annotationVersionId.toString()
? olderVersion
: laterVersions.find(v => v._id.toString() === ann.versionId.toString());
if (!version) continue;
// Important safety for toolkit exports (HF data can contain annotations created on later versions):
// Annotation offsets are relative to the version they were created on. If we apply those offsets
// directly to `prev` and the text has shifted, we will miss edits.
//
// Strategy:
// - Prefer using the slice at [start,end) in `prev` if it matches the origin slice.
// - If it doesn't match (or `prev` slice is empty/out-of-range), remap by searching the origin slice
// text inside `prev` and anchoring to the closest occurrence.
// - If we can't map, skip (better than inventing edits).
const rawStart = Number(ann.start || 0);
const rawEnd = Number(ann.end || 0);
const originContent = String(version.content || '');
const originSlice = originContent.slice(Math.max(0, rawStart), Math.max(0, rawEnd));
let mappedStart = rawStart;
let mappedEnd = rawEnd;
let annText = prev.slice(Math.max(0, rawStart), Math.max(0, rawEnd));
if (originSlice && originSlice !== annText) {
const occurrences = [];
let fromIdx = 0;
while (fromIdx <= prev.length) {
const idx = prev.indexOf(originSlice, fromIdx);
if (idx === -1) break;
occurrences.push(idx);
fromIdx = idx + 1; // allow overlapping matches
}
if (!occurrences.length) {
continue;
}
let bestIdx = occurrences[0];
let bestDist = Math.abs(bestIdx - rawStart);
for (const idx of occurrences) {
const d = Math.abs(idx - rawStart);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
}
mappedStart = bestIdx;
mappedEnd = bestIdx + originSlice.length;
annText = prev.slice(mappedStart, mappedEnd);
}
if (annText) {
const normalized = annText.trim().replace(/\s+/g, ' ');
// Store the annotation with its error type and correction
if (!annByText.has(normalized)) {
annByText.set(normalized, []);
}
annByText.get(normalized).push({
category: ann.category || 'other',
start: mappedStart,
end: mappedEnd,
correction: ann.correction || '',
originalAnn: ann, // Store full annotation for reference
});
// Also store by position for range matching
annByPosition.push({
start: mappedStart,
end: mappedEnd,
category: ann.category || 'other',
correction: ann.correction || '',
selectedText: annText,
normalized,
originalAnn: ann,
});
}
}
// Sort annotations by position
annByPosition.sort((a, b) => a.start - b.start || a.end - b.end);
// Helper function to calculate text similarity (0-1)
function calculateTextSimilarity(str1, str2) {
if (!str1 || !str2) return 0;
if (str1 === str2) return 1;
// Simple similarity: check if one contains the other, or calculate character overlap
const longer = str1.length > str2.length ? str1 : str2;
const shorter = str1.length > str2.length ? str2 : str1;
if (longer.includes(shorter)) {
return shorter.length / longer.length;
}
// Character-based similarity
const set1 = new Set(str1.split(''));
const set2 = new Set(str2.split(''));
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return union.size > 0 ? intersection.size / union.size : 0;
}
// ANNOTATION-FIRST APPROACH (exactly like Tutorial DR):
// Only process annotations from the database. Skip all diff-based processing.
const USE_ANNOTATION_FIRST = true;
let mergedAnnotations = [];
if (USE_ANNOTATION_FIRST) {
const annotationBasedItems = [];
for (const ann of annByPosition) {
const annSelectedText = ann.selectedText;
const annCorrection = ann.correction || '';
const annCategory = ann.category || 'other';
const isDeletion = !annCorrection || annCorrection.trim() === '' || annCorrection.trim() === annSelectedText.trim();
const isInsertion = annSelectedText.trim().length <= 2 && annCorrection.trim().length > annSelectedText.trim().length;
annotationBasedItems.push({
start: ann.start,
end: ann.end,
removedText: annSelectedText,
replacementText: annCorrection,
category: annCategory,
isDeleted: isDeletion,
isInsertion: isInsertion,
isMove: false,
});
}
annotationBasedItems.sort((a, b) => a.start - b.start || a.end - b.end);
mergedAnnotations = (() => {
const out = [];
const seen = new Set();
for (const it of annotationBasedItems) {
const key = [
it.start ?? '',
it.end ?? '',
String(it.category || ''),
String(it.replacementText || ''),
it.isDeleted ? '1' : '0',
it.isInsertion ? '1' : '0',
it.isMove ? '1' : '0',
].join('|');
if (seen.has(key)) continue;
seen.add(key);
out.push(it);
}
return out;
})();
} else {
// 1) Run diff to find ALL changes (like Show Diff does)
const diffParts = safeDiffTokens(prev, current);
// 1.5) First pass: Detect text moves/reordering (removed text that appears elsewhere as added text)
// This helps identify syntactic changes where text is moved rather than deleted+inserted
const removedParts = [];
const addedParts = [];
let tempOldPos = 0;
for (let i = 0; i < diffParts.length; i++) {
const part = diffParts[i];
if (part.removed) {
const removedText = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
const normalized = removedText.trim().replace(/\s+/g, ' ');
if (normalized.length > 0) {
removedParts.push({
index: i,
text: removedText,
normalized,
start: tempOldPos,
end: tempOldPos + removedText.length,
});
}
tempOldPos += removedText.length;
} else if (part.added) {
const addedText = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
const normalized = addedText.trim().replace(/\s+/g, ' ');
if (normalized.length > 0) {
// Check if this follows a removed part (would be a replacement, not a move)
const prevPart = i > 0 ? diffParts[i - 1] : null;
if (!prevPart || !prevPart.removed) {
addedParts.push({
index: i,
text: addedText,
normalized,
});
}
}
} else {
const text = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
tempOldPos += text.length;
}
}
// Match removed parts with added parts (potential moves/reorderings)
const moveMatches = new Map(); // removed index -> added index
const usedAddedIndices = new Set();
// Try to match individual removed parts with added parts
for (const removed of removedParts) {
// Check if this removal has a direct replacement (next part is added)
const nextPart = removed.index + 1 < diffParts.length ? diffParts[removed.index + 1] : null;
if (nextPart && nextPart.added) {
// This is a replacement, not a move - skip
continue;
}
// Look for matching added text elsewhere
for (const added of addedParts) {
if (usedAddedIndices.has(added.index)) continue;
const removedNorm = removed.normalized;
const addedNorm = added.normalized;
if (!removedNorm || !addedNorm) continue;
const substringMatch =
removedNorm.length > 5 &&
(removedNorm.includes(addedNorm) || addedNorm.includes(removedNorm));
const similarity = calculateTextSimilarity(removedNorm, addedNorm);
if (substringMatch || similarity > 0.7) {
moveMatches.set(removed.index, added.index);
usedAddedIndices.add(added.index);
break;
}
}
}
// 2) Generate annotations from all removed parts AND pure insertions in the diff
const diffBasedAnnotations = [];
let oldPos = 0;
const processedAddedIndices = new Set();
let processedRemovedIndices = new Set();
const usedAnnIndices = new Set(); // Track which annotations have been used for grammar/syntax errors
for (let i = 0; i < diffParts.length; i++) {
const part = diffParts[i];
if (part.removed) {
// Skip if already processed as part of a reordering group
if (processedRemovedIndices.has(i)) {
const removedText = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
oldPos += removedText.length;
continue;
}
const removedText = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
const start = oldPos;
const end = oldPos + removedText.length;
// FIRST: For grammar/syntax errors, check if there's an annotation that overlaps with this removal
// Use the annotation's selected text and correction, backed up by diff results
let matchingGrammarAnn = null;
for (let annIdx = 0; annIdx < annByPosition.length; annIdx++) {
if (usedAnnIndices.has(annIdx)) continue;
const ann = annByPosition[annIdx];
// Check if annotation overlaps with current removal position
// Annotation should start before or at removal end, and end after or at removal start
const overlaps = (ann.start < end && ann.end > start) && (ann.category === 'grammar' || ann.category === 'syntax');
if (overlaps && ann.correction && ann.correction.trim()) {
// Found a grammar/syntax annotation that overlaps with correction
matchingGrammarAnn = ann;
usedAnnIndices.add(annIdx);
break;
}
}
// If we found a grammar/syntax annotation, use it
if (matchingGrammarAnn) {
// Use the annotation's selected text and correction
const annSelectedText = matchingGrammarAnn.selectedText;
const annCorrection = matchingGrammarAnn.correction;
// Verify with diff: check if the correction appears in the current version
// This backs up the annotation with diff results
const correctionInCurrent = current.includes(annCorrection);
// Use annotation if correction exists and either appears in current version or is non-empty
if (annCorrection.trim().length > 0) {
diffBasedAnnotations.push({
start: matchingGrammarAnn.start,
end: matchingGrammarAnn.end,
removedText: annSelectedText,
replacementText: annCorrection,
category: matchingGrammarAnn.category,
isDeleted: false,
isInsertion: false,
isMove: true,
});
// Mark this removal as processed
processedRemovedIndices.add(i);
oldPos = end;
continue;
}
}
// Check if this is part of an individual move match
const isMove = moveMatches.has(i);
if (isMove) {
// This is a move/reordering - treat as replacement (not deletion)
const addedIndex = moveMatches.get(i);
const addedPart = addedParts.find(a => a.index === addedIndex);
const replacementText = addedPart ? addedPart.text : '';
// Try to match with existing annotations
const normalizedRemoved = removedText.trim().replace(/\s+/g, ' ');
let matchingAnns = annByText.get(normalizedRemoved) || [];
if (matchingAnns.length === 0) {
for (const [annText, anns] of annByText.entries()) {
if (normalizedRemoved.includes(annText) || annText.includes(normalizedRemoved)) {
matchingAnns = anns;
break;
}
}
}
// Use 'grammar' category for syntactic changes/moves, or matched annotation category
const category = matchingAnns.length > 0 ? matchingAnns[0].category : 'grammar';
diffBasedAnnotations.push({
start,
end,
removedText,
replacementText,
category,
isDeleted: false,
isInsertion: false,
isMove: true,
});
processedAddedIndices.add(addedIndex);
} else {
// Regular removal - look ahead for replacement text
let replacementText = '';
let j = i + 1;
while (j < diffParts.length && diffParts[j].added) {
const addedText = Array.isArray(diffParts[j].value) ? diffParts[j].value.join('') : String(diffParts[j].value || '');
replacementText += addedText;
j++;
}
// Check if this removed text appears elsewhere as added text (reordering detection)
// Only do this if there's no direct replacement
let isReordering = false;
if (!replacementText || !replacementText.trim()) {
// FIRST: Check if this removal is part of a larger reordering group
// This should take priority to show the full reordered section
// Get all removed parts (including current one) that don't have direct replacements
const remainingRemoved = removedParts.filter(r => {
if (processedRemovedIndices.has(r.index)) return false;
const rIdx = r.index;
// Include if not yet processed and not a direct replacement
const nextPart = rIdx + 1 < diffParts.length ? diffParts[rIdx + 1] : null;
return !(nextPart && nextPart.added);
});
// Get all added parts that haven't been processed (check all added parts in diff)
const remainingAdded = [];
for (let k = 0; k < diffParts.length; k++) {
if (diffParts[k].added && !processedAddedIndices.has(k)) {
const addedText = Array.isArray(diffParts[k].value) ? diffParts[k].value.join('') : String(diffParts[k].value || '');
const normalized = addedText.trim().replace(/\s+/g, ' ');
if (normalized.length > 0) {
remainingAdded.push({ index: k, text: addedText, normalized });
}
}
}
// Check if we have a reordering group
// This includes cases where multiple removals match one addition, or one removal matches multiple additions
if (remainingRemoved.length > 0 && remainingAdded.length > 0) {
// Combine ALL remaining removed and added parts to check for reordering
const combinedRemoved = remainingRemoved.map(r => r.normalized).join('');
const combinedAdded = remainingAdded.map(a => a.normalized).join('');
// Check if combined text makes sense as a reordering
const combinedSimilarity = calculateTextSimilarity(combinedRemoved, combinedAdded);
const removedChars = combinedRemoved.replace(/\s+/g, '').split('').sort().join('');
const addedChars = combinedAdded.replace(/\s+/g, '').split('').sort().join('');
const charMatch = removedChars === addedChars && removedChars.length > 5;
// Check character overlap
let hasSignificantOverlap = false;
if (combinedRemoved.length > 5 && combinedAdded.length > 5) {
const overlapRatio = Math.min(removedChars.length, addedChars.length) / Math.max(removedChars.length, addedChars.length);
hasSignificantOverlap = overlapRatio > 0.6; // Lower threshold
}
// Check if individual removed parts appear in added text (indicating reordering)
let individualMatches = 0;
for (const r of remainingRemoved) {
for (const a of remainingAdded) {
// Check if removed text appears in added text (or vice versa)
const removedInAdded = a.normalized.includes(r.normalized);
const addedInRemoved = r.normalized.includes(a.normalized);
const similarity = calculateTextSimilarity(r.normalized, a.normalized);
// Match if significant overlap (at least 30% of shorter string for CJK)
const minLen = Math.min(r.normalized.length, a.normalized.length);
const matchThreshold = Math.max(2, Math.floor(minLen * 0.3));
const hasSignificantOverlap = (removedInAdded && r.normalized.length >= matchThreshold) ||
(addedInRemoved && a.normalized.length >= matchThreshold) ||
(similarity > 0.3 && minLen >= 3);
if (hasSignificantOverlap) {
individualMatches++;
break; // Found match for this removal, move to next
}
}
}
// Determine if this is a reordering:
// 1. Multiple removals/addition(s) with matches
// 2. Combined text has high similarity (same content, reordered)
// 3. Character sets match (same characters, different order)
const hasMultipleParts = remainingRemoved.length > 1 || remainingAdded.length > 1;
const hasGoodSimilarity = combinedSimilarity > 0.5;
const hasMatches = individualMatches > 0;
// Treat as reordering if:
// - We have multiple parts AND found matches, OR
// - Combined similarity is high (same content reordered), OR
// - Character match (same chars, different order)
const isGroupReordering = (hasMultipleParts && hasMatches) ||
hasGoodSimilarity ||
charMatch ||
hasSignificantOverlap;
if (isGroupReordering) {
// This is a reordering - treat all parts as a single replacement
const combinedRemovedRaw = remainingRemoved.map(r => r.text).join('');
const combinedAddedRaw = remainingAdded.map(a => a.text).join('');
// Only process if current removal is the first in the group
const isFirstInGroup = remainingRemoved[0].index === i;
if (isFirstInGroup) {
replacementText = combinedAddedRaw;
isReordering = true;
console.log('[reordering-group-detected]', {
removedCount: remainingRemoved.length,
addedCount: remainingAdded.length,
individualMatches,
combinedRemoved: combinedRemovedRaw.substring(0, 150),
combinedAdded: combinedAddedRaw.substring(0, 150),
similarity: combinedSimilarity.toFixed(2),
charMatch,
hasMultipleParts,
hasGoodSimilarity,
});
// Mark all parts as processed
remainingRemoved.forEach(r => {
if (r.index !== i) {
processedRemovedIndices.add(r.index);
}
});
remainingAdded.forEach(a => processedAddedIndices.add(a.index));
const normalizedCombined = combinedRemovedRaw.trim().replace(/\s+/g, ' ');
let matchingAnns = annByText.get(normalizedCombined) || [];
if (matchingAnns.length === 0) {
for (const [annText, anns] of annByText.entries()) {
if (normalizedCombined.includes(annText) || annText.includes(normalizedCombined)) {
matchingAnns = anns;
break;
}
}
}
const category = matchingAnns.length > 0 ? matchingAnns[0].category : 'grammar';
diffBasedAnnotations.push({
start,
end: remainingRemoved[remainingRemoved.length - 1].end,
removedText: combinedRemovedRaw,
replacementText: combinedAddedRaw,
category,
isDeleted: false,
isInsertion: false,
isMove: true,
});
}
}
}
// SECOND: If no group found, check for individual matches
// Only do this if there are no other unprocessed removals that could form a group
if (!isReordering) {
// Skip individual matching if there are other removals that haven't been processed
// This gives group detection a chance to work
const hasOtherUnprocessedRemovals = removedParts.some(r =>
r.index !== i &&
!processedRemovedIndices.has(r.index) &&
!(r.index + 1 < diffParts.length && diffParts[r.index + 1] && diffParts[r.index + 1].added)
);
// Only do individual matching if no other removals could form a group
if (!hasOtherUnprocessedRemovals) {
const normalizedRemoved = removedText.trim().replace(/\s+/g, ' ');
// Check ALL added parts in the diff (not just pure insertions)
for (let k = 0; k < diffParts.length; k++) {
if (k === i) continue; // Skip current removal
const addedPart = diffParts[k];
if (!addedPart.added) continue;
if (processedAddedIndices.has(k)) continue;
const addedText = Array.isArray(addedPart.value) ? addedPart.value.join('') : String(addedPart.value || '');
const normalizedAdded = addedText.trim().replace(/\s+/g, ' ');
if (!normalizedAdded || normalizedAdded.length === 0) continue;
// Check multiple ways with lower thresholds:
const hasSubstringMatch = normalizedAdded.includes(normalizedRemoved) || normalizedRemoved.includes(normalizedAdded);
const hasRawSubstringMatch = addedText.includes(removedText) || removedText.includes(addedText);
// For CJK or longer text, check if significant portion matches
let hasSignificantMatch = false;
if (normalizedRemoved.length > 3) {
const minMatchLen = Math.max(3, Math.floor(normalizedRemoved.length * 0.5));
for (let len = normalizedRemoved.length; len >= minMatchLen; len--) {
const substr = normalizedRemoved.slice(0, len);
if (normalizedAdded.includes(substr) || addedText.includes(substr)) {
hasSignificantMatch = true;
break;
}
}
}
const similarity = calculateTextSimilarity(normalizedRemoved, normalizedAdded);
// Very low threshold: 0.5 for similarity, or any substring match
if (hasSubstringMatch || hasRawSubstringMatch || hasSignificantMatch || (normalizedRemoved.length > 2 && similarity > 0.5)) {
// This is a reordering - treat as replacement with grammar category
replacementText = addedText;
processedAddedIndices.add(k);
isReordering = true;
console.log('[reordering-single-detected]', {
removed: removedText.substring(0, 50),
added: addedText.substring(0, 50),
similarity: similarity.toFixed(2),
});
// Try to match with existing annotations
let matchingAnns = annByText.get(normalizedRemoved) || [];
if (matchingAnns.length === 0) {
for (const [annText, anns] of annByText.entries()) {
if (normalizedRemoved.includes(annText) || annText.includes(normalizedRemoved)) {
matchingAnns = anns;
break;
}
}
}
const category = matchingAnns.length > 0 ? matchingAnns[0].category : 'grammar';
diffBasedAnnotations.push({
start,
end,
removedText,
replacementText,
category,
isDeleted: false,
isInsertion: false,
isMove: true,
});
break; // Found match, exit loop
}
}
}
}
}
// Skip normal processing if we already handled as reordering
if (isReordering) {
// Update oldPos - if this was part of a group, advance past all processed removals
const processedRemovals = removedParts.filter(r => processedRemovedIndices.has(r.index) || r.index === i);
if (processedRemovals.length > 0) {
// Find the maximum end position of all processed removals
const maxEnd = Math.max(...processedRemovals.map(r => r.end));
oldPos = maxEnd;
} else {
oldPos = end;
}
continue;
}
// Normal processing for regular removals/replacements
// FIRST: Check if there's an annotation (any category) that overlaps with this removal
// Use annotation-first approach for all error types
let matchingAnn = null;
for (let annIdx = 0; annIdx < annByPosition.length; annIdx++) {
if (usedAnnIndices.has(annIdx)) continue;
const ann = annByPosition[annIdx];
// Check if annotation overlaps with current removal position
const overlaps = (ann.start < end && ann.end > start);
if (overlaps && ann.correction && ann.correction.trim()) {
// Found an annotation that overlaps - use it
matchingAnn = ann;
usedAnnIndices.add(annIdx);
break;
}
}
// If we found an annotation, use it
if (matchingAnn) {
const annSelectedText = matchingAnn.selectedText;
const annCorrection = matchingAnn.correction;
if (annCorrection.trim().length > 0) {
diffBasedAnnotations.push({
start: matchingAnn.start,
end: matchingAnn.end,
removedText: annSelectedText,
replacementText: annCorrection,
category: matchingAnn.category,
isDeleted: false,
isInsertion: false,
isMove: false,
});
// Mark this removal as processed
processedRemovedIndices.add(i);
oldPos = matchingAnn.end;
continue;
}
}
// Fallback to diff-based matching if no annotation found
const normalizedRemoved = removedText.trim().replace(/\s+/g, ' ');
let matchingAnns = annByText.get(normalizedRemoved) || [];
// If no exact match, try substring matching
if (matchingAnns.length === 0) {
for (const [annText, anns] of annByText.entries()) {
// Check if removed text contains annotated text or vice versa
if (normalizedRemoved.includes(annText) || annText.includes(normalizedRemoved)) {
matchingAnns = anns;
break;
}
}
}
// Use the first matching annotation's category, or default to 'unidiomatic'
const category = matchingAnns.length > 0 ? matchingAnns[0].category : 'unidiomatic';
diffBasedAnnotations.push({
start,
end,
removedText,
replacementText,
category,
isDeleted: !replacementText || !replacementText.trim(),
isInsertion: false,
isMove: false,
});
}
oldPos = end;
} else if (part.added) {
// Skip if this is part of a move (already handled above)
if (processedAddedIndices.has(i)) {
continue;
}
// Check if this follows a removed part (already handled above)
const prevPart = i > 0 ? diffParts[i - 1] : null;
if (prevPart && prevPart.removed) {
// This insertion was already handled as a replacement
continue;
}
// This is a pure insertion - create an annotation at the insertion point
const insertedText = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
// FIRST: Check if there's a grammar/syntax annotation (or any annotation) that overlaps with this insertion
// Use annotation-first approach for additions as well
let matchingAnn = null;
const insertionPoint = oldPos;
for (let annIdx = 0; annIdx < annByPosition.length; annIdx++) {
if (usedAnnIndices.has(annIdx)) continue;
const ann = annByPosition[annIdx];
// Check if annotation's correction matches the inserted text, or if annotation is near insertion point
const normalizedInserted = insertedText.trim().replace(/\s+/g, ' ');
const normalizedCorrection = (ann.correction || '').trim().replace(/\s+/g, ' ');
// Check if inserted text matches annotation's correction, or annotation is at/near insertion point
const textMatches = normalizedInserted === normalizedCorrection ||
normalizedInserted.includes(normalizedCorrection) ||
normalizedCorrection.includes(normalizedInserted);
const positionNear = Math.abs(ann.start - insertionPoint) <= 10; // Within 10 characters
if (textMatches || (positionNear && ann.correction && ann.correction.trim())) {
matchingAnn = ann;
usedAnnIndices.add(annIdx);
break;
}
}
// If we found an annotation, use it
if (matchingAnn) {
// Use the annotation's selected text and correction
const annSelectedText = matchingAnn.selectedText;
const annCorrection = matchingAnn.correction;
if (annCorrection.trim().length > 0) {
// For insertions, attach comment to the selected text (not a single character)
diffBasedAnnotations.push({
start: matchingAnn.start,
end: matchingAnn.end,
removedText: annSelectedText, // Use annotation's selected text
replacementText: annCorrection,
category: matchingAnn.category,
isDeleted: false,
isInsertion: true,
isMove: false,
});
// Mark this addition as processed
processedAddedIndices.add(i);
// Don't advance oldPos for insertions
continue;
}
}
// Fallback: Use the character at oldPos (or before it) to attach the comment
let insertionStart = oldPos;
let insertionEnd = oldPos;
// If we're at the end of the text, use the last character
if (insertionStart >= prev.length && prev.length > 0) {
insertionStart = prev.length - 1;
insertionEnd = prev.length;
} else if (insertionStart < prev.length) {
// Use a single character at the insertion point to attach the comment
insertionEnd = Math.min(insertionStart + 1, prev.length);
} else {
// Empty text case - use position 0
insertionStart = 0;
insertionEnd = 0;
}
// Try to match with existing annotations - check if any annotation mentions this insertion
let matchingAnns = [];
const normalizedInserted = insertedText.trim().replace(/\s+/g, ' ');
for (const [annText, anns] of annByText.entries()) {
// Check if inserted text matches any annotation's correction
if (normalizedInserted.includes(annText) || annText.includes(normalizedInserted)) {
matchingAnns = anns;
break;
}
}
// Use the first matching annotation's category, or default to 'omission' for insertions
const category = matchingAnns.length > 0 ? matchingAnns[0].category : 'omission';
// For insertions, we need to attach the comment to some text
// Use a placeholder or the character at the insertion point
const anchorText = insertionStart < prev.length ? prev.slice(insertionStart, insertionEnd) : (insertionStart > 0 ? prev.slice(insertionStart - 1, insertionStart) : ' ');
diffBasedAnnotations.push({
start: insertionStart,
end: insertionEnd,
removedText: anchorText, // Use anchor text for comment attachment
replacementText: insertedText,
category,
isDeleted: false,
isInsertion: true,
isMove: false,
});
// Don't advance oldPos for pure insertions
} else {
// Unchanged part - advance position
const text = Array.isArray(part.value) ? part.value.join('') : String(part.value || '');
oldPos += text.length;
}
}
// Merge deletions followed by matching insertions (reorderings) into single replacement annotations
const mergeDeletionInsertionPairs = (annotations) => {
const finalAnnotations = [];
const insertionPool = [];
const usedInsertionIndices = new Set();
const normalize = (text) => String(text || '').trim().replace(/\s+/g, ' ');
annotations.forEach((ann, idx) => {
if (ann.isInsertion) {
insertionPool.push({
idx,
ann,
normalized: normalize(ann.replacementText || ann.removedText || ''),
});
}
});
annotations.forEach((ann, idx) => {
if (ann.isInsertion) {
return;
}
if (ann.isDeleted && !ann.isMove) {
const removedNorm = normalize(ann.removedText || '');
if (removedNorm) {
let bestMatch = null;
let bestScore = 0;
insertionPool.forEach((entry) => {
if (usedInsertionIndices.has(entry.idx)) return;
if (!entry.normalized) return;
const hasSubstringMatch =
entry.normalized.includes(removedNorm) || removedNorm.includes(entry.normalized);
const similarity = calculateTextSimilarity(removedNorm, entry.normalized);
if (hasSubstringMatch || similarity > 0.7) {
if (similarity > bestScore) {
bestScore = similarity;
bestMatch = entry;
}
}
});
if (bestMatch) {
usedInsertionIndices.add(bestMatch.idx);
finalAnnotations.push({
...ann,
replacementText: bestMatch.ann.replacementText || '',
isDeleted: false,
isInsertion: false,
isMove: true,
category: ann.category || 'grammar',
});
return;
}
}
}
finalAnnotations.push(ann);
});
// Append leftover insertions (those not merged)
annotations.forEach((ann, idx) => {
if (ann.isInsertion && !usedInsertionIndices.has(idx)) {
finalAnnotations.push(ann);
}
});
return finalAnnotations;
};
mergedAnnotations = mergeDeletionInsertionPairs(diffBasedAnnotations);
// De-dupe identical change items so sidebar-comment export doesn't duplicate spans/comments.
// This mirrors the tutorial DR fix and keeps behavior minimal/safe.
{
const out = [];
const seen = new Set();
for (const it of mergedAnnotations) {
const key = [
it.start ?? '',
it.end ?? '',
String(it.category || ''),
String(it.removedText || ''),
String(it.replacementText || ''),
it.isDeleted ? '1' : '0',
it.isInsertion ? '1' : '0',
it.isMove ? '1' : '0',
].join('|');
if (seen.has(key)) continue;
seen.add(key);
out.push(it);
}
mergedAnnotations = out;
}
} // end diff-first fallback
// 3) Split the older text into logical lines with their global offsets
const lines = [];
let pos = 0;
while (pos <= prev.length) {
const nlIdx = prev.indexOf('\n', pos);
if (nlIdx === -1) {
if (pos <= prev.length) {
const end = prev.length;
const text = prev.slice(pos, end).replace(/\r$/, '');
lines.push({ text, start: pos, end });
}
break;
}
let lineEnd = nlIdx;
if (lineEnd > pos && prev[lineEnd - 1] === '\r') lineEnd -= 1;
const text = prev.slice(pos, lineEnd);
lines.push({ text, start: pos, end: lineEnd });
pos = nlIdx + 1; // skip '\n'
}
if (!lines.length) {
lines.push({ text: '', start: 0, end: 0 });
}
// 4) For each diff-based annotation, create per-line comment items (split if it spans lines)
const commentItems = [];
let nextCommentId = 0;
const labelMap = {
distortion: 'Distortion',
omission: 'Unjustified omission',
register: 'Inappropriate register',
unidiomatic: 'Unidiomatic expression',
grammar: 'Error of grammar, syntax',
spelling: 'Error of spelling',
punctuation: 'Error of punctuation',
addition: 'Unjustified addition',
other: 'Other',
};
for (const a of mergedAnnotations) {
const start = a.start;
const end = a.end;
if (end <= start && !a.isInsertion) continue; // Skip zero-length annotations unless they're insertions
const category = String(a.category || 'unidiomatic');
const isInsertion = a.isInsertion || false;
lines.forEach((line, lineIndex) => {
const segStart = line.start;
const segEnd = line.end;
// For insertions, we need to handle zero-length spans specially
if (isInsertion) {
// Insertion: attach comment to the selected text from annotation (not just a single character)
if (start >= segStart && start <= segEnd) {
// Use the actual annotation span (start to end) for insertions
const ovStart = Math.max(start, segStart);
const ovEnd = Math.min(end, segEnd);
if (ovEnd <= ovStart) {
// If span is zero-length, use at least one character
const insertionPoint = Math.max(start, segStart);
const localStart = insertionPoint - segStart;
let localEnd = localStart;
if (insertionPoint < segEnd) {
localEnd = Math.min(localStart + 1, segEnd - segStart);
} else if (insertionPoint === segEnd && segEnd > segStart) {
localEnd = segEnd - segStart;
localStart = Math.max(0, localEnd - 1);
} else {
localEnd = Math.max(1, segEnd - segStart);
}
const originalSpan = prev.slice(segStart + localStart, segStart + localEnd) || ' ';
const newerText = a.replacementText || '';
const cid = nextCommentId++;
commentItems.push({
id: cid,
lineIndex,
localStart,
localEnd,
originalSpan,
newerText,
isDeleted: false,
isInsertion: true,
isMove: false,
category,
globalStart: segStart + localStart,
globalEnd: segStart + localEnd,
});
} else {
// Use the full annotation span
const originalSpan = prev.slice(ovStart, ovEnd);
const newerText = a.replacementText || '';
const localStart = ovStart - segStart;
const localEnd = ovEnd - segStart;
const cid = nextCommentId++;
commentItems.push({
id: cid,
lineIndex,
localStart,
localEnd,
originalSpan,
newerText,
isDeleted: false,
isInsertion: true,
isMove: false,
category,
globalStart: ovStart,
globalEnd: ovEnd,
});
}
}
return;
}
// Regular annotation (removal/replacement)
if (end <= segStart || start >= segEnd) return; // no overlap
const ovStart = Math.max(start, segStart);
const ovEnd = Math.min(end, segEnd);
if (ovEnd <= ovStart) return;
const originalSpan = prev.slice(ovStart, ovEnd);
// Use the replacement text from diff, or empty if deleted
const newerText = a.isDeleted ? '' : (a.replacementText || originalSpan);
const isDeleted = a.isDeleted;
const localStart = ovStart - segStart;
const localEnd = ovEnd - segStart;
const cid = nextCommentId++;
commentItems.push({
id: cid,
lineIndex,
localStart,
localEnd,
originalSpan,
newerText,
isDeleted,
isInsertion: false,
isMove: a.isMove || false,
category,
globalStart: ovStart,
globalEnd: ovEnd,
});
});
}
// 3) Build paragraphs for each line, inserting comment ranges where needed
const paragraphs = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineText = line.text || '';
const lineLen = lineText.length;
const items = commentItems
.filter(ci => ci.lineIndex === i)
.sort((a, b) => a.localStart - b.localStart || a.localEnd - b.localEnd);
if (!items.length) {
paragraphs.push(new Paragraph({ children: [ new TextRun({ text: lineText }) ] }));
continue;
}
const children = [];
let cursor = 0;
for (const item of items) {
const id = item.id;
const localStart = item.localStart;
const localEnd = item.localEnd;
const originalSpan = item.originalSpan || ' ';
if (localStart > cursor) {
children.push(new TextRun({ text: lineText.slice(cursor, localStart) }));
}
// For insertions, we might have zero-length spans, but we still need to attach the comment
if (localEnd <= localStart && !item.isInsertion) continue;
// For insertions, use the originalSpan from the annotation (not from lineText)
// This ensures we don't duplicate text - we only highlight what was in the original text
const spanStart = localStart;
const spanEnd = item.isInsertion && localEnd <= localStart ? Math.min(localStart + 1, lineLen) : localEnd;
// Use originalSpan for insertions to avoid duplicating text
const spanText = item.isInsertion ? originalSpan : (spanEnd > spanStart ? lineText.slice(spanStart, spanEnd) : originalSpan);
children.push(new CommentRangeStart(id));
children.push(new TextRun({ text: spanText }));
children.push(new CommentRangeEnd(id));
children.push(new CommentReference(id));
// For insertions, don't advance cursor past the original text - we're just attaching a comment
cursor = item.isInsertion ? Math.max(cursor, spanEnd) : Math.max(spanEnd, localEnd);
}
if (cursor < lineLen) {
children.push(new TextRun({ text: lineText.slice(cursor) }));
}
paragraphs.push(new Paragraph({ children }));
}
// 4) Build comment definitions compatible with the current docx version:
// For deletions: "[Error category] "
// For changes: "[Error category] → "
// For insertions: "[Error category] → "
const commentDefs = commentItems.map((item) => {
const categoryLabel = labelMap[item.category] || item.category;
const original = item.originalSpan || '';
const newer = item.newerText || original;
let commentChildren;
if (item.isDeleted) {
commentChildren = [
new TextRun({ text: `[${categoryLabel}] ` }),
new TextRun({ text: original, strike: true }),
];
} else if (item.isInsertion) {
const commentText = `[${categoryLabel}] → ${newer}`;
commentChildren = [ new TextRun({ text: commentText }) ];
} else {
const commentText = `[${categoryLabel}] ${original} → ${newer}`;
commentChildren = [ new TextRun({ text: commentText }) ];
}
// Ensure WPS compatibility by using proper comment structure
// WPS requires sequential IDs starting from 0 and proper date format
return {
id: Number(item.id),
initials: authorInitials || 'RF',
author: authorName || 'Refinity',
date: new Date(),
children: [
new Paragraph({
children: commentChildren,
spacing: { after: 0 },
}),
],
};
});
// Build a sample comment text for logging
let sampleText = 'N/A';
if (commentItems.length > 0 && commentDefs.length > 0) {
const sampleItem = commentItems[0];
const categoryLabel = labelMap[sampleItem.category] || sampleItem.category;
const original = sampleItem.originalSpan || '';
if (sampleItem.isDeleted) {
sampleText = `[${categoryLabel}] ${original} (deleted with strikethrough)`;
} else if (sampleItem.isInsertion) {
const newer = sampleItem.newerText || '';
sampleText = `[${categoryLabel}] → ${newer} (insertion)`;
} else {
const newer = sampleItem.newerText || original;
sampleText = `[${categoryLabel}] ${original} → ${newer}`;
}
}
console.log('[compare-comments-with-corrections]', {
diffBasedAnnotations: mergedAnnotations.length,
matchedAnnotations: allAnns.length,
commentItems: commentItems.length,
commentDefs: commentDefs.length,
paragraphs: paragraphs.length,
sampleCommentId: commentItems[0]?.id,
sampleCommentText: sampleText,
hasCommentsInDoc: !!commentDefs.length,
});
const doc = new Document({
sections: [
{
properties: {},
children: paragraphs,
},
],
comments: { children: commentDefs },
});
const buffer = await Packer.toBuffer(doc);
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
} catch (e) {
console.error('[compare-comments-with-corrections] error:', e.message, e.stack);
// Fallback: plain export of older text to avoid 500s
try {
const current = String(req.body?.prev || '');
const paragraphs = String(current).split(/\r?\n/).map(line => new Paragraph({ children: [ new TextRun({ text: line }) ] }));
const doc = new Document({ sections: [ { properties: {}, children: paragraphs.length ? paragraphs : [ new Paragraph('') ] } ] });
const buffer = await Packer.toBuffer(doc);
const outName = String(req.body?.filename || 'refinity-compare-fallback.docx').replace(/[\\/]+/g,'_');
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
} catch (e2) {
console.error('[compare-comments-with-corrections] fallback error:', e2.message);
res.status(500).json({ error: 'Failed to generate comparison comments document' });
}
}
});
// Export plain .docx with the provided current text (no diff/markup)
router.post('/export-plain', async (req, res) => {
try {
const current = String(req.body?.current || '');
const outName = String(req.body?.filename || 'refinity.docx').replace(/[\/]+/g,'_');
// Split by lines to preserve paragraphs
const paragraphs = String(current).split(/\r?\n/).map(line => new Paragraph({ children: [new TextRun({ text: line })] }));
const doc = new Document({ sections: [ { properties: {}, children: paragraphs.length ? paragraphs : [ new Paragraph('') ] } ] });
const buffer = await Packer.toBuffer(doc);
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
} catch (e) {
res.status(500).json({ error: 'Failed to export document' });
}
});
// Export plain .docx with appended annotations list at the end
router.post('/export-plain-with-annotations', async (req, res) => {
try {
const current = String(req.body?.current || '');
let annotations = Array.isArray(req.body?.annotations) ? req.body.annotations : [];
const outName = String(req.body?.filename || 'refinity-with-annotations.docx').replace(/[\/]+/g,'_');
// Normalize and sort, drop overlaps conservatively
annotations = annotations
.map(a => ({ start: Number(a.start)||0, end: Number(a.end)||0, category: String(a.category||'other'), comment: String(a.comment||'') }))
.filter(a => a.end > a.start && a.start >= 0 && a.end <= current.length)
.sort((a,b)=> a.start - b.start);
// Remove overlapping by skipping any that intersect previous kept one
const nonOverlap = [];
let lastEnd = -1;
for (const a of annotations) {
if (a.start >= lastEnd) { nonOverlap.push(a); lastEnd = a.end; }
}
// Build main content segments first (plain text / highlighted spans / markers)
const segments = [];
let pos = 0;
nonOverlap.forEach((a, idx) => {
if (pos < a.start) segments.push({ text: current.slice(pos, a.start) });
const marker = `${idx+1}`;
segments.push({ text: current.slice(a.start, a.end), highlight: true });
segments.push({ text: `[${marker}]`, bold: true });
pos = a.end;
});
if (pos < current.length) segments.push({ text: current.slice(pos) });
// Convert segments into paragraphs by splitting on newlines
const paragraphs = [];
let children = [];
const pushParagraph = () => { paragraphs.push(new Paragraph({ children: children.length ? children : [ new TextRun('') ] })); children = []; };
segments.forEach(seg => {
const parts = String(seg.text || '').split(/\n/);
parts.forEach((part, i) => {
if (part.length) {
const runOpts = { text: part };
if (seg.highlight) Object.assign(runOpts, { highlight: 'yellow' });
if (seg.bold) Object.assign(runOpts, { bold: true });
children.push(new TextRun(runOpts));
}
if (i < parts.length - 1) pushParagraph();
});
});
if (children.length || paragraphs.length === 0) pushParagraph();
// Append annotations legend
const legend = [];
legend.push(new Paragraph({ children: [ new TextRun({ text: '' }) ] }));
legend.push(new Paragraph({ children: [ new TextRun({ text: 'Annotations', bold: true }) ] }));
nonOverlap.forEach((a, idx) => {
const marker = `${idx+1}`;
const snippet = current.slice(a.start, a.end);
legend.push(new Paragraph({ children: [
new TextRun({ text: `[${marker}] `, bold: true }),
new TextRun({ text: `[${a.category}] `, italics: true }),
new TextRun({ text: snippet ? `"${snippet}" — ` : '' }),
new TextRun({ text: a.comment || '' }),
]}));
});
const doc = new Document({ sections: [ { properties: {}, children: [...(paragraphs.length? paragraphs : [ new Paragraph('') ]), ...legend] } ] });
const docBuffer = await Packer.toBuffer(doc);
setDownloadHeaders(res, outName);
res.send(Buffer.from(docBuffer));
} catch (e) {
res.status(500).json({ error: 'Failed to export document with annotations' });
}
});
// True OOXML revisions (w:ins/w:del) + comments in sidebar
router.post('/track-changes-ooxml', async (req, res) => {
try {
const prev = String(req.body?.prev || '');
const current = String(req.body?.current || '');
const outName = String(req.body?.filename || 'refinity-tracked-ooxml.docx').replace(/[\\/]+/g,'_');
const authorName = String(req.body?.authorName || 'Refinity');
const authorInitials = String(req.body?.authorInitials || (authorName.split(/\s+/).map(s=>s[0]||'').join('').slice(0,3).toUpperCase()) || 'RF');
const includeComments = !!req.body?.includeComments;
// Build WordprocessingML using token-level diff (handles CJK), preserving layout
let parts = safeDiffTokens(prev, current);
// If diff yields only removals (or effectively only removals ignoring whitespace), invert to recover insertions
const sumLen = (arr, key) => arr.filter(p => p[key]).reduce((n, p) => {
const text = Array.isArray(p.value) ? p.value.join('') : String(p.value || '');
return n + text.replace(/\s+/g, '').length;
}, 0);
const hasAdded = parts.some(p => p.added);
const hasRemoved = parts.some(p => p.removed);
const addedLen = sumLen(parts, 'added');
const removedLen = sumLen(parts, 'removed');
const invertMapping = ((!hasAdded && hasRemoved) || (addedLen === 0 && removedLen > 0));
if (invertMapping) {
const swapped = safeDiffTokens(current, prev);
// Markers will be interpreted inverted in the loop below
parts = swapped;
}
let revId = 1;
let commentId = 1;
// Preserve layout by splitting into multiple paragraphs
const lineXmls = [''];
const pushLine = () => lineXmls.push('');
let addCount = 0, delCount = 0;
parts.forEach(p => {
const arr = Array.isArray(p.value) ? p.value : String(p.value || '').split(/(?<=)/);
// stream tokens into lines, splitting on '\n'
let buffer = '';
const flush = (isLast) => {
const txt = (buffer || '').replace(/&/g,'&').replace(//g,'>');
if (!isLast || txt.length) {
if (p.added) {
const id = revId++;
if (includeComments) {
const cId = commentId++;
lineXmls[lineXmls.length-1] += `
${txt}`;
} else {
lineXmls[lineXmls.length-1] += `${txt}`;
}
} else if (p.removed) {
const id = revId++;
if (includeComments) {
const cId = commentId++;
lineXmls[lineXmls.length-1] += `
${txt}`;
} else {
lineXmls[lineXmls.length-1] += `${txt}`;
}
} else {
lineXmls[lineXmls.length-1] += `${txt}`;
}
buffer = '';
}
};
arr.forEach(tok => {
if (tok === '\n') { flush(false); pushLine(); }
else buffer += tok;
});
flush(true);
});
const runXmlByParas = lineXmls.map(line => `${line || ''}`).join('');
try { console.log('[track-changes-ooxml]', { addCount, delCount, invertMapping }); } catch {}
const commentsXml = includeComments ? `
${Array.from({ length: commentId-1 }).map((_,i)=>{
const id = i+1;
const label = id % 2 === 1 ? 'Added' : 'Removed';
return `${label}`;
}).join('')}
` : '';
const documentXml = `
${runXmlByParas}
`;
const relsXml = `
`;
const documentRelsXml = includeComments ? `
` : `
`;
const contentTypes = `
${includeComments ? `` : ``}
`;
const zip = new JSZip();
zip.file('[Content_Types].xml', contentTypes);
zip.folder('_rels')?.file('.rels', relsXml);
const word = zip.folder('word');
word?.file('document.xml', documentXml);
if (includeComments) {
word?.file('comments.xml', commentsXml);
word?.folder('_rels')?.file('document.xml.rels', documentRelsXml);
}
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
setDownloadHeaders(res, outName);
res.send(Buffer.from(buffer));
} catch (e) {
res.status(500).json({ error: 'Failed to generate OOXML docx' });
}
});
// NOTE: True Word revision tracking (w:ins/w:del, w:comments) at OOXML level is possible by post-processing the .docx zip.
// If you want me to proceed, I can generate proper w:ins/w:del runs and a /word/comments.xml part with relationships.
// ---- Persistence Endpoints ----
// Tasks
router.get('/tasks', async (req, res) => {
try {
const tasks = await RefinityTask.find({}).sort({ createdAt: 1 });
res.json(tasks);
} catch (e) {
res.status(500).json({ error: 'Failed to load tasks' });
}
});
router.post('/tasks', async (req, res) => {
try {
const { title, sourceText, createdBy } = req.body || {};
if (!title || !sourceText) return res.status(400).json({ error: 'Missing title or sourceText' });
const t = await RefinityTask.create({ title, sourceText, createdBy });
res.json(t);
} catch (e) {
res.status(500).json({ error: 'Failed to create task' });
}
});
// Update a task (owner only)
router.put('/tasks/:taskId', async (req, res) => {
try {
const { taskId } = req.params;
const task = await RefinityTask.findById(taskId);
if (!task) return res.status(404).json({ error: 'Task not found' });
const requester = String(req.headers['x-user-name'] || req.headers['x-user-email'] || '').toLowerCase();
if (!requester || String(task.createdBy || '').toLowerCase() !== requester) {
return res.status(403).json({ error: 'Forbidden' });
}
const { title, sourceText } = req.body || {};
if (title !== undefined) task.title = String(title);
if (sourceText !== undefined) task.sourceText = String(sourceText);
await task.save();
res.json(task);
} catch (e) {
res.status(500).json({ error: 'Failed to update task' });
}
});
// Delete a task (admin only) and all its versions
router.delete('/tasks/:taskId', async (req, res) => {
try {
const { taskId } = req.params;
const isAdmin = String(req.headers['x-user-role'] || '').toLowerCase() === 'admin';
const requester = String(req.headers['x-user-name'] || req.headers['x-user-email'] || '').toLowerCase();
const task = await RefinityTask.findById(taskId);
if (!task) return res.status(404).json({ error: 'Task not found' });
const isOwner = requester && String(task.createdBy || '').toLowerCase() === requester;
if (!isAdmin && !isOwner) return res.status(403).json({ error: 'Forbidden' });
await RefinityVersion.deleteMany({ taskId });
await RefinityTask.deleteOne({ _id: taskId });
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'Failed to delete task' });
}
});
// Versions
router.get('/tasks/:taskId/versions', async (req, res) => {
try {
const { taskId } = req.params;
const versions = await RefinityVersion.find({ taskId }).sort({ versionNumber: 1 });
res.json(versions);
} catch (e) {
res.status(500).json({ error: 'Failed to load versions' });
}
});
router.post('/tasks/:taskId/versions', async (req, res) => {
try {
const { taskId } = req.params;
const task = await RefinityTask.findById(taskId);
if (!task) return res.status(404).json({ error: 'Task not found' });
const { originalAuthor, revisedBy, content, parentVersionId } = req.body || {};
if (!content || !originalAuthor) return res.status(400).json({ error: 'Missing required fields' });
const last = await RefinityVersion.findOne({ taskId }).sort({ versionNumber: -1 });
const nextNum = (last?.versionNumber || 0) + 1;
const v = await RefinityVersion.create({ taskId, originalAuthor, revisedBy, content, parentVersionId, versionNumber: nextNum });
res.json(v);
} catch (e) {
res.status(500).json({ error: 'Failed to create version' });
}
});
// Delete a version (admin only)
router.delete('/versions/:id', async (req, res) => {
try {
const { id } = req.params;
const isAdmin = String(req.headers['x-user-role'] || '').toLowerCase() === 'admin';
const requester = String(req.headers['x-user-name'] || req.headers['x-user-email'] || '').toLowerCase();
const v = await RefinityVersion.findById(id);
if (!v) return res.status(404).json({ error: 'Version not found' });
const ownerName = String(v.revisedBy || v.originalAuthor || '').toLowerCase();
const isOwner = requester && requester === ownerName;
if (!isAdmin && !isOwner) return res.status(403).json({ error: 'Forbidden' });
await RefinityVersion.deleteOne({ _id: id });
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'Failed to delete version' });
}
});
// Update a version (owner only)
router.put('/versions/:id', async (req, res) => {
try {
const { id } = req.params;
const requester = String(req.headers['x-user-name'] || req.headers['x-user-email'] || '').toLowerCase();
const v = await RefinityVersion.findById(id);
if (!v) return res.status(404).json({ error: 'Version not found' });
const ownerName = String(v.revisedBy || v.originalAuthor || '').toLowerCase();
if (!requester || requester !== ownerName) return res.status(403).json({ error: 'Forbidden' });
const { content } = req.body || {};
if (typeof content !== 'string') return res.status(400).json({ error: 'Missing content' });
v.content = String(content);
await v.save();
res.json(v);
} catch (e) {
res.status(500).json({ error: 'Failed to update version' });
}
});
// ----- Annotation APIs -----
router.get('/annotations', async (req, res) => {
try {
const versionId = String(req.query?.versionId || '');
if (!versionId) return res.json([]);
const rows = await RefinityAnnotation.find({ versionId }).sort({ createdAt: 1 });
res.json(rows);
} catch (e) {
res.status(500).json({ error: 'Failed to load annotations' });
}
});
router.post('/annotations', async (req, res) => {
try {
const { versionId, start, end, category, comment, correction } = req.body || {};
if (!versionId || start === undefined || end === undefined || !category) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Store creator if provided in header
const createdBy = req.headers['x-user-name'] ? String(req.headers['x-user-name']).toLowerCase() : undefined;
const row = await RefinityAnnotation.create({ versionId, start, end, category, comment, correction, ...(createdBy ? { createdBy } : {}) });
res.json(row);
} catch (e) {
res.status(500).json({ error: 'Failed to create annotation' });
}
});
router.put('/annotations/:id', async (req, res) => {
try {
const { id } = req.params;
const row = await RefinityAnnotation.findById(id);
if (!row) return res.status(404).json({ error: 'Annotation not found' });
const { start, end, category, comment, correction } = req.body || {};
if (start !== undefined) row.start = Number(start);
if (end !== undefined) row.end = Number(end);
if (category !== undefined) row.category = String(category);
if (comment !== undefined) row.comment = String(comment);
if (correction !== undefined) row.correction = String(correction);
await row.save();
res.json(row);
} catch (e) {
res.status(500).json({ error: 'Failed to update annotation' });
}
});
router.delete('/annotations/:id', async (req, res) => {
try {
const { id } = req.params;
await RefinityAnnotation.deleteOne({ _id: id });
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'Failed to delete annotation' });
}
});
function escapeHtml(str='') {
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
module.exports = router;