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;