diff --git a/app.js b/app.js index 265df71850..14d39395cb 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ /* NodeBB - A better forum platform for the modern web https://github.com/NodeBB/NodeBB/ - Copyright (C) 2013-2016 NodeBB Inc. + Copyright (C) 2013-2017 NodeBB Inc. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -389,4 +389,4 @@ function versionCheck() { winston.warn('Your version of Node.js is too outdated for NodeBB. Please update your version of Node.js.'); winston.warn('Recommended ' + range.green + ', '.reset + version.yellow + ' provided\n'.reset); } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 1175c38319..bcd4fdae53 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "start": "node loader.js", "lint": "eslint --cache .", "pretest": "npm run lint", - "test": "istanbul cover node_modules/mocha/bin/_mocha -- -R dot", + "test": "istanbul cover node_modules/mocha/bin/_mocha -- -R spec", "coveralls": "istanbul cover _mocha --report lcovonly -- -R dot && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage" }, "dependencies": { @@ -61,7 +61,7 @@ "nodebb-plugin-spam-be-gone": "0.4.10", "nodebb-rewards-essentials": "0.0.9", "nodebb-theme-lavender": "3.0.15", - "nodebb-theme-persona": "4.1.90", + "nodebb-theme-persona": "4.1.93", "nodebb-theme-vanilla": "5.1.57", "nodebb-widget-essentials": "2.0.13", "nodemailer": "2.6.4", diff --git a/public/language/en-GB/admin/extend/plugins.json b/public/language/en-GB/admin/extend/plugins.json index 8f382a290d..1661a987b7 100644 --- a/public/language/en-GB/admin/extend/plugins.json +++ b/public/language/en-GB/admin/extend/plugins.json @@ -20,6 +20,7 @@ "plugin-item.themes": "Themes", "plugin-item.deactivate": "Deactivate", "plugin-item.activate": "Activate", + "plugin-item.install": "Install", "plugin-item.uninstall": "Uninstall", "plugin-item.settings": "Settings", "plugin-item.installed": "Installed", @@ -43,4 +44,4 @@ "alert.package-manager-unreachable": "

NodeBB could not reach the package manager, an upgrade is not suggested at this time.

", "alert.incompatible": "

Your version of NodeBB (v%1) is only cleared to upgrade to v%2 of this plugin. Please update your NodeBB if you wish to install a newer version of this plugin.

", "alert.possibly-incompatible": "

No Compatibility Information Found

This plugin did not specify a specific version for installation given your NodeBB version. Full compatibility cannot be guaranteed, and may cause your NodeBB to no longer start properly.

In the event that NodeBB cannot boot properly:

$ ./nodebb reset plugin=\"%1\"

Continue installation of latest version of this plugin?

" -} \ No newline at end of file +} diff --git a/public/language/en-GB/admin/menu.json b/public/language/en-GB/admin/menu.json index 7a5327f643..6a4995ea6e 100644 --- a/public/language/en-GB/admin/menu.json +++ b/public/language/en-GB/admin/menu.json @@ -39,7 +39,7 @@ "section-appearance": "Appearance", "appearance/themes": "Themes", "appearance/skins": "Skins", - "appearance/customise": "Custom HTML & CSS", + "appearance/customise": "Custom HTML & CSS", "section-extend": "Extend", "extend/plugins": "Plugins", diff --git a/public/src/admin/admin.js b/public/src/admin/admin.js index 1c44a7e78d..58e79aa95c 100644 --- a/public/src/admin/admin.js +++ b/public/src/admin/admin.js @@ -95,6 +95,7 @@ } url = [config.relative_path, url].join('/'); + var fallback; $('#main-menu li').removeClass('active'); $('#main-menu a').removeClass('active').filter('[href="' + url + '"]').each(function () { @@ -102,36 +103,35 @@ menu .parent().addClass('active') .parents('.menu-item').addClass('active'); - - var match = menu.attr('href').match(/admin\/((.+?)\/.+?)$/); - if (!match) { - return; - } - var str = '[[admin/menu:' + match[1] + ']]'; - if (match[2] === 'settings') { - str = translator.compile('admin/menu:settings.page-title', str); - } - translator.translate(str, function (text) { - $('#main-page-title').text(text); - }); + fallback = menu.text(); }); - var title = url; - if (/admin\/general\/dashboard$/.test(title)) { - title = '[[admin/menu:general/dashboard]]'; + var mainTitle; + var pageTitle; + if (/admin\/general\/dashboard$/.test(url)) { + mainTitle = pageTitle = '[[admin/menu:general/dashboard]]'; + } else if (/admin\/plugins\//.test(url)) { + mainTitle = fallback; + pageTitle = '[[admin/menu:section-plugins]] > ' + mainTitle; } else { - title = title.match(/admin\/(.+?)\/(.+?)$/); - title = '[[admin/menu:section-' + - (title[1] === 'development' ? 'advanced' : title[1]) + - ']]' + (title[2] ? (' > [[admin/menu:' + - title[1] + '/' + title[2] + ']]') : ''); + var matches = url.match(/admin\/(.+?)\/(.+?)$/); + mainTitle = '[[admin/menu:' + matches[1] + '/' + matches[2] + ']]'; + pageTitle = '[[admin/menu:section-' + + (matches[1] === 'development' ? 'advanced' : matches[1]) + + ']]' + (matches[2] ? (' > ' + mainTitle) : ''); + if (matches[2] === 'settings') { + mainTitle = translator.compile('admin/menu:settings.page-title', mainTitle); + } } - title = '[[admin/admin:acp-title, ' + title + ']]'; + pageTitle = translator.compile('admin/admin:acp-title', pageTitle); - translator.translate(title, function (title) { + translator.translate(pageTitle, function (title) { document.title = title.replace(/>/g, '>'); }); + translator.translate(mainTitle, function (text) { + $('#main-page-title').text(text); + }); }); } diff --git a/public/src/client/category.js b/public/src/client/category.js index 4592c551a1..4afabb5093 100644 --- a/public/src/client/category.js +++ b/public/src/client/category.js @@ -5,7 +5,7 @@ define('forum/category', [ 'forum/infinitescroll', 'share', 'navigator', - 'forum/categoryTools', + 'forum/category/tools', 'sort', 'components', 'translator', diff --git a/public/src/client/categoryTools.js b/public/src/client/category/tools.js similarity index 88% rename from public/src/client/categoryTools.js rename to public/src/client/category/tools.js index 548386ffc6..947ab50d28 100644 --- a/public/src/client/categoryTools.js +++ b/public/src/client/category/tools.js @@ -4,7 +4,12 @@ /* globals define, app, socket, bootbox, ajaxify */ -define('forum/categoryTools', ['forum/topic/move', 'topicSelect', 'components', 'translator'], function (move, topicSelect, components, translator) { +define('forum/category/tools', [ + 'forum/topic/move', + 'topicSelect', + 'components', + 'translator' +], function (move, topicSelect, components, translator) { var CategoryTools = {}; @@ -13,6 +18,8 @@ define('forum/categoryTools', ['forum/topic/move', 'topicSelect', 'components', topicSelect.init(updateDropdownOptions); + handlePinnedTopicSort(); + components.get('topic/delete').on('click', function () { categoryCommand('delete', topicSelect.getSelectedTids()); return false; @@ -235,5 +242,30 @@ define('forum/categoryTools', ['forum/topic/move', 'topicSelect', 'components', getTopicEl(data.tid).remove(); } + function handlePinnedTopicSort() { + if (!ajaxify.data.privileges.isAdminOrMod) { + return; + } + app.loadJQueryUI(function () { + $('[component="category"]').sortable({ + items: '[component="category/topic"].pinned', + update: function () { + var data = []; + + var pinnedTopics = $('[component="category/topic"].pinned'); + pinnedTopics.each(function (index, element) { + data.push({tid: $(element).attr('data-tid'), order: pinnedTopics.length - index - 1}); + }); + + socket.emit('topics.orderPinnedTopics', data, function (err) { + if (err) { + return app.alertError(err.message); + } + }); + } + }); + }); + } + return CategoryTools; }); diff --git a/src/categories/topics.js b/src/categories/topics.js index c60050c667..ea75ab16a5 100644 --- a/src/categories/topics.js +++ b/src/categories/topics.js @@ -111,7 +111,7 @@ module.exports = function (Categories) { Categories.onNewPostMade = function (cid, pinned, postData, callback) { if (!cid || !postData) { - return callback(); + return setImmediate(callback); } async.parallel([ @@ -123,17 +123,23 @@ module.exports = function (Categories) { }, function (next) { if (parseInt(pinned, 10) === 1) { - next(); - } else { - db.sortedSetAdd('cid:' + cid + ':tids', postData.timestamp, postData.tid, next); + return setImmediate(next); } + + async.parallel([ + function (next) { + db.sortedSetAdd('cid:' + cid + ':tids', postData.timestamp, postData.tid, next); + }, + function (next) { + db.sortedSetIncrBy('cid:' + cid + ':tids:posts', 1, postData.tid, next); + } + ], function (err) { + next(err); + }); }, function (next) { Categories.updateRecentTid(cid, postData.tid, next); - }, - function (next) { - db.sortedSetIncrBy('cid:' + cid + ':tids:posts', 1, postData.tid, next); - } + } ], callback); }; diff --git a/src/meta/js.js b/src/meta/js.js index 87a51d9667..da3b5d9e38 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -57,7 +57,7 @@ module.exports = function (Meta) { 'public/src/client/topic/threadTools.js', 'public/src/client/categories.js', 'public/src/client/category.js', - 'public/src/client/categoryTools.js', + 'public/src/client/category/tools.js', 'public/src/modules/translator.js', 'public/src/modules/notifications.js', diff --git a/src/plugins/load.js b/src/plugins/load.js index 2d2a377070..2b597d7a2b 100644 --- a/src/plugins/load.js +++ b/src/plugins/load.js @@ -39,6 +39,11 @@ module.exports = function (Plugins) { }; Plugins.prepareForBuild = function (callback) { + Plugins.cssFiles.length = 0; + Plugins.lessFiles.length = 0; + Plugins.clientScripts.length = 0; + Plugins.acpScripts.length = 0; + async.waterfall([ async.apply(Plugins.getPluginPaths), function (paths, next) { diff --git a/src/posts/summary.js b/src/posts/summary.js index 270d9d480b..2173057e3a 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -65,8 +65,8 @@ module.exports = function (Posts) { } post.user = results.users[post.uid]; post.topic = results.topics[post.tid]; - post.category = results.categories[post.topic.cid]; - post.isMainPost = parseInt(post.pid, 10) === parseInt(post.topic.mainPid, 10); + post.category = post.topic && results.categories[post.topic.cid]; + post.isMainPost = post.topic && parseInt(post.pid, 10) === parseInt(post.topic.mainPid, 10); post.deleted = parseInt(post.deleted, 10) === 1; post.upvotes = parseInt(post.upvotes, 10) || 0; post.downvotes = parseInt(post.downvotes, 10) || 0; diff --git a/src/socket.io/posts/tools.js b/src/socket.io/posts/tools.js index dec866ce70..571d84175b 100644 --- a/src/socket.io/posts/tools.js +++ b/src/socket.io/posts/tools.js @@ -150,7 +150,7 @@ module.exports = function (SocketPosts) { return callback(new Error('[[error:cant-purge-main-post]]')); } if (results.isMain && results.isLast) { - deleteTopicOf(data.pid, socket, next); + return deleteTopicOf(data.pid, socket, next); } setImmediate(next); }, diff --git a/src/socket.io/topics/tools.js b/src/socket.io/topics/tools.js index b124c7e3fd..ede87d2599 100644 --- a/src/socket.io/topics/tools.js +++ b/src/socket.io/topics/tools.js @@ -121,4 +121,12 @@ module.exports = function (SocketTopics) { ], callback); } + SocketTopics.orderPinnedTopics = function (socket, data, callback) { + if (!Array.isArray(data)) { + return callback(new Error('[[error:invalid-data]]')); + } + + topics.tools.orderPinnedTopics(socket.uid, data, callback); + }; + }; \ No newline at end of file diff --git a/src/topics/tools.js b/src/topics/tools.js index 377b3bdfcd..c69b0692ab 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -1,6 +1,7 @@ 'use strict'; var async = require('async'); +var _ = require('underscore'); var db = require('../database'); var categories = require('../categories'); @@ -184,13 +185,13 @@ module.exports = function (Topics) { async.parallel([ async.apply(db.sortedSetAdd, 'cid:' + topicData.cid + ':tids:pinned', Date.now(), tid), async.apply(db.sortedSetRemove, 'cid:' + topicData.cid + ':tids', tid), - async.apply(db.sortedSetRemove, 'cid:' + topicData.cid + ':tids:posts', tid), + async.apply(db.sortedSetRemove, 'cid:' + topicData.cid + ':tids:posts', tid) ], next); } else { async.parallel([ async.apply(db.sortedSetRemove, 'cid:' + topicData.cid + ':tids:pinned', tid), async.apply(db.sortedSetAdd, 'cid:' + topicData.cid + ':tids', topicData.lastposttime, tid), - async.apply(db.sortedSetAdd, 'cid:' + topicData.cid + ':tids:posts', topicData.postcount, tid), + async.apply(db.sortedSetAdd, 'cid:' + topicData.cid + ':tids:posts', topicData.postcount, tid) ], next); } } @@ -211,6 +212,49 @@ module.exports = function (Topics) { ], callback); } + topicTools.orderPinnedTopics = function (uid, data, callback) { + var cid; + async.waterfall([ + function (next) { + var tids = data.map(function (topic) { + return topic && topic.tid; + }); + Topics.getTopicsFields(tids, ['cid'], next); + }, + function (topicData, next) { + var uniqueCids = _.unique(topicData.map(function (topicData) { + return topicData && parseInt(topicData.cid, 10); + })); + + if (uniqueCids.length > 1 || !uniqueCids.length || !uniqueCids[0]) { + return next(new Error('[[error:invalid-data]]')); + } + cid = uniqueCids[0]; + + privileges.categories.isAdminOrMod(cid, uid, next); + }, + function (isAdminOrMod, next) { + if (!isAdminOrMod) { + return next(new Error('[[error:no-privileges]]')); + } + async.eachSeries(data, function (topicData, next) { + async.waterfall([ + function (next) { + db.isSortedSetMember('cid:' + cid + ':tids:pinned', topicData.tid, next); + }, + function (isPinned, next) { + if (isPinned) { + db.sortedSetAdd('cid:' + cid + ':tids:pinned', topicData.order, topicData.tid, next); + } else { + setImmediate(next); + } + } + ], next); + }, next); + } + ], callback); + }; + topicTools.move = function (tid, cid, uid, callback) { var topic; async.waterfall([ diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index 745071dc4b..19c542c69c 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -28,9 +28,7 @@ [[admin/manage/users:download-csv]] - - diff --git a/test/mocha.opts b/test/mocha.opts index b0a5a2aa02..49399dd418 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,2 +1,2 @@ --reporter dot ---timeout 10000 +--timeout 15000 diff --git a/test/plugins.js b/test/plugins.js index 67709bcf49..09537d8990 100644 --- a/test/plugins.js +++ b/test/plugins.js @@ -101,6 +101,7 @@ describe('Plugins', function () { var latest; var pluginName = 'nodebb-plugin-imgur'; it('should install a plugin', function (done) { + this.timeout(20000); plugins.toggleInstall(pluginName, '1.0.16', function (err, pluginData) { assert.ifError(err); diff --git a/test/posts.js b/test/posts.js index 7b9ec677bd..b89f2368e7 100644 --- a/test/posts.js +++ b/test/posts.js @@ -185,31 +185,37 @@ describe('Post\'s', function () { }); describe('delete/restore/purge', function () { + function createTopicWithReply(callback) { + topics.post({ + uid: voterUid, + cid: cid, + title: 'topic to delete/restore/purge', + content: 'A post to delete/restore/purge' + }, function (err, topicPostData) { + assert.ifError(err); + topics.reply({ + uid: voterUid, + tid: topicPostData.topicData.tid, + timestamp: Date.now(), + content: 'A post to delete/restore and purge' + }, function (err, replyData) { + assert.ifError(err); + callback(topicPostData, replyData); + }); + }); + } + var tid; var mainPid; var replyPid; var socketPosts = require('../src/socket.io/posts'); before(function (done) { - topics.post({ - uid: voterUid, - cid: cid, - title: 'topic to delete/restore/purge', - content: 'A post to delete/restore/purge' - }, function (err, data) { - assert.ifError(err); - tid = data.topicData.tid; - mainPid = data.postData.pid; - topics.reply({ - uid: voterUid, - tid: topicData.tid, - timestamp: Date.now(), - content: 'A post to delete/restore and purge' - }, function (err, data) { - assert.ifError(err); - replyPid = data.pid; - privileges.categories.give(['purge'], cid, 'registered-users', done); - }); + createTopicWithReply(function (topicPostData, replyData) { + tid = topicPostData.topicData.tid; + mainPid = topicPostData.postData.pid; + replyPid = replyData.pid; + privileges.categories.give(['purge'], cid, 'registered-users', done); }); }); @@ -242,27 +248,48 @@ describe('Post\'s', function () { }); }); - it('should delete posts and topic', function (done) { + it('should delete posts', function (done) { socketPosts.deletePosts({uid: globalModUid}, {pids: [replyPid, mainPid], tid: tid}, function (err) { assert.ifError(err); - topics.getTopicField(tid, 'deleted', function (err, deleted) { + posts.getPostField(replyPid, 'deleted', function (err, deleted) { assert.ifError(err); assert.equal(parseInt(deleted, 10), 1); - done(); + posts.getPostField(mainPid, 'deleted', function (err, deleted) { + assert.ifError(err); + assert.equal(parseInt(deleted, 10), 1); + done(); + }); }); }); }); - it('should purge posts', function (done) { - var socketTopics = require('../src/socket.io/topics'); - socketTopics.restore({uid: globalModUid}, {tids: [tid], cid: cid}, function (err) { + it('should delete topic if last main post is deleted', function (done) { + topics.post({uid: voterUid, cid: cid, title: 'test topic', content: 'test topic'}, function (err, data) { assert.ifError(err); - socketPosts.purgePosts({uid: voterUid}, {pids: [replyPid, mainPid], tid: tid}, function (err) { + socketPosts.deletePosts({uid: globalModUid}, {pids: [data.postData.pid], tid: data.topicData.tid}, function (err) { assert.ifError(err); - posts.exists('post:' + replyPid, function (err, exists) { + topics.getTopicField(data.topicData.tid, 'deleted', function (err, deleted) { + assert.ifError(err); + assert.equal(parseInt(deleted, 10), 1); + done(); + }); + }); + }); + }); + + it('should purge posts and delete topic', function (done) { + + createTopicWithReply(function (topicPostData, replyData) { + socketPosts.purgePosts({uid: voterUid}, {pids: [replyData.pid, topicPostData.postData.pid], tid: topicPostData.topicData.tid}, function (err) { + assert.ifError(err); + posts.exists('post:' + replyData.pid, function (err, exists) { assert.ifError(err); assert.equal(exists, false); - done(); + topics.getTopicField(topicPostData.topicData.tid, 'deleted', function (err, deleted) { + assert.ifError(err); + assert.equal(parseInt(deleted, 10), 1); + done(); + }); }); }); }); diff --git a/test/topics.js b/test/topics.js index 9577f89a1e..3a192b9e94 100644 --- a/test/topics.js +++ b/test/topics.js @@ -45,8 +45,6 @@ describe('Topic\'s', function () { done(); }); }); - - }); describe('.post', function () { @@ -362,6 +360,98 @@ describe('Topic\'s', function () { }); }); + describe('order pinned topics', function () { + var tid1; + var tid2; + var tid3; + before(function (done) { + function createTopic(callback) { + topics.post({ + uid: topic.userId, + title: 'topic for test', + content: 'topic content', + cid: topic.categoryId + }, callback); + } + async.series({ + topic1: function (next) { + createTopic(next); + }, + topic2: function (next) { + createTopic(next); + }, + topic3: function (next) { + createTopic(next); + } + }, function (err, results) { + assert.ifError(err); + tid1 = results.topic1.topicData.tid; + tid2 = results.topic2.topicData.tid; + tid3 = results.topic3.topicData.tid; + async.series([ + function (next) { + topics.tools.pin(tid1, adminUid, next); + }, + function (next) { + topics.tools.pin(tid2, adminUid, next); + } + ], done); + }); + }); + + var socketTopics = require('../src/socket.io/topics'); + it('should error with invalid data', function (done) { + socketTopics.orderPinnedTopics({uid: adminUid}, null, function (err) { + assert.equal(err.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error with invalid data', function (done) { + socketTopics.orderPinnedTopics({uid: adminUid}, [null, null], function (err) { + assert.equal(err.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error with unprivileged user', function (done) { + socketTopics.orderPinnedTopics({uid: 0}, [{tid: tid1}, {tid: tid2}], function (err) { + assert.equal(err.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should not do anything if topics are not pinned', function (done) { + socketTopics.orderPinnedTopics({uid: adminUid}, [{tid: tid3}], function (err) { + assert.ifError(err); + db.isSortedSetMember('cid:' + topic.categoryId + ':tids:pinned', tid3, function (err, isMember) { + assert.ifError(err); + assert(!isMember); + done(); + }); + }); + }); + + it('should order pinned topics', function (done) { + db.getSortedSetRevRange('cid:' + topic.categoryId + ':tids:pinned', 0, -1, function (err, pinnedTids) { + assert.ifError(err); + assert.equal(pinnedTids[0], tid2); + assert.equal(pinnedTids[1], tid1); + socketTopics.orderPinnedTopics({uid: adminUid}, [{tid: tid1, order: 1}, {tid: tid2, order: 0}], function (err) { + assert.ifError(err); + db.getSortedSetRevRange('cid:' + topic.categoryId + ':tids:pinned', 0, -1, function (err, pinnedTids) { + assert.ifError(err); + assert.equal(pinnedTids[0], tid1); + assert.equal(pinnedTids[1], tid2); + done(); + }); + }); + }); + }); + + }); + + describe('.ignore', function () { var newTid; var uid;