diff --git a/.jshintrc b/.jshintrc index 3a7e093b8f..172f05cea8 100644 --- a/.jshintrc +++ b/.jshintrc @@ -83,5 +83,7 @@ "white" : false, // true: Check against strict whitespace and indentation rules // Custom Globals - "globals" : {} // additional predefined global variables + "globals" : { + "Promise": true + } // additional predefined global variables } \ No newline at end of file diff --git a/package.json b/package.json index 839a817c27..c0114b1978 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "request": "^2.44.0", "rimraf": "~2.5.0", "rss": "^1.0.0", + "sanitize-html": "^1.13.0", "semver": "^5.1.0", "serve-favicon": "^2.1.5", "sitemap": "^1.4.0", diff --git a/public/language/en-GB/admin/appearance/skins.json b/public/language/en-GB/admin/appearance/skins.json new file mode 100644 index 0000000000..4db6fbdd8a --- /dev/null +++ b/public/language/en-GB/admin/appearance/skins.json @@ -0,0 +1,9 @@ +{ + "loading": "Loading Skins...", + "homepage": "Homepage", + "select-skin": "Select Skin", + "current-skin": "Current Skin", + "skin-updated": "Skin Updated", + "applied-success": "%1 skin was succesfully applied", + "revert-success": "Skin reverted to base colours" +} \ No newline at end of file diff --git a/public/language/en-GB/admin/appearance/themes.json b/public/language/en-GB/admin/appearance/themes.json new file mode 100644 index 0000000000..3148a01337 --- /dev/null +++ b/public/language/en-GB/admin/appearance/themes.json @@ -0,0 +1,11 @@ +{ + "checking-for-installed": "Checking for installed themes...", + "homepage": "Homepage", + "select-theme": "Select Theme", + "current-theme": "Current Theme", + "no-themes": "No installed themes found", + "revert-confirm": "Are you sure you wish to restore the default NodeBB theme?", + "theme-changed": "Theme Changed", + "revert-success": "You have successfully reverted your NodeBB back to it's default theme.", + "restart-to-activate": "Please restart your NodeBB to fully activate this theme" +} \ No newline at end of file diff --git a/public/less/admin/admin.less b/public/less/admin/admin.less index 0ceeb050e5..aa5efff256 100644 --- a/public/less/admin/admin.less +++ b/public/less/admin/admin.less @@ -29,6 +29,7 @@ @import "./modules/selectable"; @import "./modules/snackbar"; @import "./modules/nprogress"; +@import "./modules/search"; body { overflow-y: scroll; diff --git a/public/less/admin/modules/search.less b/public/less/admin/modules/search.less new file mode 100644 index 0000000000..d2286005bf --- /dev/null +++ b/public/less/admin/modules/search.less @@ -0,0 +1,45 @@ +#acp-search { + .dropdown-menu { + max-height: 75vh; + overflow-y: auto; + + > li > a { + &.focus { + &:extend(.dropdown-menu>li>a:focus); + } + &:focus { + outline: none; + } + } + } + + .state-start-typing { + .keep-typing, .search-forum, .no-results { + display: none; + } + } + + .state-keep-typing { + .start-typing, .search-forum, .no-results { + display: none; + } + } + + .state-no-results { + .keep-typing, .start-typing { + display: none; + } + } + + .state-yes-results { + .keep-typing, .start-typing, .no-results { + display: none; + } + } + + .search-disabled { + .search-forum { + display: none; + } + } +} \ No newline at end of file diff --git a/public/src/admin/appearance/skins.js b/public/src/admin/appearance/skins.js index 7583dc952d..35ad5a2289 100644 --- a/public/src/admin/appearance/skins.js +++ b/public/src/admin/appearance/skins.js @@ -1,7 +1,7 @@ "use strict"; /* global define, app, socket, templates */ -define('admin/appearance/skins', function () { +define('admin/appearance/skins', ['translator'], function (translator) { var Skins = {}; Skins.init = function () { @@ -40,8 +40,8 @@ define('admin/appearance/skins', function () { app.alert({ alert_id: 'admin:theme', type: 'info', - title: 'Skin Updated', - message: themeId ? (themeId + ' skin was successfully applied') : 'Skin reverted to base colours', + title: '[[admin/appearance/skins:skin-updated]]', + message: themeId ? ('[[admin/appearance/skins:applied-success, ' + themeId + ']]') : '[[admin/appearance/skins:revert-success]]', timeout: 5000 }); }); @@ -67,40 +67,48 @@ define('admin/appearance/skins', function () { }), showRevert: true }, function (html) { - themeContainer.html(html); + translator.translate(html, function (html) { + themeContainer.html(html); - if (config['theme:src']) { - var skin = config['theme:src'] + if (config['theme:src']) { + var skin = config['theme:src'] .match(/latest\/(\S+)\/bootstrap.min.css/)[1] .replace(/(^|\s)([a-z])/g , function (m,p1,p2) {return p1 + p2.toUpperCase();}); - highlightSelectedTheme(skin); - } + highlightSelectedTheme(skin); + } + }); }); }; function highlightSelectedTheme(themeId) { - $('[data-theme]') - .removeClass('selected') - .find('[data-action="use"]').each(function () { - if ($(this).parents('[data-theme]').attr('data-theme')) { - $(this) - .html('Select Skin') - .removeClass('btn-success') - .addClass('btn-primary'); - } - }); + translator.translate('[[admin/appearance/skins:select-skin]] || [[admin/appearance/skins:current-skin]]', function (text) { + text = text.split(' || '); + var select = text[0]; + var current = text[1]; + + $('[data-theme]') + .removeClass('selected') + .find('[data-action="use"]').each(function () { + if ($(this).parents('[data-theme]').attr('data-theme')) { + $(this) + .html(select) + .removeClass('btn-success') + .addClass('btn-primary'); + } + }); - if (!themeId) { - return; - } + if (!themeId) { + return; + } - $('[data-theme="' + themeId + '"]') - .addClass('selected') - .find('[data-action="use"]') - .html('Current Skin') - .removeClass('btn-primary') - .addClass('btn-success'); + $('[data-theme="' + themeId + '"]') + .addClass('selected') + .find('[data-action="use"]') + .html(current) + .removeClass('btn-primary') + .addClass('btn-success'); + }); } return Skins; diff --git a/public/src/admin/appearance/themes.js b/public/src/admin/appearance/themes.js index 835dcda654..0c71baa4c4 100644 --- a/public/src/admin/appearance/themes.js +++ b/public/src/admin/appearance/themes.js @@ -1,7 +1,7 @@ "use strict"; /* global define, app, socket, bootbox, templates, config */ -define('admin/appearance/themes', function () { +define('admin/appearance/themes', ['translator'], function (translator) { var Themes = {}; Themes.init = function () { @@ -28,8 +28,8 @@ define('admin/appearance/themes', function () { app.alert({ alert_id: 'admin:theme', type: 'info', - title: 'Theme Changed', - message: 'Please restart your NodeBB to fully activate this theme', + title: '[[admin/appearance/themes:theme-changed]]', + message: '[[admin/appearance/themes:restart-to-activate]]', timeout: 5000, clickfn: function () { socket.emit('admin.restart'); @@ -38,27 +38,29 @@ define('admin/appearance/themes', function () { }); } }); - - $('#revert_theme').on('click', function () { - bootbox.confirm('Are you sure you wish to restore the default NodeBB theme?', function (confirm) { - if (confirm) { - socket.emit('admin.themes.set', { - type: 'local', - id: 'nodebb-theme-persona' - }, function (err) { - if (err) { - return app.alertError(err.message); - } - highlightSelectedTheme('nodebb-theme-persona'); - app.alert({ - alert_id: 'admin:theme', - type: 'success', - title: 'Theme Changed', - message: 'You have successfully reverted your NodeBB back to it\'s default theme.', - timeout: 3500 + + translator.translate('[[admin/appearance/themes:revert-confirm]]', function (revert) { + $('#revert_theme').on('click', function () { + bootbox.confirm(revert, function (confirm) { + if (confirm) { + socket.emit('admin.themes.set', { + type: 'local', + id: 'nodebb-theme-persona' + }, function (err) { + if (err) { + return app.alertError(err.message); + } + highlightSelectedTheme('nodebb-theme-persona'); + app.alert({ + alert_id: 'admin:theme', + type: 'success', + title: '[[admin/appearance/themes:theme-changed]]', + message: '[[admin/appearance/themes:revert-success]]', + timeout: 3500 + }); }); - }); - } + } + }); }); }); @@ -70,17 +72,17 @@ define('admin/appearance/themes', function () { var instListEl = $('#installed_themes'); if (!themes.length) { - instListEl.append($('<li/ >').addClass('no-themes').html('No installed themes found')); + translator.translate('[[admin/appearance/themes:no-themes]]', function (text) { + instListEl.append($('<li/ >').addClass('no-themes').html(text)); + }); return; } else { templates.parse('admin/partials/theme_list', { themes: themes }, function (html) { - require(['translator'], function (translator) { - translator.translate(html, function (html) { - instListEl.html(html); - highlightSelectedTheme(config['theme:id']); - }); + translator.translate(html, function (html) { + instListEl.html(html); + highlightSelectedTheme(config['theme:id']); }); }); } @@ -88,19 +90,25 @@ define('admin/appearance/themes', function () { }; function highlightSelectedTheme(themeId) { - $('[data-theme]') - .removeClass('selected') - .find('[data-action="use"]') - .html('Select Theme') - .removeClass('btn-success') - .addClass('btn-primary'); + translator.translate('[[admin/appearance/themes:select-theme]] || [[admin/appearance/themes:current-theme]]', function (text) { + text = text.split(' || '); + var select = text[0]; + var current = text[1]; - $('[data-theme="' + themeId + '"]') - .addClass('selected') - .find('[data-action="use"]') - .html('Current Theme') - .removeClass('btn-primary') - .addClass('btn-success'); + $('[data-theme]') + .removeClass('selected') + .find('[data-action="use"]') + .html(select) + .removeClass('btn-success') + .addClass('btn-primary'); + + $('[data-theme="' + themeId + '"]') + .addClass('selected') + .find('[data-action="use"]') + .html(current) + .removeClass('btn-primary') + .addClass('btn-success'); + }); } return Themes; diff --git a/public/src/admin/modules/search.js b/public/src/admin/modules/search.js index f64a2490e7..4f46898b8e 100644 --- a/public/src/admin/modules/search.js +++ b/public/src/admin/modules/search.js @@ -1,114 +1,148 @@ "use strict"; -/*globals define, admin, ajaxify, RELATIVE_PATH*/ +/* globals socket, app, define, ajaxify, config */ -define(function () { - var search = {}, - searchIndex; +define('admin/modules/search', ['mousetrap'], function (mousetrap) { + var search = {}; - search.init = function () { - $.getJSON(RELATIVE_PATH + '/templates/indexed.json', function (data) { - searchIndex = data; - for (var file in searchIndex) { - if (searchIndex.hasOwnProperty(file)) { - searchIndex[file] = searchIndex[file].replace(/<img/g, '<none'); // can't think of a better solution, see #2153 - searchIndex[file] = $('<div class="search-container">' + searchIndex[file] + '</div>'); - searchIndex[file].find('script').remove(); - - searchIndex[file] = searchIndex[file].text().toLowerCase().replace(/[ |\r|\n]+/g, ' '); - } - } + function nsToTitle(namespace) { + return namespace.replace('admin/', '').split('/').map(function (str) { + return str[0].toUpperCase() + str.slice(1); + }).join(' > '); + } - delete searchIndex['/admin/header.tpl']; - delete searchIndex['/admin/footer.tpl']; + function find(dict, term) { + var html = dict.filter(function (elem) { + return elem.translations.toLowerCase().includes(term); + }).map(function (params) { + var namespace = params.namespace; + var translations = params.translations; + var title = params.title == null ? nsToTitle(namespace) : params.title; + + var results = translations + // remove all lines without a match + .replace(new RegExp('^(?:(?!' + term + ').)*$', 'gmi'), '') + // get up to 25 characaters of context on both sides of the match + // and wrap the match in a `.search-match` element + .replace( + new RegExp('^[\\s\\S]*?(.{0,25})(' + term + ')(.{0,25})[\\s\\S]*?$', 'gmi'), + '...$1<span class="search-match">$2</span>$3...<br>' + ) + // collapse whitespace + .replace(/(?:\n ?)+/g, '\n'); + + return '<li role="presentation" class="result">' + + '<a role= "menuitem" href= "' + config.relative_path + '/' + namespace + '" >' + + title + + '<br>' + + '<small><code>' + + results + + '</small></code>' + + '</a>' + + '</li>'; + }).join(''); + return html; + } - setupACPSearch(); + search.init = function () { + socket.emit('admin.getSearchDict', {}, function (err, dict) { + if (err) { + app.alertError(err); + throw err; + } + setupACPSearch(dict); }); }; - function setupACPSearch() { - var menu = $('#acp-search .dropdown-menu'), - routes = [], - input = $('#acp-search input'), - firstResult = null; + function setupACPSearch(dict) { + var dropdown = $('#acp-search .dropdown'); + var menu = $('#acp-search .dropdown-menu'); + var input = $('#acp-search input'); + + if (!config.searchEnabled) { + menu.addClass('search-disabled'); + } input.on('keyup', function () { - $('#acp-search .dropdown').addClass('open'); + dropdown.addClass('open'); }); $('#acp-search').parents('form').on('submit', function (ev) { - var input = $(this).find('input'), - href = firstResult ? firstResult : RELATIVE_PATH + '/search/' + input.val(); + var selected = menu.find('li.result > a.focus').attr('href'); + if (!selected.length) { + selected = menu.find('li.result > a').first().attr('href'); + } + var href = selected ? selected : config.relative_path + '/search/' + input.val(); ajaxify.go(href.replace(/^\//, '')); setTimeout(function () { - $('#acp-search .dropdown').removeClass('open'); - $(input).blur(); + dropdown.removeClass('open'); + input.blur(); }, 150); ev.preventDefault(); return false; }); - $('#main-menu a').each(function (idx, link) { - routes.push($(link).attr('href')); + mousetrap(input[0]).bind(['up', 'down'], function (ev, key) { + var next; + if (key === 'up') { + next = menu.find('li.result > a.focus').removeClass('focus').parent().prev('.result').children(); + if (!next.length) { + next = menu.find('li.result > a').last(); + } + next.addClass('focus'); + if (menu[0].getBoundingClientRect().top > next[0].getBoundingClientRect().top) { + next[0].scrollIntoView(true); + } + } else if (key === 'down') { + next = menu.find('li.result > a.focus').removeClass('focus').parent().next('.result').children(); + if (!next.length) { + next = menu.find('li.result > a').first(); + } + next.addClass('focus'); + if (menu[0].getBoundingClientRect().bottom < next[0].getBoundingClientRect().bottom) { + next[0].scrollIntoView(false); + } + } + + ev.preventDefault(); }); + var prevValue; + input.on('keyup focus', function () { - var $input = $(this), - value = $input.val().toLowerCase(), - menuItems = $('#acp-search .dropdown-menu').html(''); + var value = input.val().toLowerCase(); - function toUpperCase(txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + if (value === prevValue) { + return; } + prevValue = value; - firstResult = null; - - if (value.length >= 3) { - for (var file in searchIndex) { - if (searchIndex.hasOwnProperty(file)) { - var position = searchIndex[file].indexOf(value); - - if (position !== -1) { - var href = file.replace('.tpl', ''), - title = href.replace(/^\/admin\//, '').split('/'), - description = searchIndex[file].substring(Math.max(0, position - 25), Math.min(searchIndex[file].length - 1, position + 25)) - .replace(value, '<span class="search-match">' + value + '</span>'); - - for (var t in title) { - if (title.hasOwnProperty(t)) { - title[t] = title[t] - .replace('-', ' ') - .replace(/\w\S*/g, toUpperCase); - } - } - - title = title.join(' > '); - href = RELATIVE_PATH + href; - firstResult = firstResult ? firstResult : href; - - if ($.inArray(href, routes) !== -1) { - menuItems.append('<li role="presentation"><a role="menuitem" href="' + href + '">' + title + '<br /><small><code>...' + description + '...</code></small></a></li>'); - } - } - } - } + menu.children('.result').remove(); - if (menuItems.html() === '') { - menuItems.append('<li role="presentation"><a role="menuitem" href="#">No results...</a></li>'); - } - } + var len = value.length; + var results; - if (value.length > 0) { - if (config.searchEnabled) { - menuItems.append('<li role="presentation" class="divider"></li>'); - menuItems.append('<li role="presentation"><a role="menuitem" target="_top" href="' + RELATIVE_PATH + '/search/' + value + '">Search the forum for <strong>' + value + '</strong></a></li>'); - } else if (value.length < 3) { - menuItems.append('<li role="presentation"><a role="menuitem" href="#">Type more to see results...</a></li>'); - } + menu.toggleClass('state-start-typing', len === 0); + menu.toggleClass('state-keep-typing', len > 0 && len < 3); + + if (len >= 3) { + menu.prepend(find(dict, value)); + + results = menu.children('.result').length; + + menu.toggleClass('state-no-results', !results); + menu.toggleClass('state-yes-results', !!results); + + menu.find('.search-forum') + .not('.divider') + .find('a') + .attr('href', config.relative_path + '/search/' + value) + .find('strong') + .html(value); } else { - menuItems.append('<li role="presentation"><a role="menuitem" href="#">Start typing to see results...</a></li>'); + menu.removeClass('state-no-results state-yes-results'); } }); } diff --git a/public/src/modules/translator.js b/public/src/modules/translator.js index 0771ab56a5..a876dba44c 100644 --- a/public/src/modules/translator.js +++ b/public/src/modules/translator.js @@ -3,7 +3,7 @@ (function (factory) { 'use strict'; function loadClient(language, namespace) { - return Promise.resolve(jQuery.getJSON(config.relative_path + '/api/language/' + language + '/' + namespace)); + return Promise.resolve(jQuery.getJSON(config.relative_path + '/api/language/' + language + '/' + encodeURIComponent(namespace))); } if (typeof define === 'function' && define.amd) { // AMD. Register as a named module @@ -339,6 +339,68 @@ Translator.moduleFactories = {}; + /** + * Remove the translator patterns from text + * @param {string} text + * @returns {string} + */ + Translator.removePatterns = function removePatterns(text) { + var len = text.length; + var cursor = 0; + var lastBreak = 0; + var level = 0; + var out = ''; + var sub; + + while (cursor < len) { + sub = text.slice(cursor, cursor + 2); + if (sub === '[[') { + if (level === 0) { + out += text.slice(lastBreak, cursor); + } + level += 1; + cursor += 2; + } else if (sub === ']]') { + level -= 1; + cursor += 2; + if (level === 0) { + lastBreak = cursor; + } + } else { + cursor += 1; + } + } + out += text.slice(lastBreak, cursor); + return out; + }; + + /** + * Escape translator patterns in text + * @param {string} text + * @returns {string} + */ + Translator.escape = function escape(text) { + return typeof text === 'string' ? text.replace(/\[\[([\S]*?)\]\]/g, '\\[\\[$1\\]\\]') : text; + }; + + /** + * Unescape escaped translator patterns in text + * @param {string} text + * @returns {string} + */ + Translator.unescape = function unescape(text) { + return typeof text === 'string' ? text.replace(/\\\[\\\[([\S]*?)\\\]\\\]/g, '[[$1]]') : text; + }; + + /** + * Construct a translator pattern + */ + Translator.compile = function compile() { + var args = Array.prototype.slice.call(arguments, 0); + + return '[[' + args.join(', ') + ']]'; + }; + return Translator; }()); @@ -348,12 +410,16 @@ */ Translator: Translator, + compile: Translator.compile, + escape: Translator.escape, + unescape: Translator.unescape, + getLanguage: Translator.getLanguage, + /** * Legacy translator function for backwards compatibility */ translate: function translate(text, language, callback) { - // console.warn('[translator] `translator.translate(text, [lang, ]callback)` is deprecated. ' + - // 'Use the `translator.Translator` class instead.'); + // TODO: deprecate? var cb = callback; var lang = language; @@ -373,31 +439,6 @@ }); }, - /** - * Construct a translator pattern - * @param {string} name - Translation name - * @param {string[]} args - Optional arguments for the pattern - */ - compile: function compile() { - var args = Array.prototype.slice.call(arguments, 0); - - return '[[' + args.join(', ') + ']]'; - }, - - /** - * Escape translation patterns from text - */ - escape: function escape(text) { - return typeof text === 'string' ? text.replace(/\[\[([\S]*?)\]\]/g, '\\[\\[$1\\]\\]') : text; - }, - - /** - * Unescape translation patterns from text - */ - unescape: function unescape(text) { - return typeof text === 'string' ? text.replace(/\\\[\\\[([\S]*?)\\\]\\\]/g, '[[$1]]') : text; - }, - /** * Add translations to the cache */ @@ -422,11 +463,6 @@ adaptor.getTranslations(language, namespace, callback); }, - /** - * Get the language of the current environment, falling back to defaults - */ - getLanguage: Translator.getLanguage, - toggleTimeagoShorthand: function toggleTimeagoShorthand() { var tmp = assign({}, jQuery.timeago.settings.strings); jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort); diff --git a/src/admin/search.js b/src/admin/search.js new file mode 100644 index 0000000000..8f567071bf --- /dev/null +++ b/src/admin/search.js @@ -0,0 +1,183 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var async = require('async'); +var sanitizeHTML = require('sanitize-html'); + +var languages = require('../languages'); +var utils = require('../../public/src/utils'); +var Translator = require('../../public/src/modules/translator').Translator; + +function filterDirectories(directories) { + return directories.map(function (dir) { + // get the relative path + return dir.replace(/^.*(admin.*?).tpl$/, '$1'); + }).filter(function (dir) { + // exclude partials + // only include subpaths + // exclude category.tpl, group.tpl, category-analytics.tpl + return !dir.includes('/partials/') && + /\/.*\//.test(dir) && + !/category|group|category\-analytics$/.test(dir); + }); +} + +function getAdminNamespaces(callback) { + utils.walk(path.resolve(__dirname, '../../public/templates/admin'), function (err, directories) { + if (err) { + return callback(err); + } + + callback(null, filterDirectories(directories)); + }); +} + +function sanitize(html) { + // reduce the template to just meaningful text + // remove all tags and strip out scripts, etc completely + return sanitizeHTML(html, { + allowedTags: [], + allowedAttributes: [], + }); +} + +function simplify(translations) { + return translations + // remove all mustaches + .replace(/(?:\{{1,2}[^\}]*?\}{1,2})/g, '') + // collapse whitespace + .replace(/(?:[ \t]*[\n\r]+[ \t]*)+/g, '\n') + .replace(/[\t ]+/g, ' '); +} + +function nsToTitle(namespace) { + return namespace.replace('admin/', '').split('/').map(function (str) { + return str[0].toUpperCase() + str.slice(1); + }).join(' > '); +} + +var fallbackCacheInProgress = {}; +var fallbackCache = {}; + +function initFallback(namespace, callback) { + fs.readFile(path.resolve(__dirname, '../../public/templates/', namespace + '.tpl'), function (err, file) { + if (err) { + return callback(err); + } + + var template = file.toString(); + + var translations = sanitize(template); + translations = Translator.removePatterns(translations); + translations = simplify(translations); + translations += '\n' + nsToTitle(namespace); + + callback(null, { + namespace: namespace, + translations: translations, + }); + }); +} + +function fallback(namespace, callback) { + if (fallbackCache[namespace]) { + return callback(null, fallbackCache[namespace]); + } + if (fallbackCacheInProgress[namespace]) { + return fallbackCacheInProgress[namespace].push(callback); + } + + fallbackCacheInProgress[namespace] = [function (err, params) { + if (err) { + return callback(err); + } + + callback(null, params); + }]; + initFallback(namespace, function (err, params) { + fallbackCacheInProgress[namespace].forEach(function (fn) { + fn(err, params); + }); + fallbackCacheInProgress[namespace] = null; + fallbackCache[namespace] = params; + }); +} + +function initDict(language, callback) { + getAdminNamespaces(function (err, namespaces) { + if (err) { + return callback(err); + } + + async.map(namespaces, function (namespace, cb) { + async.waterfall([ + function (next) { + languages.get(language, namespace, next); + }, + function (translations, next) { + if (!translations || !Object.keys(translations).length) { + return next(Error('No translations for ' + language + '/' + namespace)); + } + + // join all translations into one string separated by newlines + var str = Object.keys(translations).map(function (key) { + return translations[key]; + }).join('\n'); + + next(null, { + namespace: namespace, + translations: str, + }); + } + ], function (err, params) { + if (err) { + return fallback(namespace, function (err, params) { + if (err) { + return cb({ + namespace: namespace, + translations: '', + }); + } + + cb(null, params); + }); + } + + cb(null, params); + }); + }, callback); + }); +} + +var cacheInProgress = {}; +var cache = {}; + +function getDictionary(language, callback) { + if (cache[language]) { + return callback(null, cache[language]); + } + if (cacheInProgress[language]) { + return cacheInProgress[language].push(callback); + } + + cacheInProgress[language] = [function (err, params) { + if (err) { + return callback(err); + } + + callback(null, params); + }]; + initDict(language, function (err, params) { + cacheInProgress[language].forEach(function (fn) { + fn(err, params); + }); + cacheInProgress[language] = null; + cache[language] = params; + }); +} + +module.exports.getDictionary = getDictionary; +module.exports.filterDirectories = filterDirectories; +module.exports.simplify = simplify; +module.exports.sanitize = sanitize; diff --git a/src/languages.js b/src/languages.js index 86563628b2..f3b9aa5743 100644 --- a/src/languages.js +++ b/src/languages.js @@ -75,7 +75,7 @@ Languages.list = function (callback) { fs.readFile(configPath, function (err, stream) { if (err) { - next(); + return next(err); } languages.push(JSON.parse(stream.toString())); next(); diff --git a/src/meta/templates.js b/src/meta/templates.js index 4e7f934624..fd5c1f71e1 100644 --- a/src/meta/templates.js +++ b/src/meta/templates.js @@ -12,7 +12,6 @@ var plugins = require('../plugins'); var utils = require('../../public/src/utils'); var Templates = {}; -var searchIndex = {}; Templates.compile = function (callback) { callback = callback || function () {}; @@ -131,10 +130,6 @@ function compile(callback) { } } - if (relativePath.match(/^\/admin\/[\s\S]*?/)) { - addIndex(relativePath, file); - } - mkdirp.sync(path.join(viewsPath, relativePath.split('/').slice(0, -1).join('/'))); fs.writeFile(path.join(viewsPath, relativePath), file, next); }, function (err) { @@ -143,25 +138,11 @@ function compile(callback) { return callback(err); } - compileIndex(viewsPath, function (err) { - if (err) { - return callback(err); - } - winston.verbose('[meta/templates] Successfully compiled templates.'); + winston.verbose('[meta/templates] Successfully compiled templates.'); - callback(); - }); + callback(); }); }); } - -function addIndex(path, file) { - searchIndex[path] = file; -} - -function compileIndex(viewsPath, callback) { - fs.writeFile(path.join(viewsPath, '/indexed.json'), JSON.stringify(searchIndex), callback); -} - module.exports = Templates; \ No newline at end of file diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js index c3220ed094..141d567f11 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -15,6 +15,7 @@ var emailer = require('../emailer'); var db = require('../database'); var analytics = require('../analytics'); var index = require('./index'); +var getAdminSearchDict = require('../admin/search').getDictionary; var SocketAdmin = { user: require('./admin/user'), @@ -277,5 +278,15 @@ SocketAdmin.deleteAllEvents = function (socket, data, callback) { events.deleteAll(callback); }; +SocketAdmin.getSearchDict = function (socket, data, callback) { + user.getSettings(socket.uid, function (err, settings) { + if (err) { + return callback(err); + } + var lang = settings.userLang || meta.config.defaultLang || 'en-GB'; + getAdminSearchDict(lang, callback); + }); +}; + module.exports = SocketAdmin; diff --git a/src/views/admin/appearance/skins.tpl b/src/views/admin/appearance/skins.tpl index 0c1b543a47..c5d1355f08 100644 --- a/src/views/admin/appearance/skins.tpl +++ b/src/views/admin/appearance/skins.tpl @@ -1,6 +1,6 @@ <div id="skins" class="row skins"> <div class="directory row" id="bootstrap_themes"> - <i class="fa fa-refresh fa-spin"></i> Loading Skins + <i class="fa fa-refresh fa-spin"></i> [[admin/appearance/skins:loading]] </div> <div data-type="bootswatch" data-theme="" data-css=""> diff --git a/src/views/admin/appearance/themes.tpl b/src/views/admin/appearance/themes.tpl index e67c6a2755..8203c84e8b 100644 --- a/src/views/admin/appearance/themes.tpl +++ b/src/views/admin/appearance/themes.tpl @@ -1,6 +1,6 @@ <div id="themes" class="themes"> <div class="directory row" id="installed_themes"> - <i class="fa fa-refresh fa-spin"></i> Checking for installed themes... + <i class="fa fa-refresh fa-spin"></i> [[admin/appearance/themes:checking-for-installed]] </div> </div> diff --git a/src/views/admin/partials/menu.tpl b/src/views/admin/partials/menu.tpl index 0fea719e46..9bc0c48d7e 100644 --- a/src/views/admin/partials/menu.tpl +++ b/src/views/admin/partials/menu.tpl @@ -149,7 +149,23 @@ <div class="" id="acp-search" > <div class="dropdown"> <input type="text" data-toggle="dropdown" class="form-control" placeholder="Search..."> - <ul class="dropdown-menu dropdown-menu-right" role="menu"></ul> + <ul class="dropdown-menu dropdown-menu-right state-start-typing" role="menu"> + <li role="presentation" class="no-results"> + <a>No results...</a> + </li> + <li role="presentation" class="divider search-forum"></li> + <li role="presentation" class="search-forum"> + <a role="menuitem" target="_top" href="#"> + Search the forum for <strong></strong> + </a> + </li> + <li role="presentation" class="keep-typing"> + <a>Type more to see results...</a> + </li> + <li role="presentation" class="start-typing"> + <a>Start typing to see results...</a> + </li> + </ul> </div> </div> </form> diff --git a/src/views/admin/partials/theme_list.tpl b/src/views/admin/partials/theme_list.tpl index 1517eb5c9e..26a226b803 100644 --- a/src/views/admin/partials/theme_list.tpl +++ b/src/views/admin/partials/theme_list.tpl @@ -10,13 +10,13 @@ <!-- IF themes.url --> <p> - <a href="{themes.url}" target="_blank">Homepage</a> + <a href="{themes.url}" target="_blank">[[admin/appearance/themes:homepage]]</a> </p> <!-- ENDIF themes.url --> </div> <div class="mdl-card__actions mdl-card--border"> <a class="mdl-button mdl-button--colored mdl-js-button mdl-js-ripple-effect" data-action="use"> - <!-- IF themes.skin -->Select Skin<!-- ELSE -->Select Theme<!-- ENDIF themes.skin --> + <!-- IF themes.skin -->[[admin/appearance/skins:select-skin]]<!-- ELSE -->[[admin/appearance/themes:select-theme]]<!-- ENDIF themes.skin --> </a> </div> </div> diff --git a/test/search-admin.js b/test/search-admin.js new file mode 100644 index 0000000000..216d26d35f --- /dev/null +++ b/test/search-admin.js @@ -0,0 +1,82 @@ +'use strict'; +/*global require*/ + +var assert = require('assert'); +var search = require('../src/admin/search'); + +describe('admin search', function () { + describe('filterDirectories', function () { + it('should resolve all paths to relative paths', function (done) { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + it('should exclude partials', function (done) { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + 'dfahdfsgf/admin/partials/hgkfds/fdhsdfh.tpl', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + it('should exclude files in the admin directory', function (done) { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + 'dfdasg/admin/hjkdfsk.tpl', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + }); + + describe('sanitize', function () { + it('should strip out scripts', function (done) { + assert.equal( + search.sanitize('Pellentesque tristique senectus' + + '<script>alert("nope");</script> habitant morbi'), + 'Pellentesque tristique senectus' + + ' habitant morbi' + ); + done(); + }); + it('should remove all tags', function (done) { + assert.equal( + search.sanitize('<p>Pellentesque <b>habitant morbi</b> tristique senectus' + + 'Aenean <i>vitae</i> est.Mauris <a href="placerat">eleifend</a> leo.</p>'), + 'Pellentesque habitant morbi tristique senectus' + + 'Aenean vitae est.Mauris eleifend leo.' + ); + done(); + }); + }); + + describe('simplify', function () { + it('should remove all mustaches', function (done) { + assert.equal( + search.simplify( + 'Pellentesque tristique {{senectus}}habitant morbi' + + 'liquam tincidunt {mauris.eu}risus' + ), + 'Pellentesque tristique habitant morbi' + + 'liquam tincidunt risus' + ); + done(); + }); + it('should collapse all whitespace', function (done) { + assert.equal( + search.simplify( + 'Pellentesque tristique habitant morbi' + + ' \n\n liquam tincidunt mauris eu risus.' + ), + 'Pellentesque tristique habitant morbi' + + '\nliquam tincidunt mauris eu risus.' + ); + done(); + }); + }); +}); \ No newline at end of file diff --git a/test/translator.js b/test/translator.js index 9df035cfde..91fbf8f696 100644 --- a/test/translator.js +++ b/test/translator.js @@ -233,3 +233,15 @@ describe('Translator modules', function () { done(); }); }); + +describe('Translator static methods', function () { + describe('.removePatterns', function () { + it('should remove translator patterns from text', function (done) { + assert.strictEqual( + Translator.removePatterns('Lorem ipsum dolor [[sit:amet]], consectetur adipiscing elit. [[sed:vitae, [[semper:dolor]]]] lorem'), + 'Lorem ipsum dolor , consectetur adipiscing elit. lorem' + ); + done(); + }); + }); +});