diff --git a/core/config.js b/core/config.js index adf82630..f529304a 100644 --- a/core/config.js +++ b/core/config.js @@ -41,7 +41,12 @@ function hasMessageConferenceAndArea(config) { return result; } -function init(configPath, cb) { +function init(configPath, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + async.waterfall( [ function loadUserConfig(callback) { @@ -56,7 +61,7 @@ function init(configPath, cb) { let configJson; try { - configJson = hjson.parse(configData); + configJson = hjson.parse(configData, options); } catch(e) { return callback(e); } diff --git a/core/ftn_address.js b/core/ftn_address.js index 3e849b55..616b4965 100644 --- a/core/ftn_address.js +++ b/core/ftn_address.js @@ -31,7 +31,7 @@ module.exports = class Address { this.zone === other.zone && this.point === other.point && this.domain === other.domain - ); + ); } getMatchAddr(pattern) { @@ -40,7 +40,7 @@ module.exports = class Address { let addr = { }; if(m[1]) { - addr.zone = m[1].slice(0, -1) + addr.zone = m[1].slice(0, -1); if('*' !== addr.zone) { addr.zone = parseInt(addr.zone); } @@ -116,7 +116,7 @@ module.exports = class Address { ('*' === addr.zone || this.zone === addr.zone) && ('*' === addr.point || this.point === addr.point) && ('*' === addr.domain || this.domain === addr.domain) - ); + ); } return false; @@ -193,6 +193,6 @@ module.exports = class Address { } return (left.domain || '').localeCompare(right.domain || ''); - } + }; } -} +}; diff --git a/core/message_area.js b/core/message_area.js index 8fe250a1..484d22ec 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -35,6 +35,8 @@ exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent; function getAvailableMessageConferences(client, options) { options = options || { includeSystemInternal : false }; + + assert(client || true === options.noClient); // perform ACS check per conf & omit system_internal if desired return _.omitBy(Config.messageConferences, (conf, confTag) => { @@ -42,7 +44,7 @@ function getAvailableMessageConferences(client, options) { return true; } - return !client.acs.hasMessageConfRead(conf); + return client && !client.acs.hasMessageConfRead(conf); }); } diff --git a/core/oputil/oputil_common.js b/core/oputil/oputil_common.js index 3bdb5ec0..847acbf4 100644 --- a/core/oputil/oputil_common.js +++ b/core/oputil/oputil_common.js @@ -12,6 +12,7 @@ const async = require('async'); exports.printUsageAndSetExitCode = printUsageAndSetExitCode; exports.getDefaultConfigPath = getDefaultConfigPath; +exports.getConfigPath = getConfigPath; exports.initConfigAndDatabases = initConfigAndDatabases; exports.getAreaAndStorage = getAreaAndStorage; @@ -40,10 +41,14 @@ function getDefaultConfigPath() { return resolvePath('~/.config/enigma-bbs/config.hjson'); } -function initConfig(cb) { - const configPath = argv.config ? argv.config : config.getDefaultPath(); +function getConfigPath() { + return argv.config ? argv.config : config.getDefaultPath(); +} - config.init(configPath, cb); +function initConfig(cb) { + const configPath = getConfigPath(); + + config.init(configPath, { keepWsc : true }, cb); } function initConfigAndDatabases(cb) { diff --git a/core/oputil/oputil_config.js b/core/oputil/oputil_config.js index 7071f459..6158359e 100644 --- a/core/oputil/oputil_config.js +++ b/core/oputil/oputil_config.js @@ -7,8 +7,10 @@ const resolvePath = require('../../core/misc_util.js').resolvePath; const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; const ExitCodes = require('./oputil_common.js').ExitCodes; const argv = require('./oputil_common.js').argv; -const getDefaultConfigPath = require('./oputil_common.js').getDefaultConfigPath; +const getConfigPath = require('./oputil_common.js').getConfigPath; const getHelpFor = require('./oputil_help.js').getHelpFor; +const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; +const Errors = require('../../core/enig_error.js').Errors; // deps const async = require('async'); @@ -17,6 +19,7 @@ const mkdirsSync = require('fs-extra').mkdirsSync; const fs = require('fs'); const hjson = require('hjson'); const paths = require('path'); +const _ = require('lodash'); exports.handleConfigCommand = handleConfigCommand; @@ -38,7 +41,7 @@ const QUESTIONS = { { name : 'configPath', message : 'Configuration path:', - default : argv.config ? argv.config : getDefaultConfigPath(), + default : getConfigPath(), when : answers => answers.createNewConfig }, ], @@ -232,27 +235,326 @@ function askNewConfigQuestions(cb) { ); } +function writeConfig(config, path) { + config = hjson.stringify(config, { bracesSameLine : true, spaces : '\t', keepWsc : true, quotes : 'strings' } ); + + try { + fs.writeFileSync(path, config, 'utf8'); + return true; + } catch(e) { + return false; + } +} + +function buildNewConfig() { + askNewConfigQuestions( (err, configPath, config) => { + if(err) { + return; + } + + if(writeConfig(config, configPath)) { + console.info('Configuration generated'); + } else { + console.error('Failed writing configuration'); + } + }); +} + +function validateUplinks(uplinks) { + const ftnAddress = require('../../core/ftn_address.js'); + const valid = uplinks.every(ul => { + const addr = ftnAddress.fromString(ul); + return addr; + }); + return valid; +} + +function getMsgAreaImportType(path) { + if(argv.type) { + return argv.type.toLowerCase(); + } + + const ext = paths.extname(path).toLowerCase().substr(1); + return ext; // .bbs|.na|... +} + +function importAreas() { + const importPath = argv._[argv._.length - 1]; + if(!importPath) { + return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); + } + + const importType = getMsgAreaImportType(importPath); + if('na' !== importType && 'bbs' !== importType) { + return console.error(`"${importType}" is not a recognized import file type`); + } + + // optional data - we'll prompt if for anything not found + let confTag = argv.conf; + let networkName = argv.network; + let uplinks = argv.uplinks; + if(uplinks) { + uplinks = uplinks.split(/[\s,]+/); + } + + let importEntries; + + async.waterfall( + [ + function readImportFile(callback) { + fs.readFile(importPath, 'utf8', (err, importData) => { + if(err) { + return callback(err); + } + + importEntries = getImportEntries(importType, importData); + if(0 === importEntries.length) { + return callback(Errors.Invalid('Invalid or empty import file')); + } + + // We should have enough to validate uplinks + if('bbs' === importType) { + for(let i = 0; i < importEntries.length; ++i) { + if(!validateUplinks(importEntries[i].uplinks)) { + return callback(Errors.Invalid('Invalid uplink(s)')); + } + } + } else { + if(!validateUplinks(uplinks)) { + return callback(Errors.Invalid('Invalid uplink(s)')); + } + } + + return callback(null); + }); + }, + function init(callback) { + return initConfigAndDatabases(callback); + }, + function validateAndCollectInput(callback) { + const msgArea = require('../../core/message_area.js'); + const Config = require('../../core/config.js').config; + + let msgConfs = msgArea.getSortedAvailMessageConferences(null, { noClient : true } ); + if(!msgConfs) { + return callback(Errors.DoesNotExist('No conferences exist in your configuration')); + } + + msgConfs = msgConfs.map(mc => { + return { + name : mc.conf.name, + value : mc.confTag, + }; + }); + + if(confTag && !msgConfs.find(mc => { + return confTag === mc.value; + })) + { + return callback(Errors.DoesNotExist(`Conference "${confTag}" does not exist`)); + } + + let existingNetworkNames = []; + if(_.has(Config, 'messageNetworks.ftn.networks')) { + existingNetworkNames = Object.keys(Config.messageNetworks.ftn.networks); + } + + if(0 === existingNetworkNames.length) { + return callback(Errors.DoesNotExist('No FTN style networks exist in your configuration')); + } + + if(networkName && !existingNetworkNames.find(net => networkName === net)) { + return callback(Errors.DoesNotExist(`FTN style Network "${networkName}" does not exist`)); + } + + getAnswers([ + { + name : 'confTag', + message : 'Message conference:', + type : 'list', + choices : msgConfs, + pageSize : 10, + when : !confTag, + }, + { + name : 'networkName', + message : 'Network name:', + type : 'list', + choices : existingNetworkNames, + when : !networkName, + }, + { + name : 'uplinks', + message : 'Uplink(s) (comma separated):', + type : 'input', + validate : (input) => { + const inputUplinks = input.split(/[\s,]+/); + return validateUplinks(inputUplinks) ? true : 'Invalid uplink(s)'; + }, + when : !uplinks && 'bbs' !== importType, + } + ], + answers => { + confTag = confTag || answers.confTag; + networkName = networkName || answers.networkName; + uplinks = uplinks || answers.uplinks; + + importEntries.forEach(ie => { + ie.areaTag = ie.ftnTag.toLowerCase(); + }); + + return callback(null); + }); + }, + function confirmWithUser(callback) { + const Config = require('../../core/config.js').config; + + console.info(`Importing the following for "${confTag}" - (${Config.messageConferences[confTag].name} - ${Config.messageConferences[confTag].desc})`); + importEntries.forEach(ie => { + console.info(` ${ie.ftnTag} - ${ie.name}`); + }); + + console.info(''); + console.info('Importing will NOT create required FTN network configurations.'); + console.info('If you have not yet done this, you will need to complete additional steps after importing.'); + console.info('See docs/msg_networks.md for details.'); + console.info(''); + + getAnswers([ + { + name : 'proceed', + message : 'Proceed?', + type : 'confirm', + } + ], + answers => { + return callback(answers.proceed ? null : Errors.General('User canceled')); + }); + + }, + function loadConfigHjson(callback) { + const configPath = getConfigPath(); + fs.readFile(configPath, 'utf8', (err, confData) => { + if(err) { + return callback(err); + } + + let config; + try { + config = hjson.parse(confData, { keepWsc : true } ); + } catch(e) { + return callback(e); + } + return callback(null, config); + + }); + }, + function performImport(config, callback) { + const confAreas = { messageConferences : {} }; + confAreas.messageConferences[confTag] = { areas : {} }; + + const msgNetworks = { messageNetworks : { ftn : { areas : {} } } }; + + importEntries.forEach(ie => { + const specificUplinks = ie.uplinks || uplinks; // AREAS.BBS has specific uplinks per area + + confAreas.messageConferences[confTag].areas[ie.areaTag] = { + name : ie.name, + desc : ie.name, + }; + + msgNetworks.messageNetworks.ftn.areas[ie.areaTag] = { + network : networkName, + tag : ie.ftnTag, + uplinks : specificUplinks + }; + }); + + + const newConfig = _.defaultsDeep(config, confAreas, msgNetworks); + const configPath = getConfigPath(); + + if(!writeConfig(newConfig, configPath)) { + return callback(Errors.UnexpectedState('Failed writing configuration')); + } + + return callback(null); + } + ], + err => { + if(err) { + console.error(err.reason ? err.reason : err.message); + } else { + const addFieldUpd = 'bbs' === importType ? '"name" and "desc"' : '"desc"'; + console.info('Configuration generated.'); + console.info(`You may wish to validate changes made to ${getConfigPath()}`); + console.info(`as well as update ${addFieldUpd} fields, sorting, etc.`); + console.info(''); + } + } + ); + +} + +function getImportEntries(importType, importData) { + let importEntries = []; + + if('na' === importType) { + // + // parse out + // TAG DESC + // + const re = /^([^\s]+)\s+([^\r\n]+)/gm; + let m; + + while( (m = re.exec(importData) )) { + importEntries.push({ + ftnTag : m[1], + name : m[2], + }); + } + } else if ('bbs' === importType) { + // + // Various formats for AREAS.BBS seem to exist. We want to support as much as possible. + // + // SBBS http://www.synchro.net/docs/sbbsecho.html#AREAS.BBS + // CODE TAG UPLINKS + // + // VADV https://www.vadvbbs.com/products/vadv/support/docs/docs_vfido.php#AREAS.BBS + // TAG UPLINKS + // + // Misc + // PATH|OTHER TAG UPLINKS + // + // Assume the second item is TAG and 1:n UPLINKS (space and/or comma sep) after (at the end) + // + const re = /^[^\s]+\s+([^\s]+)\s+([^\n]+)$/gm; + let m; + while ( (m = re.exec(importData) )) { + const tag = m[1]; + + importEntries.push({ + ftnTag : tag, + name : `Area: ${tag}`, + uplinks : m[2].split(/[\s,]+/), + }); + } + } + + return importEntries; +} + function handleConfigCommand() { if(true === argv.help) { return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); } - if(argv.new) { - askNewConfigQuestions( (err, configPath, config) => { - if(err) { - return; - } - - config = hjson.stringify(config, { bracesSameLine : true, spaces : '\t' } ); - - try { - fs.writeFileSync(configPath, config, 'utf8'); - console.info('Configuration generated'); - } catch(e) { - console.error('Exception attempting to create config: ' + e.toString()); - } - }); - } else { - return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); - } + const action = argv._[1]; + + switch(action) { + case 'new' : return buildNewConfig(); + case 'import-areas' : return importAreas(); + + default : return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); + } } diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 6a2a18cb..f84257e7 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -308,5 +308,7 @@ function handleFileBaseCommand() { switch(action) { case 'info' : return displayFileAreaInfo(); case 'scan' : return scanFileAreas(); + + default : return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); } } \ No newline at end of file diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index b04bdb38..91973295 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -13,6 +13,7 @@ const usageHelp = exports.USAGE_HELP = { global args: --config PATH : specify config path (${getDefaultConfigPath()}) + --no-prompt : assume defaults/don't prompt for input where possible where is one of: user : user utilities @@ -32,10 +33,12 @@ valid args: `, Config : -`usage: optutil.js config +`usage: optutil.js config [] -valid args: - --new : generate a new/initial configuration +where is one of: + new : generate a new/initial configuration + import-na [CONF_TAG] : import fidonet *.NA file + if CONF_TAG is not supplied, it will be prompted for `, FileBase : `usage: oputil.js fb [] [] @@ -52,8 +55,6 @@ valid scan : valid info : --show-desc : display short description, if any - - ` }; diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js index 90d811f5..afe243bf 100644 --- a/core/oputil/oputil_user.js +++ b/core/oputil/oputil_user.js @@ -6,6 +6,7 @@ const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetE const ExitCodes = require('./oputil_common.js').ExitCodes; const argv = require('./oputil_common.js').argv; const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; +const getHelpFor = require('./oputil_help.js').getHelpFor; const async = require('async'); const _ = require('lodash'); @@ -14,7 +15,7 @@ exports.handleUserCommand = handleUserCommand; function handleUserCommand() { if(true === argv.help || !_.isString(argv.user) || 0 === argv.user.length) { - return printUsageAndSetExitCode('User', ExitCodes.ERROR); + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } if(_.isString(argv.password)) {