diff --git a/README.md b/README.md index 9ed90948..072633a8 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Please see the [Quickstart](docs/index.md#quickstart) ## License Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license: -Copyright (c) 2015, Bryan D. Ashby +Copyright (c) 2015-2016, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/core/acs_parser.js b/core/acs_parser.js index 874033db..454e1ba5 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -804,16 +804,13 @@ module.exports = (function() { return !isNaN(value) && user.getAge() >= value; }, AS : function accountStatus() { - - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - return _.findIndex(value, function cmp(accStatus) { - return parseInt(accStatus, 10) === parseInt(user.properties.account_status, 10); - }) > -1; + const userAccountStatus = parseInt(user.properties.account_status, 10); + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(userAccountStatus) > -1; }, EC : function isEncoding() { switch(value) { @@ -842,7 +839,7 @@ module.exports = (function() { // :TODO: implement me!! return false; }, - SC : function isSecerConnection() { + SC : function isSecureConnection() { return client.session.isSecure; }, ML : function minutesLeft() { @@ -870,16 +867,20 @@ module.exports = (function() { return !isNaN(value) && client.term.termWidth >= value; }, ID : function isUserId(value) { - return user.userId === value; + if(!_.isArray(value)) { + value = [ value ]; + } + + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(user.userId) > -1; }, WD : function isOneOfDayOfWeek() { - // :TODO: return true if DoW - if(_.isNumber(value)) { - - } else if(_.isArray(value)) { - + if(!_.isArray(value)) { + value = [ value ]; } - return false; + + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(new Date().getDay()) > -1; }, MM : function isMinutesPastMidnight() { // :TODO: return true if value is >= minutes past midnight sys time diff --git a/core/acs_util.js b/core/acs_util.js index fe111fe1..0f91927d 100644 --- a/core/acs_util.js +++ b/core/acs_util.js @@ -7,8 +7,13 @@ var acsParser = require('./acs_parser.js'); var _ = require('lodash'); var assert = require('assert'); +exports.checkAcs = checkAcs; exports.getConditionalValue = getConditionalValue; +function checkAcs(client, acsString) { + return acsParser.parse(acsString, { client : client } ); +} + function getConditionalValue(client, condArray, memberName) { assert(_.isObject(client)); assert(_.isArray(condArray)); diff --git a/core/art.js b/core/art.js index 9cf0dd9b..5efdbdee 100644 --- a/core/art.js +++ b/core/art.js @@ -12,6 +12,7 @@ var events = require('events'); var util = require('util'); var ansi = require('./ansi_term.js'); var aep = require('./ansi_escape_parser.js'); +var sauce = require('./sauce.js'); var _ = require('lodash'); @@ -20,10 +21,6 @@ exports.getArtFromPath = getArtFromPath; exports.display = display; exports.defaultEncodingFromExtension = defaultEncodingFromExtension; -var SAUCE_SIZE = 128; -var SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' -var COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' - // :TODO: Return MCI code information // :TODO: process SAUCE comments // :TODO: return font + font mapped information from SAUCE @@ -43,156 +40,6 @@ var SUPPORTED_ART_TYPES = { // :TODO: extension for topaz ansi/ascii. }; -// -// See -// http://www.acid.org/info/sauce/sauce.htm -// -// :TODO: Move all SAUCE stuff to sauce.js -function readSAUCE(data, cb) { - if(data.length < SAUCE_SIZE) { - cb(new Error('No SAUCE record present')); - return; - } - - var offset = data.length - SAUCE_SIZE; - var sauceRec = data.slice(offset); - - binary.parse(sauceRec) - .buffer('id', 5) - .buffer('version', 2) - .buffer('title', 35) - .buffer('author', 20) - .buffer('group', 20) - .buffer('date', 8) - .word32lu('fileSize') - .word8('dataType') - .word8('fileType') - .word16lu('tinfo1') - .word16lu('tinfo2') - .word16lu('tinfo3') - .word16lu('tinfo4') - .word8('numComments') - .word8('flags') - .buffer('tinfos', 22) // SAUCE 00.5 - .tap(function onVars(vars) { - - if(!SAUCE_ID.equals(vars.id)) { - cb(new Error('No SAUCE record present')); - return; - } - - var ver = iconv.decode(vars.version, 'cp437'); - - if('00' !== ver) { - cb(new Error('Unsupported SAUCE version: ' + ver)); - return; - } - - var sauce = { - id : iconv.decode(vars.id, 'cp437'), - version : iconv.decode(vars.version, 'cp437').trim(), - title : iconv.decode(vars.title, 'cp437').trim(), - author : iconv.decode(vars.author, 'cp437').trim(), - group : iconv.decode(vars.group, 'cp437').trim(), - date : iconv.decode(vars.date, 'cp437').trim(), - fileSize : vars.fileSize, - dataType : vars.dataType, - fileType : vars.fileType, - tinfo1 : vars.tinfo1, - tinfo2 : vars.tinfo2, - tinfo3 : vars.tinfo3, - tinfo4 : vars.tinfo4, - numComments : vars.numComments, - flags : vars.flags, - tinfos : vars.tinfos, - }; - - var dt = SAUCE_DATA_TYPES[sauce.dataType]; - if(dt && dt.parser) { - sauce[dt.name] = dt.parser(sauce); - } - - cb(null, sauce); - }); -} - -// :TODO: These need completed: -var SAUCE_DATA_TYPES = {}; -SAUCE_DATA_TYPES[0] = { name : 'None' }; -SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; -SAUCE_DATA_TYPES[2] = 'Bitmap'; -SAUCE_DATA_TYPES[3] = 'Vector'; -SAUCE_DATA_TYPES[4] = 'Audio'; -SAUCE_DATA_TYPES[5] = 'BinaryText'; -SAUCE_DATA_TYPES[6] = 'XBin'; -SAUCE_DATA_TYPES[7] = 'Archive'; -SAUCE_DATA_TYPES[8] = 'Executable'; - -var SAUCE_CHARACTER_FILE_TYPES = {}; -SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; -SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; -SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; -SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; -SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; -SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; -SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; -SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; -SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; - -// -// Map of SAUCE font -> encoding hint -// -// Note that this is the same mapping that x84 uses. Be compatible! -// -var SAUCE_FONT_TO_ENCODING_HINT = { - 'Amiga MicroKnight' : 'amiga', - 'Amiga MicroKnight+' : 'amiga', - 'Amiga mOsOul' : 'amiga', - 'Amiga P0T-NOoDLE' : 'amiga', - 'Amiga Topaz 1' : 'amiga', - 'Amiga Topaz 1+' : 'amiga', - 'Amiga Topaz 2' : 'amiga', - 'Amiga Topaz 2+' : 'amiga', - 'Atari ATASCII' : 'atari', - 'IBM EGA43' : 'cp437', - 'IBM EGA' : 'cp437', - 'IBM VGA25G' : 'cp437', - 'IBM VGA50' : 'cp437', - 'IBM VGA' : 'cp437', -}; - -['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', -'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { - var codec = 'cp' + page; - SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; -}); - -function parseCharacterSAUCE(sauce) { - var result = {}; - - result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; - - if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { - // convience: create ansiFlags - sauce.ansiFlags = sauce.flags; - - var i = 0; - while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { - ++i; - } - var fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); - if(fontName.length > 0) { - result.fontName = fontName; - } - } - - return result; -} - function getFontNameFromSAUCE(sauce) { if(sauce.Character) { return sauce.Character.fontName; @@ -249,7 +96,7 @@ function getArtFromPath(path, options, cb) { } if(options.readSauce === true) { - readSAUCE(data, function onSauce(err, sauce) { + sauce.readSAUCE(data, function onSauce(err, sauce) { if(err) { cb(null, getResult()); } else { diff --git a/core/asset.js b/core/asset.js index 7f25a683..566add7c 100644 --- a/core/asset.js +++ b/core/asset.js @@ -2,7 +2,6 @@ 'use strict'; var Config = require('./config.js').config; -var theme = require('./theme.js'); var _ = require('lodash'); var assert = require('assert'); diff --git a/core/bbs.js b/core/bbs.js index 92b0e094..5c61e014 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -1,6 +1,9 @@ /* jslint node: true */ 'use strict'; +//var SegfaultHandler = require('segfault-handler'); +//SegfaultHandler.registerHandler('enigma-bbs-segfault.log'); + // ENiGMA½ var conf = require('./config.js'); var logger = require('./logger.js'); @@ -21,7 +24,7 @@ function bbsMain() { async.waterfall( [ function processArgs(callback) { - var args = parseArgs(); + const args = parseArgs(); var configPath; @@ -37,8 +40,7 @@ function bbsMain() { } } - var configPathSupplied = _.isString(configPath); - callback(null, configPath || conf.getDefaultPath(), configPathSupplied); + callback(null, configPath || conf.getDefaultPath(), _.isString(configPath)); }, function initConfig(configPath, configPathSupplied, callback) { conf.init(configPath, function configInit(err) { @@ -117,7 +119,7 @@ function initialize(cb) { process.exit(); }); - + // Init some extensions require('string-format').extend(String.prototype, require('./string_util.js').stringFormatExtensions); diff --git a/core/config.js b/core/config.js index 6bc9ac61..dc8f3a8e 100644 --- a/core/config.js +++ b/core/config.js @@ -8,10 +8,37 @@ var paths = require('path'); var async = require('async'); var _ = require('lodash'); var hjson = require('hjson'); +var assert = require('assert'); exports.init = init; exports.getDefaultPath = getDefaultPath; +function hasMessageConferenceAndArea(config) { + assert(_.isObject(config.messageConferences)); // we create one ourself! + + const nonInternalConfs = Object.keys(config.messageConferences).filter(confTag => { + return 'system_internal' !== confTag; + }); + + if(0 === nonInternalConfs.length) { + return false; + } + + // :TODO: there is likely a better/cleaner way of doing this + + var result = false; + _.forEach(nonInternalConfs, confTag => { + if(_.has(config.messageConferences[confTag], 'areas') && + Object.keys(config.messageConferences[confTag].areas) > 0) + { + result = true; + return false; // stop iteration + } + }); + + return result; +} + function init(configPath, cb) { async.waterfall( [ @@ -48,18 +75,13 @@ function init(configPath, cb) { // // Various sections must now exist in config // - if(!_.has(mergedConfig, 'messages.areas.') || - !_.isArray(mergedConfig.messages.areas) || - 0 === mergedConfig.messages.areas.length || - !_.isString(mergedConfig.messages.areas[0].name)) - { - var msgAreasErr = new Error('Please create at least one message area'); + if(hasMessageConferenceAndArea(mergedConfig)) { + var msgAreasErr = new Error('Please create at least one message conference and area!'); msgAreasErr.code = 'EBADCONFIG'; callback(msgAreasErr); - return; - } - - callback(null, mergedConfig); + } else { + callback(null, mergedConfig); + } } ], function complete(err, mergedConfig) { @@ -150,6 +172,7 @@ function getDefaultConfig() { paths : { mods : paths.join(__dirname, './../mods/'), servers : paths.join(__dirname, './servers/'), + msgNetworks : paths.join(__dirname, './msg_networks/'), art : paths.join(__dirname, './../mods/art/'), themes : paths.join(__dirname, './../mods/themes/'), logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such @@ -183,6 +206,25 @@ function getDefaultConfig() { } }, + messageConferences : { + system_internal : { + name : 'System Internal', + desc : 'Built in conference for private messages, bulletins, etc.', + + areas : { + private_mail : { + name : 'Private Mail', + desc : 'Private user to user mail/email', + }, + + local_bulletin : { + name : 'System Bulletins', + desc : 'Bulletin messages for all users', + } + } + } + }, + messages : { areas : [ { name : 'private_mail', desc : 'Private Email', groups : [ 'users' ] } diff --git a/core/database.js b/core/database.js index 1b8f9afa..cb1a97f4 100644 --- a/core/database.js +++ b/core/database.js @@ -132,7 +132,7 @@ function createMessageBaseTables() { dbs.message.run( 'CREATE TABLE IF NOT EXISTS message (' + ' message_id INTEGER PRIMARY KEY,' + - ' area_name VARCHAR NOT NULL,' + + ' area_tag VARCHAR NOT NULL,' + ' message_uuid VARCHAR(36) NOT NULL,' + ' reply_to_message_id INTEGER,' + ' to_user_name VARCHAR NOT NULL,' + @@ -198,9 +198,9 @@ function createMessageBaseTables() { dbs.message.run( 'CREATE TABLE IF NOT EXISTS user_message_area_last_read (' + ' user_id INTEGER NOT NULL,' + - ' area_name VARCHAR NOT NULL,' + + ' area_tag VARCHAR NOT NULL,' + ' message_id INTEGER NOT NULL,' + - ' UNIQUE(user_id, area_name)' + + ' UNIQUE(user_id, area_tag)' + ');' ); diff --git a/core/fse.js b/core/fse.js index a8cc6843..69c5e418 100644 --- a/core/fse.js +++ b/core/fse.js @@ -7,7 +7,7 @@ var ansi = require('../core/ansi_term.js'); var theme = require('../core/theme.js'); var MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView; var Message = require('../core/message.js'); -var getMessageAreaByName = require('../core/message_area.js').getMessageAreaByName; +var getMessageAreaByTag = require('../core/message_area.js').getMessageAreaByTag; var updateMessageAreaLastReadId = require('../core/message_area.js').updateMessageAreaLastReadId; var getUserIdAndName = require('../core/user.js').getUserIdAndName; @@ -75,6 +75,8 @@ var MCICodeIds = { HashTags : 9, MessageID : 10, ReplyToMsgID : 11, + + // :TODO: ConfName }, @@ -104,15 +106,15 @@ function FullScreenEditorModule(options) { // editorMode : view | edit | quote // // menuConfig.config or extraArgs - // messageAreaName + // messageAreaTag // messageIndex / messageTotal // toUserId // this.editorType = config.editorType; this.editorMode = config.editorMode; - if(config.messageAreaName) { - this.messageAreaName = config.messageAreaName; + if(config.messageAreaTag) { + this.messageAreaTag = config.messageAreaTag; } this.messageIndex = config.messageIndex || 0; @@ -121,8 +123,8 @@ function FullScreenEditorModule(options) { // extraArgs can override some config if(_.isObject(options.extraArgs)) { - if(options.extraArgs.messageAreaName) { - this.messageAreaName = options.extraArgs.messageAreaName; + if(options.extraArgs.messageAreaTag) { + this.messageAreaTag = options.extraArgs.messageAreaTag; } if(options.extraArgs.messageIndex) { this.messageIndex = options.extraArgs.messageIndex; @@ -134,9 +136,6 @@ function FullScreenEditorModule(options) { this.toUserId = options.extraArgs.toUserId; } } - - console.log(this.toUserId) - console.log(this.messageAreaName) this.isReady = false; @@ -149,7 +148,7 @@ function FullScreenEditorModule(options) { }; this.isLocalEmail = function() { - return Message.WellKnownAreaNames.Private === self.messageAreaName; + return Message.WellKnownAreaTags.Private === self.messageAreaTag; }; this.isReply = function() { @@ -217,7 +216,7 @@ function FullScreenEditorModule(options) { var headerValues = self.viewControllers.header.getFormData().value; var msgOpts = { - areaName : self.messageAreaName, + areaTag : self.messageAreaTag, toUserName : headerValues.to, fromUserName : headerValues.from, subject : headerValues.subject, @@ -235,7 +234,7 @@ function FullScreenEditorModule(options) { self.message = message; updateMessageAreaLastReadId( - self.client.user.userId, self.messageAreaName, self.message.messageId, + self.client.user.userId, self.messageAreaTag, self.message.messageId, function lastReadUpdated() { if(self.isReady) { @@ -631,7 +630,7 @@ function FullScreenEditorModule(options) { }; this.initHeaderGeneric = function() { - self.setHeaderText(MCICodeIds.ViewModeHeader.AreaName, getMessageAreaByName(self.messageAreaName).desc); + self.setHeaderText(MCICodeIds.ViewModeHeader.AreaName, getMessageAreaByTag(self.messageAreaTag).name); }; this.initHeaderViewMode = function() { @@ -965,13 +964,10 @@ function FullScreenEditorModule(options) { require('util').inherits(FullScreenEditorModule, MenuModule); -FullScreenEditorModule.prototype.enter = function(client) { - FullScreenEditorModule.super_.prototype.enter.call(this, client); - - +FullScreenEditorModule.prototype.enter = function() { + FullScreenEditorModule.super_.prototype.enter.call(this); }; FullScreenEditorModule.prototype.mciReady = function(mciData, cb) { this.mciReadyHandler(mciData, cb); - //this['mciReadyHandler' + _.capitalize(this.editorType)](mciData); }; diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index a63ea79f..a9603f2e 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -1,9 +1,10 @@ /* jslint node: true */ 'use strict'; -var MailPacket = require('./mail_packet.js'); +//var MailPacket = require('./mail_packet.js'); var ftn = require('./ftn_util.js'); var Message = require('./message.js'); +var sauce = require('./sauce.js'); var _ = require('lodash'); var assert = require('assert'); @@ -12,6 +13,8 @@ var fs = require('fs'); var util = require('util'); var async = require('async'); var iconv = require('iconv-lite'); +var buffers = require('buffers'); +var moment = require('moment'); /* :TODO: should probably be broken up @@ -20,6 +23,493 @@ var iconv = require('iconv-lite'); FTNPacketExport: message(s) -> packet */ +/* +Reader: file to ftn data +Writer: ftn data to packet + +Data to toMessage +Data.fromMessage + +FTNMessage.toMessage() => Message +FTNMessage.fromMessage() => Create from Message + +* read: header -> simple {} obj, msg -> Message object +* read: read(..., iterator): iterator('header', ...), iterator('message', msg) +* write: provide information to go into header + +* Logic of "Is this for us"/etc. elsewhere +*/ + +const FTN_PACKET_HEADER_SIZE = 58; // fixed header size +const FTN_PACKET_HEADER_TYPE = 2; +const FTN_PACKET_MESSAGE_TYPE = 2; + +// EOF + SAUCE.id + SAUCE.version ('00') +const FTN_MESSAGE_SAUCE_HEADER = + new Buffer( [ 0x1a, 'S', 'A', 'U', 'C', 'E', '0', '0' ] ); + +const FTN_MESSAGE_KLUDGE_PREFIX = '\x01'; + +function FTNPacket() { + + var self = this; + + this.parsePacketHeader = function(packetBuffer, cb) { + assert(Buffer.isBuffer(packetBuffer)); + + // + // See the following specs: + // http://ftsc.org/docs/fts-0001.016 + // http://ftsc.org/docs/fsc-0048.002 + // + if(packetBuffer.length < FTN_PACKET_HEADER_SIZE) { + cb(new Error('Buffer too small')); + return; + } + + binary.parse(packetBuffer) + .word16lu('origNode') + .word16lu('destNode') + .word16lu('year') + .word16lu('month') + .word16lu('day') + .word16lu('hour') + .word16lu('minute') + .word16lu('second') + .word16lu('baud') + .word16lu('packetType') + .word16lu('origNet') + .word16lu('destNet') + .word8('prodCodeLo') + .word8('revisionMajor') // aka serialNo + .buffer('password', 8) // null padded C style string + .word16lu('origZone') + .word16lu('destZone') + // Additions in FSC-0048.002 follow... + .word16lu('auxNet') + .word16lu('capWordA') + .word8('prodCodeHi') + .word8('revisionMinor') + .word16lu('capWordB') + .word16lu('originZone2') + .word16lu('destZone2') + .word16lu('originPoint') + .word16lu('destPoint') + .word32lu('prodData') + .tap(packetHeader => { + // Convert password from NULL padded array to string + packetHeader.password = ftn.stringFromFTN(packetHeader.password); + + if(FTN_PACKET_HEADER_TYPE !== packetHeader.packetType) { + cb(new Error('Unsupported header type: ' + packetHeader.packetType)); + return; + } + + // + // Date/time components into something more reasonable + // Note: The names above match up with object members moment() allows + // + packetHeader.created = moment(packetHeader); + + cb(null, packetHeader); + }); + }; + + this.writePacketHeader = function(headerInfo, ws) { + let buffer = new Buffer(FTN_PACKET_HEADER_SIZE); + + buffer.writeUInt16LE(headerInfo.origNode, 0); + buffer.writeUInt16LE(headerInfo.destNode, 2); + buffer.writeUInt16LE(headerInfo.created.year(), 4); + buffer.writeUInt16LE(headerInfo.created.month(), 6); + buffer.writeUInt16LE(headerInfo.created.date(), 8); + buffer.writeUInt16LE(headerInfo.created.hour(), 10); + buffer.writeUInt16LE(headerInfo.created.minute(), 12); + buffer.writeUInt16LE(headerInfo.created.second(), 14); + buffer.writeUInt16LE(headerInfo.baud, 16); + buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); + buffer.writeUInt16LE(headerInfo.origNet, 20); + buffer.writeUInt16LE(headerInfo.destNet, 22); + buffer.writeUInt8(headerInfo.prodCodeLo, 24); + buffer.writeUInt8(headerInfo.revisionMajor, 25); + + const pass = ftn.stringToNullPaddedBuffer(headerInfo.password, 8); + pass.copy(buffer, 26); + + buffer.writeUInt16LE(headerInfo.origZone, 34); + buffer.writeUInt16LE(headerInfo.destZone, 36); + + // FSC-0048.002 additions... + buffer.writeUInt16LE(headerInfo.auxNet, 38); + buffer.writeUInt16LE(headerInfo.capWordA, 40); + buffer.writeUInt8(headerInfo.prodCodeHi, 42); + buffer.writeUInt8(headerInfo.revisionMinor, 43); + buffer.writeUInt16LE(headerInfo.capWordB, 44); + buffer.writeUInt16LE(headerInfo.origZone2, 46); + buffer.writeUInt16LE(headerInfo.destZone2, 48); + buffer.writeUInt16LE(headerInfo.origPoint, 50); + buffer.writeUInt16LE(headerInfo.destPoint, 52); + buffer.writeUInt32LE(headerInfo.prodData, 54); + + ws.write(buffer); + }; + + this.processMessageBody = function(messageBodyBuffer, cb) { + // + // From FTS-0001.16: + // "Message text is unbounded and null terminated (note exception below). + // + // A 'hard' carriage return, 0DH, marks the end of a paragraph, and must + // be preserved. + // + // So called 'soft' carriage returns, 8DH, may mark a previous + // processor's automatic line wrap, and should be ignored. Beware that + // they may be followed by linefeeds, or may not. + // + // All linefeeds, 0AH, should be ignored. Systems which display message + // text should wrap long lines to suit their application." + // + // This can be a bit tricky: + // * Decoding as CP437 converts 0x8d -> 0xec, so we'll need to correct for that + // * Many kludge lines specify an encoding. If we find one of such lines, we'll + // likely need to re-decode as the specified encoding + // * SAUCE is binary-ish data, so we need to inspect for it before any + // decoding occurs + // + let messageBodyData = { + message : [], + kludgeLines : {}, // KLUDGE:[value1, value2, ...] map + seenBy : [], + }; + + function addKludgeLine(line) { + const sepIndex = line.indexOf(':'); + const key = line.substr(0, sepIndex).toUpperCase(); + const value = line.substr(sepIndex + 1).trim(); + + // + // Allow mapped value to be either a key:value if there is only + // one entry, or key:[value1, value2,...] if there are more + // + if(messageBodyData.kludgeLines[key]) { + if(!_.isArray(messageBodyData.kludgeLines[key])) { + messageBodyData.kludgeLines[key] = [ messageBodyData.kludgeLines[key] ]; + } + messageBodyData.kludgeLines[key].push(value); + } else { + messageBodyData.kludgeLines[key] = value; + } + } + + async.series( + [ + function extractSauce(callback) { + // :TODO: This is wrong: SAUCE may not have EOF marker for one, also if it's + // present, we need to extract it but keep the rest of hte message intact as it likely + // has SEEN-BY, PATH, and other kludge information *appended* + const sauceHeaderPosition = messageBodyBuffer.indexOf(FTN_MESSAGE_SAUCE_HEADER); + if(sauceHeaderPosition > -1) { + sauce.readSAUCE(messageBodyBuffer.slice(sauceHeaderPosition), (err, theSauce) => { + if(!err) { + // we read some SAUCE - don't re-process that portion into the body + messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition); + messageBodyData.sauce = theSauce; + } + callback(null); // failure to read SAUCE is OK + }); + } else { + callback(null); + } + }, + function extractMessageData(callback) { + const messageLines = + iconv.decode(messageBodyBuffer, 'CP437').replace(/[\xec\n]/g, '').split(/\r/g); + + let preOrigin = true; + + messageLines.forEach(line => { + if(0 === line.length) { + messageBodyData.message.push(''); + return; + } + + if(preOrigin) { + if(line.startsWith('AREA:')) { + messageBodyData.area = line.substring(line.indexOf(':') + 1).trim(); + } else if(line.startsWith('--- ')) { + // Tear Lines are tracked allowing for specialized display/etc. + messageBodyData.tearLine = line; + } else if(/[ ]{1,2}(\* )?Origin\: /.test(line)) { // To spec is " * Origin: ..." + messageBodyData.originLine = line; + preOrigin = false; + } else if(FTN_MESSAGE_KLUDGE_PREFIX === line.charAt(0)) { + addKludgeLine(line.slice(1)); + } else { + // regular ol' message line + messageBodyData.message.push(line); + } + } else { + if(line.startsWith('SEEN-BY:')) { + messageBodyData.seenBy.push(line.substring(line.indexOf(':') + 1).trim()); + } else if(FTN_MESSAGE_KLUDGE_PREFIX === line.charAt(0)) { + addKludgeLine(line.slice(1)); + } + } + }); + + callback(null); + } + ], + function complete(err) { + messageBodyData.message = messageBodyData.message.join('\n'); + cb(messageBodyData); + } + ); + }; + + this.parsePacketMessages = function(messagesBuffer, iterator, cb) { + const NULL_TERM_BUFFER = new Buffer( [ 0 ] ); + + binary.stream(messagesBuffer).loop(function looper(end, vars) { + // + // Some variable names used here match up directly with well known + // meta data names used with FTN messages. + // + this + .word16lu('messageType') + .word16lu('ftn_orig_node') + .word16lu('ftn_dest_node') + .word16lu('ftn_orig_network') + .word16lu('ftn_dest_network') + .word8('ftn_attr_flags1') + .word8('ftn_attr_flags2') + .word16lu('ftn_cost') + .scan('modDateTime', NULL_TERM_BUFFER) // :TODO: 20 bytes max + .scan('toUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max + .scan('fromUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max + .scan('subject', NULL_TERM_BUFFER) // :TODO: 72 bytes max + .scan('message', NULL_TERM_BUFFER) + .tap(function tapped(msgData) { + if(!msgData.ftn_orig_node) { + // end marker -- no more messages + end(); + cb(null); + return; + } + + if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) { + end(); + cb(new Error('Unsupported message type: ' + msgData.messageType)); + return; + } + + // + // Convert null terminated arrays to strings + // + let convMsgData = {}; + [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => { + convMsgData[k] = iconv.decode(msgData[k], 'CP437'); + }); + + // + // The message body itself is a special beast as it may + // contain special origin lines, kludges, SAUCE in the case + // of ANSI files, etc. + // + let msg = new Message( { + toUserName : convMsgData.toUserName, + fromUserName : convMsgData.fromUserName, + subject : convMsgData.subject, + modTimestamp : ftn.getDateFromFtnDateTime(convMsgData.modDateTime), + }); + + msg.meta.FtnProperty = {}; + msg.meta.FtnProperty.ftn_orig_node = msgData.ftn_orig_node; + msg.meta.FtnProperty.ftn_dest_node = msgData.ftn_dest_node; + msg.meta.FtnProperty.ftn_orig_network = msgData.ftn_orig_network; + msg.meta.FtnProperty.ftn_dest_network = msgData.ftn_dest_network; + msg.meta.FtnProperty.ftn_attr_flags1 = msgData.ftn_attr_flags1; + msg.meta.FtnProperty.ftn_attr_flags2 = msgData.ftn_attr_flags2; + msg.meta.FtnProperty.ftn_cost = msgData.ftn_cost; + + self.processMessageBody(msgData.message, function processed(messageBodyData) { + msg.message = messageBodyData.message; + msg.meta.FtnKludge = messageBodyData.kludgeLines; + + if(messageBodyData.tearLine) { + msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine; + } + if(messageBodyData.seenBy.length > 0) { + msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy; + } + if(messageBodyData.area) { + msg.meta.FtnProperty.ftn_area = messageBodyData.area; + } + if(messageBodyData.originLine) { + msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine; + } + + iterator('message', msg); + }) + }); + }); + }; + + this.writeMessage = function(message, ws) { + let basicHeader = new Buffer(34); + + basicHeader.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_node, 4); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_network, 6); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_network, 8); + basicHeader.writeUInt8(message.meta.FtnProperty.ftn_attr_flags1, 10); + basicHeader.writeUInt8(message.meta.FtnProperty.ftn_attr_flags2, 11); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12); + + // + // From http://ftsc.org/docs/fts-0001.016: + // DateTime = (* a character string 20 characters long *) + // (* 01 Jan 86 02:34:56 *) + // DayOfMonth " " Month " " Year " " + // " " HH ":" MM ":" SS + // Null + // + // DayOfMonth = "01" | "02" | "03" | ... | "31" (* Fido 0 fills *) + // Month = "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | + // "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" + // Year = "01" | "02" | .. | "85" | "86" | ... | "99" | "00" + // HH = "00" | .. | "23" + // MM = "00" | .. | "59" + // SS = "00" | .. | "59" + const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0'); + dateTimeBuffer.copy(basicHeader, 14); + + ws.write(basicHeader); + + // toUserName & fromUserName: up to 36 bytes in length, NULL term'd + // :TODO: DRY... + let encBuf = iconv.encode(message.toUserName + '\0', 'CP437').slice(0, 36); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + encBuf = iconv.encode(message.fromUserName + '\0', 'CP437').slice(0, 36); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + // subject: up to 72 bytes in length, NULL term'd + encBuf = iconv.encode(message.subject + '\0', 'CP437').slice(0, 72); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + // + // message: unbound length, NULL term'd + // + // We need to build in various special lines - kludges, area, + // seen-by, etc. + // + // :TODO: Put this in it's own method + let msgBody = ''; + + function appendMeta(k, m) { + if(m) { + let a = m; + if(!_.isArray(a)) { + a = [ a ]; + } + a.forEach(v => { + msgBody += `${k}: ${v}\n`; + }); + } + } + + // :TODO: is Area really any differnt (e.g. no space between AREA:the_area) + if(message.meta.FtnProperty.ftn_area) { + msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\n`; + } + + Object.keys(message.meta.FtnKludge).forEach(k => { + if('PATH' !== k) { + appendMeta(k, message.meta.FtnKludge[k]); + } + }); + + msgBody += message.message; + + appendMeta('', message.meta.FtnProperty.ftn_tear_line); + appendMeta('', message.meta.FtnProperty.ftn_origin); + + appendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); + appendMeta('PATH', message.meta.FtnKludge['PATH']); + + ws.write(iconv.encode(msgBody + '\0', 'CP437')); + }; + + this.parsePacketBuffer = function(packetBuffer, iterator, cb) { + async.series( + [ + function processHeader(callback) { + self.parsePacketHeader(packetBuffer, (err, header) => { + if(!err) { + iterator('header', header); + } + callback(err); + }); + }, + function processMessages(callback) { + self.parsePacketMessages( + packetBuffer.slice(FTN_PACKET_HEADER_SIZE), + iterator, + callback); + } + ], + cb + ); + }; +} + +FTNPacket.prototype.read = function(pathOrBuffer, iterator, cb) { + var self = this; + + async.series( + [ + function getBufferIfPath(callback) { + if(_.isString(pathOrBuffer)) { + fs.readFile(pathOrBuffer, (err, data) => { + pathOrBuffer = data; + callback(err); + }); + } else { + callback(null); + } + }, + function parseBuffer(callback) { + self.parsePacketBuffer(pathOrBuffer, iterator, callback); + } + ], + cb // completion callback + ); +}; + +FTNPacket.prototype.write = function(path, headerInfo, messages, cb) { + headerInfo.created = headerInfo.created || moment(); + headerInfo.baud = headerInfo.baud || 0; + // :TODO: Other defaults? + + if(!_.isArray(messages)) { + messages = [ messages ] ; + } + + let ws = fs.createWriteStream(path); + this.writePacketHeader(headerInfo, ws); + + messages.forEach(msg => { + this.writeMessage(msg, ws); + }); +}; + + + // // References // * http://ftsc.org/docs/fts-0001.016 @@ -30,7 +520,7 @@ var iconv = require('iconv-lite'); // function FTNMailPacket(options) { - MailPacket.call(this, options); + //MailPacket.call(this, options); var self = this; self.KLUDGE_PREFIX = '\x01'; @@ -77,7 +567,7 @@ function FTNMailPacket(options) { .word16lu('second') .word16lu('baud') .word16lu('packetType') - .word16lu('originNet') + .word16lu('origNet') .word16lu('destNet') .word8('prodCodeLo') .word8('revisionMajor') // aka serialNo @@ -100,35 +590,110 @@ function FTNMailPacket(options) { // :TODO: Don't hard code magic # here if(2 !== packetHeader.packetType) { + console.log(packetHeader.packetType) cb(new Error('Packet is not Type-2')); return; } + + // :TODO: convert date information -> .created + + packetHeader.created = moment(packetHeader); + /* + packetHeader.year, packetHeader.month, packetHeader.day, packetHeader.hour, + packetHeader.minute, packetHeader.second);*/ // :TODO: validate & pass error if failure cb(null, packetHeader); }); }; + + this.getPacketHeaderBuffer = function(packetHeader, options) { + options = options || {}; + + if(options.created) { + options.created = moment(options.created); // ensure we have a moment obj + } else { + options.created = moment(); + } + + let buffer = new Buffer(58); + + buffer.writeUInt16LE(packetHeader.origNode, 0); + buffer.writeUInt16LE(packetHeader.destNode, 2); + buffer.writeUInt16LE(options.created.year(), 4); + buffer.writeUInt16LE(options.created.month(), 6); + buffer.writeUInt16LE(options.created.date(), 8); + buffer.writeUInt16LE(options.created.hour(), 10); + buffer.writeUInt16LE(options.created.minute(), 12); + buffer.writeUInt16LE(options.created.second(), 14); + buffer.writeUInt16LE(0x0000, 16); + buffer.writeUInt16LE(0x0002, 18); + buffer.writeUInt16LE(packetHeader.origNet, 20); + buffer.writeUInt16LE(packetHeader.destNet, 22); + buffer.writeUInt8(packetHeader.prodCodeLo, 24); + buffer.writeUInt8(packetHeader.revisionMajor, 25); + + const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); + pass.copy(buffer, 26); + + buffer.writeUInt16LE(packetHeader.origZone, 34); + buffer.writeUInt16LE(packetHeader.destZone, 36); + + // FSC-0048.002 additions... + buffer.writeUInt16LE(packetHeader.auxNet, 38); + buffer.writeUInt16LE(packetHeader.capWordA, 40); + buffer.writeUInt8(packetHeader.prodCodeHi, 42); + buffer.writeUInt8(packetHeader.revisionMinor, 43); + buffer.writeUInt16LE(packetHeader.capWordB, 44); + buffer.writeUInt16LE(packetHeader.origZone2, 46); + buffer.writeUInt16LE(packetHeader.destZone2, 48); + buffer.writeUInt16LE(packetHeader.origPoint, 50); + buffer.writeUInt16LE(packetHeader.destPoint, 52); + buffer.writeUInt32LE(packetHeader.prodData, 54); + + return buffer; + }; + + self.setOrAppend = function(value, dst) { + if(dst) { + if(!_.isArray(dst)) { + dst = [ dst ]; + } + + dst.push(value); + } else { + dst = value; + } + } - self.getMessageMeta = function(msgBody) { + self.getMessageMeta = function(msgBody, msgData) { var meta = { FtnKludge : msgBody.kludgeLines, FtnProperty : {}, }; if(msgBody.tearLine) { - meta.FtnProperty.ftn_tear_line = [ msgBody.tearLine ]; + meta.FtnProperty.ftn_tear_line = msgBody.tearLine; } if(msgBody.seenBy.length > 0) { meta.FtnProperty.ftn_seen_by = msgBody.seenBy; } if(msgBody.area) { - meta.FtnProperty.ftn_area = [ msgBody.area ]; + meta.FtnProperty.ftn_area = msgBody.area; } if(msgBody.originLine) { - meta.FtnProperty.ftn_origin = [ msgBody.originLine ]; + meta.FtnProperty.ftn_origin = msgBody.originLine; } + meta.FtnProperty.ftn_orig_node = msgData.origNode; + meta.FtnProperty.ftn_dest_node = msgData.destNode; + meta.FtnProperty.ftn_orig_network = msgData.origNet; + meta.FtnProperty.ftn_dest_network = msgData.destNet; + meta.FtnProperty.ftn_attr_flags1 = msgData.attrFlags1; + meta.FtnProperty.ftn_attr_flags2 = msgData.attrFlags2; + meta.FtnProperty.ftn_cost = msgData.cost; + return meta; }; @@ -172,13 +737,15 @@ function FTNMailPacket(options) { var preOrigin = true; function addKludgeLine(kl) { - var kludgeParts = kl.split(':'); + const kludgeParts = kl.split(':'); kludgeParts[0] = kludgeParts[0].toUpperCase(); kludgeParts[1] = kludgeParts[1].trim(); - (msgBody.kludgeLines[kludgeParts[0]] = msgBody.kludgeLines[kludgeParts[0]] || []).push(kludgeParts[1]); + self.setOrAppend(kludgeParts[1], msgBody.kludgeLines[kludgeParts[0]]); } + var sauceBuffers; + msgLines.forEach(function nextLine(line) { if(0 === line.length) { msgBody.message.push(''); @@ -196,10 +763,12 @@ function FTNMailPacket(options) { preOrigin = false; } else if(self.KLUDGE_PREFIX === line.charAt(0)) { addKludgeLine(line.slice(1)); + } else if(!sauceBuffers || _.startsWith(line, '\x1aSAUCE00')) { + sauceBuffers = sauceBuffers || buffers(); + sauceBuffers.push(new Buffer(line)); } else { msgBody.message.push(line); } - // :TODO: SAUCE/etc. can be present? } else { if(_.startsWith(line, 'SEEN-BY:')) { msgBody.seenBy.push(line.substring(line.indexOf(':') + 1).trim()); @@ -209,29 +778,36 @@ function FTNMailPacket(options) { } }); + if(sauceBuffers) { + // :TODO: parse sauce -> sauce buffer. This needs changes to this method to return message & optional sauce + } + cb(null, msgBody); }; - this.extractMessages = function(buffer, cb) { - var nullTermBuf = new Buffer( [ 0 ] ); + this.extractMessages = function(buffer, iterator, cb) { + assert(Buffer.isBuffer(buffer)); + assert(_.isFunction(iterator)); + + const NULL_TERM_BUFFER = new Buffer( [ 0 ] ); binary.stream(buffer).loop(function looper(end, vars) { this .word16lu('messageType') - .word16lu('originNode') + .word16lu('origNode') .word16lu('destNode') - .word16lu('originNet') + .word16lu('origNet') .word16lu('destNet') .word8('attrFlags1') .word8('attrFlags2') .word16lu('cost') - .scan('modDateTime', nullTermBuf) - .scan('toUserName', nullTermBuf) - .scan('fromUserName', nullTermBuf) - .scan('subject', nullTermBuf) - .scan('message', nullTermBuf) + .scan('modDateTime', NULL_TERM_BUFFER) + .scan('toUserName', NULL_TERM_BUFFER) + .scan('fromUserName', NULL_TERM_BUFFER) + .scan('subject', NULL_TERM_BUFFER) + .scan('message', NULL_TERM_BUFFER) .tap(function tapped(msgData) { - if(!msgData.originNode) { + if(!msgData.origNode) { end(); cb(null); return; @@ -247,20 +823,25 @@ function FTNMailPacket(options) { // Now, create a Message object // var msg = new Message( { - // :TODO: areaId needs to be looked up via AREA line - may need a 1:n alias -> area ID lookup + // AREA FTN -> local conf/area occurs elsewhere toUserName : msgData.toUserName, fromUserName : msgData.fromUserName, subject : msgData.subject, message : msgBody.message.join('\n'), // :TODO: \r\n is better? modTimestamp : ftn.getDateFromFtnDateTime(msgData.modDateTime), - meta : self.getMessageMeta(msgBody), + meta : self.getMessageMeta(msgBody, msgData), + + }); - - self.emit('message', msg); // :TODO: Placeholder + + iterator(msg); + //self.emit('message', msg); // :TODO: Placeholder }); }); }); }; + + //this.getMessageHeaderBuffer = function(headerInfo) this.parseFtnMessages = function(buffer, cb) { var nullTermBuf = new Buffer( [ 0 ] ); @@ -269,9 +850,9 @@ function FTNMailPacket(options) { binary.stream(buffer).loop(function looper(end, vars) { this .word16lu('messageType') - .word16lu('originNode') + .word16lu('origNode') .word16lu('destNode') - .word16lu('originNet') + .word16lu('origNet') .word16lu('destNet') .word8('attrFlags1') .word8('attrFlags2') @@ -282,7 +863,7 @@ function FTNMailPacket(options) { .scan('subject', nullTermBuf) .scan('message', nullTermBuf) .tap(function tapped(msgData) { - if(!msgData.originNode) { + if(!msgData.origNode) { end(); cb(null, fidoMessages); return; @@ -302,7 +883,10 @@ function FTNMailPacket(options) { }); }; - this.extractMesssagesFromPacketBuffer = function(packetBuffer, cb) { + this.extractMesssagesFromPacketBuffer = function(packetBuffer, iterator, cb) { + assert(Buffer.isBuffer(packetBuffer)); + assert(_.isFunction(iterator)); + async.waterfall( [ function parseHeader(callback) { @@ -318,7 +902,8 @@ function FTNMailPacket(options) { }, function extractEmbeddedMessages(callback) { // note: packet header is 58 bytes in length - self.extractMessages(packetBuffer.slice(58), function extracted(err) { + self.extractMessages( + packetBuffer.slice(58), iterator, function extracted(err) { callback(err); }); } @@ -361,7 +946,7 @@ function FTNMailPacket(options) { }; } -require('util').inherits(FTNMailPacket, MailPacket); +//require('util').inherits(FTNMailPacket, MailPacket); FTNMailPacket.prototype.parse = function(path, cb) { var self = this; @@ -385,41 +970,67 @@ FTNMailPacket.prototype.parse = function(path, cb) { ); }; -FTNMailPacket.prototype.read = function(options) { - FTNMailPacket.super_.prototype.read.call(this, options); - +FTNMailPacket.prototype.read = function(pathOrBuffer, iterator, cb) { var self = this; - if(_.isString(options.packetPath)) { + if(_.isString(pathOrBuffer)) { async.waterfall( [ function readPacketFile(callback) { - fs.readFile(options.packetPath, function packetData(err, data) { + fs.readFile(pathOrBuffer, function packetData(err, data) { callback(err, data); }); }, function extractMessages(data, callback) { - self.extractMesssagesFromPacketBuffer(data, function extracted(err) { - callback(err); - }); + self.extractMesssagesFromPacketBuffer(data, iterator, callback); } ], - function complete(err) { - if(err) { - self.emit('error', err); - } - } + cb ); - } else if(Buffer.isBuffer(options.packetBuffer)) { + } else if(Buffer.isBuffer(pathOrBuffer)) { } }; -FTNMailPacket.prototype.write = function(options) { - FTNMailPacket.super_.prototype.write.call(this, options); +FTNMailPacket.prototype.write = function(messages, fileName, options) { + if(!_.isArray(messages)) { + messages = [ messages ]; + } + + + }; +var ftnPacket = new FTNPacket(); +var theHeader; +var written = false; +ftnPacket.read( + process.argv[2], + function iterator(dataType, data) { + if('header' === dataType) { + theHeader = data; + console.log(theHeader); + } else if('message' === dataType) { + const msg = data; + console.log(msg); + if(!written) { + written = true; + + let messages = [ msg ]; + ftnPacket.write('/home/nuskooler/Downloads/ftnout/test1.pkt', theHeader, messages, err => { + + }); + + } + } + }, + function completion(err) { + console.log(err); + } +); + +/* var mailPacket = new FTNMailPacket( { nodeAddresses : { @@ -434,11 +1045,42 @@ var mailPacket = new FTNMailPacket( } ); -mailPacket.on('message', function msgParsed(msg) { - console.log(msg); -}); -mailPacket.read( { packetPath : '/home/nuskooler/ownCloud/Projects/ENiGMA½ BBS/FTNPackets/BAD_BNDL.007' } ); +var didWrite = false; +mailPacket.read( + process.argv[2], + //'/home/nuskooler/ownCloud/Projects/ENiGMA½ BBS/FTNPackets/mf/extracted/27000425.pkt', + function packetIter(msg) { + console.log(msg); + if(_.has(msg, 'meta.FtnProperty.ftn_area')) { + console.log('AREA: ' + msg.meta.FtnProperty.ftn_area); + } + + if(!didWrite) { + console.log(mailPacket.packetHeader); + console.log('-----------'); + + + didWrite = true; + + let outTest = fs.createWriteStream('/home/nuskooler/Downloads/ftnout/test1.pkt'); + let buffer = mailPacket.getPacketHeaderBuffer(mailPacket.packetHeader); + //mailPacket.write(buffer, msg.packetHeader); + outTest.write(buffer); + } + }, + function complete(err) { + console.log(err); + } +); +*/ +/* + Area Map + networkName: { + area_tag: conf_name:area_tag_name + ... + } +*/ /* mailPacket.parse('/home/nuskooler/ownCloud/Projects/ENiGMA½ BBS/FTNPackets/BAD_BNDL.007', function parsed(err, messages) { diff --git a/core/ftn_util.js b/core/ftn_util.js index 6ff0430b..41c8dfc6 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -10,11 +10,14 @@ var binary = require('binary'); var fs = require('fs'); var util = require('util'); var iconv = require('iconv-lite'); +var moment = require('moment'); // :TODO: Remove "Ftn" from most of these -- it's implied in the module exports.stringFromFTN = stringFromFTN; +exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer; exports.getFormattedFTNAddress = getFormattedFTNAddress; exports.getDateFromFtnDateTime = getDateFromFtnDateTime; +exports.getDateTimeString = getDateTimeString; exports.getQuotePrefix = getQuotePrefix; @@ -33,6 +36,14 @@ function stringFromFTN(buf, encoding) { return iconv.decode(buf.slice(0, nullPos), encoding || 'utf-8'); } +function stringToNullPaddedBuffer(s, bufLen) { + let buffer = new Buffer(bufLen).fill(0x00); + let enc = iconv.encode(s, 'CP437').slice(0, bufLen); + for(let i = 0; i < enc.length; ++i) { + buffer[i] = enc[i]; + } + return buffer; +} // // Convert a FTN style DateTime string to a Date object @@ -44,9 +55,34 @@ function getDateFromFtnDateTime(dateTime) { // "Tue 01 Jan 80 00:00" // "27 Feb 15 00:00:03" // + // :TODO: Use moment.js here return (new Date(Date.parse(dateTime))).toISOString(); } +function getDateTimeString(m) { + // + // From http://ftsc.org/docs/fts-0001.016: + // DateTime = (* a character string 20 characters long *) + // (* 01 Jan 86 02:34:56 *) + // DayOfMonth " " Month " " Year " " + // " " HH ":" MM ":" SS + // Null + // + // DayOfMonth = "01" | "02" | "03" | ... | "31" (* Fido 0 fills *) + // Month = "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | + // "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" + // Year = "01" | "02" | .. | "85" | "86" | ... | "99" | "00" + // HH = "00" | .. | "23" + // MM = "00" | .. | "59" + // SS = "00" | .. | "59" + // + if(!moment.isMoment(m)) { + m = moment(m); + } + + return m.format('DD MMM YY HH:mm:ss'); +} + function getFormattedFTNAddress(address, dimensions) { //var addr = util.format('%d:%d', address.zone, address.net); var addr = '{0}:{1}'.format(address.zone, address.net); diff --git a/core/menu_module.js b/core/menu_module.js index e052b1db..503829c1 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -25,6 +25,10 @@ function MenuModule(options) { var self = this; this.menuName = options.menuName; this.menuConfig = options.menuConfig; + this.client = options.client; + + // :TODO: this and the line below with .config creates empty ({}) objects in the theme -- + // ...which we really should not do. If they aren't there already, don't use 'em. this.menuConfig.options = options.menuConfig.options || {}; this.menuMethods = {}; // methods called from @method's @@ -190,10 +194,7 @@ require('util').inherits(MenuModule, PluginModule); require('./mod_mixins.js').ViewControllerManagement.call(MenuModule.prototype); -MenuModule.prototype.enter = function(client) { - this.client = client; - assert(_.isObject(client)); - +MenuModule.prototype.enter = function() { if(_.isString(this.menuConfig.status)) { this.client.currentStatus = this.menuConfig.status; } else { diff --git a/core/menu_stack.js b/core/menu_stack.js index 5caf6531..64e8df40 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -131,7 +131,7 @@ MenuStack.prototype.goto = function(name, options, cb) { modInst.restoreSavedState(options.savedState); } - modInst.enter(self.client); + modInst.enter(); self.client.log.trace( { stack : _.map(self.stack, function(si) { return si.name; } ) }, diff --git a/core/menu_util.js b/core/menu_util.js index 601888a8..a00edb0b 100644 --- a/core/menu_util.js +++ b/core/menu_util.js @@ -4,10 +4,8 @@ // ENiGMA½ var moduleUtil = require('./module_util.js'); var Log = require('./logger.js').log; -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 getFullConfig = require('./config_util.js').getFullConfig; var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; var acsUtil = require('./acs_util.js'); @@ -68,17 +66,18 @@ function loadMenu(options, cb) { }); }, function loadMenuModule(menuConfig, callback) { - var modAsset = asset.getModuleAsset(menuConfig.module); - var modSupplied = null !== modAsset; - var modLoadOpts = { + const modAsset = asset.getModuleAsset(menuConfig.module); + const modSupplied = null !== modAsset; + + const modLoadOpts = { name : modSupplied ? modAsset.asset : 'standard_menu', path : (!modSupplied || 'systemModule' === modAsset.type) ? __dirname : Config.paths.mods, category : (!modSupplied || 'systemModule' === modAsset.type) ? null : 'mods', }; moduleUtil.loadModuleEx(modLoadOpts, function moduleLoaded(err, mod) { - var modData = { + const modData = { name : modLoadOpts.name, config : menuConfig, mod : mod, @@ -97,7 +96,8 @@ function loadMenu(options, cb) { { menuName : options.name, menuConfig : modData.config, - extraArgs : options.extraArgs + extraArgs : options.extraArgs, + client : options.client, }); callback(null, moduleInstance); } catch(e) { @@ -174,7 +174,7 @@ function handleAction(client, formData, conf) { assert(_.isObject(conf)); assert(_.isString(conf.action)); - var actionAsset = asset.parseAsset(conf.action); + const actionAsset = asset.parseAsset(conf.action); assert(_.isObject(actionAsset)); switch(actionAsset.type) { @@ -245,84 +245,3 @@ function handleNext(client, nextSpec, conf) { break; } } - - - -// :TODO: Seems better in theme.js, but that includes ViewController...which would then include theme.js -// ...theme.js only brings in VC to create themed pause prompt. Perhaps that should live elsewhere -/* -function applyGeneralThemeCustomization(options) { - // - // options.name - // options.client - // options.type - // options.config - // - assert(_.isString(options.name)); - assert(_.isObject(options.client)); - assert("menus" === options.type || "prompts" === options.type); - - if(_.has(options.client.currentTheme, [ 'customization', options.type, options.name ])) { - var themeConfig = options.client.currentTheme.customization[options.type][options.name]; - - if(themeConfig.config) { - Object.keys(themeConfig.config).forEach(function confEntry(conf) { - if(options.config[conf]) { - _.defaultsDeep(options.config[conf], themeConfig.config[conf]); - } else { - options.config[conf] = themeConfig.config[conf]; - } - }); - } - } -} -*/ - -/* -function applyMciThemeCustomization(options) { - // - // options.name : menu/prompt name - // options.mci : menu/prompt .mci section - // options.client : client - // options.type : menu|prompt - // options.formId : (optional) form ID in cases where multiple forms may exist wanting their own customization - // - // In the case of formId, the theme must include the ID as well, e.g.: - // { - // ... - // "2" : { - // "TL1" : { ... } - // } - // } - // - assert(_.isString(options.name)); - assert("menus" === options.type || "prompts" === options.type); - assert(_.isObject(options.client)); - - if(_.isUndefined(options.mci)) { - options.mci = {}; - } - - if(_.has(options.client.currentTheme, [ 'customization', options.type, options.name ])) { - var themeConfig = options.client.currentTheme.customization[options.type][options.name]; - - if(options.formId && _.has(themeConfig, options.formId.toString())) { - // form ID found - use exact match - themeConfig = themeConfig[options.formId]; - } - - if(themeConfig.mci) { - Object.keys(themeConfig.mci).forEach(function mciEntry(mci) { - // :TODO: a better way to do this? - if(options.mci[mci]) { - _.defaults(options.mci[mci], themeConfig.mci[mci]); - } else { - options.mci[mci] = themeConfig.mci[mci]; - } - }); - } - } - - // :TODO: apply generic stuff, e.g. "VM" (vs "VM1") -} -*/ \ No newline at end of file diff --git a/core/message.js b/core/message.js index 4b99fcfc..cdbfe67f 100644 --- a/core/message.js +++ b/core/message.js @@ -16,7 +16,7 @@ function Message(options) { options = options || {}; this.messageId = options.messageId || 0; // always generated @ persist - this.areaName = options.areaName || Message.WellKnownAreaNames.Invalid; + this.areaTag = options.areaTag || Message.WellKnownAreaTags.Invalid; this.uuid = uuid.v1(); this.replyToMsgId = options.replyToMsgId || 0; this.toUserName = options.toUserName || ''; @@ -55,7 +55,7 @@ function Message(options) { }; this.isPrivate = function() { - return this.areaName === Message.WellKnownAreaNames.Private ? true : false; + return this.areaTag === Message.WellKnownAreaTags.Private ? true : false; }; this.getMessageTimestampString = function(ts) { @@ -80,7 +80,7 @@ function Message(options) { */ } -Message.WellKnownAreaNames = { +Message.WellKnownAreaTags = { Invalid : '', Private : 'private_mail', Bulletin : 'local_bulletin', @@ -104,16 +104,21 @@ Message.SystemMetaNames = { LocalFromUserID : 'local_from_user_id', }; -Message.FtnPropertyNames = { - FtnCost : 'ftn_cost', +Message.FtnPropertyNames = { FtnOrigNode : 'ftn_orig_node', FtnDestNode : 'ftn_dest_node', FtnOrigNetwork : 'ftn_orig_network', FtnDestNetwork : 'ftn_dest_network', + FtnAttrFlags1 : 'ftn_attr_flags1', + FtnAttrFlags2 : 'ftn_attr_flags2', + FtnCost : 'ftn_cost', FtnOrigZone : 'ftn_orig_zone', FtnDestZone : 'ftn_dest_zone', FtnOrigPoint : 'ftn_orig_point', FtnDestPoint : 'ftn_dest_point', + + + FtnAttribute : 'ftn_attribute', FtnTearLine : 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001 @@ -141,7 +146,7 @@ Message.prototype.load = function(options, cb) { [ function loadMessage(callback) { msgDb.get( - 'SELECT message_id, area_name, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, ' + + 'SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, ' + 'message, modified_timestamp, view_count ' + 'FROM message ' + 'WHERE message_uuid=? ' + @@ -149,7 +154,7 @@ Message.prototype.load = function(options, cb) { [ options.uuid ], function row(err, msgRow) { self.messageId = msgRow.message_id; - self.areaName = msgRow.area_name; + self.areaTag = msgRow.area_tag; self.messageUuid = msgRow.message_uuid; self.replyToMsgId = msgRow.reply_to_message_id; self.toUserName = msgRow.to_user_name; @@ -202,8 +207,8 @@ Message.prototype.persist = function(cb) { }, function storeMessage(callback) { msgDb.run( - 'INSERT INTO message (area_name, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?);', [ self.areaName, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, self.getMessageTimestampString(self.modTimestamp) ], + 'INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?);', [ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, self.getMessageTimestampString(self.modTimestamp) ], function msgInsert(err) { if(!err) { self.messageId = this.lastID; diff --git a/core/message_area.js b/core/message_area.js index 53aec52b..957a0e37 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -5,100 +5,275 @@ var msgDb = require('./database.js').dbs.message; var Config = require('./config.js').config; var Message = require('./message.js'); var Log = require('./logger.js').log; +var checkAcs = require('./acs_util.js').checkAcs; var async = require('async'); var _ = require('lodash'); var assert = require('assert'); -exports.getAvailableMessageAreas = getAvailableMessageAreas; -exports.getDefaultMessageArea = getDefaultMessageArea; -exports.getMessageAreaByName = getMessageAreaByName; +exports.getAvailableMessageConferences = getAvailableMessageConferences; +exports.getSortedAvailMessageConferences = getSortedAvailMessageConferences; +exports.getAvailableMessageAreasByConfTag = getAvailableMessageAreasByConfTag; +exports.getSortedAvailMessageAreasByConfTag = getSortedAvailMessageAreasByConfTag; +exports.getDefaultMessageConferenceTag = getDefaultMessageConferenceTag; +exports.getDefaultMessageAreaTagByConfTag = getDefaultMessageAreaTagByConfTag; +exports.getMessageConferenceByTag = getMessageConferenceByTag; +exports.getMessageAreaByTag = getMessageAreaByTag; +exports.changeMessageConference = changeMessageConference; exports.changeMessageArea = changeMessageArea; exports.getMessageListForArea = getMessageListForArea; exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; exports.getMessageAreaLastReadId = getMessageAreaLastReadId; exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; -function getAvailableMessageAreas(options) { - // example: [ { "name" : "local_music", "desc" : "Music Discussion", "groups" : ["somegroup"] }, ... ] - options = options || {}; +const CONF_AREA_RW_ACS_DEFAULT = 'GM[users]'; +const AREA_MANAGE_ACS_DEFAULT = 'GM[sysops]'; - var areas = Config.messages.areas; - var avail = []; - for(var i = 0; i < areas.length; ++i) { - if(true !== options.includePrivate && - Message.WellKnownAreaNames.Private === areas[i].name) - { - continue; - } +const AREA_ACS_DEFAULT = { + read : CONF_AREA_RW_ACS_DEFAULT, + write : CONF_AREA_RW_ACS_DEFAULT, + manage : AREA_MANAGE_ACS_DEFAULT, +}; - avail.push(areas[i]); - } - - return avail; +function getAvailableMessageConferences(client, options) { + options = options || { includeSystemInternal : false }; + + // perform ACS check per conf & omit system_internal if desired + return _.omit(Config.messageConferences, (v, k) => { + if(!options.includeSystemInternal && 'system_internal' === k) { + return true; + } + + const readAcs = v.acs || CONF_AREA_RW_ACS_DEFAULT; + return !checkAcs(client, readAcs); + }); } -function getDefaultMessageArea() { - // - // Return first non-private/etc. area name. This will be from config.hjson - // - return getAvailableMessageAreas()[0]; - /* - var avail = getAvailableMessageAreas(); - for(var i = 0; i < avail.length; ++i) { - if(Message.WellKnownAreaNames.Private !== avail[i].name) { - return avail[i]; - } - } - */ -} - -function getMessageAreaByName(areaName) { - areaName = areaName.toLowerCase(); - - var availAreas = getAvailableMessageAreas( { includePrivate : true } ); - var index = _.findIndex(availAreas, function pred(an) { - return an.name == areaName; +function getSortedAvailMessageConferences(client, options) { + var sorted = _.map(getAvailableMessageConferences(client, options), (v, k) => { + return { + confTag : k, + conf : v, + }; + }); + + sorted.sort((a, b) => { + return a.conf.name.localeCompare(b.conf.name); }); - if(index > -1) { - return availAreas[index]; - } + return sorted; } -function changeMessageArea(client, areaName, cb) { +// Return an *object* of available areas within |confTag| +function getAvailableMessageAreasByConfTag(confTag, options) { + options = options || {}; + + if(_.has(Config.messageConferences, [ confTag, 'areas' ])) { + const areas = Config.messageConferences[confTag].areas; + + if(!options.client || true === options.noAcsCheck) { + // everything - no ACS checks + return areas; + } else { + // perform ACS check per area + return _.omit(areas, (v, k) => { + const readAcs = _.has(v, 'acs.read') ? v.acs.read : CONF_AREA_RW_ACS_DEFAULT; + return !checkAcs(options.client, readAcs); + }); + } + } +} + +function getSortedAvailMessageAreasByConfTag(confTag, options) { + const areas = getAvailableMessageAreasByConfTag(confTag, options); + + // :TODO: should probably be using localeCompare / sort + return _.sortBy(_.map(areas, (v, k) => { + return { + areaTag : k, + area : v, + }; + }), o => o.area.name); // sort by name +} + +function getDefaultMessageConferenceTag(client, disableAcsCheck) { + // + // Find the first conference marked 'default'. If found, + // inspect |client| against *read* ACS using defaults if not + // specified. + // + // If the above fails, just go down the list until we get one + // that passes. + // + // It's possible that we end up with nothing here! + // + // Note that built in 'system_internal' is always ommited here + // + let defaultConf = _.findKey(Config.messageConferences, o => o.default); + if(defaultConf) { + const acs = Config.messageConferences[defaultConf].acs || CONF_AREA_RW_ACS_DEFAULT; + if(true === disableAcsCheck || checkAcs(client, acs)) { + return defaultConf; + } + } + + // just use anything we can + defaultConf = _.findKey(Config.messageConferences, (o, k) => { + const acs = o.acs || CONF_AREA_RW_ACS_DEFAULT; + return 'system_internal' !== k && (true === disableAcsCheck || checkAcs(client, acs)); + }); + + return defaultConf; +} + +function getDefaultMessageAreaTagByConfTag(client, confTag, disableAcsCheck) { + // + // Similar to finding the default conference: + // Find the first entry marked 'default', if any. If found, check | client| against + // *read* ACS. If this fails, just find the first one we can that passes checks. + // + // It's possible that we end up with nothing! + // + confTag = confTag || getDefaultMessageConferenceTag(client); + + if(confTag && _.has(Config.messageConferences, [ confTag, 'areas' ])) { + const areaPool = Config.messageConferences[confTag].areas; + let defaultArea = _.findKey(areaPool, o => o.default); + if(defaultArea) { + const readAcs = _.has(areaPool, [ defaultArea, 'acs', 'read' ]) ? areaPool[defaultArea].acs.read : AREA_ACS_DEFAULT.read; + if(true === disableAcsCheck || checkAcs(client, readAcs)) { + return defaultArea; + } + } + + defaultArea = _.findKey(areaPool, (o, k) => { + const readAcs = _.has(areaPool, [ defaultArea, 'acs', 'read' ]) ? areaPool[defaultArea].acs.read : AREA_ACS_DEFAULT.read; + return (true === disableAcsCheck || checkAcs(client, readAcs)); + }); + + return defaultArea; + } +} + +function getMessageConferenceByTag(confTag) { + return Config.messageConferences[confTag]; +} + +function getMessageAreaByTag(areaTag, optionalConfTag) { + const confs = Config.messageConferences; + + if(_.isString(optionalConfTag)) { + if(_.has(confs, [ optionalConfTag, 'areas', areaTag ])) { + return confs[optionalConfTag].areas[areaTag]; + } + } else { + // + // No confTag to work with - we'll have to search through them all + // + var area; + _.forEach(confs, (v, k) => { + if(_.has(v, [ 'areas', areaTag ])) { + area = v.areas[areaTag]; + return false; // stop iteration + } + }); + + return area; + } +} + +function changeMessageConference(client, confTag, cb) { + async.waterfall( + [ + function getConf(callback) { + const conf = getMessageConferenceByTag(confTag); + + if(conf) { + callback(null, conf); + } else { + callback(new Error('Invalid message conference tag')); + } + }, + function getDefaultAreaInConf(conf, callback) { + const areaTag = getDefaultMessageAreaTagByConfTag(client, confTag); + const area = getMessageAreaByTag(areaTag, confTag); + + if(area) { + callback(null, conf, { areaTag : areaTag, area : area } ); + } else { + callback(new Error('No available areas for this user in conference')); + } + }, + function validateAccess(conf, areaInfo, callback) { + const confAcs = conf.acs || CONF_AREA_RW_ACS_DEFAULT; + + if(!checkAcs(client, confAcs)) { + callback(new Error('User does not have access to this conference')); + } else { + const areaAcs = _.has(areaInfo, 'area.acs.read') ? areaInfo.area.acs.read : CONF_AREA_RW_ACS_DEFAULT; + if(!checkAcs(client, areaAcs)) { + callback(new Error('User does not have access to default area in this conference')); + } else { + callback(null, conf, areaInfo); + } + } + }, + function changeConferenceAndArea(conf, areaInfo, callback) { + const newProps = { + message_conf_tag : confTag, + message_area_tag : areaInfo.areaTag, + }; + client.user.persistProperties(newProps, err => { + callback(err, conf, areaInfo); + }); + }, + ], + function complete(err, conf, areaInfo) { + if(!err) { + client.log.info( { confTag : confTag, confName : conf.name, areaTag : areaInfo.areaTag }, 'Current message conference changed'); + } else { + client.log.warn( { confTag : confTag, error : err.message }, 'Could not change message conference'); + } + cb(err); + } + ); +} + +function changeMessageArea(client, areaTag, cb) { async.waterfall( [ function getArea(callback) { - var area = getMessageAreaByName(areaName); + const area = getMessageAreaByTag(areaTag); if(area) { callback(null, area); } else { - callback(new Error('Invalid message area')); + callback(new Error('Invalid message area tag')); } }, function validateAccess(area, callback) { - if(_.isArray(area.groups) && ! - client.user.isGroupMember(area.groups)) - { + // + // Need at least *read* to access the area + // + const readAcs = _.has(area, 'acs.read') ? area.acs.read : CONF_AREA_RW_ACS_DEFAULT; + if(!checkAcs(client, readAcs)) { callback(new Error('User does not have access to this area')); } else { callback(null, area); } }, function changeArea(area, callback) { - client.user.persistProperty('message_area_name', area.name, function persisted(err) { + client.user.persistProperty('message_area_tag', areaTag, function persisted(err) { callback(err, area); }); } ], function complete(err, area) { if(!err) { - client.log.info( area, 'Current message area changed'); + client.log.info( { areaTag : areaTag, area : area }, 'Current message area changed'); } else { - client.log.warn( { area : area, error : err.message }, 'Could not change message area'); + client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change message area'); } cb(err); @@ -119,9 +294,9 @@ function getMessageFromRow(row) { }; } -function getNewMessagesInAreaForUser(userId, areaName, cb) { +function getNewMessagesInAreaForUser(userId, areaTag, cb) { // - // If |areaName| is Message.WellKnownAreaNames.Private, + // If |areaTag| is Message.WellKnownAreaTags.Private, // only messages addressed to |userId| should be returned. // // Only messages > lastMessageId should be returned @@ -131,7 +306,7 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { async.waterfall( [ function getLastMessageId(callback) { - getMessageAreaLastReadId(userId, areaName, function fetched(err, lastMessageId) { + getMessageAreaLastReadId(userId, areaTag, function fetched(err, lastMessageId) { callback(null, lastMessageId || 0); // note: willingly ignoring any errors here! }); }, @@ -139,9 +314,9 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { var sql = 'SELECT message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count ' + 'FROM message ' + - 'WHERE area_name="' + areaName + '" AND message_id > ' + lastMessageId; + 'WHERE area_tag ="' + areaTag + '" AND message_id > ' + lastMessageId; - if(Message.WellKnownAreaNames.Private === areaName) { + if(Message.WellKnownAreaTags.Private === areaTag) { sql += ' AND message_id in (' + 'SELECT message_id from message_meta where meta_category=' + Message.MetaCategories.System + @@ -150,8 +325,6 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { sql += ' ORDER BY message_id;'; - console.log(sql) - msgDb.each(sql, function msgRow(err, row) { if(!err) { msgList.push(getMessageFromRow(row)); @@ -160,18 +333,17 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { } ], function complete(err) { - console.log(msgList) cb(err, msgList); } ); } -function getMessageListForArea(options, areaName, cb) { +function getMessageListForArea(options, areaTag, cb) { // // options.client (required) // - options.client.log.debug( { areaName : areaName }, 'Fetching available messages'); + options.client.log.debug( { areaTag : areaTag }, 'Fetching available messages'); assert(_.isObject(options.client)); @@ -193,9 +365,9 @@ function getMessageListForArea(options, areaName, cb) { msgDb.each( 'SELECT message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count ' + 'FROM message ' + - 'WHERE area_name=? ' + + 'WHERE area_tag = ? ' + 'ORDER BY message_id;', - [ areaName.toLowerCase() ], + [ areaTag.toLowerCase() ], function msgRow(err, row) { if(!err) { msgList.push(getMessageFromRow(row)); @@ -214,24 +386,24 @@ function getMessageListForArea(options, areaName, cb) { ); } -function getMessageAreaLastReadId(userId, areaName, cb) { +function getMessageAreaLastReadId(userId, areaTag, cb) { msgDb.get( 'SELECT message_id ' + 'FROM user_message_area_last_read ' + - 'WHERE user_id = ? AND area_name = ?;', - [ userId, areaName ], + 'WHERE user_id = ? AND area_tag = ?;', + [ userId, areaTag ], function complete(err, row) { cb(err, row ? row.message_id : 0); } ); } -function updateMessageAreaLastReadId(userId, areaName, messageId, cb) { +function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { // :TODO: likely a better way to do this... async.waterfall( [ function getCurrent(callback) { - getMessageAreaLastReadId(userId, areaName, function result(err, lastId) { + getMessageAreaLastReadId(userId, areaTag, function result(err, lastId) { lastId = lastId || 0; callback(null, lastId); // ignore errors as we default to 0 }); @@ -239,25 +411,29 @@ function updateMessageAreaLastReadId(userId, areaName, messageId, cb) { function update(lastId, callback) { if(messageId > lastId) { msgDb.run( - 'REPLACE INTO user_message_area_last_read (user_id, area_name, message_id) ' + + 'REPLACE INTO user_message_area_last_read (user_id, area_tag, message_id) ' + 'VALUES (?, ?, ?);', - [ userId, areaName, messageId ], - callback + [ userId, areaTag, messageId ], + function written(err) { + callback(err, true); // true=didUpdate + } ); } else { callback(null); } } ], - function complete(err) { + function complete(err, didUpdate) { if(err) { Log.debug( - { error : err.toString(), userId : userId, areaName : areaName, messageId : messageId }, + { error : err.toString(), userId : userId, areaTag : areaTag, messageId : messageId }, 'Failed updating area last read ID'); } else { - Log.trace( - { userId : userId, areaName : areaName, messageId : messageId }, - 'Area last read ID updated'); + if(true === didUpdate) { + Log.trace( + { userId : userId, areaTag : areaTag, messageId : messageId }, + 'Area last read ID updated'); + } } cb(err); } diff --git a/core/module_util.js b/core/module_util.js index 55dd61ed..c66155f1 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -14,38 +14,40 @@ exports.loadModuleEx = loadModuleEx; exports.loadModule = loadModule; exports.loadModulesForCategory = loadModulesForCategory; + function loadModuleEx(options, cb) { assert(_.isObject(options)); assert(_.isString(options.name)); assert(_.isString(options.path)); - var modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; + const modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; if(_.isObject(modConfig) && false === modConfig.enabled) { cb(new Error('Module "' + options.name + '" is disabled')); return; } + var mod; try { - var mod = require(paths.join(options.path, options.name + '.js')); - - if(!_.isObject(mod.moduleInfo)) { - cb(new Error('Module is missing "moduleInfo" section')); - return; - } - - if(!_.isFunction(mod.getModule)) { - cb(new Error('Invalid or missing "getModule" method for module!')); - return; - } - - // Safe configuration, if any, for convience to the module - mod.runtime = { config : modConfig }; - - cb(null, mod); + mod = require(paths.join(options.path, options.name + '.js')); } catch(e) { cb(e); } + + if(!_.isObject(mod.moduleInfo)) { + cb(new Error('Module is missing "moduleInfo" section')); + return; + } + + if(!_.isFunction(mod.getModule)) { + cb(new Error('Invalid or missing "getModule" method for module!')); + return; + } + + // Ref configuration, if any, for convience to the module + mod.runtime = { config : modConfig }; + + cb(null, mod); } function loadModule(name, category, cb) { @@ -61,7 +63,7 @@ function loadModule(name, category, cb) { }); } -function loadModulesForCategory(category, cb) { +function loadModulesForCategory(category, iterator) { var path = Config.paths[category]; fs.readdir(path, function onFiles(err, files) { @@ -72,8 +74,7 @@ function loadModulesForCategory(category, cb) { var filtered = files.filter(function onFilter(file) { return '.js' === paths.extname(file); }); filtered.forEach(function onFile(file) { - var modName = paths.basename(file, '.js'); - loadModule(paths.basename(file, '.js'), category, cb); + loadModule(paths.basename(file, '.js'), category, iterator); }); }); } diff --git a/core/msg_network_module.js b/core/msg_network_module.js new file mode 100644 index 00000000..8d4a9e0c --- /dev/null +++ b/core/msg_network_module.js @@ -0,0 +1,25 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +var PluginModule = require('./plugin_module.js').PluginModule; + +exports.MessageNetworkModule = MessageNetworkModule; + +function MessageNetworkModule() { + PluginModule.call(this); +} + +require('util').inherits(MessageNetworkModule, PluginModule); + +MessageNetworkModule.prototype.startup = function(cb) { + cb(null); +}; + +MessageNetworkModule.prototype.shutdown = function(cb) { + cb(null); +}; + +MessageNetworkModule.prototype.record = function(message, cb) { + cb(null); +}; \ No newline at end of file diff --git a/core/msg_networks/ftn_msg_network_module.js b/core/msg_networks/ftn_msg_network_module.js new file mode 100644 index 00000000..f81aef9f --- /dev/null +++ b/core/msg_networks/ftn_msg_network_module.js @@ -0,0 +1,27 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +var MessageNetworkModule = require('./msg_network_module.js').MessageNetworkModule; + +function FTNMessageNetworkModule() { + MessageNetworkModule.call(this); +} + +require('util').inherits(FTNMessageNetworkModule, MessageNetworkModule); + +FTNMessageNetworkModule.prototype.startup = function(cb) { + cb(null); +}; + +FTNMessageNetworkModule.prototype.shutdown = function(cb) { + cb(null); +}; + +FTNMessageNetworkModule.prototype.record = function(message, cb) { + cb(null); + + // :TODO: should perhaps record in batches - e.g. start an event, record + // to temp location until time is hit or N achieved such that if multiple + // messages are being created a .FTN file is not made for each one +}; \ No newline at end of file diff --git a/core/new_scan.js b/core/new_scan.js index 3201402a..2b1f8a1f 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -7,6 +7,7 @@ var Message = require('./message.js'); var MenuModule = require('./menu_module.js').MenuModule; var ViewController = require('../core/view_controller.js').ViewController; +var _ = require('lodash'); var async = require('async'); exports.moduleInfo = { @@ -36,10 +37,11 @@ function NewScanModule(options) { var self = this; var config = this.menuConfig.config; - this.currentStep = 'messageAreas'; - this.currentScanAux = 0; // e.g. Message.WellKnownAreaNames.Private when currentSteps = messageAreas + this.currentStep = 'messageConferences'; + this.currentScanAux = {}; - this.scanStartFmt = config.scanStartFmt || 'Scanning {desc}...'; + // :TODO: Make this conf/area specific: + this.scanStartFmt = config.scanStartFmt || 'Scanning {confName} - {areaName}...'; this.scanFinishNoneFmt = config.scanFinishNoneFmt || 'Nothing new'; this.scanFinishNewFmt = config.scanFinishNewFmt || '{count} entries found'; this.scanCompleteMsg = config.scanCompleteMsg || 'Finished newscan'; @@ -57,10 +59,65 @@ function NewScanModule(options) { if(view) { } }; + + this.newScanMessageConference = function(cb) { + // lazy init + if(!self.sortedMessageConfs) { + const getAvailOpts = { includeSystemInternal : true }; // find new private messages, bulletins, etc. - this.newScanMessageArea = function(cb) { - var availMsgAreas = msgArea.getAvailableMessageAreas( { includePrivate : true } ); - var currentArea = availMsgAreas[self.currentScanAux]; + self.sortedMessageConfs = _.map(msgArea.getAvailableMessageConferences(self.client, getAvailOpts), (v, k) => { + return { + confTag : k, + conf : v, + }; + }); + + // + // Sort conferences by name, other than 'system_internal' which should + // always come first such that we display private mails/etc. before + // other conferences & areas + // + self.sortedMessageConfs.sort((a, b) => { + if('system_internal' === a.confTag) { + return -1; + } else { + return a.conf.name.localeCompare(b.conf.name); + } + }); + + self.currentScanAux.conf = self.currentScanAux.conf || 0; + self.currentScanAux.area = self.currentScanAux.area || 0; + } + + const currentConf = self.sortedMessageConfs[self.currentScanAux.conf]; + + async.series( + [ + function scanArea(callback) { + //self.currentScanAux.area = self.currentScanAux.area || 0; + + self.newScanMessageArea(currentConf, function areaScanComplete(err) { + if(self.sortedMessageConfs.length > self.currentScanAux.conf + 1) { + self.currentScanAux.conf += 1; + self.currentScanAux.area = 0; + + self.newScanMessageConference(cb); // recursive to next conf + //callback(null); + } else { + self.updateScanStatus(self.scanCompleteMsg); + callback(new Error('No more conferences')); + } + }); + } + ], + cb + ); + }; + + this.newScanMessageArea = function(conf, cb) { + // :TODO: it would be nice to cache this - must be done by conf! + const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : self.client } ); + const currentArea = sortedAreas[self.currentScanAux.area]; // // Scan and update index until we find something. If results are found, @@ -70,8 +127,8 @@ function NewScanModule(options) { [ function checkAndUpdateIndex(callback) { // Advance to next area if possible - if(availMsgAreas.length >= self.currentScanAux + 1) { - self.currentScanAux += 1; + if(sortedAreas.length >= self.currentScanAux.area + 1) { + self.currentScanAux.area += 1; callback(null); } else { self.updateScanStatus(self.scanCompleteMsg); @@ -80,22 +137,30 @@ function NewScanModule(options) { }, function updateStatusScanStarted(callback) { self.updateScanStatus(self.scanStartFmt.format({ - desc : currentArea.desc, + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + areaDesc : currentArea.area.desc, })); callback(null); }, function newScanAreaAndGetMessages(callback) { msgArea.getNewMessagesInAreaForUser( - self.client.user.userId, currentArea.name, function msgs(err, msgList) { + self.client.user.userId, currentArea.areaTag, function msgs(err, msgList) { if(!err) { if(0 === msgList.length) { self.updateScanStatus(self.scanFinishNoneFmt.format({ - desc : currentArea.desc, + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + areaDesc : currentArea.area.desc, })); } else { self.updateScanStatus(self.scanFinishNewFmt.format({ - desc : currentArea.desc, - count : msgList.length, + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + count : msgList.length, })); } } @@ -107,14 +172,14 @@ function NewScanModule(options) { if(msgList && msgList.length > 0) { var nextModuleOpts = { extraArgs: { - messageAreaName : currentArea.name, + messageAreaTag : currentArea.areaTag, messageList : msgList, } }; self.gotoMenu(config.newScanMessageList || 'newScanMessageList', nextModuleOpts); } else { - self.newScanMessageArea(cb); + self.newScanMessageArea(conf, cb); } } ], @@ -161,10 +226,10 @@ NewScanModule.prototype.mciReady = function(mciData, cb) { }, function performCurrentStepScan(callback) { switch(self.currentStep) { - case 'messageAreas' : - self.newScanMessageArea(function scanComplete(err) { - callback(null); // finished - }); + case 'messageConferences' : + self.newScanMessageConference(function scanComplete(err) { + callback(null); // finished + }); break; default : @@ -180,9 +245,3 @@ NewScanModule.prototype.mciReady = function(mciData, cb) { } ); }; - -/* -NewScanModule.prototype.finishedLoading = function() { - NewScanModule.super_.prototype.finishedLoading.call(this); -}; -*/ \ No newline at end of file diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 2d9d4a7d..b8d7bbbc 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -3,7 +3,7 @@ var Config = require('./config.js').config; var Log = require('./logger.js').log; -var getMessageAreaByName = require('./message_area.js').getMessageAreaByName; +var getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag; var clientConnections = require('./client_connections.js'); var sysProp = require('./system_property.js'); @@ -63,10 +63,15 @@ function getPredefinedMCIValue(client, code) { return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : ''; }, - MA : function messageAreaDescription() { - var area = getMessageAreaByName(client.user.properties.message_area_name); - return area ? area.desc : ''; + MA : function messageAreaName() { + const area = getMessageAreaByTag(client.user.properties.message_area_tag); + return area ? area.name : ''; }, + + ML : function messageAreaDescription() { + const area = getMessageAreaByTag(client.user.properties.message_area_tag); + return area ? area.desc : ''; + }, SH : function termHeight() { return client.term.termHeight.toString(); }, SW : function termWidth() { return client.term.termWidth.toString(); }, diff --git a/core/sauce.js b/core/sauce.js new file mode 100644 index 00000000..6db85533 --- /dev/null +++ b/core/sauce.js @@ -0,0 +1,165 @@ +/* jslint node: true */ +'use strict'; + +var binary = require('binary'); +var iconv = require('iconv-lite'); + +exports.readSAUCE = readSAUCE; + + +const SAUCE_SIZE = 128; +const SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' +const COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' + +// :TODO: SAUCE should be a class +// - with getFontName() +// - ...other methods + +// +// See +// http://www.acid.org/info/sauce/sauce.htm +// +function readSAUCE(data, cb) { + if(data.length < SAUCE_SIZE) { + cb(new Error('No SAUCE record present')); + return; + } + + var offset = data.length - SAUCE_SIZE; + var sauceRec = data.slice(offset); + + binary.parse(sauceRec) + .buffer('id', 5) + .buffer('version', 2) + .buffer('title', 35) + .buffer('author', 20) + .buffer('group', 20) + .buffer('date', 8) + .word32lu('fileSize') + .word8('dataType') + .word8('fileType') + .word16lu('tinfo1') + .word16lu('tinfo2') + .word16lu('tinfo3') + .word16lu('tinfo4') + .word8('numComments') + .word8('flags') + .buffer('tinfos', 22) // SAUCE 00.5 + .tap(function onVars(vars) { + + if(!SAUCE_ID.equals(vars.id)) { + cb(new Error('No SAUCE record present')); + return; + } + + var ver = iconv.decode(vars.version, 'cp437'); + + if('00' !== ver) { + cb(new Error('Unsupported SAUCE version: ' + ver)); + return; + } + + var sauce = { + id : iconv.decode(vars.id, 'cp437'), + version : iconv.decode(vars.version, 'cp437').trim(), + title : iconv.decode(vars.title, 'cp437').trim(), + author : iconv.decode(vars.author, 'cp437').trim(), + group : iconv.decode(vars.group, 'cp437').trim(), + date : iconv.decode(vars.date, 'cp437').trim(), + fileSize : vars.fileSize, + dataType : vars.dataType, + fileType : vars.fileType, + tinfo1 : vars.tinfo1, + tinfo2 : vars.tinfo2, + tinfo3 : vars.tinfo3, + tinfo4 : vars.tinfo4, + numComments : vars.numComments, + flags : vars.flags, + tinfos : vars.tinfos, + }; + + var dt = SAUCE_DATA_TYPES[sauce.dataType]; + if(dt && dt.parser) { + sauce[dt.name] = dt.parser(sauce); + } + + cb(null, sauce); + }); +} + +// :TODO: These need completed: +var SAUCE_DATA_TYPES = {}; +SAUCE_DATA_TYPES[0] = { name : 'None' }; +SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; +SAUCE_DATA_TYPES[2] = 'Bitmap'; +SAUCE_DATA_TYPES[3] = 'Vector'; +SAUCE_DATA_TYPES[4] = 'Audio'; +SAUCE_DATA_TYPES[5] = 'BinaryText'; +SAUCE_DATA_TYPES[6] = 'XBin'; +SAUCE_DATA_TYPES[7] = 'Archive'; +SAUCE_DATA_TYPES[8] = 'Executable'; + +var SAUCE_CHARACTER_FILE_TYPES = {}; +SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; +SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; +SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; +SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; +SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; +SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; +SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; +SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; +SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; + +// +// Map of SAUCE font -> encoding hint +// +// Note that this is the same mapping that x84 uses. Be compatible! +// +var SAUCE_FONT_TO_ENCODING_HINT = { + 'Amiga MicroKnight' : 'amiga', + 'Amiga MicroKnight+' : 'amiga', + 'Amiga mOsOul' : 'amiga', + 'Amiga P0T-NOoDLE' : 'amiga', + 'Amiga Topaz 1' : 'amiga', + 'Amiga Topaz 1+' : 'amiga', + 'Amiga Topaz 2' : 'amiga', + 'Amiga Topaz 2+' : 'amiga', + 'Atari ATASCII' : 'atari', + 'IBM EGA43' : 'cp437', + 'IBM EGA' : 'cp437', + 'IBM VGA25G' : 'cp437', + 'IBM VGA50' : 'cp437', + 'IBM VGA' : 'cp437', +}; + +['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', +'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { + var codec = 'cp' + page; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; +}); + +function parseCharacterSAUCE(sauce) { + var result = {}; + + result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; + + if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { + // convience: create ansiFlags + sauce.ansiFlags = sauce.flags; + + var i = 0; + while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { + ++i; + } + var fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); + if(fontName.length > 0) { + result.fontName = fontName; + } + } + + return result; +} \ No newline at end of file diff --git a/core/standard_menu.js b/core/standard_menu.js index d38a84f7..9848acb5 100644 --- a/core/standard_menu.js +++ b/core/standard_menu.js @@ -18,8 +18,8 @@ function StandardMenuModule(menuConfig) { require('util').inherits(StandardMenuModule, MenuModule); -StandardMenuModule.prototype.enter = function(client) { - StandardMenuModule.super_.prototype.enter.call(this, client); +StandardMenuModule.prototype.enter = function() { + StandardMenuModule.super_.prototype.enter.call(this); }; StandardMenuModule.prototype.beforeArt = function() { diff --git a/core/theme.js b/core/theme.js index 29498434..f23c6481 100644 --- a/core/theme.js +++ b/core/theme.js @@ -203,13 +203,13 @@ function getMergedTheme(menuConfig, promptConfig, theme) { } } - [ 'menus', 'prompts' ].forEach(function areaEntry(areaName) { - _.keys(mergedTheme[areaName]).forEach(function menuEntry(menuName) { + [ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) { + _.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) { var createdFormSection = false; - var mergedThemeMenu = mergedTheme[areaName][menuName]; + var mergedThemeMenu = mergedTheme[sectionName][menuName]; - if(_.has(theme, [ 'customization', areaName, menuName ])) { - var menuTheme = theme.customization[areaName][menuName]; + if(_.has(theme, [ 'customization', sectionName, menuName ])) { + var menuTheme = theme.customization[sectionName][menuName]; // config block is direct assign/overwrite // :TODO: should probably be _.merge() @@ -217,7 +217,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) { mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config); } - if('menus' === areaName) { + if('menus' === sectionName) { if(_.isObject(mergedThemeMenu.form)) { getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) { applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey); @@ -233,7 +233,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) { createdFormSection = true; } } - } else if('prompts' === areaName) { + } else if('prompts' === sectionName) { // no 'form' or form keys for prompts -- direct to mci applyToForm(mergedThemeMenu, menuTheme); } @@ -247,7 +247,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) { // * There is/was no explicit 'form' section // * There is no 'prompt' specified // - if('menus' === areaName && !_.isString(mergedThemeMenu.prompt) && + if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) && (createdFormSection || !_.isObject(mergedThemeMenu.form))) { mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } ); diff --git a/core/user_login.js b/core/user_login.js index 72ca22e2..0c22490c 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -56,7 +56,7 @@ function userLogin(client, username, password, cb) { // update client logger with addition of username client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username }); - client.log.info('Successful login'); + client.log.info('Successful login'); async.parallel( [ diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index 6e290969..c220b557 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -27,7 +27,7 @@ function VerticalMenuView(options) { this.dimens.height = Math.min(self.dimens.height, self.client.term.termHeight - self.position.row); } - if(this.autoScale.width) { + if(self.autoScale.width) { var l = 0; self.items.forEach(function item(i) { if(i.text.length > l) { @@ -148,6 +148,17 @@ VerticalMenuView.prototype.setFocus = function(focused) { VerticalMenuView.prototype.setFocusItemIndex = function(index) { VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + + //this.updateViewVisibleItems(); + + // :TODO: |viewWindow| must be updated to reflect position change -- + // if > visibile then += by diff, if < visible + + if(this.focusedItemIndex > this.viewWindow.bottom) { + } else if (this.focusedItemIndex < this.viewWindow.top) { + // this.viewWindow.top--; +// this.viewWindow.bottom--; + } this.redraw(); }; diff --git a/core/view_controller.js b/core/view_controller.js index 7fbed5cd..8e4b36b9 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -488,7 +488,6 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) { assert(_.isObject(options.mciMap)); var self = this; - var promptName = _.isString(options.promptName) ? options.promptName : self.client.currentMenuModule.menuConfig.prompt; var promptConfig = _.isObject(options.config) ? options.config : self.client.currentMenuModule.menuConfig.promptConfig; var initialFocusId = 1; // default to first diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index dc9e339e..14a03042 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -16,15 +16,13 @@ return !isNaN(value) && user.getAge() >= value; }, AS : function accountStatus() { - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - return _.findIndex(value, function cmp(accStatus) { - return parseInt(accStatus, 10) === parseInt(user.properties.account_status, 10); - }) > -1; + const userAccountStatus = parseInt(user.properties.account_status, 10); + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(userAccountStatus) > -1; }, EC : function isEncoding() { switch(value) { @@ -53,7 +51,7 @@ // :TODO: implement me!! return false; }, - SC : function isSecerConnection() { + SC : function isSecureConnection() { return client.session.isSecure; }, ML : function minutesLeft() { @@ -81,28 +79,20 @@ return !isNaN(value) && client.term.termWidth >= value; }, ID : function isUserId(value) { - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - return _.findIndex(value, function cmp(uid) { - return user.userId === parseInt(uid, 10); - }) > -1; + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(user.userId) > -1; }, WD : function isOneOfDayOfWeek() { - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - var nowDayOfWeek = new Date().getDay(); - - return _.findIndex(value, function cmp(dow) { - return nowDayOfWeek === parseInt(dow, 10); - }) > -1; + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(new Date().getDay()) > -1; }, MM : function isMinutesPastMidnight() { // :TODO: return true if value is >= minutes past midnight sys time diff --git a/mods/abracadabra.js b/mods/abracadabra.js index 5e5ff34d..c5aaafa6 100644 --- a/mods/abracadabra.js +++ b/mods/abracadabra.js @@ -177,12 +177,6 @@ function AbracadabraModule(options) { require('util').inherits(AbracadabraModule, MenuModule); -/* -AbracadabraModule.prototype.enter = function(client) { - AbracadabraModule.super_.prototype.enter.call(this, client); -}; -*/ - AbracadabraModule.prototype.leave = function() { AbracadabraModule.super_.prototype.leave.call(this); diff --git a/mods/menu.hjson b/mods/menu.hjson index de4f40da..4008a121 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -393,7 +393,7 @@ }, editorMode: edit editorType: email - messageAreaName: private_mail + messageAreaTag: private_mail toUserId: 1 /* always to +op */ } form: { @@ -806,7 +806,7 @@ }, editorMode: edit editorType: email - messageAreaName: private_mail + messageAreaTag: private_mail toUserId: 1 /* always to +op */ } form: { @@ -1019,6 +1019,10 @@ value: { command: "P" } action: @menu:messageAreaNewPost } + { + value: { command: "J" } + action: @menu:messageAreaChangeCurrentConference + } { value: { command: "C" } action: @menu:messageAreaChangeCurrentArea @@ -1041,7 +1045,39 @@ } ] } + + messageAreaChangeCurrentConference: { + art: CCHANGE + module: msg_conf_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: conf + } + } + submit: { + *: [ + { + value: { conf: null } + action: @method:changeConference + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + messageAreaChangeCurrentArea: { + // :TODO: rename this art to ACHANGE art: CHANGE module: msg_area_list form: { @@ -1070,6 +1106,7 @@ } } } + messageAreaMessageList: { module: msg_list art: MSGLIST diff --git a/mods/msg_area_list.js b/mods/msg_area_list.js index 4935e271..a984e9ff 100644 --- a/mods/msg_area_list.js +++ b/mods/msg_area_list.js @@ -5,7 +5,6 @@ var MenuModule = require('../core/menu_module.js').MenuModule; var ViewController = require('../core/view_controller.js').ViewController; var messageArea = require('../core/message_area.js'); var strUtil = require('../core/string_util.js'); -//var msgDb = require('./database.js').dbs.message; var async = require('async'); var assert = require('assert'); @@ -43,30 +42,33 @@ function MessageAreaListModule(options) { var self = this; - this.messageAreas = messageArea.getAvailableMessageAreas(); + this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag( + self.client.user.properties.message_conf_tag, + { client : self.client } + ); this.menuMethods = { changeArea : function(formData, extraArgs) { if(1 === formData.submitId) { - var areaName = self.messageAreas[formData.value.area].name; + const areaTag = self.messageAreas[formData.value.area].areaTag; - messageArea.changeMessageArea(self.client, areaName, function areaChanged(err) { - if(err) { - self.client.term.pipeWrite('\n|00Cannot change area: ' + err.message + '\n'); + messageArea.changeMessageArea(self.client, areaTag, function areaChanged(err) { + if(err) { + self.client.term.pipeWrite('\n|00Cannot change area: ' + err.message + '\n'); - setTimeout(function timeout() { - self.prevMenu(); - }, 1000); - } else { - self.prevMenu(); - } - }); + setTimeout(function timeout() { + self.prevMenu(); + }, 1000); + } else { + self.prevMenu(); + } + }); } } }; this.setViewText = function(id, text) { - var v = self.viewControllers.areaList.getView(id); + const v = self.viewControllers.areaList.getView(id); if(v) { v.setText(text); } @@ -78,7 +80,7 @@ require('util').inherits(MessageAreaListModule, MenuModule); MessageAreaListModule.prototype.mciReady = function(mciData, cb) { var self = this; - var vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); + const vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); async.series( [ @@ -99,26 +101,29 @@ MessageAreaListModule.prototype.mciReady = function(mciData, cb) { }); }, function populateAreaListView(callback) { - var listFormat = self.menuConfig.config.listFormat || '{index} ) - {desc}'; - var focusListFormat = self.menuConfig.config.focusListFormat || listFormat; - - var areaListItems = []; - var focusListItems = []; - - // :TODO: use _.map() here - for(var i = 0; i < self.messageAreas.length; ++i) { - areaListItems.push(listFormat.format( - { index : i, name : self.messageAreas[i].name, desc : self.messageAreas[i].desc } ) - ); - focusListItems.push(focusListFormat.format( - { index : i, name : self.messageAreas[i].name, desc : self.messageAreas[i].desc } ) - ); - } - - var areaListView = vc.getView(1); - - areaListView.setItems(areaListItems); - areaListView.setFocusItems(focusListItems); + const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}'; + const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; + + const areaListView = vc.getView(1); + let i = 1; + areaListView.setItems(_.map(self.messageAreas, v => { + return listFormat.format({ + index : i++, + areaTag : v.area.areaTag, + name : v.area.name, + desc : v.area.desc, + }); + })); + + i = 1; + areaListView.setFocusItems(_.map(self.messageAreas, v => { + return focusListFormat.format({ + index : i++, + areaTag : v.area.areaTag, + name : v.area.name, + desc : v.area.desc, + }) + })); areaListView.redraw(); diff --git a/mods/msg_area_post_fse.js b/mods/msg_area_post_fse.js index 4723da68..2b0c488d 100644 --- a/mods/msg_area_post_fse.js +++ b/mods/msg_area_post_fse.js @@ -56,11 +56,11 @@ function AreaPostFSEModule(options) { require('util').inherits(AreaPostFSEModule, FullScreenEditorModule); -AreaPostFSEModule.prototype.enter = function(client) { +AreaPostFSEModule.prototype.enter = function() { - if(_.isString(client.user.properties.message_area_name) && !_.isString(this.messageAreaName)) { - this.messageAreaName = client.user.properties.message_area_name; + if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) { + this.messageAreaTag = this.client.user.properties.message_area_tag; } - AreaPostFSEModule.super_.prototype.enter.call(this, client); + AreaPostFSEModule.super_.prototype.enter.call(this); }; diff --git a/mods/msg_area_view_fse.js b/mods/msg_area_view_fse.js index 7d1d1e44..09d823ed 100644 --- a/mods/msg_area_view_fse.js +++ b/mods/msg_area_view_fse.js @@ -72,7 +72,7 @@ function AreaViewFSEModule(options) { if(_.isString(extraArgs.menu)) { var modOpts = { extraArgs : { - messageAreaName : self.messageAreaName, + messageAreaTag : self.messageAreaTag, replyToMessage : self.message, } }; diff --git a/mods/msg_conf_list.js b/mods/msg_conf_list.js new file mode 100644 index 00000000..308785d5 --- /dev/null +++ b/mods/msg_conf_list.js @@ -0,0 +1,122 @@ +/* jslint node: true */ +'use strict'; + +var MenuModule = require('../core/menu_module.js').MenuModule; +var ViewController = require('../core/view_controller.js').ViewController; +var messageArea = require('../core/message_area.js'); + +var async = require('async'); +var assert = require('assert'); +var _ = require('lodash'); + +exports.getModule = MessageConfListModule; + +exports.moduleInfo = { + name : 'Message Conference List', + desc : 'Module for listing / choosing message conferences', + author : 'NuSkooler', +}; + +var MciCodesIds = { + ConfList : 1, + CurrentConf : 2, + + // :TODO: + // # areas in con + // +}; + +function MessageConfListModule(options) { + MenuModule.call(this, options); + + var self = this; + + this.messageConfs = messageArea.getSortedAvailMessageConferences(self.client); + + this.menuMethods = { + changeConference : function(formData, extraArgs) { + if(1 === formData.submitId) { + const confTag = self.messageConfs[formData.value.conf].confTag; + + messageArea.changeMessageConference(self.client, confTag, err => { + if(err) { + self.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`); + + setTimeout(function timeout() { + self.prevMenu(); + }, 1000); + } else { + self.prevMenu(); + } + }); + } + } + }; + + this.setViewText = function(id, text) { + const v = self.viewControllers.areaList.getView(id); + if(v) { + v.setText(text); + } + }; +} + +require('util').inherits(MessageConfListModule, MenuModule); + +MessageConfListModule.prototype.mciReady = function(mciData, cb) { + var self = this; + const vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); + + async.series( + [ + function callParentMciReady(callback) { + MessageConfListModule.super_.prototype.mciReady.call(this, mciData, callback); + }, + function loadFromConfig(callback) { + let loadOpts = { + callingMenu : self, + mciMap : mciData.menu, + formId : 0, + }; + + vc.loadFromMenuConfig(loadOpts, callback); + }, + function populateConfListView(callback) { + const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}'; + const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; + + const confListView = vc.getView(1); + let i = 1; + confListView.setItems(_.map(self.messageConfs, v => { + return listFormat.format({ + index : i++, + confTag : v.conf.confTag, + name : v.conf.name, + desc : v.conf.desc, + }); + })); + + i = 1; + confListView.setFocusItems(_.map(self.messageConfs, v => { + return focusListFormat.format({ + index : i++, + confTag : v.conf.confTag, + name : v.conf.name, + desc : v.conf.desc, + }) + })); + + confListView.redraw(); + + callback(null); + }, + function populateTextViews(callback) { + // :TODO: populate other avail MCI, e.g. current conf name + callback(null); + } + ], + function complete(err) { + cb(err); + } + ); +}; \ No newline at end of file diff --git a/mods/msg_list.js b/mods/msg_list.js index c5fbe768..fbffa162 100644 --- a/mods/msg_list.js +++ b/mods/msg_list.js @@ -52,15 +52,15 @@ function MessageListModule(options) { var self = this; var config = this.menuConfig.config; - this.messageAreaName = config.messageAreaName; + this.messageAreaTag = config.messageAreaTag; if(options.extraArgs) { // - // |extraArgs| can override |messageAreaName| provided by config + // |extraArgs| can override |messageAreaTag| provided by config // as well as supply a pre-defined message list // - if(options.extraArgs.messageAreaName) { - this.messageAreaName = options.extraArgs.messageAreaName; + if(options.extraArgs.messageAreaTag) { + this.messageAreaTag = options.extraArgs.messageAreaTag; } if(options.extraArgs.messageList) { @@ -73,7 +73,7 @@ function MessageListModule(options) { if(1 === formData.submitId) { var modOpts = { extraArgs : { - messageAreaName : self.messageAreaName, + messageAreaTag : self.messageAreaTag, messageList : self.messageList, messageIndex : formData.value.message, } @@ -94,15 +94,15 @@ function MessageListModule(options) { require('util').inherits(MessageListModule, MenuModule); -MessageListModule.prototype.enter = function(client) { - MessageListModule.super_.prototype.enter.call(this, client); +MessageListModule.prototype.enter = function() { + MessageListModule.super_.prototype.enter.call(this); // - // Config can specify |messageAreaName| else it comes from + // Config can specify |messageAreaTag| else it comes from // the user's current area // - if(!this.messageAreaName) { - this.messageAreaName = client.user.properties.message_area_name; + if(!this.messageAreaTag) { + this.messageAreaTag = this.client.user.properties.message_area_tag; } }; @@ -110,6 +110,8 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { var self = this; var vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + var firstNewEntryIndex; + async.series( [ function callParentMciReady(callback) { @@ -130,7 +132,7 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { if(_.isArray(self.messageList)) { callback(0 === self.messageList.length ? new Error('No messages in area') : null); } else { - messageArea.getMessageListForArea( { client : self.client }, self.messageAreaName, function msgs(err, msgList) { + messageArea.getMessageListForArea( { client : self.client }, self.messageAreaTag, function msgs(err, msgList) { if(msgList && 0 === msgList.length) { callback(new Error('No messages in area')); } else { @@ -141,7 +143,7 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { } }, function getLastReadMesageId(callback) { - messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaName, function lastRead(err, lastReadId) { + messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaTag, function lastRead(err, lastReadId) { self.lastReadId = lastReadId || 0; callback(null); // ignore any errors, e.g. missing value }); @@ -158,6 +160,13 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { var msgNum = 1; function getMsgFmtObj(mle) { + + if(_.isUndefined(firstNewEntryIndex) && + mle.messageId > self.lastReadId) + { + firstNewEntryIndex = msgNum - 1; + } + return { msgNum : msgNum++, subj : mle.subject, @@ -180,14 +189,18 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { msgListView.on('index update', function indexUpdated(idx) { self.setViewText(MciCodesIds.MsgSelNum, (idx + 1).toString()); }); - + msgListView.redraw(); + + if(firstNewEntryIndex > 0) { + msgListView.setFocusItemIndex(firstNewEntryIndex); + } callback(null); }, function populateOtherMciViews(callback) { - self.setViewText(MciCodesIds.MsgAreaDesc, messageArea.getMessageAreaByName(self.messageAreaName).desc); + self.setViewText(MciCodesIds.MsgAreaDesc, messageArea.getMessageAreaByTag(self.messageAreaTag).name); self.setViewText(MciCodesIds.MsgSelNum, (vc.getView(MciCodesIds.MsgList).getData() + 1).toString()); self.setViewText(MciCodesIds.MsgTotal, self.messageList.length.toString()); diff --git a/mods/nua.js b/mods/nua.js index c2a955f2..7d83e518 100644 --- a/mods/nua.js +++ b/mods/nua.js @@ -5,7 +5,7 @@ var user = require('../core/user.js'); var theme = require('../core/theme.js'); var login = require('../core/system_menu_method.js').login; var Config = require('../core/config.js').config; -var getDefaultMessageArea = require('../core/message_area.js').getDefaultMessageArea; +var messageArea = require('../core/message_area.js'); var async = require('async'); @@ -65,6 +65,16 @@ function NewUserAppModule(options) { newUser.username = formData.value.username; + // + // We have to disable ACS checks for initial default areas as the user is not yet ready + // + var confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck + var areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck + + // can't store undefined! + confTag = confTag || ''; + areaTag = areaTag || ''; + newUser.properties = { real_name : formData.value.realName, birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), @@ -74,14 +84,12 @@ function NewUserAppModule(options) { email_address : formData.value.email, web_address : formData.value.web, account_created : new Date().toISOString(), - - message_area_name : getDefaultMessageArea().name, + + message_conf_tag : confTag, + message_area_tag : areaTag, term_height : self.client.term.termHeight, - term_width : self.client.term.termWidth, - - // :TODO: This is set in User.create() -- proabbly don't need it here: - //account_status : Config.users.requireActivation ? user.User.AccountStatus.inactive : user.User.AccountStatus.active, + term_width : self.client.term.termWidth, // :TODO: Other defaults // :TODO: should probably have a place to create defaults/etc. @@ -92,8 +100,8 @@ function NewUserAppModule(options) { } else { newUser.properties.theme_id = Config.defaults.theme; } - - // :TODO: .create() should also validate email uniqueness! + + // :TODO: User.create() should validate email uniqueness! newUser.create( { password : formData.value.password }, function created(err) { if(err) { self.client.log.info( { error : err, username : formData.value.username }, 'New user creation failed'); diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/mods/themes/luciano_blocktronics/theme.hjson index 60d56059..44bd8131 100644 --- a/mods/themes/luciano_blocktronics/theme.hjson +++ b/mods/themes/luciano_blocktronics/theme.hjson @@ -172,8 +172,8 @@ messageAreaChangeCurrentArea: { config: { - listFormat: "|00|15{index} |07- |03{desc}" - focusListFormat: "|00|19|15{index} - {desc}" + listFormat: "|00|15{index} |07- |03{name}" + focusListFormat: "|00|19|15{index} - {name}" } mci: { VM1: { @@ -310,6 +310,19 @@ } } } + + newScanMessageList: { + config: { + listFormat: "|00|15 {msgNum:<5.5}|03{subj:<29.29} |15{from:<20.20} {ts}" + focusListFormat: "|00|19> |15{msgNum:<5.5}{subj:<29.29} {from:<20.20} {ts}" + dateTimeFormat: ddd MMM Do + } + mci: { + VM1: { + height: 14 + } + } + } } } } \ No newline at end of file diff --git a/mods/user_list.js b/mods/user_list.js index 59826753..5303e420 100644 --- a/mods/user_list.js +++ b/mods/user_list.js @@ -2,7 +2,7 @@ 'use strict'; var MenuModule = require('../core/menu_module.js').MenuModule; -var userDb = require('../core/database.js').dbs.user; +//var userDb = require('../core/database.js').dbs.user; var getUserList = require('../core/user.js').getUserList; var ViewController = require('../core/view_controller.js').ViewController; diff --git a/mods/whos_online.js b/mods/whos_online.js index 6b0ef4c1..a607ae02 100644 --- a/mods/whos_online.js +++ b/mods/whos_online.js @@ -84,7 +84,6 @@ WhosOnlineModule.prototype.mciReady = function(mciData, cb) { return listFormat.format(oe); })); - // :TODO: This is a hack until pipe codes are better implemented onlineListView.focusItems = onlineListView.items; onlineListView.redraw(); diff --git a/oputil.js b/oputil.js index 5eef5339..5087b9b1 100755 --- a/oputil.js +++ b/oputil.js @@ -13,7 +13,7 @@ var assert = require('assert'); var argv = require('minimist')(process.argv.slice(2)); -var ExitCodes = { +const ExitCodes = { SUCCESS : 0, ERROR : -1, BAD_COMMAND : -2, @@ -28,9 +28,13 @@ function printUsage(command) { usage = 'usage: oputil.js [--version] [--help]\n' + ' []' + - '\n' + + '\n\n' + 'global args:\n' + - ' --config PATH : specify config path'; + ' --config PATH : specify config path' + + '\n\n' + + 'commands:\n' + + ' user : User utilities' + + '\n'; break; case 'user' : @@ -47,7 +51,7 @@ function printUsage(command) { } function initConfig(cb) { - var configPath = argv.config ? argv.config : config.getDefaultPath(); + const configPath = argv.config ? argv.config : config.getDefaultPath(); config.init(configPath, cb); } @@ -88,7 +92,7 @@ function handleUserCommand() { assert(_.isNumber(userId)); assert(userId > 0); - var u = new user.User(); + let u = new user.User(); u.userId = userId; u.setNewAuthCredentials(argv.password, function credsSet(err) {