From a8b223edd0c9671d9ee2504303321e4a990a696b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:14:52 +0000 Subject: [PATCH 1/7] Initial plan From 46f42c6bd231f03e2b16337a37c3d1b87881df28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:24:47 +0000 Subject: [PATCH 2/7] feat: include raw text diff in end-of-edit notification email Agent-Logs-Url: https://github.com/ether/ep_email_notifications/sessions/7af1b8df-0696-44d9-9bc0-e532920c0962 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- static/tests/backend/specs/textDiff.js | 25 ++++++++++++++ textDiff.js | 45 ++++++++++++++++++++++++++ update.js | 32 +++++++++++++++--- 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 static/tests/backend/specs/textDiff.js create mode 100644 textDiff.js diff --git a/static/tests/backend/specs/textDiff.js b/static/tests/backend/specs/textDiff.js new file mode 100644 index 0000000..c806991 --- /dev/null +++ b/static/tests/backend/specs/textDiff.js @@ -0,0 +1,25 @@ +'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', + ); + }); +}); diff --git a/textDiff.js b/textDiff.js new file mode 100644 index 0000000..f6281ea --- /dev/null +++ b/textDiff.js @@ -0,0 +1,45 @@ +'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'); +}; + +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 ''; + + const lcs = Array.from({length: before.length + 1}, () => Array(after.length + 1).fill(0)); + for (let i = 1; i <= before.length; i++) { + for (let j = 1; j <= after.length; j++) { + lcs[i][j] = before[i - 1] === after[j - 1] + ? lcs[i - 1][j - 1] + 1 + : Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + + const changes = []; + let i = before.length; + let j = after.length; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && before[i - 1] === after[j - 1]) { + i--; + j--; + continue; + } + if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) { + changes.push(`+${after[j - 1]}`); + j--; + continue; + } + changes.push(`-${before[i - 1]}`); + i--; + } + + return changes.reverse().join('\n'); +}; diff --git a/update.js b/update.js index 3e58eff..0f75744 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; @@ -44,16 +45,26 @@ exports.padUpdate = (hookName, _pad) => { if (timers[padId]) return; // an interval already exists so don't create + 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); const notifyBegin = async (padId) => { + try { + const {text = ''} = await API.getText(padId); + if (timers[padId]) timers[padId].startText = text; + } catch (err) { + console.error(err); + } + console.warn(`Getting pad email stuff for ${padId}`); const recipients = await db.get(`emailSubscription:${padId}`); // get everyone we need to email if (!recipients) return; @@ -87,7 +98,17 @@ const notifyBegin = async (padId) => { }; const notifyEnd = async (padId) => { - // TODO: get the modified contents to include in the email + let diffText = ''; + try { + const startText = timers[padId] ? timers[padId].startText : ''; + const {text = ''} = await API.getText(padId); + diffText = textDiff(startText, text); + } catch (err) { + console.error(err); + } + const changesSection = diffText + ? `\nChanges:\n${diffText}\n` + : '\nChanges:\n(no text changes detected)\n'; const recipients = await db.get(`emailSubscription:${padId}`); // get everyone we need to email if (!recipients) return; @@ -107,7 +128,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 +151,10 @@ const sendUpdates = async (padId) => { return; } console.warn('Interval went stale so deleting it from object and timer'); - clearInterval(timers[padId]); // remove the interval timer - delete timers[padId]; // remove the entry from the padId + const timer = timers[padId]; + clearInterval(timer && timer.interval); // remove the interval timer await notifyEnd(padId); + delete timers[padId]; // remove the entry from the padId // 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 From 54ac44a505f03aef14e7d2b3bc2e4e8066e77a86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:26:26 +0000 Subject: [PATCH 3/7] chore: address review feedback for diff email changes Agent-Logs-Url: https://github.com/ether/ep_email_notifications/sessions/7af1b8df-0696-44d9-9bc0-e532920c0962 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- textDiff.js | 25 ++++++++++++++----------- update.js | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/textDiff.js b/textDiff.js index f6281ea..90964cd 100644 --- a/textDiff.js +++ b/textDiff.js @@ -24,21 +24,24 @@ module.exports = (beforeText, afterText) => { } const changes = []; - let i = before.length; - let j = after.length; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && before[i - 1] === after[j - 1]) { - i--; - j--; + 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 (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) { - changes.push(`+${after[j - 1]}`); - j--; + if (afterIndex > 0 && + (beforeIndex === 0 || + lcs[beforeIndex][afterIndex - 1] >= lcs[beforeIndex - 1][afterIndex])) { + changes.push(`+${after[afterIndex - 1]}`); + afterIndex--; continue; } - changes.push(`-${before[i - 1]}`); - i--; + changes.push(`-${before[beforeIndex - 1]}`); + beforeIndex--; } return changes.reverse().join('\n'); diff --git a/update.js b/update.js index 0f75744..ae7f081 100644 --- a/update.js +++ b/update.js @@ -152,7 +152,7 @@ const sendUpdates = async (padId) => { } console.warn('Interval went stale so deleting it from object and timer'); const timer = timers[padId]; - clearInterval(timer && timer.interval); // remove the interval timer + if (timer) clearInterval(timer.interval); // remove the interval timer await notifyEnd(padId); delete timers[padId]; // remove the entry from the padId // The status of the users relationship with the pad -- From 83d9fbed227d01af3ee8df92368f818a49029256 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:27:22 +0000 Subject: [PATCH 4/7] chore: align diff helper naming with review feedback Agent-Logs-Url: https://github.com/ether/ep_email_notifications/sessions/7af1b8df-0696-44d9-9bc0-e532920c0962 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- textDiff.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/textDiff.js b/textDiff.js index 90964cd..a2250ed 100644 --- a/textDiff.js +++ b/textDiff.js @@ -15,11 +15,11 @@ module.exports = (beforeText, afterText) => { before.every((line, index) => line === after[index])) return ''; const lcs = Array.from({length: before.length + 1}, () => Array(after.length + 1).fill(0)); - for (let i = 1; i <= before.length; i++) { - for (let j = 1; j <= after.length; j++) { - lcs[i][j] = before[i - 1] === after[j - 1] - ? lcs[i - 1][j - 1] + 1 - : Math.max(lcs[i - 1][j], lcs[i][j - 1]); + 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]); } } From 1428dad2a8708036539652d6a143bc6e4af26c45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:28:52 +0000 Subject: [PATCH 5/7] fix: avoid timer race and large-input diff overhead Agent-Logs-Url: https://github.com/ether/ep_email_notifications/sessions/7af1b8df-0696-44d9-9bc0-e532920c0962 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- static/tests/backend/specs/textDiff.js | 8 ++++++++ textDiff.js | 3 +++ update.js | 10 ++++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/static/tests/backend/specs/textDiff.js b/static/tests/backend/specs/textDiff.js index c806991..6968365 100644 --- a/static/tests/backend/specs/textDiff.js +++ b/static/tests/backend/specs/textDiff.js @@ -22,4 +22,12 @@ describe('textDiff', function () { '-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-1000')); + }); }); diff --git a/textDiff.js b/textDiff.js index a2250ed..f37ace5 100644 --- a/textDiff.js +++ b/textDiff.js @@ -13,6 +13,9 @@ module.exports = (beforeText, afterText) => { const after = splitLines(afterText); if (before.length === after.length && before.every((line, index) => line === after[index])) return ''; + if (before.length > 1000 || after.length > 1000) { + return before.map((line) => `-${line}`).concat(after.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++) { diff --git a/update.js b/update.js index ae7f081..94c0dbe 100644 --- a/update.js +++ b/update.js @@ -97,18 +97,15 @@ const notifyBegin = async (padId) => { })); }; -const notifyEnd = async (padId) => { +const notifyEnd = async (padId, startText = '') => { let diffText = ''; try { - const startText = timers[padId] ? timers[padId].startText : ''; const {text = ''} = await API.getText(padId); diffText = textDiff(startText, text); } catch (err) { console.error(err); } - const changesSection = diffText - ? `\nChanges:\n${diffText}\n` - : '\nChanges:\n(no text changes detected)\n'; + const changesSection = diffText ? `\n${diffText}\n` : ''; const recipients = await db.get(`emailSubscription:${padId}`); // get everyone we need to email if (!recipients) return; @@ -152,9 +149,10 @@ const sendUpdates = async (padId) => { } console.warn('Interval went stale so deleting it from object and timer'); const timer = timers[padId]; + const startText = timer ? timer.startText : ''; if (timer) clearInterval(timer.interval); // remove the interval timer - await notifyEnd(padId); delete timers[padId]; // remove the entry from the 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 From 41e186084961a7032f744e1db1ab80a6a21eb8c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:29:54 +0000 Subject: [PATCH 6/7] chore: extract max diff line threshold constant Agent-Logs-Url: https://github.com/ether/ep_email_notifications/sessions/7af1b8df-0696-44d9-9bc0-e532920c0962 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- textDiff.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/textDiff.js b/textDiff.js index f37ace5..72e23fe 100644 --- a/textDiff.js +++ b/textDiff.js @@ -8,12 +8,14 @@ const splitLines = (text) => { : normalized.split('\n'); }; +const MAX_DIFF_LINES = 1000; + 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 > 1000 || after.length > 1000) { + if (before.length > MAX_DIFF_LINES || after.length > MAX_DIFF_LINES) { return before.map((line) => `-${line}`).concat(after.map((line) => `+${line}`)).join('\n'); } From a65529ff6141a67bb1f828fb6a7065120fcf2b51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:31:19 +0000 Subject: [PATCH 7/7] fix: harden diff generation for races and large pads Agent-Logs-Url: https://github.com/ether/ep_email_notifications/sessions/7af1b8df-0696-44d9-9bc0-e532920c0962 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- static/tests/backend/specs/textDiff.js | 3 ++- textDiff.js | 11 ++++++-- update.js | 36 +++++++++++++++----------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/static/tests/backend/specs/textDiff.js b/static/tests/backend/specs/textDiff.js index 6968365..2643e98 100644 --- a/static/tests/backend/specs/textDiff.js +++ b/static/tests/backend/specs/textDiff.js @@ -28,6 +28,7 @@ describe('textDiff', function () { 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-1000')); + assert(diff.includes('+after-0')); + assert(!diff.includes('-before-999')); }); }); diff --git a/textDiff.js b/textDiff.js index 72e23fe..d0dfca4 100644 --- a/textDiff.js +++ b/textDiff.js @@ -9,14 +9,21 @@ const splitLines = (text) => { }; 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) { - return before.map((line) => `-${line}`).concat(after.map((line) => `+${line}`)).join('\n'); + 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)); diff --git a/update.js b/update.js index 94c0dbe..f2d1e3c 100644 --- a/update.js +++ b/update.js @@ -36,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; @@ -45,9 +55,10 @@ 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: '', + startText, }; console.debug(`Someone started editing ${padId}`); notifyBegin(padId); @@ -58,13 +69,6 @@ exports.padUpdate = (hookName, _pad) => { const padUrl = (padId) => urlToPads + encodeURIComponent(padId); const notifyBegin = async (padId) => { - try { - const {text = ''} = await API.getText(padId); - if (timers[padId]) timers[padId].startText = text; - } catch (err) { - console.error(err); - } - console.warn(`Getting pad email stuff for ${padId}`); const recipients = await db.get(`emailSubscription:${padId}`); // get everyone we need to email if (!recipients) return; @@ -99,11 +103,13 @@ const notifyBegin = async (padId) => { const notifyEnd = async (padId, startText = '') => { let diffText = ''; - try { - const {text = ''} = await API.getText(padId); - diffText = textDiff(startText, text); - } catch (err) { - console.error(err); + 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` : ''; @@ -149,7 +155,7 @@ const sendUpdates = async (padId) => { } console.warn('Interval went stale so deleting it from object and timer'); const timer = timers[padId]; - const startText = timer ? timer.startText : ''; + 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, startText);