From 4aab8224eda69431c48be37ee5714fd296d260a5 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 13 Jun 2018 21:02:00 -0600 Subject: [PATCH] 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 --- core/bbs.js | 7 +- core/client.js | 7 ++ core/config.js | 90 +++++++++---------- core/config_cache.js | 121 +++++++++++-------------- core/config_util.js | 61 +++++++++++-- core/fse.js | 13 +-- core/theme.js | 205 +++++++++++++++++++++---------------------- core/user_config.js | 13 +-- 8 files changed, 275 insertions(+), 242 deletions(-) diff --git a/core/bbs.js b/core/bbs.js index a7246b8b..883e1a87 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -190,10 +190,13 @@ function initialize(cb) { function initStatLog(callback) { return require('./stat_log.js').init(callback); }, + function initConfigs(callback) { + return require('./config_util.js').init(callback); + }, function initThemes(callback) { // Have to pull in here so it's after Config init - require('./theme.js').initAvailableThemes(function onThemesInit(err, themeCount) { - logger.log.info({ themeCount : themeCount }, 'Themes initialized'); + require('./theme.js').initAvailableThemes( (err, themeCount) => { + logger.log.info({ themeCount }, 'Themes initialized'); return callback(err); }); }, diff --git a/core/client.js b/core/client.js index d75b1b6d..d3692cd5 100644 --- a/core/client.js +++ b/core/client.js @@ -38,6 +38,7 @@ const User = require('./user.js'); const Config = require('./config.js').config; const MenuStack = require('./menu_stack.js'); const ACS = require('./acs.js'); +const Events = require('./events.js'); // deps const stream = require('stream'); @@ -110,6 +111,12 @@ function Client(/*input, output*/) { 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 diff --git a/core/config.js b/core/config.js index 63e52032..b11c43e9 100644 --- a/core/config.js +++ b/core/config.js @@ -2,13 +2,12 @@ 'use strict'; // ENiGMA½ +const Errors = require('./enig_error.js').Errors; // deps -const fs = require('graceful-fs'); const paths = require('path'); const async = require('async'); const _ = require('lodash'); -const hjson = require('hjson'); const assert = require('assert'); exports.init = init; @@ -40,39 +39,13 @@ function hasMessageConferenceAndArea(config) { return result; } -function init(configPath, options, cb) { - if(!cb && _.isFunction(options)) { - cb = options; - options = {}; - } - +function mergeValidateAndFinalize(config, cb) { async.waterfall( [ - function loadUserConfig(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) { - + function mergeWithDefaultConfig(callback) { const mergedConfig = _.mergeWith( getDefaultConfig(), - configJson, (conf1, conf2) => { + config, (conf1, conf2) => { // Arrays should always concat if(_.isArray(conf1)) { // :TODO: look for collisions & override dupes @@ -89,26 +62,53 @@ function init(configPath, options, cb) { // // :TODO: Logic is broken here: if(hasMessageConferenceAndArea(mergedConfig)) { - var msgAreasErr = new Error('Please create at least one message conference and area!'); - msgAreasErr.code = 'EBADCONFIG'; - return callback(msgAreasErr); - } else { - return callback(null, mergedConfig); + return callback(Errors.MissingConfig('Please create at least one message conference and area!')); } + return callback(null, mergedConfig); + }, + function setIt(mergedConfig, callback) { + exports.config = mergedConfig; + + exports.config.get = (path) => { + return _.get(exports.config, path); + }; + + return callback(null); } ], - function complete(err, mergedConfig) { - exports.config = mergedConfig; - - exports.config.get = function(path) { - return _.get(exports.config, path); - }; - - return cb(err); + err => { + if(cb) { + 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() { // e.g. /enigma-bbs-install-path/config/ return './config/'; @@ -804,7 +804,7 @@ function getDefaultConfig() { }, misc : { - preAuthIdleLogoutSeconds : 60 * 3, // 2m + preAuthIdleLogoutSeconds : 60 * 3, // 3m idleLogoutSeconds : 60 * 6, // 6m }, diff --git a/core/config_cache.js b/core/config_cache.js index 875e1b2e..8ec7280c 100644 --- a/core/config_cache.js +++ b/core/config_cache.js @@ -1,85 +1,70 @@ /* jslint node: true */ 'use strict'; -var Config = require('./config.js').config; -var Log = require('./logger.js').log; +// deps +const paths = require('path'); +const fs = require('graceful-fs'); +const hjson = require('hjson'); +const sane = require('sane'); -var paths = require('path'); -var fs = require('graceful-fs'); -var events = require('events'); -var util = require('util'); -var assert = require('assert'); -var hjson = require('hjson'); -var _ = require('lodash'); +module.exports = new class ConfigCache +{ + constructor() { + this.cache = new Map(); // path->parsed config + } -function ConfigCache() { - events.EventEmitter.call(this); + getConfigWithOptions(options, cb) { + const cached = this.cache.has(options.filePath); - var self = this; - this.cache = {}; // filePath -> HJSON - //this.gaze = new Gaze(); + if(options.forceReCache || !cached) { + this.recacheConfigFromFile(options.filePath, (err, config) => { + if(!err && !cached) { + const watcher = sane( + paths.dirname(options.filePath), + { + glob : `**/${paths.basename(options.filePath)}` + } + ); - this.reCacheConfigFromFile = function(filePath, cb) { - fs.readFile(filePath, { encoding : 'utf-8' }, function fileRead(err, data) { - try { - self.cache[filePath] = hjson.parse(data); - cb(null, self.cache[filePath]); - } catch(e) { - Log.error( { filePath : filePath, error : e.toString() }, 'Failed recaching'); - cb(e); - } - }); - }; + watcher.on('change', (fileName, fileRoot) => { + require('./logger.js').log.info( { fileName, fileRoot }, 'Configuration file changed; re-caching'); -/* - this.gaze.on('error', function gazeErr(err) { + this.recacheConfigFromFile(paths.join(fileRoot, fileName), err => { + if(!err) { + if(options.callback) { + options.callback( { fileName, fileRoot } ); + } + } + }); + }); + } + return cb(err, config, true); + }); + } else { + return cb(null, this.cache.get(options.filePath), false); + } + } - }); + getConfig(filePath, cb) { + return this.getConfigWithOptions( { filePath }, cb); + } - this.gaze.on('changed', function fileChanged(filePath) { - assert(filePath in self.cache); - - Log.info( { path : filePath }, 'Configuration file changed; re-caching'); - - self.reCacheConfigFromFile(filePath, function reCached(err) { + recacheConfigFromFile(path, cb) { + fs.readFile(path, { encoding : 'utf-8' }, (err, data) => { if(err) { - Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration'); - } else { - self.emit('recached', filePath); + return cb(err); } - }); - }); - */ -} - -util.inherits(ConfigCache, events.EventEmitter); - -ConfigCache.prototype.getConfigWithOptions = function(options, cb) { - assert(_.isString(options.filePath)); - - // 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); + let parsed; + try { + parsed = hjson.parse(data); + this.cache.set(path, parsed); + } catch(e) { + require('./logger.js').log.error( { filePath : path, error : e.message }, 'Failed to re-cache' ); + return cb(e); } - 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(); diff --git a/core/config_util.js b/core/config_util.js index 40723d9a..94ce4344 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -1,18 +1,65 @@ /* jslint node: true */ '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; -function getFullConfig(filePath, cb) { +function getConfigPath(filePath) { // |filePath| is assumed to be in the config path if it's only a file name if('.' === paths.dirname(filePath)) { filePath = paths.join(Config.paths.config, filePath); } + return filePath; +} - configCache.getConfig(filePath, function loaded(err, configJson) { - cb(err, configJson); +function init(cb) { + // 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); }); -} \ No newline at end of file +} diff --git a/core/fse.js b/core/fse.js index 8a409589..70b1ecec 100644 --- a/core/fse.js +++ b/core/fse.js @@ -532,12 +532,13 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul theme.displayThemedAsset( art[n], self.client, - { font : self.menuConfig.font }, + { font : self.menuConfig.font, acsCondMember : 'art' }, function displayed(err) { next(err); } ); }, function complete(err) { + //self.body.height = self.client.term.termHeight - self.header.height - 1; callback(err); }); }, @@ -607,14 +608,11 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul self.beforeArt(callback); }, function displayHeaderAndBodyArt(callback) { - assert(_.isString(art.header)); - assert(_.isString(art.body)); - async.eachSeries( [ 'header', 'body' ], function dispArt(n, next) { theme.displayThemedAsset( art[n], self.client, - { font : self.menuConfig.font }, + { font : self.menuConfig.font, acsCondMember : 'art' }, function displayed(err, artData) { if(artData) { mciData[n] = artData; @@ -879,13 +877,10 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul async.waterfall( [ function clearAndDisplayArt(callback) { - - // :TODO: use termHeight, not hard coded 24 here: - // :TODO: NetRunner does NOT support delete line, so this does not work: self.client.term.rawWrite( 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) { callback(err, artData); diff --git a/core/theme.js b/core/theme.js index 2c984ae0..77c737fc 100644 --- a/core/theme.js +++ b/core/theme.js @@ -5,12 +5,13 @@ const Config = require('./config.js').config; const art = require('./art.js'); const ansi = require('./ansi_term.js'); 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 asset = require('./asset.js'); const ViewController = require('./view_controller.js').ViewController; const Errors = require('./enig_error.js').Errors; const ErrorReasons = require('./enig_error.js').ErrorReasons; +const Events = require('./events.js'); const fs = require('graceful-fs'); 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) { return cb(err); } @@ -89,7 +102,7 @@ function loadTheme(themeID, cb) { }); } -const availableThemes = {}; +const availableThemes = new Map(); const IMMUTABLE_MCI_PROPERTIES = [ 'maxLength', 'argName', 'submit', 'validate' @@ -248,6 +261,56 @@ function getMergedTheme(menuConfig, promptConfig, theme) { 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) { async.waterfall( @@ -281,7 +344,7 @@ function initAvailableThemes(cb) { }, function populateAvailable(menuConfig, promptConfig, themeDirectories, callback) { async.each(themeDirectories, (themeId, nextThemeDir) => { // theme dir = theme ID - loadTheme(themeId, (err, theme, themePath) => { + loadTheme(themeId, (err, theme) => { if(err) { if(ErrorReasons.NotEnabled !== err.reasonCode) { Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme'); @@ -290,31 +353,27 @@ function initAvailableThemes(cb) { return nextThemeDir(null); // try next } - availableThemes[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 - } - }); - } - }); - + Object.assign(theme.info, { themeId } ); + availableThemes.set(themeId, getMergedTheme(menuConfig, promptConfig, theme)); return nextThemeDir(null); }); }, 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 => { - return cb(err, availableThemes ? availableThemes.length : 0); + return cb(err, availableThemes.size); } ); } @@ -324,31 +383,30 @@ function getAvailableThemes() { } function getRandomTheme() { - if(Object.getOwnPropertyNames(availableThemes).length > 0) { - var themeIds = Object.keys(availableThemes); + if(availableThemes.size > 0) { + const themeIds = [ ...availableThemes.keys() ]; return themeIds[Math.floor(Math.random() * themeIds.length)]; } } function setClientTheme(client, themeId) { - let logMsg; - const availThemes = getAvailableThemes(); - client.currentTheme = availThemes[themeId]; - if(client.currentTheme) { - logMsg = 'Set client theme'; + let msg; + let setThemeId; + 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 { - client.currentTheme = availThemes[Config.defaults.theme]; - if(client.currentTheme) { - 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'; - } + msg = 'Failed setting theme by system default ID; Using the first one we can find'; + setThemeId = availThemes.keys().next().value; } - 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) { @@ -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) { const useTempViewController = _.isUndefined(options.viewController); @@ -663,6 +654,10 @@ function displayThemedAsset(assetSpec, client, options, cb) { options = {}; } + if(Array.isArray(assetSpec) && _.isString(options.acsCondMember)) { + assetSpec = client.acs.getConditionalValue(assetSpec, options.acsCondMember); + } + const artAsset = asset.getArtAsset(assetSpec); if(!artAsset) { return cb(new Error('Asset not found: ' + assetSpec)); diff --git a/core/user_config.js b/core/user_config.js index f60d2155..6a51a36b 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -164,13 +164,14 @@ exports.getModule = class UserConfigModule extends MenuModule { vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, 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 { - themeId : themeId, - name : t.info.name, - author : t.info.author, - desc : _.isString(t.info.desc) ? t.info.desc : '', - group : _.isString(t.info.group) ? t.info.group : '', + themeId : theme.info.themeId, + name : theme.info.name, + author : theme.info.author, + desc : _.isString(theme.info.desc) ? theme.info.desc : '', + group : _.isString(theme.info.group) ? theme.info.group : '', }; }), 'name');