diff --git a/core/ansi_term.js b/core/ansi_term.js index 903d5b08..d4b35d3d 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -205,53 +205,6 @@ var FONT_MAP = { }; - -var SYNC_TERM_FONTS = [ - 'cp437', - 'cp1251', - 'koi8_r', - 'iso8859_2', - 'iso8859_4', - 'cp866', - 'iso8859_9', - 'haik8', - 'iso8859_8', - 'koi8_u', - 'iso8859_15', - 'iso8859_4', - 'koi8_r_b', - 'iso8859_4', - 'iso8859_5', - 'ARMSCII_8', - 'iso8859_15', - 'cp850', - 'cp850', - 'cp885', - 'cp1251', - 'iso8859_7', - 'koi8-r_c', - 'iso8859_4', - 'iso8859_1', - 'cp866', - 'cp437', - 'cp866', - 'cp885', - 'cp866_u', - 'iso8859_1', - 'cp1131', - 'c64_upper', - 'c64_lower', - 'c128_upper', - 'c128_lower', - 'atari', - 'pot_noodle', - 'mo_soul', - 'microknight_plus', - 'topaz_plus', - 'microknight', - 'topaz', -]; - // Create methods such as up(), nextLine(),... Object.keys(CONTROL).forEach(function onControlName(name) { var code = CONTROL[name]; @@ -338,21 +291,6 @@ function disableVT100LineWrapping() { // // See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt // -// :TODO: allow full spec here. -/* -function setFont(name, fontPage) { - fontPage = miscUtil.valueWithDefault(fontPage, 0); - - assert(fontPage === 0 || fontPage === 1); // see spec - - var i = SYNC_TERM_FONTS.indexOf(name); - if(-1 != i) { - return ESC_CSI + fontPage + ';' + i + ' D'; - } - return ''; -} -*/ - function setFont(name, fontPage) { name = name.toLowerCase().replace(/ /g, '_'); // conform to map diff --git a/core/art.js b/core/art.js index bea77442..973c106c 100644 --- a/core/art.js +++ b/core/art.js @@ -13,6 +13,8 @@ var util = require('util'); var ansi = require('./ansi_term.js'); var aep = require('./ansi_escape_parser.js'); +var _ = require('lodash'); + exports.getArt = getArt; exports.getArtFromPath = getArtFromPath; exports.display = display; @@ -395,7 +397,19 @@ function display(options, cb) { var pauseKeys = miscUtil.valueWithDefault(options.pauseKeys, []); var pauseAtTermHeight = miscUtil.valueWithDefault(options.pauseAtTermHeight, false); var mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ' '); - var iceColors = miscUtil.valueWithDefault(options.iceColors, false); + + var iceColors = options.iceColors; + if(_.isUndefined(options.iceColors)) { + // detect from SAUCE, if present + iceColors = false; + if(_.isObject(options.sauce) && _.isNumber(options.sauce.ansiFlags)) { + if(options.sauce.ansiFlags & (1 << 0)) { + iceColors = true; + } + } + } + + //var iceColors = miscUtil.valueWithDefault(options.iceColors, false); // :TODO: support pause/cancel & pause @ termHeight var canceled = false; @@ -437,6 +451,7 @@ function display(options, cb) { options.client.on('cursor position report', onCPR); parser.on('mci', function onMCI(mciCode, id, args) { + // :TODO: ensure generatedId's do not conflict with any |id| id = id || generatedId++; var mapItem = mciCode + id; // :TODO: Avoid mutiple [] lookups here diff --git a/core/asset.js b/core/asset.js index b239f185..8015a351 100644 --- a/core/asset.js +++ b/core/asset.js @@ -1,7 +1,11 @@ /* jslint node: true */ 'use strict'; +var _ = require('lodash'); +var assert = require('assert'); + exports.parseAsset = parseAsset; +exports.getArtAsset = getArtAsset; var ALL_ASSETS = [ 'art', @@ -10,8 +14,7 @@ var ALL_ASSETS = [ 'prompt', ]; -// \@(art|menu|method)\:([\w\.]*)(?:\/?([\w\d\_]+))* -var ASSET_RE = new RegExp('\\@(' + ALL_ASSETS.join('|') + ')\\:([\\w\\.]*)(?:\\?/([\\w\\d\\_]+))*'); +var ASSET_RE = new RegExp('\\@(' + ALL_ASSETS.join('|') + ')\\:([\\w\\d\\.]*)(?:\\/([\\w\\d\\_]+))*'); function parseAsset(s) { var m = ASSET_RE.exec(s); @@ -28,4 +31,22 @@ function parseAsset(s) { return result; } +} + +function getArtAsset(art, cb) { + if(!_.isString(art)) { + return null; + } + + if('@' === art[0]) { + var artAsset = parseAsset(art); + assert('art' === artAsset.type || 'method' === artAsset.type); + + return artAsset; + } else { + return { + type : 'art', + asset : art, + }; + } } \ No newline at end of file diff --git a/core/bbs.js b/core/bbs.js index be72ae9b..226fff0c 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -12,10 +12,70 @@ var iconv = require('iconv-lite'); var paths = require('path'); var async = require('async'); var util = require('util'); -var async = require('async'); +var _ = require('lodash'); exports.bbsMain = bbsMain; +function bbsMain() { + async.waterfall( + [ + function processArgs(callback) { + var args = parseArgs(); + + var configPath; + + if(args.indexOf('--help') > 0) { + // :TODO: display help + } else { + var argCount = args.length; + for(var i = 0; i < argCount; ++i) { + var arg = args[i]; + if('--config' == arg) { + configPath = args[i + 1]; + } + } + } + + var configPathSupplied = _.isString(configPath); + callback(null, configPath || conf.getDefaultPath(), configPathSupplied); + }, + function initConfig(configPath, configPathSupplied, callback) { + conf.init(configPath, function configInit(err) { + + // + // If the user supplied a path and we can't read/parse it + // then it's a fatal error + // + if(configPathSupplied && err) { + if('ENOENT' === err.code) { + console.error('Configuration file does not existing: ' + configPath); + } else { + console.error('Failed parsing configuration: ' + configPath); + } + callback(err); + } else { + callback(null); + } + }); + }, + function initSystem(callback) { + initialize(function init(err) { + if(err) { + console.error('Error initializing: ' + util.inspect(err)); + } + callback(err); + }); + } + ], + function complete(err) { + if(!err) { + startListening(); + } + } + ); +} + +/* function bbsMain() { var mainArgs = parseArgs(); @@ -62,6 +122,7 @@ function bbsMain() { startListening(); }); } +*/ function parseArgs() { var args = []; diff --git a/core/client.js b/core/client.js index ae634665..df0ed384 100644 --- a/core/client.js +++ b/core/client.js @@ -216,7 +216,7 @@ Client.prototype.gotoMenuModule = function(options, cb) { if(err) { cb(err); } else { - Log.debug({ name : options.name }, 'Goto menu module'); + Log.debug( { menuName : options.name }, 'Goto menu module'); modInst.enter(self); diff --git a/core/config.js b/core/config.js index d2194d49..935aa5f8 100644 --- a/core/config.js +++ b/core/config.js @@ -1,68 +1,117 @@ /* jslint node: true */ 'use strict'; -var fs = require('fs'); -var paths = require('path'); -var miscUtil = require('./misc_util.js'); +var miscUtil = require('./misc_util.js'); -// :TODO: it would be nice to allow for defaults here & .json file only overrides -- e.g. merge the two +var fs = require('fs'); +var paths = require('path'); +var stripJsonComments = require('strip-json-comments'); +var async = require('async'); +var _ = require('lodash'); -module.exports = { - defaultPath : function() { - var base = miscUtil.resolvePath('~/'); - if(base) { - return paths.join(base, '.enigmabbs', 'config.json'); - } - }, +exports.init = init; +exports.getDefaultPath = getDefaultPath; - initFromFile : function(path, cb) { - var data = fs.readFileSync(path, 'utf8'); - // :TODO: strip comments - this.config = JSON.parse(data); - }, +function init(configPath, cb) { - createDefault : function() { - this.config = { - bbsName : 'Another Fine ENiGMA½ BBS', - - // :TODO: probably replace this with 'firstMenu' or somthing once that's available - entryMod : 'matrix', - - preLoginTheme : '*', - - users : { - usernameMin : 2, - usernameMax : 22, - passwordMin : 6, - requireActivation : true, // require SysOp activation? - }, - - defaults : { - theme : 'NU-MAYA', - passwordChar : '*', - }, - - paths : { - mods : paths.join(__dirname, './../mods/'), - servers : paths.join(__dirname, './servers/'), - art : paths.join(__dirname, './../mods/art/'), - themes : paths.join(__dirname, './../mods/art/themes/'), - logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such - db : paths.join(__dirname, './../db/'), - }, - - servers : { - telnet : { - port : 8888, - enabled : true, - }, - ssh : { - port : 8889, - enabled : true, - rsaPrivateKey : paths.join(__dirname, './../misc/default_key.rsa'), - dsaPrivateKey : paths.join(__dirname, './../misc/default_key.dsa'), + // Probably many better ways of doing this: + // :TODO: See http://jsfiddle.net/jlowery2663/z8at6knn/4/ + var recursiveMerge = function(target, source) { + for(var p in source) { + try { + if(_.isObject(source)) { + target[p] = recursiveMerge(target[p], source[p]); + } else { + target[p] = source[p]; } + } catch(e) { + target[p] = source[p]; + } + } + return target; + }; + + async.waterfall( + [ + function loadUserConfig(callback) { + + fs.readFile(configPath, { encoding : 'utf8' }, function configData(err, data) { + if(err) { + callback(null, { } ); + } else { + try { + var configJson = JSON.parse(stripJsonComments(data)); + callback(null, configJson); + } catch(e) { + callback(e); + } + } + }); }, - }; + function mergeWithDefaultConfig(menuConfig, callback) { + var mergedConfig = recursiveMerge(menuConfig, getDefaultConfig()); + callback(null, mergedConfig); + } + ], + function complete(err, mergedConfig) { + exports.config = mergedConfig; + cb(err); + } + ); +} + +function getDefaultPath() { + var base = miscUtil.resolvePath('~/'); + if(base) { + return paths.join(base, '.enigmabbs', 'config.json'); } -}; \ No newline at end of file +} + +function getDefaultConfig() { + return { + general : { + boardName : 'Another Fine ENiGMA½ BBS', + }, + + firstMenu : 'connected', + + preLoginTheme : '*', + + users : { + usernameMin : 2, + usernameMax : 22, + usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+]+$', + passwordMin : 6, + passwordMax : 256, + requireActivation : true, // require SysOp activation? + invalidUsernames : [], + }, + + defaults : { + theme : 'NU-MAYA', // :TODO: allow "*" here + passwordChar : '*', // TODO: move to user ? + }, + + paths : { + mods : paths.join(__dirname, './../mods/'), + servers : paths.join(__dirname, './servers/'), + art : paths.join(__dirname, './../mods/art/'), + themes : paths.join(__dirname, './../mods/art/themes/'), + logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such + db : paths.join(__dirname, './../db/'), + }, + + servers : { + telnet : { + port : 8888, + enabled : true, + }, + ssh : { + port : 8889, + enabled : true, + rsaPrivateKey : paths.join(__dirname, './../misc/default_key.rsa'), + dsaPrivateKey : paths.join(__dirname, './../misc/default_key.dsa'), + } + }, + }; +} diff --git a/core/connect.js b/core/connect.js index 570491ec..1683ab5c 100644 --- a/core/connect.js +++ b/core/connect.js @@ -70,42 +70,14 @@ function connectEntry(client) { ansiQueryTermSizeIfNeeded(client); prepareTerminal(term); + + // + // Always show a ENiGMA½ banner + // displayBanner(term); setTimeout(function onTimeout() { - term.write(ansi.clearScreen()); - - - var dispOptions = { - name : 'CONNECT', - client : client, - }; - - // :TODO: if connect.js were a MenuModule, MCI/etc. would function here! - // ... could also avoid some of the boilerplate code - theme.displayThemeArt(dispOptions, function artDisplayed(err) { - var timeout = err ? 0 : 2000; - - setTimeout(function timeout() { - client.gotoMenuModule( { name : Config.entryMod } ); - }, timeout); - }); - - /*artwork.getArt('CONNECT', { random : true, readSauce : true }, function onArt(err, art) { - var timeout = 0; - - if(!err) { - term.write(art.data); - timeout = 1000; - } - - setTimeout(function onTimeout() { - term.write(ansi.clearScreen()); - - client.gotoMenuModule({ name : Config.entryMod } ); - }, timeout); - }); -*/ + client.gotoMenuModule( { name : Config.firstMenu }); }, 500); } diff --git a/core/edit_text_view.js b/core/edit_text_view.js index 1c836dba..01a1b1ba 100644 --- a/core/edit_text_view.js +++ b/core/edit_text_view.js @@ -6,6 +6,7 @@ var miscUtil = require('./misc_util.js'); var strUtil = require('./string_util.js'); var util = require('util'); var assert = require('assert'); +var _ = require('lodash'); exports.EditTextView = EditTextView; @@ -32,8 +33,7 @@ EditTextView.prototype.onKeyPress = function(key, isSpecial) { assert(1 === key.length); // :TODO: how to handle justify left/center? - - if(this.text.length < this.maxLength) { + if(!_.isNumber(this.maxLength) || this.text.length < this.maxLength) { key = strUtil.stylizeString(key, this.textStyle); this.text += key; diff --git a/core/mci_view_factory.js b/core/mci_view_factory.js index 914d8059..4a3890b2 100644 --- a/core/mci_view_factory.js +++ b/core/mci_view_factory.js @@ -23,7 +23,7 @@ MCIViewFactory.prototype.getPredefinedViewLabel = function(code) { var label; switch(code) { // :TODO: Fix conflict with ButtonView (BN); chagne to BT - case 'BN' : label = Config.bbsName; break; + case 'BN' : label = Config.general.boardName; break; case 'VL' : label = 'ENiGMA½ v' + packageJson.version; break; case 'VN' : label = packageJson.version; break; @@ -93,7 +93,7 @@ MCIViewFactory.prototype.createFromMCI = function(mci) { // Edit Text case 'ET' : if(setOption(0, 'maxLength')) { - options.maxLength = parseInt(options.maxLength, 10); + options.maxLength = parseInt(options.maxLength, 10); // ensure number options.dimens = { width : options.maxLength }; } diff --git a/core/menu_module.js b/core/menu_module.js index cd7b16d7..aaec5646 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -3,11 +3,16 @@ var PluginModule = require('./plugin_module.js').PluginModule; var theme = require('./theme.js'); +var art = require('./art.js'); var Log = require('./logger.js').log; var ansi = require('./ansi_term.js'); +var asset = require('./asset.js'); +//var promptUtil = require('./prompt_util.js'); +var ViewController = require('./view_controller.js').ViewController; var async = require('async'); var assert = require('assert'); +var _ = require('lodash'); exports.MenuModule = MenuModule; @@ -17,30 +22,129 @@ function MenuModule(options) { var self = this; this.menuConfig = options.menuConfig; this.menuConfig.options = options.menuConfig.options || {}; - this.menuMethods = {}; - this.viewControllers = []; + this.menuMethods = {}; // methods called from @method's + this.viewControllers = {}; // name->vc + + this.displayArtAsset = function(assetSpec, cb) { + var artAsset = asset.getArtAsset(assetSpec); + + if(!artAsset) { + cb(new Error('Asset not found: ' + assetSpec)); + return; + } + + var dispOptions = { + name : artAsset.asset, + client : self.client, + font : self.menuConfig.font, + }; + + switch(artAsset.type) { + case 'art' : + theme.displayThemeArt(dispOptions, function displayed(err, mciMap) { + cb(err, mciMap); + }); + break; + + case 'method' : + // :TODO: fetch and render via method/generator + break; + + default : + cb(new Error('Unsupported art asset type')); + break; + } + }; this.initSequence = function() { + var mciData = { }; + async.waterfall( [ function beforeDisplayArt(callback) { self.beforeArt(); callback(null); }, - function displayArt(callback) { + function displayMenuArt(callback) { + if(_.isString(self.menuConfig.art)) { + self.displayArtAsset(self.menuConfig.art, function displayed(err, mciMap) { + mciData.menu = mciMap; + callback(err); + }); + } else { + callback(null); + } + }, + function moveToPromptLocation(callback) { + if(self.menuConfig.prompt) { + // :TODO: fetch and move cursor to prompt location, if supplied. See notes/etc. on placements + } + + callback(null); + }, + function displayPromptArt(callback) { + if(_.isString(self.menuConfig.prompt)) { + // If a prompt is specified, we need the configuration + if(!_.isObject(self.menuConfig.promptConfig)) { + callback(new Error('Prompt specified but configuraiton not found!')); + return; + } + + // Prompts *must* have art. If it's missing it's an error + // :TODO: allow inline prompts in the future, e.g. @inline:memberName -> "memberName" : { ... } + var promptConfig = self.menuConfig.promptConfig; + self.displayArtAsset(promptConfig.art, function displayed(err, mciMap) { + mciData.prompt = mciMap; + callback(err); + }); + } else { + callback(null); + } + }, + function afterArtDisplayed(callback) { + self.mciReady(mciData); + callback(null); + } + ], + function complete(err) { + if(err) { + // :TODO: what to do exactly????? + } + + self.finishedLoading(); + } + ); + }; +/* + this.initSequence2 = function() { + async.waterfall( + [ + function beforeDisplayArt(callback) { + self.beforeArt(); + callback(null); + }, + function displayArtAsset(callback) { + var artAsset = asset.getArtAsset(self.menuConfig.art); + + if(!artAsset) { + // no art to display + callback(null, null); + return; + } + var dispOptions = { - name : self.menuConfig.art, - font : self.menuConfig.font, + name : artAsset.asset, client : self.client, + font : self.menuConfig.font, }; - theme.displayThemeArt(dispOptions, function onArt(err, mciMap) { - // :TODO: If the art simply is not found, or failed to load... we need to continue - if(err) { - console.log('TODO: log this error properly... maybe handle slightly diff.'); - } - callback(null, mciMap); - }); + if('art' === artAsset.type) { + theme.displayThemeArt(dispOptions, function displayedArt(err, mciMap) { + callback(null, mciMap); + }); + } else if('method' === artAsset.type) { + // :TODO: support fetching the asset (e.g. rendering into buffer) -> display it. + } }, function afterArtDisplayed(mciMap, callback) { if(mciMap) { @@ -48,6 +152,42 @@ function MenuModule(options) { } callback(null); + }, + function loadPrompt(callback) { + if(!_.isString(self.menuConfig.prompt)) { + // no prompt supplied + callback(null, null); + return; + } + + var loadPromptOpts = { + name : self.menuConfig.prompt, + client : self.client, + }; + + promptUtil.loadPrompt(loadPromptOpts, function promptLoaded(err, prompt) { + callback(err, prompt); + }); + }, + function displayPrompt(prompt, callback) { + if(!prompt) { + callback(null); + return; + } + + // :TODO: go to proper prompt location before displaying + + var dispOptions = { + art : prompt.artInfo.data, + sauce : prompt.artInfo.sauce, + client : self.client, + font : prompt.config.font, + }; + + + art.display(dispOptions, function displayed(err, mciMap) { + + }); } ], function complete(err) { @@ -60,26 +200,28 @@ function MenuModule(options) { } ); }; + */ } require('util').inherits(MenuModule, PluginModule); MenuModule.prototype.enter = function(client) { this.client = client; - assert(typeof client !== 'undefined'); + assert(_.isObject(client)); this.initSequence(); }; MenuModule.prototype.leave = function() { - var count = this.viewControllers.length; - for(var i = 0; i < count; ++i) { - this.viewControllers[i].detachClientEvents(); - } + var self = this; + Object.keys(this.viewControllers).forEach(function entry(name) { + self.viewControllers[name].detachClientEvents(); + }); }; -MenuModule.prototype.addViewController = function(vc) { - this.viewControllers.push(vc); +MenuModule.prototype.addViewController = function(name, vc) { + assert(!this.viewControllers[name]); + this.viewControllers[name] = vc; return vc; }; @@ -87,11 +229,20 @@ MenuModule.prototype.beforeArt = function() { if(this.menuConfig.options.clearScreen) { this.client.term.write(ansi.resetScreen()); } - }; -MenuModule.prototype.mciReady = function(mciMap) { +MenuModule.prototype.mciReady = function(mciData) { }; MenuModule.prototype.finishedLoading = function() { + + var self = this; + + if(_.isNumber(this.menuConfig.options.nextTimeout) && + _.isString(this.menuConfig.next)) + { + setTimeout(function nextTimeout() { + self.client.gotoMenuModule( { name : self.menuConfig.next } ); + }, this.menuConfig.options.nextTimeout); + } }; \ No newline at end of file diff --git a/core/menu_util.js b/core/menu_util.js index e4beae57..66ebd4e7 100644 --- a/core/menu_util.js +++ b/core/menu_util.js @@ -4,7 +4,10 @@ // ENiGMA½ var moduleUtil = require('./module_util.js'); var Log = require('./logger.js').log; -var conf = require('./config.js'); +var conf = require('./config.js'); // :TODO: remove me! +var Config = require('./config.js').config; +var asset = require('./asset.js'); +var theme = require('./theme.js'); var fs = require('fs'); var paths = require('path'); @@ -17,7 +20,111 @@ var stripJsonComments = require('strip-json-comments'); exports.loadMenu = loadMenu; exports.getFormConfig = getFormConfig; + +function loadModJSON(fileName, cb) { + // :TODO: really need to cache menu.json and prompt.json only reloading if they change - see chokidar & gaze npms + var filePath = paths.join(Config.paths.mods, fileName); + + fs.readFile(filePath, { encoding : 'utf8' }, function jsonData(err, data) { + try { + var json = JSON.parse(stripJsonComments(data)); + cb(null, json); + } catch(e) { + cb(e); + } + }); +} + +function getMenuConfig(name, cb) { + var menuConfig; + + async.waterfall( + [ + function loadMenuJSON(callback) { + loadModJSON('menu.json', function loaded(err, menuJson) { + callback(err, menuJson); + }); + }, + function locateMenuConfig(menuJson, callback) { + if(_.isObject(menuJson[name])) { + menuConfig = menuJson[name]; + callback(null); + } else { + callback(new Error('No menu entry for \'' + name + '\'')); + } + }, + function loadPromptJSON(callback) { + if(_.isString(menuConfig.prompt)) { + loadModJSON('prompt.json', function loaded(err, promptJson) { + callback(err, promptJson); + }); + } else { + callback(null, null); + } + }, + function locatePromptConfig(promptJson, callback) { + if(promptJson) { + if(_.isObject(promptJson[menuConfig.prompt])) { + menuConfig.promptConfig = promptJson[menuConfig.prompt]; + } else { + callback(new Error('No prompt entry for \'' + menuConfig.prompt + '\'')); + return; + } + } + callback(null); + } + ], + function complete(err) { + cb(err, menuConfig); + } + ); +} + function loadMenu(options, cb) { + assert(_.isObject(options)); + assert(_.isString(options.name)); + assert(_.isObject(options.client)); + + async.waterfall( + [ + function getMenuConfiguration(callback) { + getMenuConfig(options.name, function menuConfigLoaded(err, menuConfig) { + callback(err, menuConfig); + }); + }, + function loadMenuModule(menuConfig, callback) { + var moduleName = menuConfig.module || 'standard_menu'; + + moduleUtil.loadModule(moduleName, 'mods', function moduleLoaded(err, mod) { + var modData = { + name : moduleName, + config : menuConfig, + mod : mod, + }; + + callback(err, modData); + }); + }, + function createModuleInstance(modData, callback) { + Log.debug( + { moduleName : modData.name, args : options.args, config : modData.config, info : modData.mod.modInfo }, + 'Creating menu module instance'); + + try { + var moduleInstance = new modData.mod.getModule( { menuConfig : modData.config, args : options.args } ); + callback(null, moduleInstance); + } catch(e) { + callback(e); + } + } + ], + function complete(err, modInst) { + cb(err, modInst); + } + ); +} + +function loadMenu2(options, cb) { assert(options); assert(options.name); @@ -33,6 +140,7 @@ function loadMenu(options, cb) { async.waterfall( [ + // :TODO: Need a good way to cache this information & only (re)load if modified function loadMenuConfig(callback) { var configJsonPath = paths.join(conf.config.paths.mods, 'menu.json'); diff --git a/core/module_util.js b/core/module_util.js index 9b728a4c..bc5f8b25 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -42,6 +42,7 @@ function loadModule(name, category, cb) { return; } + // :TODO: what was the point of this? Remove it mod.runtime = { config : config }; diff --git a/core/theme.js b/core/theme.js index 5b5f128d..a51f049e 100644 --- a/core/theme.js +++ b/core/theme.js @@ -112,7 +112,7 @@ function getThemeArt(name, themeID, options, cb) { // set/override some options options.asAnsi = true; - options.readSauce = true; // can help with encoding + options.readSauce = true; // encoding/fonts/etc. options.random = miscUtil.valueWithDefault(options.random, true); options.basePath = paths.join(Config.paths.themes, themeID); @@ -138,28 +138,19 @@ function displayThemeArt(options, cb) { assert(_.isObject(options.client)); assert(_.isString(options.name)); - getThemeArt(options.name, options.client.user.properties.art_theme_id, function onArt(err, artInfo) { + getThemeArt(options.name, options.client.user.properties.art_theme_id, function themeArt(err, artInfo) { if(err) { cb(err); } else { - var iceColors = false; - if(artInfo.sauce && artInfo.sauce.ansiFlags) { - if(artInfo.sauce.ansiFlags & (1 << 0)) { - iceColors = true; - } - } - var dispOptions = { art : artInfo.data, sauce : artInfo.sauce, client : options.client, - iceColors : iceColors, font : options.font, }; - - art.display(dispOptions, function onDisplayed(err, mci) { - cb(err, mci, artInfo); + art.display(dispOptions, function displayed(err, mciMap) { + cb(err, mciMap, artInfo); }); } }); diff --git a/core/view.js b/core/view.js index aad3e320..a4997278 100644 --- a/core/view.js +++ b/core/view.js @@ -183,7 +183,7 @@ View.prototype.onSpecialKeyPress = function(keyName) { if(this.isSpecialKeyMapped('accept', keyName)) { this.emit('action', 'accept'); } else if(this.isSpecialKeyMapped('next', keyName)) { - this.emit('action', 'next'); + console.log('next') } }; diff --git a/core/view_controller.js b/core/view_controller.js index 6d0db7ef..b1e6e8cf 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -5,6 +5,7 @@ var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; var menuUtil = require('./menu_util.js'); var Log = require('./logger.js').log; +var Config = require('./config.js').config; var asset = require('./asset.js'); var events = require('events'); @@ -12,9 +13,12 @@ var util = require('util'); var assert = require('assert'); var async = require('async'); var _ = require('lodash'); +var paths = require('path'); exports.ViewController = ViewController; +var MCI_REGEXP = /([A-Z]{2})([0-9]{1,2})/; + function ViewController(options) { assert(_.isObject(options)); assert(_.isObject(options.client)); @@ -44,7 +48,7 @@ function ViewController(options) { } }; - this.onViewAction = function(action) { + this.viewActionListener = function(action) { switch(action) { case 'next' : self.emit('action', { view : this, action : action }); @@ -84,11 +88,17 @@ function ViewController(options) { }; var viewData; + var view; for(var id in self.views) { try { - viewData = self.views[id].getData(); - if(typeof viewData !== 'undefined') { - formData.value[id] = viewData; + view = self.views[id]; + viewData = view.getData(); + if(!_.isUndefined(viewData)) { + if(_.isString(view.submitArgName)) { + formData.value[view.submitArgName] = viewData; + } else { + formData.value[id] = viewData; + } } } catch(e) { Log.error(e); // :TODO: Log better ;) @@ -108,6 +118,42 @@ function ViewController(options) { self.emitSwitchFocus = false; }; + this.handleSubmitAction = function(callingMenu, formData, conf) { + assert(_.isObject(conf)); + assert(_.isString(conf.action)); + + var actionAsset = asset.parseAsset(conf.action); + assert(_.isObject(actionAsset)); + + var extraArgs; + if(conf.extraArgs) { + extraArgs = self.formatMenuArgs(conf.extraArgs); + } + + switch(actionAsset.type) { + case 'method' : + if(_.isString(actionAsset.location)) { + // :TODO: allow omition of '.js' + var methodMod = require(paths.join(Config.paths.mods, actionAsset.location)); + if(_.isFunction(methodMod[actionAsset.asset])) { + methodMod[actionAsset.asset](callingMenu, formData, extraArgs); + } + } else { + // local to current module + var currentModule = self.client.currentMenuModule; + if(_.isFunction(currentModule.menuMethods[actionAsset.asset])) { + currentModule.menuMethods[actionAsset.asset](formData, extraArgs); + } + } + break; + + case 'menu' : + // :TODO: update everythign to handle this format + self.client.gotoMenuModule( { name : actionAsset.asset, submitData : formData, extraArgs : extraArgs } ); + break; + } + }; + this.attachClientEvents(); } @@ -128,7 +174,7 @@ ViewController.prototype.detachClientEvents = function() { if(!this.attached) { return; } - + this.client.removeListener('key press', this.onClientKeyPress); this.client.removeListener('special key', this.onClientSpecialKeyPress); @@ -218,13 +264,206 @@ ViewController.prototype.loadFromMCIMap = function(mciMap) { var view = factory.createFromMCI(mci); if(view) { - view.on('action', self.onViewAction); + view.on('action', self.viewActionListener); self.addView(view); view.redraw(); // :TODO: This can result in double redraw() if we set focus on this item after } }); }; +function setViewPropertiesFromMCIConf(view, conf) { + view.submit = conf.submit || false; + + if(_.isArray(conf.items)) { + view.setItems(conf.items); + } + + if(_.isString(conf.text)) { + view.setText(conf.text); + } + + if(_.isString(conf.argName)) { + view.submitArgName = conf.argName; + } +} + +ViewController.prototype.loadFromPrompt = function(options, cb) { + assert(_.isObject(options)); + //assert(_.isObject(options.promptConfig)); + assert(_.isObject(options.callingMenu)); + assert(_.isObject(options.callingMenu.menuConfig)); + assert(_.isObject(options.callingMenu.menuConfig.promptConfig)); + assert(_.isObject(options.mciMap)); + + var promptConfig = options.callingMenu.menuConfig.promptConfig; + var self = this; + var factory = new MCIViewFactory(this.client); + var initialFocusId = 1; // default to first + + // :TODO: if 'submit' is not present anywhere default to last ID + + async.waterfall( + [ + function createViewsFromMCI(callback) { + async.each(Object.keys(options.mciMap), function entry(name, nextItem) { + var mci = options.mciMap[name]; + var view = factory.createFromMCI(mci); + + if(view) { + view.on('action', self.viewActionListener); + + self.addView(view); + + view.redraw(); // :TODO: fix double-redraw if this is the item we set focus to! + } + + nextItem(null); + }, + function complete(err) { + self.setViewOrder(); + callback(err); + }); + }, + function applyPromptConfig(callback) { + var highestId = 1; + var submitId; + + async.each(Object.keys(promptConfig.mci), function entry(mci, nextItem) { + var mciMatch = mci.match(MCI_REGEXP); // :TODO: what about auto-generated IDs? Do they simply not apply to menu configs? + + var viewId = parseInt(mciMatch[2]); + assert(!isNaN(viewId)); + + var view = self.getView(viewId); + var mciConf = promptConfig.mci[mci]; + + setViewPropertiesFromMCIConf(view, mciConf); + + if(mciConf.focus) { + initialFocusId = viewId; + } + + if(view.submit) { + submitId = viewId; + } + + nextItem(null); + }, + function complete(err) { + + // default to highest ID if no 'submit' entry present + if(!submitId) { + self.getView(highestId).submit = true; + } + + callback(err); + }); + }, + function setupSubmit(callback) { + + self.on('submit', function promptSubmit(formData) { + // :TODO: Need to come up with a way to log without dumping sensitive form data here, e.g. remove password, etc. + Log.trace( { formData : formData }, 'Prompt submit'); + + var actionAsset = asset.parseAsset(promptConfig.action); + assert(_.isObject(actionAsset)); + + var extraArgs; + if(promptConfig.extraArgs) { + extraArgs = self.formatMenuArgs(promptConfig.extraArgs); + } + + + self.handleSubmitAction(options.callingMenu, formData, promptConfig); + + + + + /*var formattedArgs; + if(conf.args) { + formattedArgs = self.formatMenuArgs(conf.args); + } + + var actionAsset = asset.parseAsset(conf.action); + assert(_.isObject(actionAsset)); + + if('method' === actionAsset.type) { + if(actionAsset.location) { + // :TODO: call with (client, args, ...) at least. + } else { + // local to current module + var currentMod = self.client.currentMenuModule; + if(currentMod.menuMethods[actionAsset.asset]) { + currentMod.menuMethods[actionAsset.asset](formattedArgs); + } + } + } else if('menu' === actionAsset.type) { + self.client.gotoMenuModule( { name : actionAsset.asset, args : formattedArgs } ); + }*/ + }); + + callback(null); + }, + function setInitialFocus(callback) { + if(initialFocusId) { + self.switchFocus(initialFocusId); + } + } + ], + function complete(err) { + console.log(err) + cb(err); + } + ); +}; + +/* +ViewController.prototype.loadFromPrompt = function(options, cb) { + assert(_.isObject(options)); + assert(_.isObject(options.prompt)); + assert(_.isObject(options.prompt.artInfo)); + assert(_.isObject(options.prompt.artInfo.mciMap)); + assert(_.isObject(options.prompt.config)); + + + // + // Prompts are like simplified forms: + // * They do not contain submit information themselves; this must + // the owning menu: options.prompt.config + // * There is only one form in a prompt (e.g. form 0, but this is not explicit) + // * Only one MCI mapping: options.prompt.artInfo.mciMap + // + var self = this; + var factory = new MCIViewFactory(this.client); + var mciMap = options.prompt.artInfo.mciMap; + + async.waterfall( + [ + function createViewsFromMCI(callback) { + async.each(Object.keys(mciMap), function mciEntry(name, nextItem) { + var mci = mciMap[name]; + var view = factory.createFromMCI(mci); + + if(view) { + self.addView(view); + view.redraw(); // :TODO: fix double-redraw if this is the item we set focus to! + } + + nextItem(null); + }, + function mciComplete(err) { + callback(err); + }); + } + ], + function compelte(err) { + cb(err); + } + ); + +}; +*/ + ViewController.prototype.loadFromMCIMapAndConfig = function(options, cb) { assert(options.mciMap); @@ -247,7 +486,7 @@ ViewController.prototype.loadFromMCIMapAndConfig = function(options, cb) { if(err) { // :TODO: fix logging of err here: - Log.warn( + Log.trace( { err : err.toString(), mci : Object.keys(options.mciMap), formIdKey : formIdKey } , 'Unable to load menu configuration'); } @@ -255,13 +494,13 @@ ViewController.prototype.loadFromMCIMapAndConfig = function(options, cb) { callback(null); }); }, - function createViewsFromMCIMap(callback) { + function createViewsFromMCI(callback) { async.each(Object.keys(options.mciMap), function onMciEntry(name, eachCb) { var mci = options.mciMap[name]; var view = factory.createFromMCI(mci); if(view) { - view.on('action', self.onViewAction); + view.on('action', self.viewActionListener); self.addView(view); view.redraw(); // :TODO: This can result in double redraw() if we set focus on this item after } @@ -269,7 +508,6 @@ ViewController.prototype.loadFromMCIMapAndConfig = function(options, cb) { }, function eachMciComplete(err) { self.setViewOrder(); - callback(err); }); }, diff --git a/mods/apply.js b/mods/apply.js index 676c4690..b5f7629e 100644 --- a/mods/apply.js +++ b/mods/apply.js @@ -92,6 +92,12 @@ function ApplyModule(menuConfig) { return; } + var re = new RegExp(Config.users.usernamePattern); + if(!re.test(args.username)) { + cb('Handle contains invalid characters!', [ 1 ] ); + return; + } + if(args.pw.length < Config.users.passwordMin) { cb('Password too short!', [ 9, 10 ]); return; @@ -123,13 +129,13 @@ ApplyModule.prototype.beforeArt = function() { ApplyModule.super_.prototype.beforeArt.call(this); }; -ApplyModule.prototype.mciReady = function(mciMap) { - ApplyModule.super_.prototype.mciReady.call(this, mciMap); +ApplyModule.prototype.mciReady = function(mciMaps) { + ApplyModule.super_.prototype.mciReady.call(this, mciMaps); var self = this; self.viewController = self.addViewController(new ViewController({ client : self.client } )); - self.viewController.loadFromMCIMapAndConfig( { mciMap : mciMap, menuConfig : self.menuConfig }, function onViewReady(err) { + self.viewController.loadFromMCIMapAndConfig( { mciMap : mciMaps.menu, menuConfig : self.menuConfig }, function onViewReady(err) { }); }; \ No newline at end of file diff --git a/mods/login.js b/mods/login.js index 93177a78..91925f2d 100644 --- a/mods/login.js +++ b/mods/login.js @@ -21,6 +21,37 @@ exports.moduleInfo = { exports.getModule = LoginModule; +exports.attemptLogin = attemptLogin; + +function attemptLogin(callingMenu, formData, extraArgs) { + var client = callingMenu.client; + + client.user.authenticate(formData.value.username, formData.value.password, function authenticated(err) { + if(err) { + Log.info( { username : formData.value.username }, 'Failed login attempt %s', err); + + client.gotoMenuModule( { name : callingMenu.menuConfig.fallback } ); + } else { + // use client.user so we can get correct case + Log.info( { username : callingMenu.client.user.username }, 'Successful login'); + + async.parallel( + [ + function loadThemeConfig(callback) { + theme.getThemeInfo(client.user.properties.art_theme_id, function themeInfo(err, info) { + client.currentThemeInfo = info; + callback(null); + }); + } + ], + function complete(err, results) { + client.gotoMenuModule( { name : callingMenu.menuConfig.next } ); + } + ); + } + }); +} + function LoginModule(menuConfig) { MenuModule.call(this, menuConfig); @@ -91,12 +122,12 @@ LoginModule.prototype.beforeArt = function() { //this.client.term.write(ansi.resetScreen()); }; -LoginModule.prototype.mciReady = function(mciMap) { - LoginModule.super_.prototype.mciReady.call(this, mciMap); +LoginModule.prototype.mciReady = function(mciData) { + LoginModule.super_.prototype.mciReady.call(this, mciData); var self = this; self.viewController = self.addViewController(new ViewController( { client : self.client } )); - self.viewController.loadFromMCIMapAndConfig( { mciMap : mciMap, menuConfig : self.menuConfig }, function onViewReady(err) { + self.viewController.loadFromMCIMapAndConfig( { mciMap : mciData.menu, menuConfig : self.menuConfig }, function onViewReady(err) { }); }; \ No newline at end of file diff --git a/mods/logoff.js b/mods/logoff.js index 8c4df5b7..1618ddd7 100644 --- a/mods/logoff.js +++ b/mods/logoff.js @@ -28,8 +28,8 @@ LogOffModule.prototype.beforeArt = function() { this.client.term.write(ansi.resetScreen()); }; -LogOffModule.prototype.mciReady = function(mciMap) { - LogOffModule.super_.prototype.mciReady.call(this, mciMap); +LogOffModule.prototype.mciReady = function(mciData) { + LogOffModule.super_.prototype.mciReady.call(this, mciData); }; LogOffModule.prototype.finishedLoading = function() { diff --git a/mods/matrix.js b/mods/matrix.js index 2fc60df3..70a3407b 100644 --- a/mods/matrix.js +++ b/mods/matrix.js @@ -38,8 +38,6 @@ MatrixModule.prototype.enter = function(client) { MatrixModule.prototype.beforeArt = function() { MatrixModule.super_.prototype.beforeArt.call(this); - - this.client.term.write(ansi.resetScreen()); }; MatrixModule.prototype.mciReady = function(mciMap) { diff --git a/mods/menu.json b/mods/menu.json index 00692c14..323d8142 100644 --- a/mods/menu.json +++ b/mods/menu.json @@ -10,6 +10,7 @@ // @method:scriptName[.js]/methodName (foreign .js) // @art:artName // @method:/methodName (local to module.js) + // ... pass isFocused/etc. into draw method "draw" : { "normal" : ..., "focus" : ... @@ -20,10 +21,18 @@ } } */ + "connected" : { + "art" : "connect", + "next" : "matrix", + "options" : { + "clearScreen" : true, + "nextTimeout" : 1500 + } + }, "matrix" : { "art" : "matrix", "form" : { - "0" : { + "0" : { // :TODO: Make form "0" the default if missing (e.g. optional) "VM1" : { "mci" : { "VM1" : { @@ -57,12 +66,18 @@ } }, "login" : { - "art" : "login", // TODO: rename to login_form - "module" : "login", + //"art" : "login", // TODO: rename to login_form + "prompt" : "userCredentials", + "fallback" : "matrix", + "next" : "newUserActive", + //"module" : "login", + /* "form" : { "0" : { "BT3BT4ET1ET2TL5" :{ "mci" :{ + // :TODO: LIke prompts, assign "argName" values here, e.g.: + // "argName" : "username", ... "ET1" : { "focus" : true }, @@ -80,25 +95,30 @@ { "value" : { "3" : null }, "action" : "@method:attemptLogin", + // :TODO: see above about argName; + // any other args should be "extraArgs" "args" : { "next" : { + // :TODO: just use menu.next "success" : "newUserActive" }, "username" : "{1}", "password" : "{2}" - } // :TODO: rename to actionArgs ? + } } ], "4" : [ // Cancel { "value" : { "4" : null }, "action" : "@menu:matrix" + // :TODO: Just use menu.fallback, e.g. @fallback } ] } } } }, + */ "options" : { "clearScreen" : true } @@ -165,19 +185,10 @@ } }, "newUserActive" : { - "art" : "NEWACT", + "art" : "STATS", "options" : { // :TODO: implement MCI codes for this "clearScreen" : true - }, - "form" : { - "0" : { - "UN1UR2" : { - "mci" : { - - } - } - } } } } \ No newline at end of file diff --git a/mods/standard_menu.js b/mods/standard_menu.js index ddddc2ff..69249c85 100644 --- a/mods/standard_menu.js +++ b/mods/standard_menu.js @@ -29,19 +29,61 @@ StandardMenuModule.prototype.beforeArt = function() { StandardMenuModule.super_.prototype.beforeArt.call(this); }; -StandardMenuModule.prototype.mciReady = function(mciMap) { - StandardMenuModule.super_.prototype.mciReady.call(this, mciMap); +StandardMenuModule.prototype.mciReady = function(mciData) { + StandardMenuModule.super_.prototype.mciReady.call(this, mciData); var self = this; + // + // A quick rundown: + // * We may have mciData.menu, mciData.prompt, or both. + // * Prompt form is favored over menu form if both are present. + // * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve) + // + self.viewControllers = {}; + + var vcOpts = { client : self.client }; + + if(mciData.menu) { + self.viewControllers.menu = new ViewController(vcOpts); + } + + if(mciData.prompt) { + self.viewControllers.prompt = new ViewController(vcOpts); + } + + var viewsReady = function(err) { + // :TODO: Hrm..... + }; + + + if(self.viewControllers.menu) { + var menuLoadOpts = { + mciMap : mciData.menu, + menuConfig : self.menuConfig, + withForm : !mciData.prompt, + }; + + self.viewControllers.menu.loadFromMCIMapAndConfig(menuLoadOpts, viewsReady); + } + + if(self.viewControllers.prompt) { + var promptLoadOpts = { + callingMenu : self, + mciMap : mciData.prompt, + //promptConfig : self.menuConfig.promptConfig, + }; + + self.viewControllers.prompt.loadFromPrompt(promptLoadOpts, viewsReady); + } + + /* var vc = self.addViewController(new ViewController({ client : self.client } )); - vc.loadFromMCIMapAndConfig( { mciMap : mciMap, menuConfig : self.menuConfig }, function onViewReady(err) { + vc.loadFromMCIMapAndConfig( { mciMap : mciData.menu, menuConfig : self.menuConfig }, function onViewReady(err) { if(err) { console.log(err); } else { - /* vc.on('submit', function onFormSubmit(formData) { - console.log(formData); - });*/ } }); +*/ };