diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js index bc0b1652b5..866bc4bb9d 100644 --- a/public/src/admin/manage/category.js +++ b/public/src/admin/manage/category.js @@ -8,44 +8,9 @@ define('admin/manage/category', [ 'autocomplete' ], function (uploader, iconSelect, colorpicker, autocomplete) { var Category = {}; + var modified_categories = {}; Category.init = function () { - var modified_categories = {}; - - function modified(el) { - var cid = $(el).parents('form').attr('data-cid'); - - if (cid) { - modified_categories[cid] = modified_categories[cid] || {}; - modified_categories[cid][$(el).attr('data-name')] = $(el).val(); - - app.flags = app.flags || {}; - app.flags._unsaved = true; - } - } - - function save(e) { - e.preventDefault(); - - if(Object.keys(modified_categories).length) { - socket.emit('admin.categories.update', modified_categories, function (err, result) { - if (err) { - return app.alertError(err.message); - } - - if (result && result.length) { - app.flags._unsaved = false; - app.alert({ - title: 'Updated Categories', - message: 'Category IDs ' + result.join(', ') + ' was successfully updated.', - type: 'success', - timeout: 2000 - }); - } - }); - modified_categories = {}; - } - } $('.blockclass, form.category select').each(function () { var $this = $(this); @@ -85,7 +50,28 @@ define('admin/manage/category', [ $('[data-name="bgColor"], [data-name="color"]').each(enableColorPicker); - $('#save').on('click', save); + $('#save').on('click', function () { + if (Object.keys(modified_categories).length) { + socket.emit('admin.categories.update', modified_categories, function (err, result) { + if (err) { + return app.alertError(err.message); + } + + if (result && result.length) { + app.flags._unsaved = false; + app.alert({ + title: 'Updated Categories', + message: 'Category IDs ' + result.join(', ') + ' was successfully updated.', + type: 'success', + timeout: 2000 + }); + } + }); + modified_categories = {}; + } + return false; + }); + $('.purge').on('click', function (e) { e.preventDefault(); @@ -171,8 +157,37 @@ define('admin/manage/category', [ }); Category.setupPrivilegeTable(); + + handleTags(); }; + function modified(el) { + var cid = ajaxify.data.category.cid; + + if (cid) { + modified_categories[cid] = modified_categories[cid] || {}; + modified_categories[cid][$(el).attr('data-name')] = $(el).val(); + + app.flags = app.flags || {}; + app.flags._unsaved = true; + } + } + + function handleTags() { + var tagEl = $('#tag-whitelist'); + tagEl.tagsinput({ + confirmKeys: [13, 44], + trimValue: true + }); + + ajaxify.data.category.tagWhitelist.forEach(function (tag) { + tagEl.tagsinput('add', tag); + }); + tagEl.on('itemAdded itemRemoved', function (event) { + modified(tagEl); + }); + } + Category.setupPrivilegeTable = function () { $('.privilege-table-container').on('change', 'input[type="checkbox"]', function () { var checkboxEl = $(this), diff --git a/public/src/utils.js b/public/src/utils.js index 0b78b34030..42d4b6f3ad 100644 --- a/public/src/utils.js +++ b/public/src/utils.js @@ -101,7 +101,7 @@ }, cleanUpTag: function (tag, maxLength) { - if (typeof tag !== 'string' || !tag.length ) { + if (typeof tag !== 'string' || !tag.length) { return ''; } diff --git a/src/categories.js b/src/categories.js index 28d99a7d7f..4458e09481 100644 --- a/src/categories.js +++ b/src/categories.js @@ -141,6 +141,9 @@ var privileges = require('./privileges'); parents: function (next) { Categories.getParents(cids, next); }, + tagWhitelist: function (next) { + Categories.getTagWhitelist(cids, next); + }, hasRead: function (next) { Categories.hasReadCategories(cids, uid, next); } @@ -149,20 +152,26 @@ var privileges = require('./privileges'); return callback(err); } - var categories = results.categories; - var hasRead = results.hasRead; uid = parseInt(uid, 10); - for(var i = 0; i < results.categories.length; ++i) { - if (categories[i]) { - categories[i]['unread-class'] = (parseInt(categories[i].topic_count, 10) === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread'; - categories[i].children = results.children[i]; - categories[i].parent = results.parents[i] || undefined; - calculateTopicPostCount(categories[i]); + results.categories.forEach(function (category, i) { + if (category) { + category['unread-class'] = (parseInt(category.topic_count, 10) === 0 || (results.hasRead[i] && uid !== 0)) ? '' : 'unread'; + category.children = results.children[i]; + category.parent = results.parents[i] || undefined; + category.tagWhitelist = results.tagWhitelist[i]; + calculateTopicPostCount(category); } - } + }); + + callback(null, results.categories); + }); + }; - callback(null, categories); + Categories.getTagWhitelist = function (cids, callback) { + var keys = cids.map(function (cid) { + return 'cid:' + cid + ':tag:whitelist'; }); + db.getSortedSetsMembers(keys, callback); }; function calculateTopicPostCount(category) { diff --git a/src/categories/delete.js b/src/categories/delete.js index f91842e487..06d4931132 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -54,6 +54,7 @@ module.exports = function (Categories) { 'cid:' + cid + ':read_by_uid', 'cid:' + cid + ':ignorers', 'cid:' + cid + ':children', + 'cid:' + cid + ':tag:whitelist', 'category:' + cid ], next); }, diff --git a/src/categories/update.js b/src/categories/update.js index 485d3e3834..afaad6d974 100644 --- a/src/categories/update.js +++ b/src/categories/update.js @@ -2,7 +2,9 @@ 'use strict'; var async = require('async'); + var db = require('../database'); +var meta = require('../meta'); var utils = require('../../public/src/utils'); var translator = require('../../public/src/modules/translator'); var plugins = require('../plugins'); @@ -66,6 +68,8 @@ module.exports = function (Categories) { function updateCategoryField(cid, key, value, callback) { if (key === 'parentCid') { return updateParent(cid, value, callback); + } else if (key === 'tagWhitelist') { + return updateTagWhitelist(cid, value, callback); } async.waterfall([ @@ -112,6 +116,25 @@ module.exports = function (Categories) { }); } + function updateTagWhitelist(cid, tags, callback) { + tags = tags.split(','); + tags = tags.map(function (tag) { + return utils.cleanUpTag(tag, meta.config.maximumTagLength); + }).filter(Boolean); + + async.waterfall([ + function (next) { + db.delete('cid:' + cid + ':tag:whitelist', next); + }, + function (next) { + var scores = tags.map(function (tag, index) { + return index; + }); + db.sortedSetAdd('cid:' + cid + ':tag:whitelist', scores, tags, next); + } + ], callback); + } + function updateOrder(cid, order, callback) { async.waterfall([ function (next) { diff --git a/src/meta/css.js b/src/meta/css.js index 583927e04a..b9bc1787b6 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -78,6 +78,7 @@ module.exports = function (Meta) { acpSource += '\n@import "..' + path.sep + 'public/less/generics.less";\n'; acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/colorpicker/colorpicker.css";\n'; acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/jquery/css/smoothness/jquery-ui.css";'; + acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.css";'; minify(acpSource, paths, 'acpCache', callback); } diff --git a/src/socket.io/topics/tags.js b/src/socket.io/topics/tags.js index 67e48fcce8..8829229ca8 100644 --- a/src/socket.io/topics/tags.js +++ b/src/socket.io/topics/tags.js @@ -1,10 +1,29 @@ 'use strict'; var async = require('async'); +var db = require('../../database'); var topics = require('../../topics'); var utils = require('../../../public/src/utils'); module.exports = function (SocketTopics) { + + SocketTopics.isTagAllowed = function (socket, data, callback) { + if (!data || !data.cid || !data.tag) { + return callback(new Error('[[error:invalid-data]]')); + } + async.waterfall([ + function (next) { + db.getSortedSetRange('cid:' + data.cid + ':tag:whitelist', 0, -1, next); + }, + function (tagWhitelist, next) { + if (!tagWhitelist.length) { + return next(null, true); + } + next(null, tagWhitelist.indexOf(data.tag) !== -1); + } + ], callback); + }; + SocketTopics.autocompleteTags = function (socket, data, callback) { topics.autocompleteTags(data, callback); }; diff --git a/src/topics/tags.js b/src/topics/tags.js index a88c5119d5..3f6bfbab36 100644 --- a/src/topics/tags.js +++ b/src/topics/tags.js @@ -31,6 +31,10 @@ module.exports = function (Topics) { return tag && tag.length >= (meta.config.minimumTagLength || 3) && array.indexOf(tag) === index; }); + filterCategoryTags(tags, tid, next); + }, + function (_tags, next) { + tags = _tags; var keys = tags.map(function (tag) { return 'tag:' + tag + ':topics'; }); @@ -39,15 +43,35 @@ module.exports = function (Topics) { async.apply(db.setAdd, 'topic:' + tid + ':tags', tags), async.apply(db.sortedSetsAdd, keys, timestamp, tid) ], function (err) { - if (err) { - return next(err); - } - async.each(tags, updateTagCount, next); + next(err); }); + }, + function (next) { + async.each(tags, updateTagCount, next); } ], callback); }; + function filterCategoryTags(tags, tid, callback) { + async.waterfall([ + function (next) { + Topics.getTopicField(tid, 'cid', next); + }, + function (cid, next) { + db.getSortedSetRange('cid:' + cid + ':tag:whitelist', 0, -1, next); + }, + function (tagWhitelist, next) { + if (!tagWhitelist.length) { + return next(null, tags); + } + tags = tags.filter(function (tag) { + return tagWhitelist.indexOf(tag) !== -1; + }); + next(null, tags); + } + ], callback); + } + Topics.createEmptyTag = function (tag, callback) { if (!tag) { return callback(new Error('[[error:invalid-tag]]')); @@ -273,7 +297,7 @@ module.exports = function (Topics) { if (plugins.hasListeners('filter:topics.searchTags')) { plugins.fireHook('filter:topics.searchTags', {data: data}, next); } else { - findMatches(data.query, next); + findMatches(data.query, 0, next); } }, function (result, next) { @@ -295,7 +319,7 @@ module.exports = function (Topics) { if (plugins.hasListeners('filter:topics.autocompleteTags')) { plugins.fireHook('filter:topics.autocompleteTags', {data: data}, next); } else { - findMatches(data.query, next); + findMatches(data.query, data.cid, next); } }, function (result, next) { @@ -304,10 +328,21 @@ module.exports = function (Topics) { ], callback); }; - function findMatches(query, callback) { + function findMatches(query, cid, callback) { async.waterfall([ function (next) { - db.getSortedSetRevRange('tags:topic:count', 0, -1, next); + if (parseInt(cid, 10)) { + db.getSortedSetRange('cid:' + cid + ':tag:whitelist', 0, -1, next); + } else { + setImmediate(next, null, []); + } + }, + function (tagWhitelist, next) { + if (tagWhitelist.length) { + setImmediate(next, null, tagWhitelist); + } else { + db.getSortedSetRevRange('tags:topic:count', 0, -1, next); + } }, function (tags, next) { query = query.toLowerCase(); diff --git a/src/views/admin/manage/category.tpl b/src/views/admin/manage/category.tpl index 814b319913..7b2638b584 100644 --- a/src/views/admin/manage/category.tpl +++ b/src/views/admin/manage/category.tpl @@ -59,6 +59,10 @@ </div> </div> </fieldset> + <fieldset> + <label for="tag-whitelist">Tag Whitelist</label><br /> + <input id="tag-whitelist" type="text" class="form-control" placeholder="Enter category tags here" data-name="tagWhitelist" value="" /> + </fieldset> </div> </div> diff --git a/test/categories.js b/test/categories.js index 1a95846264..e0e035410b 100644 --- a/test/categories.js +++ b/test/categories.js @@ -541,7 +541,80 @@ describe('Categories', function () { }); }); }); + }); + + describe('tag whitelist', function () { + var cid; + var socketTopics = require('../src/socket.io/topics'); + before(function (done) { + Categories.create({ + name: 'test' + }, function (err, category) { + assert.ifError(err); + cid = category.cid; + done(); + }); + }); + + it('should error if data is invalid', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, null, function (err) { + assert.equal(err.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should return true if category whitelist is empty', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'notallowed', cid: cid}, function (err, allowed) { + assert.ifError(err); + assert(allowed); + done(); + }); + }); + it('should add tags to category whitelist', function (done) { + var data = {}; + data[cid] = { + tagWhitelist: 'nodebb,jquery,javascript' + }; + Categories.update(data, function (err) { + assert.ifError(err); + db.getSortedSetRange('cid:' + cid + ':tag:whitelist', 0, -1, function (err, tagWhitelist) { + assert.ifError(err); + assert.deepEqual(['nodebb', 'jquery', 'javascript'], tagWhitelist); + done(); + }); + }); + }); + + it('should return false if category whitelist does not have tag', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'notallowed', cid: cid}, function (err, allowed) { + assert.ifError(err); + assert(!allowed); + done(); + }); + }); + + it('should return true if category whitelist has tag', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'nodebb', cid: cid}, function (err, allowed) { + assert.ifError(err); + assert(allowed); + done(); + }); + }); + + it('should post a topic with only allowed tags', function (done) { + Topics.post({ + uid: posterUid, + cid: cid, + title: 'Test Topic Title', + content: 'The content of test topic', + tags: ['nodebb', 'jquery', 'notallowed'] + }, function (err, data) { + assert.ifError(err); + assert.equal(data.topicData.tags.length, 2); + done(); + }); + }); });