diff --git a/static/tests/backend/specs/textDiff.js b/static/tests/backend/specs/textDiff.js new file mode 100644 index 0000000..2643e98 --- /dev/null +++ b/static/tests/backend/specs/textDiff.js @@ -0,0 +1,34 @@ +'use strict'; + +const assert = require('assert').strict; +const textDiff = require('../../../../textDiff'); + +describe('textDiff', function () { + it('returns empty string when text has not changed', async function () { + assert.equal(textDiff('line one\nline two\n', 'line one\nline two\n'), ''); + }); + + it('returns added lines with plus prefix', async function () { + assert.equal(textDiff('line one\n', 'line one\nline two\n'), '+line two'); + }); + + it('returns removed lines with minus prefix', async function () { + assert.equal(textDiff('line one\nline two\n', 'line one\n'), '-line two'); + }); + + it('returns both removed and added lines for replacement', async function () { + assert.equal( + textDiff('line one\nline two\n', 'line one\nline three\n'), + '-line two\n+line three', + ); + }); + + it('falls back to simple add/remove output for large texts', async function () { + const before = Array.from({length: 1001}, (_, i) => `before-${i}`).join('\n'); + const after = Array.from({length: 1001}, (_, i) => `after-${i}`).join('\n'); + const diff = textDiff(before, after); + assert(diff.includes('-before-0')); + assert(diff.includes('+after-0')); + assert(!diff.includes('-before-999')); + }); +}); diff --git a/textDiff.js b/textDiff.js new file mode 100644 index 0000000..d0dfca4 --- /dev/null +++ b/textDiff.js @@ -0,0 +1,60 @@ +'use strict'; + +const splitLines = (text) => { + const normalized = String(text ?? '').replace(/\r\n/g, '\n'); + if (normalized === '') return []; + return normalized.endsWith('\n') + ? normalized.slice(0, -1).split('\n') + : normalized.split('\n'); +}; + +const MAX_DIFF_LINES = 1000; +const MAX_DIFF_CELLS = 500000; +const MAX_FALLBACK_CHANGES = 400; + +module.exports = (beforeText, afterText) => { + const before = splitLines(beforeText); + const after = splitLines(afterText); + if (before.length === after.length && + before.every((line, index) => line === after[index])) return ''; + if (before.length > MAX_DIFF_LINES || + after.length > MAX_DIFF_LINES || + before.length * after.length > MAX_DIFF_CELLS) { + const halfLimit = Math.floor(MAX_FALLBACK_CHANGES / 2); + return before.slice(0, halfLimit).map((line) => `-${line}`) + .concat(after.slice(0, halfLimit).map((line) => `+${line}`)) + .join('\n'); + } + + const lcs = Array.from({length: before.length + 1}, () => Array(after.length + 1).fill(0)); + for (let beforeIdx = 1; beforeIdx <= before.length; beforeIdx++) { + for (let afterIdx = 1; afterIdx <= after.length; afterIdx++) { + lcs[beforeIdx][afterIdx] = before[beforeIdx - 1] === after[afterIdx - 1] + ? lcs[beforeIdx - 1][afterIdx - 1] + 1 + : Math.max(lcs[beforeIdx - 1][afterIdx], lcs[beforeIdx][afterIdx - 1]); + } + } + + const changes = []; + let beforeIndex = before.length; + let afterIndex = after.length; + while (beforeIndex > 0 || afterIndex > 0) { + if (beforeIndex > 0 && afterIndex > 0 && + before[beforeIndex - 1] === after[afterIndex - 1]) { + beforeIndex--; + afterIndex--; + continue; + } + if (afterIndex > 0 && + (beforeIndex === 0 || + lcs[beforeIndex][afterIndex - 1] >= lcs[beforeIndex - 1][afterIndex])) { + changes.push(`+${after[afterIndex - 1]}`); + afterIndex--; + continue; + } + changes.push(`-${before[beforeIndex - 1]}`); + beforeIndex--; + } + + return changes.reverse().join('\n'); +}; diff --git a/update.js b/update.js index 3e58eff..f2d1e3c 100644 --- a/update.js +++ b/update.js @@ -7,6 +7,7 @@ const API = require('ep_etherpad-lite/node/db/API'); const email = require('emailjs'); const settings = require('ep_etherpad-lite/node/utils/Settings'); const util = require('util'); +const textDiff = require('./textDiff'); const SMTPClient = email.SMTPClient; @@ -35,7 +36,17 @@ const server = new SMTPClient(emailServer); const emailFooter = "\nYou can unsubscribe from these emails in the pad's Settings window.\n"; -exports.padUpdate = (hookName, _pad) => { +const getPadText = async (padId) => { + try { + const {text = ''} = await API.getText(padId); + return text; + } catch (err) { + console.error(err); + return null; + } +}; + +exports.padUpdate = async (hookName, _pad) => { if (!pluginSettings) return false; const pad = _pad.pad; @@ -44,11 +55,15 @@ exports.padUpdate = (hookName, _pad) => { if (timers[padId]) return; // an interval already exists so don't create + const startText = await getPadText(padId); + timers[padId] = { + interval: setInterval(() => sendUpdates(padId), checkFrequency), + startText, + }; console.debug(`Someone started editing ${padId}`); notifyBegin(padId); console.debug(`Created an interval time check for ${padId}`); // if not then create one and write it to the timers object - timers[padId] = setInterval(() => sendUpdates(padId), checkFrequency); }; const padUrl = (padId) => urlToPads + encodeURIComponent(padId); @@ -86,8 +101,17 @@ const notifyBegin = async (padId) => { })); }; -const notifyEnd = async (padId) => { - // TODO: get the modified contents to include in the email +const notifyEnd = async (padId, startText = '') => { + let diffText = ''; + if (typeof startText === 'string') { + try { + const {text = ''} = await API.getText(padId); + diffText = textDiff(startText, text); + } catch (err) { + console.error(err); + } + } + const changesSection = diffText ? `\n${diffText}\n` : ''; const recipients = await db.get(`emailSubscription:${padId}`); // get everyone we need to email if (!recipients) return; @@ -107,7 +131,7 @@ const notifyEnd = async (padId) => { let message; try { message = await util.promisify(server.send.bind(server))({ - text: `This pad is done being edited:\n <${padUrl(padId)}>\n${emailFooter}`, + text: `This pad is done being edited:\n <${padUrl(padId)}>${changesSection}${emailFooter}`, from: `${fromName} <${fromEmail}>`, to: recipient, subject: `Someone finished editing ${padId}`, @@ -130,9 +154,11 @@ const sendUpdates = async (padId) => { return; } console.warn('Interval went stale so deleting it from object and timer'); - clearInterval(timers[padId]); // remove the interval timer + const timer = timers[padId]; + const startText = timer ? timer.startText : null; + if (timer) clearInterval(timer.interval); // remove the interval timer delete timers[padId]; // remove the entry from the padId - await notifyEnd(padId); + await notifyEnd(padId, startText); // The status of the users relationship with the pad -- // IE if it's subscribed to this pad / if it's already on the pad // This comes frmo the database