Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions static/tests/backend/specs/textDiff.js
Original file line number Diff line number Diff line change
@@ -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'));
});
});
60 changes: 60 additions & 0 deletions textDiff.js
Original file line number Diff line number Diff line change
@@ -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');
};
40 changes: 33 additions & 7 deletions update.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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}`,
Expand All @@ -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
Expand Down
Loading