enigma-bbs/core/view_controller.js

960 lines
32 KiB
JavaScript

/* jslint node: true */
'use strict';
// ENiGMA½
var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory;
var menuUtil = require('./menu_util.js');
var asset = require('./asset.js');
var ansi = require('./ansi_term.js');
// deps
var events = require('events');
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));
events.EventEmitter.call(this);
var self = this;
this.client = options.client;
this.views = {}; // map of ID -> view
this.formId = options.formId || 0;
this.mciViewFactory = new MCIViewFactory(this.client); // :TODO: can this not be a singleton?
this.noInput = _.isBoolean(options.noInput) ? options.noInput : false;
this.actionKeyMap = {};
//
// Small wrapper/proxy around handleAction() to ensure we do not allow
// input/additional actions queued while performing an action
//
this.handleActionWrapper = function (formData, actionBlock, cb) {
if (self.waitActionCompletion) {
if (cb) {
return cb(null);
}
return; // ignore until this is finished!
}
self.client.log.trace({ actionBlock }, 'Action match');
self.waitActionCompletion = true;
menuUtil.handleAction(self.client, formData, actionBlock, err => {
if (err) {
// :TODO: What can we really do here?
if ('ALREADYTHERE' === err.reasonCode) {
self.client.log.trace(err.reason);
} else {
self.client.log.warn({ err: err }, 'Error during handleAction()');
}
}
self.waitActionCompletion = false;
if (cb) {
return cb(null);
}
});
};
this.clientKeyPressHandler = function (ch, key) {
//
// Process key presses treating form submit mapped keys special.
// Everything else is forwarded on to the focused View, if any.
//
var actionForKey = key ? self.actionKeyMap[key.name] : self.actionKeyMap[ch];
if (actionForKey) {
if (_.isNumber(actionForKey.viewId)) {
//
// Key works on behalf of a view -- switch focus & submit
//
self.switchFocus(actionForKey.viewId);
self.submitForm(key);
} else if (_.isString(actionForKey.action)) {
const formData = self.getFocusedView() ? self.getFormData() : {};
self.handleActionWrapper(
Object.assign({ ch: ch, key: key }, formData), // formData + key info
actionForKey
); // actionBlock
}
} else {
if (self.focusedView && self.focusedView.acceptsInput) {
self.focusedView.onKeyPress(ch, key);
}
}
};
this.viewActionListener = function (action, key) {
switch (action) {
case 'next':
self.emit('action', { view: this, action: action, key: key });
self.nextFocus();
break;
case 'accept':
if (self.focusedView && self.focusedView.submit) {
// :TODO: need to do validation here!!!
var focusedView = self.focusedView;
self.validateView(
focusedView,
function validated(err, newFocusedViewId) {
if (err) {
var newFocusedView =
self.getView(newFocusedViewId) || focusedView;
self.setViewFocusWithEvents(newFocusedView, true);
} else {
self.submitForm(key);
}
}
);
//self.submitForm(key);
} else {
self.nextFocus();
}
break;
}
};
this.submitForm = function (key) {
self.emit('submit', this.getFormData(key));
};
this.getLogFriendlyFormData = function (formData) {
var safeFormData = _.cloneDeep(formData);
if (safeFormData.value.password) {
safeFormData.value.password = '*****';
}
if (safeFormData.value.passwordConfirm) {
safeFormData.value.passwordConfirm = '*****';
}
return safeFormData;
};
this.switchFocusEvent = function (event, view) {
if (self.emitSwitchFocus) {
return;
}
self.emitSwitchFocus = true;
self.emit(event, view);
self.emitSwitchFocus = false;
};
this.createViewsFromMCI = function (mciMap, cb) {
async.each(
Object.keys(mciMap),
(name, nextItem) => {
const mci = mciMap[name];
const view = self.mciViewFactory.createFromMCI(mci);
if (view) {
if (false === self.noInput) {
view.on('action', self.viewActionListener);
}
self.addView(view);
}
return nextItem(null);
},
err => {
self.setViewOrder();
return cb(err);
}
);
};
// :TODO: move this elsewhere
this.setViewPropertiesFromMCIConf = function (view, conf) {
var propAsset;
var propValue;
for (var propName in conf) {
propAsset = asset.getViewPropertyAsset(conf[propName]);
if (propAsset) {
switch (propAsset.type) {
case 'config':
propValue = asset.resolveConfigAsset(conf[propName]);
break;
case 'sysStat':
propValue = asset.resolveSystemStatAsset(conf[propName]);
break;
// :TODO: handle @art (e.g. text : @art ...)
case 'method':
case 'systemMethod':
if ('validate' === propName) {
// :TODO: handle propAsset.location for @method script specification
if ('systemMethod' === propAsset.type) {
// :TODO: implementation validation @systemMethod handling!
var methodModule = require(paths.join(
__dirname,
'system_view_validate.js'
));
if (_.isFunction(methodModule[propAsset.asset])) {
propValue = methodModule[propAsset.asset];
}
} else {
if (
_.isFunction(
self.client.currentMenuModule.menuMethods[
propAsset.asset
]
)
) {
propValue =
self.client.currentMenuModule.menuMethods[
propAsset.asset
];
}
}
} else {
if (_.isString(propAsset.location)) {
// :TODO: clean this code up!
} else {
if ('systemMethod' === propAsset.type) {
// :TODO:
} else {
// local to current module
var currentModule = self.client.currentMenuModule;
if (
_.isFunction(
currentModule.menuMethods[propAsset.asset]
)
) {
// :TODO: Fix formData & extraArgs... this all needs general processing
propValue = currentModule.menuMethods[
propAsset.asset
]({}, {}); //formData, conf.extraArgs);
}
}
}
}
break;
default:
propValue = conf[propName];
break;
}
} else {
propValue = conf[propName];
}
if (!_.isUndefined(propValue)) {
view.setPropertyValue(propName, propValue);
}
}
};
this.applyViewConfig = function (config, cb) {
let highestId = 1;
let submitId;
let initialFocusId = 1;
async.each(
Object.keys(config.mci || {}),
function entry(mci, nextItem) {
const mciMatch = mci.match(MCI_REGEXP); // :TODO: How to handle auto-generated IDs????
if (null === mciMatch) {
self.client.log.warn({ mci: mci }, 'Unable to parse MCI code');
return;
}
const viewId = parseInt(mciMatch[2]);
assert(!isNaN(viewId), 'Cannot parse view ID: ' + mciMatch[2]); // shouldn't be possible with RegExp used
if (viewId > highestId) {
highestId = viewId;
}
const view = self.getView(viewId);
if (!view) {
self.client.log.warn({ viewId: viewId }, 'Cannot find view');
nextItem(null);
return;
}
const mciConf = config.mci[mci];
self.setViewPropertiesFromMCIConf(view, mciConf);
if (mciConf.focus) {
initialFocusId = viewId;
}
if (true === view.submit) {
submitId = viewId;
}
nextItem(null);
},
err => {
// default to highest ID if no 'submit' entry present
if (!submitId) {
var highestIdView = self.getView(highestId);
if (highestIdView) {
highestIdView.submit = true;
} else {
self.client.log.warn(
{ highestId: highestId },
'View does not exist'
);
}
}
return cb(err, { initialFocusId: initialFocusId });
}
);
};
// method for comparing submitted form data to configuration entries
this.actionBlockValueComparator = function (formValue, actionValue) {
//
// For a match to occur, one of the following must be true:
//
// * actionValue is a Object:
// a) All key/values must exactly match
// b) value is null; The key (view ID or "argName") must be present
// in formValue. This is a wildcard/any match.
// * actionValue is a Number: This represents a view ID that
// must be present in formValue.
// * actionValue is a string: This represents a view with
// "argName" set that must be present in formValue.
//
if (_.isUndefined(actionValue)) {
return false;
}
if (_.isNumber(actionValue) || _.isString(actionValue)) {
if (_.isUndefined(formValue[actionValue])) {
return false;
}
} else {
/*
:TODO: support:
value: {
someArgName: [ "key1", "key2", ... ],
someOtherArg: [ "key1, ... ]
}
*/
var actionValueKeys = Object.keys(actionValue);
for (var i = 0; i < actionValueKeys.length; ++i) {
var viewId = actionValueKeys[i];
if (!_.has(formValue, viewId)) {
return false;
}
if (
null !== actionValue[viewId] &&
actionValue[viewId] !== formValue[viewId]
) {
return false;
}
}
}
return true;
};
if (!options.detached) {
this.attachClientEvents();
}
this.setViewFocusWithEvents = function (view, focused) {
if (!view || !view.acceptsFocus) {
return;
}
if (focused) {
self.switchFocusEvent('return', view);
self.focusedView = view;
} else {
self.switchFocusEvent('leave', view);
}
view.setFocus(focused);
};
this.validateView = function (view, cb) {
if (view && _.isFunction(view.validate)) {
view.validate(view.getData(), function validateResult(err) {
var viewValidationListener =
self.client.currentMenuModule.menuMethods.viewValidationListener;
if (_.isFunction(viewValidationListener)) {
if (err) {
err.view = view; // pass along the view that failed
}
viewValidationListener(
err,
function validationComplete(newViewFocusId) {
cb(err, newViewFocusId);
}
);
} else {
cb(err);
}
});
} else {
cb(null);
}
};
}
util.inherits(ViewController, events.EventEmitter);
ViewController.prototype.attachClientEvents = function () {
if (this.attached) {
return;
}
var self = this;
this.client.on('key press', this.clientKeyPressHandler);
Object.keys(this.views).forEach(function vid(i) {
// remove, then add to ensure we only have one listener
self.views[i].removeListener('action', self.viewActionListener);
self.views[i].on('action', self.viewActionListener);
});
this.attached = true;
};
ViewController.prototype.detachClientEvents = function () {
if (!this.attached) {
return;
}
this.client.removeListener('key press', this.clientKeyPressHandler);
for (var id in this.views) {
this.views[id].removeAllListeners();
}
this.attached = false;
};
ViewController.prototype.viewExists = function (id) {
return id in this.views;
};
ViewController.prototype.addView = function (view) {
assert(!this.viewExists(view.id), 'View with ID ' + view.id + ' already exists');
this.views[view.id] = view;
};
ViewController.prototype.getView = function (id) {
return this.views[id];
};
ViewController.prototype.hasView = function (id) {
return this.getView(id) ? true : false;
};
ViewController.prototype.getViewsByMciCode = function (mciCode) {
if (!Array.isArray(mciCode)) {
mciCode = [mciCode];
}
const views = [];
_.each(this.views, v => {
if (mciCode.includes(v.mciCode)) {
views.push(v);
}
});
return views;
};
ViewController.prototype.getFocusedView = function () {
return this.focusedView;
};
ViewController.prototype.setFocus = function (focused) {
if (focused) {
this.attachClientEvents();
} else {
this.detachClientEvents();
}
this.setViewFocusWithEvents(this.focusedView, focused);
};
ViewController.prototype.resetInitialFocus = function () {
if (this.formInitialFocusId) {
return this.switchFocus(this.formInitialFocusId);
}
};
ViewController.prototype.switchFocus = function (id) {
//
// Perform focus switching validation now
//
var self = this;
var focusedView = self.focusedView;
self.validateView(focusedView, function validated(err, newFocusedViewId) {
if (err) {
var newFocusedView = self.getView(newFocusedViewId) || focusedView;
self.setViewFocusWithEvents(newFocusedView, true);
} else {
self.attachClientEvents();
// remove from old
self.setViewFocusWithEvents(focusedView, false);
// set to new
self.setViewFocusWithEvents(self.getView(id), true);
}
});
};
ViewController.prototype.nextFocus = function () {
let nextFocusView = this.focusedView ? this.focusedView : this.views[this.firstId];
// find the next view that accepts focus
while (nextFocusView && nextFocusView.nextId) {
nextFocusView = this.getView(nextFocusView.nextId);
if (!nextFocusView || nextFocusView.acceptsFocus) {
break;
}
}
if (nextFocusView && this.focusedView !== nextFocusView) {
this.switchFocus(nextFocusView.id);
}
};
ViewController.prototype.setViewOrder = function (order) {
var viewIdOrder = order || [];
if (0 === viewIdOrder.length) {
for (var id in this.views) {
if (this.views[id].acceptsFocus) {
viewIdOrder.push(id);
}
}
viewIdOrder.sort(function intSort(a, b) {
return a - b;
});
}
if (viewIdOrder.length > 0) {
var count = viewIdOrder.length - 1;
for (var i = 0; i < count; ++i) {
this.views[viewIdOrder[i]].nextId = viewIdOrder[i + 1];
}
this.firstId = viewIdOrder[0];
var lastId =
viewIdOrder.length > 1 ? viewIdOrder[viewIdOrder.length - 1] : this.firstId;
this.views[lastId].nextId = this.firstId;
}
};
ViewController.prototype.redrawAll = function (initialFocusId) {
this.client.term.rawWrite(ansi.hideCursor());
for (var id in this.views) {
if (initialFocusId === id) {
continue; // will draw @ focus
}
this.views[id].redraw();
}
this.client.term.rawWrite(ansi.showCursor());
};
ViewController.prototype.loadFromPromptConfig = function (options, cb) {
assert(_.isObject(options));
assert(_.isObject(options.mciMap));
var self = this;
var promptConfig = _.isObject(options.config)
? options.config
: self.client.currentMenuModule.menuConfig.promptConfig;
var initialFocusId = 1; // default to first
async.waterfall(
[
function createViewsFromMCI(callback) {
self.createViewsFromMCI(options.mciMap, function viewsCreated(err) {
callback(err);
});
},
function applyViewConfiguration(callback) {
if (_.isObject(promptConfig.mci)) {
self.applyViewConfig(promptConfig, function configApplied(err, info) {
initialFocusId = info.initialFocusId;
callback(err);
});
} else {
callback(null);
}
},
function prepareFormSubmission(callback) {
if (false === self.noInput) {
self.on('submit', function promptSubmit(formData) {
self.client.log.trace({ formData }, 'Prompt submit');
const doSubmitNotify = () => {
if (options.submitNotify) {
options.submitNotify();
}
};
const handleIt = (fd, conf) => {
self.handleActionWrapper(fd, conf, () => {
doSubmitNotify();
});
};
if (_.isString(self.client.currentMenuModule.menuConfig.action)) {
handleIt(formData, self.client.currentMenuModule.menuConfig);
} else {
//
// Menus that reference prompts can have a special "submit" block without the
// hassle of by-form-id configurations, etc.
//
// "submit" : [
// { ... }
// ]
//
const menuConfig = self.client.currentMenuModule.menuConfig;
let submitConf;
if (Array.isArray(menuConfig.submit)) {
// standalone prompts)) {
submitConf = menuConfig.submit;
} else {
// look for embedded prompt configurations - using their own form ID within the menu
submitConf =
_.get(menuConfig, [
'form',
formData.id,
'submit',
formData.submitId,
]) ||
_.get(menuConfig, [
'form',
formData.id,
'submit',
'*',
]);
}
if (!Array.isArray(submitConf)) {
doSubmitNotify();
return self.client.log.debug(
'No configuration to handle submit'
);
}
// locate any matching action block
const actionBlock = submitConf.find(actionBlock =>
_.isEqualWith(
formData.value,
actionBlock.value,
self.actionBlockValueComparator
)
);
if (actionBlock) {
handleIt(formData, actionBlock);
} else {
doSubmitNotify();
}
}
});
}
callback(null);
},
function loadActionKeys(callback) {
if (!_.isObject(promptConfig) || !_.isArray(promptConfig.actionKeys)) {
return callback(null);
}
promptConfig.actionKeys.forEach(ak => {
//
// * 'keys' must be present and be an array of key names
// * If 'viewId' is present, key(s) will focus & submit on behalf
// of the specified view.
// * If 'action' is present, that action will be procesed when
// triggered by key(s)
//
// Ultimately, create a map of key -> { action block }
//
if (!_.isArray(ak.keys)) {
return;
}
ak.keys.forEach(kn => {
self.actionKeyMap[kn] = ak;
});
});
return callback(null);
},
function drawAllViews(callback) {
self.redrawAll(initialFocusId);
callback(null);
},
function setInitialViewFocus(callback) {
if (initialFocusId) {
self.switchFocus(initialFocusId);
}
callback(null);
},
],
function complete(err) {
cb(err);
}
);
};
ViewController.prototype.loadFromMenuConfig = function (options, cb) {
assert(_.isObject(options));
if (!_.isObject(options.mciMap)) {
cb(new Error('Missing option: mciMap'));
return;
}
var self = this;
var formIdKey = options.formId ? options.formId.toString() : '0';
this.formInitialFocusId = 1; // default to first
var formConfig;
// :TODO: honor options.withoutForm
async.waterfall(
[
function findMatchingFormConfig(callback) {
menuUtil.getFormConfigByIDAndMap(
self.client.currentMenuModule.menuConfig,
formIdKey,
options.mciMap,
function matchingConfig(err, fc) {
formConfig = fc;
if (err) {
// non-fatal
self.client.log.trace(
{
reason: err.message,
mci: Object.keys(options.mciMap),
formId: formIdKey,
},
'Unable to find matching form configuration'
);
}
callback(null);
}
);
},
function createViews(callback) {
self.createViewsFromMCI(options.mciMap, function viewsCreated(err) {
callback(err);
});
},
/*
function applyThemeCustomization(callback) {
formConfig = formConfig || {};
formConfig.mci = formConfig.mci || {};
//self.client.currentMenuModule.menuConfig.config = self.client.currentMenuModule.menuConfig.config || {};
//console.log('menu config.....');
//console.log(self.client.currentMenuModule.menuConfig)
menuUtil.applyMciThemeCustomization({
name : self.client.currentMenuModule.menuName,
type : 'menus',
client : self.client,
mci : formConfig.mci,
//config : self.client.currentMenuModule.menuConfig.config,
formId : formIdKey,
});
//console.log('after theme...')
//console.log(self.client.currentMenuModule.menuConfig.config)
callback(null);
},
*/
function applyViewConfiguration(callback) {
if (_.isObject(formConfig)) {
self.applyViewConfig(formConfig, function configApplied(err, info) {
self.formInitialFocusId = info.initialFocusId;
callback(err);
});
} else {
callback(null);
}
},
function prepareFormSubmission(callback) {
if (!_.isObject(formConfig) || !_.isObject(formConfig.submit)) {
callback(null);
return;
}
self.on('submit', function formSubmit(formData) {
self.client.log.trace({ formData }, 'Form submit');
//
// Locate configuration for this form ID
//
const confForFormId =
_.get(formConfig, ['submit', formData.submitId]) ||
_.get(formConfig, ['submit', '*']);
if (!Array.isArray(confForFormId)) {
return self.client.log.debug(
{ formId: formData.submitId },
'No configuration for form ID'
);
}
// locate a matching action block, if any
const actionBlock = confForFormId.find(actionBlock =>
_.isEqualWith(
formData.value,
actionBlock.value,
self.actionBlockValueComparator
)
);
if (actionBlock) {
self.handleActionWrapper(formData, actionBlock);
}
});
callback(null);
},
function loadActionKeys(callback) {
if (!_.isObject(formConfig) || !_.isArray(formConfig.actionKeys)) {
callback(null);
return;
}
formConfig.actionKeys.forEach(function akEntry(ak) {
//
// * 'keys' must be present and be an array of key names
// * If 'viewId' is present, key(s) will focus & submit on behalf
// of the specified view.
// * If 'action' is present, that action will be procesed when
// triggered by key(s)
//
// Ultimately, create a map of key -> { action block }
//
if (!_.isArray(ak.keys)) {
return;
}
ak.keys.forEach(function actionKeyName(kn) {
self.actionKeyMap[kn] = ak;
});
});
callback(null);
},
function drawAllViews(callback) {
self.redrawAll(self.formInitialFocusId);
callback(null);
},
function setInitialViewFocus(callback) {
if (self.formInitialFocusId) {
self.switchFocus(self.formInitialFocusId);
}
callback(null);
},
],
function complete(err) {
if (_.isFunction(cb)) {
cb(err);
}
}
);
};
ViewController.prototype.formatMCIString = function (format) {
var self = this;
var view;
return format.replace(/{(\d+)}/g, function replacer(match, number) {
view = self.getView(number);
if (!view) {
return match;
}
return view.getData();
});
};
ViewController.prototype.getFormData = function (key) {
/*
Example form data:
{
id : 0,
submitId : 1,
value : {
"1" : "hurp",
"2" : [ 'a', 'b', ... ],
"3" 2,
"pants" : "no way"
}
}
*/
const formData = {
id: this.formId,
submitId: this.focusedView.id,
value: {},
};
if (key) {
formData.key = key;
}
let viewData;
_.each(this.views, view => {
try {
// don't fill forms with static, non user-editable data data
if (!view.acceptsInput) {
return;
}
// some form values may be omitted from submission all together
if (view.omitFromSubmission) {
return;
}
viewData = view.getData();
if (_.isUndefined(viewData)) {
return;
}
formData.value[view.submitArgName ? view.submitArgName : view.id] = viewData;
} catch (e) {
this.client.log.error(
{ error: e.message },
'Exception caught gathering form data'
);
}
});
return formData;
};