diff --git a/install/package.json b/install/package.json
index 99411a5c3d..2dffc94228 100644
--- a/install/package.json
+++ b/install/package.json
@@ -39,6 +39,7 @@
"cropperjs": "^1.2.2",
"csurf": "^1.9.0",
"daemon": "^1.1.0",
+ "diff": "^3.4.0",
"express": "^4.16.2",
"express-session": "^1.15.6",
"express-useragent": "1.0.8",
diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json
index e084a9f24f..4b30d84356 100644
--- a/public/language/en-GB/topic.json
+++ b/public/language/en-GB/topic.json
@@ -35,6 +35,7 @@
"moved": "Moved",
"copy-ip": "Copy IP",
"ban-ip": "Ban IP",
+ "view-history": "Edit History",
"bookmark_instructions" : "Click here to return to the last read post in this thread.",
@@ -143,5 +144,9 @@
"stale.create": "Create a new topic",
"stale.reply_anyway": "Reply to this topic anyway",
- "link_back": "Re: [%1](%2)\n\n"
+ "link_back": "Re: [%1](%2)\n\n",
+
+ "diffs.title": "Post Edit History",
+ "diffs.description": "This post has %1 revisions. Click one of the revisions below to see the post content at that point in time.",
+ "diffs.no-revisions-description": "This post has %1 revisions."
}
diff --git a/public/src/client/topic/diffs.js b/public/src/client/topic/diffs.js
new file mode 100644
index 0000000000..63dd2b7b28
--- /dev/null
+++ b/public/src/client/topic/diffs.js
@@ -0,0 +1,65 @@
+'use strict';
+
+define('forum/topic/diffs', ['benchpress', 'translator'], function (Benchpress, translator) {
+ var Diffs = {};
+
+ Diffs.open = function (pid) {
+ var localeStringOpts = { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' };
+
+ socket.emit('posts.getDiffs', { pid: pid }, function (err, timestamps) {
+ if (err) {
+ return app.alertError(err.message);
+ }
+
+ Benchpress.parse('partials/modals/post_history', {
+ diffs: timestamps.map(function (timestamp) {
+ return {
+ timestamp: timestamp,
+ pretty: new Date(timestamp).toLocaleString(config.userLang.replace('_', '-'), localeStringOpts),
+ };
+ }),
+ numDiffs: timestamps.length,
+ }, function (html) {
+ translator.translate(html, function (html) {
+ var modal = bootbox.dialog({
+ title: '[[topic:diffs.title]]',
+ message: html,
+ });
+
+ if (!timestamps.length) {
+ return;
+ }
+
+ var selectEl = modal.find('select');
+ var postContainer = modal.find('ul.posts-list');
+
+ selectEl.on('change', function () {
+ Diffs.load(pid, this.value, postContainer);
+ });
+
+ modal.on('shown.bs.modal', function () {
+ Diffs.load(pid, selectEl.val(), postContainer);
+ });
+ });
+ });
+ });
+ };
+
+ Diffs.load = function (pid, since, postContainer) {
+ socket.emit('posts.showPostAt', { pid: pid, since: since }, function (err, data) {
+ if (err) {
+ return app.alertError(err.message);
+ }
+
+ data.deleted = !!parseInt(data.deleted, 10);
+
+ app.parseAndTranslate('partials/posts_list', 'posts', {
+ posts: [data],
+ }, function (html) {
+ postContainer.empty().append(html);
+ });
+ });
+ };
+
+ return Diffs;
+});
diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js
index 9cbfdbb366..a164f97ee2 100644
--- a/public/src/client/topic/postTools.js
+++ b/public/src/client/topic/postTools.js
@@ -8,7 +8,8 @@ define('forum/topic/postTools', [
'translator',
'forum/topic/votes',
'forum/topic/move-post',
-], function (share, navigator, components, translator, votes, movePost) {
+ 'forum/topic/diffs',
+], function (share, navigator, components, translator, votes, movePost, diffs) {
var PostTools = {};
var staleReplyAnyway = false;
@@ -139,6 +140,11 @@ define('forum/topic/postTools', [
}
});
+ postContainer.on('click', '[component="post/view-history"], [component="post/edit-indicator"]', function () {
+ var btn = $(this);
+ diffs.open(getData(btn, 'data-pid'));
+ });
+
postContainer.on('click', '[component="post/delete"]', function () {
var btn = $(this);
var timestamp = parseInt(getData(btn, 'data-timestamp'), 10);
diff --git a/src/posts.js b/src/posts.js
index cf30bd4c64..6061017b89 100644
--- a/src/posts.js
+++ b/src/posts.js
@@ -25,6 +25,7 @@ require('./posts/tools')(Posts);
require('./posts/votes')(Posts);
require('./posts/bookmarks')(Posts);
require('./posts/queue')(Posts);
+require('./posts/diffs')(Posts);
Posts.exists = function (pid, callback) {
db.isSortedSetMember('posts:pid', pid, callback);
diff --git a/src/posts/diffs.js b/src/posts/diffs.js
new file mode 100644
index 0000000000..1ce67f8e0f
--- /dev/null
+++ b/src/posts/diffs.js
@@ -0,0 +1,65 @@
+'use strict';
+
+var async = require('async');
+var validator = require('validator');
+
+var db = require('../database');
+var diff = require('diff');
+
+module.exports = function (Posts) {
+ Posts.diffs = {};
+
+ Posts.diffs.exists = function (pid, callback) {
+ db.sortedSetCard('post:' + pid + ':diffs', function (err, numDiffs) {
+ return callback(err, numDiffs > 0);
+ });
+ };
+
+ Posts.diffs.list = function (pid, callback) {
+ db.getSortedSetRangeWithScores('post:' + pid + ':diffs', 0, -1, function (err, diffs) {
+ callback(err, diffs ? diffs.map(function (diffObj) {
+ return diffObj.score;
+ }).reverse() : null);
+ });
+ };
+
+ Posts.diffs.save = function (pid, oldContent, newContent, callback) {
+ db.sortedSetAdd('post:' + pid + ':diffs', Date.now(), diff.createPatch('', newContent, oldContent), callback);
+ };
+
+ Posts.diffs.load = function (pid, since, uid, callback) {
+ // Retrieves all diffs made since `since` and replays them to reconstruct what the post looked like at `since`
+ since = parseInt(since, 10);
+
+ if (isNaN(since) || since > Date.now()) {
+ return callback(new Error('[[error:invalid-data]]'));
+ }
+
+ async.parallel({
+ post: async.apply(Posts.getPostSummaryByPids, [pid], uid, {
+ parse: false,
+ }),
+ diffs: async.apply(db.getSortedSetRangeByScore.bind(db), 'post:' + pid + ':diffs', 0, -1, since, Date.now()),
+ }, function (err, data) {
+ if (err) {
+ return callback(err);
+ }
+
+ data.post = data.post[0];
+ data.post.content = validator.unescape(data.post.content);
+
+ // Replace content with re-constructed content from that point in time
+ data.post.content = data.diffs.reverse().reduce(function (content, diffString) {
+ return diff.applyPatch(content, diffString);
+ }, data.post.content);
+
+ // Clear editor data (as it is outdated for this content)
+ delete data.post.edited;
+ data.post.editor = null;
+
+ Posts.parsePost(data.post, function (err, post) {
+ callback(err, post);
+ });
+ });
+ };
+};
diff --git a/src/posts/edit.js b/src/posts/edit.js
index 8780fa6016..2ca8e3b534 100644
--- a/src/posts/edit.js
+++ b/src/posts/edit.js
@@ -20,6 +20,7 @@ module.exports = function (Posts) {
});
Posts.edit = function (data, callback) {
+ var oldContent; // for diffing purposes
var postData;
var results;
@@ -39,6 +40,7 @@ module.exports = function (Posts) {
}
postData = _postData;
+ oldContent = postData.content;
postData.content = data.content;
postData.edited = Date.now();
postData.editor = data.uid;
@@ -63,6 +65,9 @@ module.exports = function (Posts) {
results = _results;
Posts.setPostFields(data.pid, postData, next);
},
+ function (next) {
+ Posts.diffs.save(data.pid, oldContent, data.content, next);
+ },
function (next) {
postData.cid = results.topic.cid;
postData.topic = results.topic;
diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js
index 39dd1fe64e..e0b7884ece 100644
--- a/src/socket.io/posts.js
+++ b/src/socket.io/posts.js
@@ -20,6 +20,7 @@ require('./posts/move')(SocketPosts);
require('./posts/votes')(SocketPosts);
require('./posts/bookmarks')(SocketPosts);
require('./posts/tools')(SocketPosts);
+require('./posts/diffs')(SocketPosts);
SocketPosts.reply = function (socket, data, callback) {
if (!data || !data.tid || (parseInt(meta.config.minimumPostLength, 10) !== 0 && !data.content)) {
diff --git a/src/socket.io/posts/diffs.js b/src/socket.io/posts/diffs.js
new file mode 100644
index 0000000000..7f208dc71f
--- /dev/null
+++ b/src/socket.io/posts/diffs.js
@@ -0,0 +1,13 @@
+'use strict';
+
+var posts = require('../../posts');
+
+module.exports = function (SocketPosts) {
+ SocketPosts.getDiffs = function (socket, data, callback) {
+ posts.diffs.list(data.pid, callback);
+ };
+
+ SocketPosts.showPostAt = function (socket, data, callback) {
+ posts.diffs.load(data.pid, data.since, socket.uid, callback);
+ };
+};
diff --git a/src/socket.io/posts/tools.js b/src/socket.io/posts/tools.js
index a61e50ec6c..b8533937ce 100644
--- a/src/socket.io/posts/tools.js
+++ b/src/socket.io/posts/tools.js
@@ -51,6 +51,7 @@ module.exports = function (SocketPosts) {
postSharing: function (next) {
social.getActivePostSharing(next);
},
+ history: async.apply(posts.diffs.exists, data.pid),
}, next);
},
function (results, next) {
@@ -64,6 +65,7 @@ module.exports = function (SocketPosts) {
results.posts.display_moderator_tools = results.posts.display_edit_tools || results.posts.display_delete_tools;
results.posts.display_move_tools = results.isAdmin || results.isModerator;
results.posts.display_ip_ban = (results.isAdmin || results.isGlobalMod) && !results.posts.selfPost;
+ results.posts.display_history = results.history;
if (!results.isAdmin && !results.isGlobalMod && !results.isModerator) {
results.posts.ip = undefined;