Initial version of hot-reload of config, menus, and prompts

* Themes use ES6 Map vs object{}
* Re-write and re-enable config cache using sane
* Events sent for config, prompt, or menu changes
* Event sent for theme changes
* Theme (or parent menu/prompt) changes cause re-merge and updates to connected clients
This commit is contained in:
Bryan Ashby 2018-06-13 21:02:00 -06:00
parent 1870db7d38
commit 4aab8224ed
8 changed files with 275 additions and 242 deletions

View File

@ -190,10 +190,13 @@ function initialize(cb) {
function initStatLog(callback) { function initStatLog(callback) {
return require('./stat_log.js').init(callback); return require('./stat_log.js').init(callback);
}, },
function initConfigs(callback) {
return require('./config_util.js').init(callback);
},
function initThemes(callback) { function initThemes(callback) {
// Have to pull in here so it's after Config init // Have to pull in here so it's after Config init
require('./theme.js').initAvailableThemes(function onThemesInit(err, themeCount) { require('./theme.js').initAvailableThemes( (err, themeCount) => {
logger.log.info({ themeCount : themeCount }, 'Themes initialized'); logger.log.info({ themeCount }, 'Themes initialized');
return callback(err); return callback(err);
}); });
}, },

View File

@ -38,6 +38,7 @@ const User = require('./user.js');
const Config = require('./config.js').config; const Config = require('./config.js').config;
const MenuStack = require('./menu_stack.js'); const MenuStack = require('./menu_stack.js');
const ACS = require('./acs.js'); const ACS = require('./acs.js');
const Events = require('./events.js');
// deps // deps
const stream = require('stream'); const stream = require('stream');
@ -110,6 +111,12 @@ function Client(/*input, output*/) {
this.input.on('data', this.dataHandler); this.input.on('data', this.dataHandler);
}; };
Events.on(Events.getSystemEvents().ThemeChanged, ( { themeId } ) => {
if(_.get(this.currentTheme, 'info.themeId') === themeId) {
this.currentTheme = require('./theme.js').getAvailableThemes().get(themeId);
}
});
// //
// Peek at incoming |data| and emit events for any special // Peek at incoming |data| and emit events for any special

View File

