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;