Spaces:
Sleeping
Sleeping
| 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 <br> so paragraphs and blank lines are kept | |
| const toHtml = (s) => escapeHtml(s).replace(/\r?\n/g, '<br/>'); | |
| const html = parts.map(p => { | |
| const text = Array.isArray(p.value) ? p.value.join('') : String(p.value || ''); | |
| if (p.added) return `<span style="background:#dcfce7;color:#065f46">${toHtml(text)}</span>`; | |
| if (p.removed) return `<span style="background:#fee2e2;color:#991b1b;text-decoration:line-through">${toHtml(text)}</span>`; | |
| return `<span>${toHtml(text)}</span>`; | |
| }).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] <original with strikethrough>" | |
| // For changes: "[Error category] <original> → <newer>" | |
| // For insertions: "[Error category] → <inserted text>" | |
| 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,'<').replace(/>/g,'>'); | |
| if (!isLast || txt.length) { | |
| if (p.added) { | |
| const id = revId++; | |
| if (includeComments) { | |
| const cId = commentId++; | |
| lineXmls[lineXmls.length-1] += ` | |
| <w:commentRangeStart w:id="${cId}"/><w:ins w:id="${id}" w:author="${authorName}" w:date="${new Date().toISOString()}"><w:r><w:rPr/><w:t xml:space="preserve">${txt}</w:t></w:r></w:ins><w:commentRangeEnd w:id="${cId}"/><w:r><w:rPr/><w:commentReference w:id="${cId}"/></w:r>`; | |
| } else { | |
| lineXmls[lineXmls.length-1] += `<w:ins w:id="${id}" w:author="${authorName}" w:date="${new Date().toISOString()}"><w:r><w:rPr/><w:t xml:space="preserve">${txt}</w:t></w:r></w:ins>`; | |
| } | |
| } else if (p.removed) { | |
| const id = revId++; | |
| if (includeComments) { | |
| const cId = commentId++; | |
| lineXmls[lineXmls.length-1] += ` | |
| <w:commentRangeStart w:id="${cId}"/><w:del w:id="${id}" w:author="${authorName}" w:date="${new Date().toISOString()}"><w:r><w:rPr/><w:delText xml:space="preserve">${txt}</w:delText></w:r></w:del><w:commentRangeEnd w:id="${cId}"/><w:r><w:rPr/><w:commentReference w:id="${cId}"/></w:r>`; | |
| } else { | |
| lineXmls[lineXmls.length-1] += `<w:del w:id="${id}" w:author="${authorName}" w:date="${new Date().toISOString()}"><w:r><w:rPr/><w:delText xml:space="preserve">${txt}</w:delText></w:r></w:del>`; | |
| } | |
| } else { | |
| lineXmls[lineXmls.length-1] += `<w:r><w:rPr/><w:t xml:space="preserve">${txt}</w:t></w:r>`; | |
| } | |
| buffer = ''; | |
| } | |
| }; | |
| arr.forEach(tok => { | |
| if (tok === '\n') { flush(false); pushLine(); } | |
| else buffer += tok; | |
| }); | |
| flush(true); | |
| }); | |
| const runXmlByParas = lineXmls.map(line => `<w:p>${line || '<w:r><w:t/>'}</w:p>`).join(''); | |
| try { console.log('[track-changes-ooxml]', { addCount, delCount, invertMapping }); } catch {} | |
| const commentsXml = includeComments ? `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <w:comments xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"> | |
| ${Array.from({ length: commentId-1 }).map((_,i)=>{ | |
| const id = i+1; | |
| const label = id % 2 === 1 ? 'Added' : 'Removed'; | |
| return `<w:comment w:id="${id}" w:author="${authorName}" w:initials="${authorInitials}" w:date="${new Date().toISOString()}"><w:p><w:r><w:t>${label}</w:t></w:r></w:p></w:comment>`; | |
| }).join('')} | |
| </w:comments>` : ''; | |
| const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"> | |
| <w:body> | |
| ${runXmlByParas} | |
| <w:sectPr/> | |
| </w:body> | |
| </w:document>`; | |
| const relsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> | |
| <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/> | |
| </Relationships>`; | |
| const documentRelsXml = includeComments ? `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> | |
| <Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/> | |
| </Relationships>` : `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> | |
| </Relationships>`; | |
| const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> | |
| <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> | |
| <Default Extension="xml" ContentType="application/xml"/> | |
| <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> | |
| ${includeComments ? `<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>` : ``} | |
| </Types>`; | |
| 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, '"') | |
| .replace(/'/g, '''); | |
| } | |
| module.exports = router; | |