@ -2,13 +2,12 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Errors = require('./enig_error.js').Errors;
// deps // deps
const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const hjson = require('hjson');
const assert = require('assert'); const assert = require('assert');
exports.init = init; exports.init = init;
@ -40,39 +39,13 @@ function hasMessageConferenceAndArea(config) {
return result; return result;
} }
function init(configPath, options, cb) { function mergeValidateAndFinalize(config, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
async.waterfall( async.waterfall(
[ [
function loadUserConfig(callback) { function mergeWithDefaultConfig(callback) {
if(!_.isString(configPath)) {
return callback(null, { } );
}
fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => {
if(err) {
return callback(err);
}
let configJson;
try {
configJson = hjson.parse(configData, options);
} catch(e) {
return callback(e);
}
return callback(null, configJson);
});
},
function mergeWithDefaultConfig(configJson, callback) {
const mergedConfig = _.mergeWith( const mergedConfig = _.mergeWith(
getDefaultConfig(), getDefaultConfig(),
configJson, (conf1, conf2) => { config, (conf1, conf2) => {
// Arrays should always concat // Arrays should always concat
if(_.isArray(conf1)) { if(_.isArray(conf1)) {
// :TODO: look for collisions & override dupes // :TODO: look for collisions & override dupes
@ -89,26 +62,53 @@ function init(configPath, options, cb) {
// //
// :TODO: Logic is broken here: // :TODO: Logic is broken here:
if(hasMessageConferenceAndArea(mergedConfig)) { if(hasMessageConferenceAndArea(mergedConfig)) {
var msgAreasErr = new Error('Please create at least one message conference and area!'); return callback(Errors.MissingConfig('Please create at least one message conference and area!'));
msgAreasErr.code = 'EBADCONFIG'; }
return callback(msgAreasErr);
} else {
return callback(null, mergedConfig); return callback(null, mergedConfig);
} },
} function setIt(mergedConfig, callback) {
],
function complete(err, mergedConfig) {
exports.config = mergedConfig; exports.config = mergedConfig;
exports.config.get = function(path) { exports.config.get = (path) => {
return _.get(exports.config, path); return _.get(exports.config, path);
}; };
return callback(null);
}
],
err => {
if(cb) {
return cb(err); return cb(err);
} }
}
); );
} }
function init(configPath, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
ConfigCache.getConfig(reCachedPath, (err, config) => {
if(!err) {
mergeValidateAndFinalize(config);
}
});
};
const ConfigCache = require('./config_cache.js');
ConfigCache.getConfigWithOptions( { filePath : configPath, callback : changed }, (err, config) => {
if(err) {
return cb(err);
}
return mergeValidateAndFinalize(config, cb);
});
}
function getDefaultPath() { function getDefaultPath() {
// e.g. /enigma-bbs-install-path/config/ // e.g. /enigma-bbs-install-path/config/
return './config/'; return './config/';
@ -804,7 +804,7 @@ function getDefaultConfig() {
}, },
misc : { misc : {
preAuthIdleLogoutSeconds : 60 * 3, // 2m preAuthIdleLogoutSeconds : 60 * 3, // 3m
idleLogoutSeconds : 60 * 6, // 6m idleLogoutSeconds : 60 * 6, // 6m
}, },

View File

@ -1,85 +1,70 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var Config = require('./config.js').config; // deps
var Log = require('./logger.js').log; const paths = require('path');
const fs = require('graceful-fs');
const hjson = require('hjson');
const sane = require('sane');
var paths = require('path'); module.exports = new class ConfigCache
var fs = require('graceful-fs'); {
var events = require('events'); constructor() {
var util = require('util'); this.cache = new Map(); // path->parsed config
var assert = require('assert'); }
var hjson = require('hjson');
var _ = require('lodash');
function ConfigCache() { getConfigWithOptions(options, cb) {
events.EventEmitter.call(this); const cached = this.cache.has(options.filePath);
var self = this; if(options.forceReCache || !cached) {
this.cache = {}; // filePath -> HJSON this.recacheConfigFromFile(options.filePath, (err, config) => {
//this.gaze = new Gaze(); if(!err && !cached) {
const watcher = sane(
paths.dirname(options.filePath),
{
glob : `**/${paths.basename(options.filePath)}`
}
);
this.reCacheConfigFromFile = function(filePath, cb) { watcher.on('change', (fileName, fileRoot) => {
fs.readFile(filePath, { encoding : 'utf-8' }, function fileRead(err, data) { require('./logger.js').log.info( { fileName, fileRoot }, 'Configuration file changed; re-caching');
try {
self.cache[filePath] = hjson.parse(data); this.recacheConfigFromFile(paths.join(fileRoot, fileName), err => {
cb(null, self.cache[filePath]); if(!err) {
} catch(e) { if(options.callback) {
Log.error( { filePath : filePath, error : e.toString() }, 'Failed recaching'); options.callback( { fileName, fileRoot } );
cb(e); }
} }
}); });
};
/*
this.gaze.on('error', function gazeErr(err) {
}); });
}
return cb(err, config, true);
});
} else {
return cb(null, this.cache.get(options.filePath), false);
}
}
this.gaze.on('changed', function fileChanged(filePath) { getConfig(filePath, cb) {
assert(filePath in self.cache); return this.getConfigWithOptions( { filePath }, cb);
}
Log.info( { path : filePath }, 'Configuration file changed; re-caching'); recacheConfigFromFile(path, cb) {
fs.readFile(path, { encoding : 'utf-8' }, (err, data) => {
self.reCacheConfigFromFile(filePath, function reCached(err) {
if(err) { if(err) {
Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration'); return cb(err);
} else {
self.emit('recached', filePath);
} }
});
});
*/
} let parsed;
try {
util.inherits(ConfigCache, events.EventEmitter); parsed = hjson.parse(data);
this.cache.set(path, parsed);
ConfigCache.prototype.getConfigWithOptions = function(options, cb) { } catch(e) {
assert(_.isString(options.filePath)); require('./logger.js').log.error( { filePath : path, error : e.message }, 'Failed to re-cache' );
return cb(e);
// var self = this;
var isCached = (options.filePath in this.cache);
if(options.forceReCache || !isCached) {
this.reCacheConfigFromFile(options.filePath, function fileCached(err, config) {
if(!err && !isCached) {
//self.gaze.add(options.filePath);
} }
cb(err, config, true);
return cb(null, parsed);
}); });
} else {
cb(null, this.cache[options.filePath], false);
} }
}; };
ConfigCache.prototype.getConfig = function(filePath, cb) {
this.getConfigWithOptions( { filePath : filePath }, cb);
};
ConfigCache.prototype.getModConfig = function(fileName, cb) {
this.getConfig(paths.join(Config.paths.mods, fileName), cb);
};
module.exports = exports = new ConfigCache();

View File

@ -1,18 +1,65 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const Config = require('./config.js').config;
const configCache = require('./config_cache.js');
const paths = require('path');
const Config = require('./config.js').config;
const ConfigCache = require('./config_cache.js');
const Events = require('./events.js');
// deps
const paths = require('path');
const async = require('async');
exports.init = init;
exports.getFullConfig = getFullConfig; exports.getFullConfig = getFullConfig;
function getFullConfig(filePath, cb) { function getConfigPath(filePath) {
// |filePath| is assumed to be in the config path if it's only a file name // |filePath| is assumed to be in the config path if it's only a file name
if('.' === paths.dirname(filePath)) { if('.' === paths.dirname(filePath)) {
filePath = paths.join(Config.paths.config, filePath); filePath = paths.join(Config.paths.config, filePath);
} }
return filePath;
}
configCache.getConfig(filePath, function loaded(err, configJson) { function init(cb) {
cb(err, configJson); // pre-cache menu.hjson and prompt.hjson + establish events
const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
if(reCachedPath === getConfigPath(Config.general.menuFile)) {
Events.emit(Events.getSystemEvents().MenusChanged);
} else if(reCachedPath === getConfigPath(Config.general.promptFile)) {
Events.emit(Events.getSystemEvents().PromptsChanged);
}
};
async.series(
[
function menu(callback) {
return ConfigCache.getConfigWithOptions(
{
filePath : getConfigPath(Config.general.menuFile),
callback : changed,
},
callback
);
},
function prompt(callback) {
return ConfigCache.getConfigWithOptions(
{
filePath : getConfigPath(Config.general.promptFile),
callback : changed,
},
callback
);
}
],
err => {
return cb(err);
}
);
}
function getFullConfig(filePath, cb) {
ConfigCache.getConfig(getConfigPath(filePath), (err, config) => {
return cb(err, config);
}); });
} }

View File

@ -532,12 +532,13 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
theme.displayThemedAsset( theme.displayThemedAsset(
art[n], art[n],
self.client, self.client,
{ font : self.menuConfig.font }, { font : self.menuConfig.font, acsCondMember : 'art' },
function displayed(err) { function displayed(err) {
next(err); next(err);
} }
); );
}, function complete(err) { }, function complete(err) {
//self.body.height = self.client.term.termHeight - self.header.height - 1;
callback(err); callback(err);
}); });
}, },
@ -607,14 +608,11 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
self.beforeArt(callback); self.beforeArt(callback);
}, },
function displayHeaderAndBodyArt(callback) { function displayHeaderAndBodyArt(callback) {
assert(_.isString(art.header));
assert(_.isString(art.body));
async.eachSeries( [ 'header', 'body' ], function dispArt(n, next) { async.eachSeries( [ 'header', 'body' ], function dispArt(n, next) {
theme.displayThemedAsset( theme.displayThemedAsset(
art[n], art[n],
self.client, self.client,
{ font : self.menuConfig.font }, { font : self.menuConfig.font, acsCondMember : 'art' },
function displayed(err, artData) { function displayed(err, artData) {
if(artData) { if(artData) {
mciData[n] = artData; mciData[n] = artData;
@ -879,13 +877,10 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
// :TODO: use termHeight, not hard coded 24 here:
// :TODO: NetRunner does NOT support delete line, so this does not work: // :TODO: NetRunner does NOT support delete line, so this does not work:
self.client.term.rawWrite( self.client.term.rawWrite(
ansi.goto(self.header.height + 1, 1) + ansi.goto(self.header.height + 1, 1) +
ansi.deleteLine(24 - self.header.height)); ansi.deleteLine((self.client.term.termHeight - self.header.height) - 1));
theme.displayThemeArt( { name : self.menuConfig.config.art.quote, client : self.client }, function displayed(err, artData) { theme.displayThemeArt( { name : self.menuConfig.config.art.quote, client : self.client }, function displayed(err, artData) {
callback(err, artData); callback(err, artData);

View File

@ -5,12 +5,13 @@ const Config = require('./config.js').config;
const art = require('./art.js'); const art = require('./art.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const configCache = require('./config_cache.js'); const ConfigCache = require('./config_cache.js');
const getFullConfig = require('./config_util.js').getFullConfig; const getFullConfig = require('./config_util.js').getFullConfig;
const asset = require('./asset.js'); const asset = require('./asset.js');
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const ErrorReasons = require('./enig_error.js').ErrorReasons; const ErrorReasons = require('./enig_error.js').ErrorReasons;
const Events = require('./events.js');
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
@ -63,11 +64,23 @@ function refreshThemeHelpers(theme) {
}; };
} }
function loadTheme(themeID, cb) { function loadTheme(themeId, cb) {
const path = paths.join(Config.paths.themes, themeId, 'theme.hjson');
const path = paths.join(Config.paths.themes, themeID, 'theme.hjson'); const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
if(reCachedPath === path) {
reloadTheme(themeId);
}
};
configCache.getConfigWithOptions( { filePath : path, forceReCache : true }, (err, theme) => { const getOpts = {
filePath : path,
forceReCache : true,
callback : changed,
};
ConfigCache.getConfigWithOptions(getOpts, (err, theme) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
@ -89,7 +102,7 @@ function loadTheme(themeID, cb) {
}); });
} }
const availableThemes = {}; const availableThemes = new Map();
const IMMUTABLE_MCI_PROPERTIES = [ const IMMUTABLE_MCI_PROPERTIES = [
'maxLength', 'argName', 'submit', 'validate' 'maxLength', 'argName', 'submit', 'validate'
@ -248,6 +261,56 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
return mergedTheme; return mergedTheme;
} }
function reloadTheme(themeId) {
async.waterfall(
[
function loadMenuConfig(callback) {
getFullConfig(Config.general.menuFile, (err, menuConfig) => {
return callback(err, menuConfig);
});
},
function loadPromptConfig(menuConfig, callback) {
getFullConfig(Config.general.promptFile, (err, promptConfig) => {
return callback(err, menuConfig, promptConfig);
});
},
function loadIt(menuConfig, promptConfig, callback) {
loadTheme(themeId, (err, theme) => {
if(err) {
if(ErrorReasons.NotEnabled !== err.reasonCode) {
Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme');
return;
}
return callback(err);
}
Object.assign(theme.info, { themeId } );
availableThemes.set(themeId, getMergedTheme(menuConfig, promptConfig, theme));
Events.emit(
Events.getSystemEvents().ThemeChanged,
{ themeId }
);
return callback(null, theme);
});
}
],
(err, theme) => {
if(err) {
Log.warn( { themeId, error : err.message }, 'Failed to reload theme');
} else {
Log.debug( { info : theme.info }, 'Theme recached' );
}
}
);
}
function reloadAllThemes()
{
async.each([ ...availableThemes.keys() ], themeId => reloadTheme(themeId));
}
function initAvailableThemes(cb) { function initAvailableThemes(cb) {
async.waterfall( async.waterfall(
@ -281,7 +344,7 @@ function initAvailableThemes(cb) {
}, },
function populateAvailable(menuConfig, promptConfig, themeDirectories, callback) { function populateAvailable(menuConfig, promptConfig, themeDirectories, callback) {
async.each(themeDirectories, (themeId, nextThemeDir) => { // theme dir = theme ID async.each(themeDirectories, (themeId, nextThemeDir) => { // theme dir = theme ID
loadTheme(themeId, (err, theme, themePath) => { loadTheme(themeId, (err, theme) => {
if(err) { if(err) {
if(ErrorReasons.NotEnabled !== err.reasonCode) { if(ErrorReasons.NotEnabled !== err.reasonCode) {
Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme'); Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme');
@ -290,31 +353,27 @@ function initAvailableThemes(cb) {
return nextThemeDir(null); // try next return nextThemeDir(null); // try next
} }
availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme); Object.assign(theme.info, { themeId } );
availableThemes.set(themeId, getMergedTheme(menuConfig, promptConfig, theme));
configCache.on('recached', recachedPath => {
if(themePath === recachedPath) {
loadTheme(themeId, (err, reloadedTheme) => {
if(!err) {
// :TODO: This is still broken - Need to reapply *latest* menu config and prompt configs to theme at very least
Log.debug( { info : theme.info }, 'Theme recached' );
availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, reloadedTheme);
} else if(ErrorReasons.NotEnabled === err.reasonCode) {
// :TODO: we need to disable this theme -- users may be using it! We'll need to re-assign them if so
}
});
}
});
return nextThemeDir(null); return nextThemeDir(null);
}); });
}, err => { }, err => {
return callback(err); return callback(err);
}); });
},
function initEvents(callback) {
Events.on(Events.getSystemEvents().MenusChanged, () => {
return reloadAllThemes();
});
Events.on(Events.getSystemEvents().PromptsChanged, () => {
return reloadAllThemes();
});
return callback(null);
} }
], ],
err => { err => {
return cb(err, availableThemes ? availableThemes.length : 0); return cb(err, availableThemes.size);
} }
); );
} }
@ -324,31 +383,30 @@ function getAvailableThemes() {
} }
function getRandomTheme() { function getRandomTheme() {
if(Object.getOwnPropertyNames(availableThemes).length > 0) { if(availableThemes.size > 0) {
var themeIds = Object.keys(availableThemes); const themeIds = [ ...availableThemes.keys() ];
return themeIds[Math.floor(Math.random() * themeIds.length)]; return themeIds[Math.floor(Math.random() * themeIds.length)];
} }
} }
function setClientTheme(client, themeId) { function setClientTheme(client, themeId) {
let logMsg;
const availThemes = getAvailableThemes(); const availThemes = getAvailableThemes();
client.currentTheme = availThemes[themeId]; let msg;
if(client.currentTheme) { let setThemeId;
logMsg = 'Set client theme'; if(availThemes.has(themeId)) {
msg = 'Set client theme';
setThemeId = themeId;
} else if(availThemes.has(Config.defaults.theme)) {
msg = 'Failed setting theme by supplied ID; Using default';
setThemeId = Config.defaults.theme;
} else { } else {
client.currentTheme = availThemes[Config.defaults.theme]; msg = 'Failed setting theme by system default ID; Using the first one we can find';
if(client.currentTheme) { setThemeId = availThemes.keys().next().value;
logMsg = 'Failed setting theme by supplied ID; Using default';
} else {
client.currentTheme = availThemes[Object.keys(availThemes)[0]];
logMsg = 'Failed setting theme by system default ID; Using the first one we can find';
}
} }
client.log.debug( { themeId : themeId, info : client.currentTheme.info }, logMsg); client.currentTheme = availThemes.get(setThemeId);
client.log.debug( { setThemeId, requestedThemeId : themeId, info : client.currentTheme.info }, msg);
} }
function getThemeArt(options, cb) { function getThemeArt(options, cb) {
@ -465,73 +523,6 @@ function displayThemeArt(options, cb) {
}); });
} }
/*
function displayThemedPrompt(name, client, options, cb) {
async.waterfall(
[
function loadConfig(callback) {
configCache.getModConfig('prompt.hjson', (err, promptJson) => {
if(err) {
return callback(err);
}
if(_.has(promptJson, [ 'prompts', name ] )) {
return callback(Errors.DoesNotExist(`Prompt "${name}" does not exist`));
}
const promptConfig = promptJson.prompts[name];
if(!_.isObject(promptConfig)) {
return callback(Errors.Invalid(`Prompt "${name} is invalid`));
}
return callback(null, promptConfig);
});
},
function display(promptConfig, callback) {
if(options.clearScreen) {
client.term.rawWrite(ansi.clearScreen());
}
//
// If we did not clear the screen, don't let the font change
//
const dispOptions = Object.assign( {}, promptConfig.options );
if(!options.clearScreen) {
dispOptions.font = 'not_really_a_font!';
}
displayThemedAsset(
promptConfig.art,
client,
dispOptions,
(err, artData) => {
if(err) {
return callback(err);
}
return callback(null, promptConfig, artData.mciMap);
}
);
},
function prepViews(promptConfig, mciMap, callback) {
vc = new ViewController( { client : client } );
const loadOpts = {
promptName : name,
mciMap : mciMap,
config : promptConfig,
};
vc.loadFromPromptConfig(loadOpts, err => {
callback(null);
});
}
]
);
}
*/
function displayThemedPrompt(name, client, options, cb) { function displayThemedPrompt(name, client, options, cb) {
const useTempViewController = _.isUndefined(options.viewController); const useTempViewController = _.isUndefined(options.viewController);
@ -663,6 +654,10 @@ function displayThemedAsset(assetSpec, client, options, cb) {
options = {}; options = {};
} }
if(Array.isArray(assetSpec) && _.isString(options.acsCondMember)) {
assetSpec = client.acs.getConditionalValue(assetSpec, options.acsCondMember);
}
const artAsset = asset.getArtAsset(assetSpec); const artAsset = asset.getArtAsset(assetSpec);
if(!artAsset) { if(!artAsset) {
return cb(new Error('Asset not found: ' + assetSpec)); return cb(new Error('Asset not found: ' + assetSpec));

View File

@ -164,13 +164,14 @@ exports.getModule = class UserConfigModule extends MenuModule {
vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback);
}, },
function prepareAvailableThemes(callback) { function prepareAvailableThemes(callback) {
self.availThemeInfo = _.sortBy(_.map(theme.getAvailableThemes(), function makeThemeInfo(t, themeId) { self.availThemeInfo = _.sortBy([...theme.getAvailableThemes()].map(entry => {
const theme = entry[1];
return { return {
themeId : themeId, themeId : theme.info.themeId,
name : t.info.name, name : theme.info.name,
author : t.info.author, author : theme.info.author,
desc : _.isString(t.info.desc) ? t.info.desc : '', desc : _.isString(theme.info.desc) ? theme.info.desc : '',
group : _.isString(t.info.group) ? t.info.group : '', group : _.isString(theme.info.group) ? theme.info.group : '',
}; };
}), 'name'); }), 'name');