From 70b5d7a124b50447a99aa3709880d27bb77931c2 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 26 Jan 2018 21:36:16 -0700 Subject: [PATCH] MAJOR refactor of Message class * ES6 class vs old style * Add findMessages(filter, ...) similar to FileEntry.findFiles() allowing many filter types used throughout the system --- core/message.js | 1258 ++++++++++++++++++++++++++--------------------- 1 file changed, 711 insertions(+), 547 deletions(-) diff --git a/core/message.js b/core/message.js index f99efa8a..6c47a934 100644 --- a/core/message.js +++ b/core/message.js @@ -5,9 +5,11 @@ const msgDb = require('./database.js').dbs.message; const wordWrapText = require('./word_wrap.js').wordWrapText; const ftnUtil = require('./ftn_util.js'); const createNamedUUID = require('./uuid_util.js').createNamedUUID; -const getISOTimestampString = require('./database.js').getISOTimestampString; const Errors = require('./enig_error.js').Errors; const ANSI = require('./ansi_term.js'); +const { + sanatizeString, + getISOTimestampString } = require('./database.js'); const { isAnsi, isFormattedLine, @@ -25,74 +27,15 @@ const assert = require('assert'); const moment = require('moment'); const iconvEncode = require('iconv-lite').encode; -module.exports = Message; - const ENIGMA_MESSAGE_UUID_NAMESPACE = uuidParse.parse('154506df-1df8-46b9-98f8-ebb5815baaf8'); -function Message(options) { - options = options || {}; - - this.messageId = options.messageId || 0; // always generated @ persist - this.areaTag = options.areaTag || Message.WellKnownAreaTags.Invalid; - - if(options.uuid) { - // note: new messages have UUID generated @ time of persist. See also Message.createMessageUUID() - this.uuid = options.uuid; - } - - this.replyToMsgId = options.replyToMsgId || 0; - this.toUserName = options.toUserName || ''; - this.fromUserName = options.fromUserName || ''; - this.subject = options.subject || ''; - this.message = options.message || ''; - - if(_.isDate(options.modTimestamp) || moment.isMoment(options.modTimestamp)) { - this.modTimestamp = moment(options.modTimestamp); - } else if(_.isString(options.modTimestamp)) { - this.modTimestamp = moment(options.modTimestamp); - } - - this.viewCount = options.viewCount || 0; - - this.meta = { - System : {}, // we'll always have this one - }; - - if(_.isObject(options.meta)) { - _.defaultsDeep(this.meta, options.meta); - } - - if(options.meta) { - this.meta = options.meta; - } - - this.hashTags = options.hashTags || []; - - this.isValid = function() { - // :TODO: validate as much as possible - return true; - }; - - this.isPrivate = function() { - return Message.isPrivateAreaTag(this.areaTag); - }; - - this.isFromRemoteUser = function() { - return null !== _.get(this, 'meta.System.remote_from_user', null); - }; -} - -Message.WellKnownAreaTags = { +const WELL_KNOWN_AREA_TAGS = { Invalid : '', Private : 'private_mail', Bulletin : 'local_bulletin', }; -Message.isPrivateAreaTag = function(areaTag) { - return areaTag.toLowerCase() === Message.WellKnownAreaTags.Private; -}; - -Message.SystemMetaNames = { +const SYSTEM_META_NAMES = { LocalToUserID : 'local_to_user_id', LocalFromUserID : 'local_from_user_id', StateFlags0 : 'state_flags0', // See Message.StateFlags0 @@ -103,19 +46,20 @@ Message.SystemMetaNames = { }; // Types for Message.SystemMetaNames.ExternalFlavor meta -Message.AddressFlavor = { +const ADDRESS_FLAVOR = { Local : 'local', // local / non-remote addressing FTN : 'ftn', // FTN style Email : 'email', }; -Message.StateFlags0 = { +const STATE_FLAGS0 = { None : 0x00000000, Imported : 0x00000001, // imported from foreign system Exported : 0x00000002, // exported to foreign system }; -Message.FtnPropertyNames = { +// :TODO: these should really live elsewhere... +const FTN_PROPERTY_NAMES = { // packet header oriented FtnOrigNode : 'ftn_orig_node', FtnDestNode : 'ftn_dest_node', @@ -143,518 +87,738 @@ Message.FtnPropertyNames = { FtnSeenBy : 'ftn_seen_by', // http://ftsc.org/docs/fts-0004.001 }; -// Note: kludges are stored with their names as-is +module.exports = class Message { + constructor( + { + messageId = 0, areaTag = Message.WellKnownAreaTags.Invalid, uuid, replyToMsgId = 0, + toUserName = '', fromUserName = '', subject = '', message = '', modTimestamp = moment(), + meta, hashTags = [], + } = { } + ) + { + this.messageId = messageId; + this.areaTag = areaTag; + this.uuid = uuid; + this.replyToMsgId = replyToMsgId; + this.toUserName = toUserName; + this.fromUserName = fromUserName; + this.subject = subject; + this.message = message; -Message.prototype.setLocalToUserId = function(userId) { - this.meta.System = this.meta.System || {}; - this.meta.System[Message.SystemMetaNames.LocalToUserID] = userId; -}; + if(_.isDate(modTimestamp) || _.isString(modTimestamp)) { + modTimestamp = moment(modTimestamp); + } -Message.prototype.setLocalFromUserId = function(userId) { - this.meta.System = this.meta.System || {}; - this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId; -}; + this.modTimestamp = modTimestamp; -Message.prototype.setRemoteToUser = function(remoteTo) { - this.meta.System = this.meta.System || {}; - this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo; -}; + this.meta = {}; + _.defaultsDeep(this.meta, { System : {} }, meta); -Message.prototype.setExternalFlavor = function(flavor) { - this.meta.System = this.meta.System || {}; - this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor; -}; - -Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) { - assert(_.isString(areaTag)); - assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); - assert(_.isString(subject)); - assert(_.isString(body)); - - if(!moment.isMoment(modTimestamp)) { - modTimestamp = moment(modTimestamp); + this.hashTags = hashTags; } - areaTag = iconvEncode(areaTag.toUpperCase(), 'CP437'); - modTimestamp = iconvEncode(modTimestamp.format('DD MMM YY HH:mm:ss'), 'CP437'); - subject = iconvEncode(subject.toUpperCase().trim(), 'CP437'); - body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); + isValid() { return true; } // :TODO: obviously useless; look into this or remove it - return uuidParse.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] ))); -}; + static isPrivateAreaTag(areaTag) { + return areaTag.toLowerCase() === Message.WellKnownAreaTags.Private; + } -Message.getMessageIdByUuid = function(uuid, cb) { - msgDb.get( - `SELECT message_id - FROM message - WHERE message_uuid = ? - LIMIT 1;`, - [ uuid ], - (err, row) => { - if(err) { - cb(err); + isPrivate() { + return Message.isPrivateAreaTag(this.areaTag); + } + + isFromRemoteUser() { + return null !== _.get(this, 'meta.System.remote_from_user', null); + } + + static get WellKnownAreaTags() { + return WELL_KNOWN_AREA_TAGS; + } + + static get SystemMetaNames() { + return SYSTEM_META_NAMES; + } + + static get AddressFlavor() { + return ADDRESS_FLAVOR; + } + + static get StateFlags0() { + return STATE_FLAGS0; + } + + static get FtnPropertyNames() { + return FTN_PROPERTY_NAMES; + } + + setLocalToUserId(userId) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalToUserID] = userId; + } + + setLocalFromUserId(userId) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId; + } + + setRemoteToUser(remoteTo) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo; + } + + setExternalFlavor(flavor) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor; + } + + static createMessageUUID(areaTag, modTimestamp, subject, body) { + assert(_.isString(areaTag)); + assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); + assert(_.isString(subject)); + assert(_.isString(body)); + + if(!moment.isMoment(modTimestamp)) { + modTimestamp = moment(modTimestamp); + } + + areaTag = iconvEncode(areaTag.toUpperCase(), 'CP437'); + modTimestamp = iconvEncode(modTimestamp.format('DD MMM YY HH:mm:ss'), 'CP437'); + subject = iconvEncode(subject.toUpperCase().trim(), 'CP437'); + body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); + + return uuidParse.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] ))); + } + + /* + Find message IDs or UUIDs by filter. Available filters/options: + + filter.uuids - use with resultType='id' + filter.ids - use with resultType='uuid' + filter.toUserName + filter.fromUserName + filter.replyToMesageId + filter.newerThanTimestamp + filter.newerThanMessageId + *filter.confTag - all area tags in confTag + filter.areaTag + *filter.metaTuples - {category, name, value} + + *filter.terms - FTS search + + filter.sort = modTimestamp | messageId + filter.order = ascending | (descending) + + filter.limit + filter.resultType = (id) | uuid | count + filter.extraFields = [] + + filter.privateTagUserId = - if set, only private messages belonging to are processed + (any other areaTag or confTag filters will be ignored) + + *=NYI + */ + static findMessages(filter, cb) { + filter = filter || {}; + + filter.resultType = filter.resultType || 'id'; + filter.extraFields = filter.extraFields || []; + + const field = 'id' === filter.resultType ? 'message_id' : 'message_uuid'; + + if(moment.isMoment(filter.newerThanTimestamp)) { + filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp); + } + + let sql; + if('count' === filter.resultType) { + sql = + `SELECT COUNT() AS count + FROM message m`; + + } else { + sql = + `SELECT DISTINCT m.${field}${filter.extraFields.length > 0 ? ', ' + filter.extraFields.map(f => `m.${f}`).join(', ') : ''} + FROM message m`; + } + + const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; + let sqlOrderBy; + let sqlWhere = ''; + + function appendWhereClause(clause) { + if(sqlWhere) { + sqlWhere += ' AND '; } else { + sqlWhere += ' WHERE '; + } + sqlWhere += clause; + } + + // currently only avail sort + if('modTimestamp' === filter.sort) { + sqlOrderBy = `ORDER BY m.modified_timestamp ${sqlOrderDir}`; + } else { + sqlOrderBy = `ORDER BY m.message_id ${sqlOrderDir}`; + } + + if(Array.isArray(filter.ids)) { + appendWhereClause(`m.message_id IN (${filter.ids.join(', ')})`); + } + + if(Array.isArray(filter.uuids)) { + const uuidList = filter.uuids.map(u => `"${u}"`).join(', '); + appendWhereClause(`m.message_id IN (${uuidList})`); + } + + + if(_.isNumber(filter.privateTagUserId)) { + appendWhereClause(`m.area_tag = "${Message.WellKnownAreaTags.Private}"`); + appendWhereClause( + `m.message_id IN ( + SELECT message_id + FROM message_meta + WHERE meta_category = "System" AND meta_name = "${Message.SystemMetaNames.LocalToUserID}" AND meta_value = ${filter.privateTagUserId} + )`); + } else { + let areaTags = []; + if(filter.confTag && filter.confTag.length > 0) { + // :TODO: grab areas from conf -> add to areaTags[] + } + + if(areaTags.length > 0 || filter.areaTag && filter.areaTag.length > 0) { + if(Array.isArray(filter.areaTag)) { + areaTags = areaTags.concat(filter.areaTag); + } else if(_.isString(filter.areaTag)) { + areaTags.push(filter.areaTag); + } + + areaTags = _.uniq(areaTags); // remove any dupes + + if(areaTags.length > 1) { + const areaList = filter.areaTag.map(t => `"${t}"`).join(', '); + appendWhereClause(`m.area_tag IN(${areaList})`); + } else { + appendWhereClause(`m.area_tag = "${areaTags[0]}"`); + } + } + } + + [ 'toUserName', 'fromUserName', 'replyToMessageId' ].forEach(field => { + if(_.isString(filter[field]) && filter[field].length > 0) { + appendWhereClause(`m.${_.snakeCase(field)} = "${sanatizeString(filter[field])}"`); + } + }); + + if(_.isString(filter.newerThanTimestamp) && filter.newerThanTimestamp.length > 0) { + appendWhereClause(`DATETIME(m.modified_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`); + } + + if(_.isNumber(filter.newerThanMessageId)) { + appendWhereClause(`m.message_id > ${filter.newerThanMessageId}`); + } + + sql += `${sqlWhere} ${sqlOrderBy}`; + + if(_.isNumber(filter.limit)) { + sql += ` LIMIT ${filter.limit}`; + } + + sql += ';'; + + if('count' === filter.resultType) { + msgDb.get(sql, (err, row) => { + return cb(err, row ? row.count : 0); + }); + } else { + const matches = []; + const extra = filter.extraFields.length > 0; + msgDb.each(sql, (err, row) => { + if(_.isObject(row)) { + matches.push(extra ? row : row[field]); + } + }, err => { + return cb(err, matches); + }); + } + } + + // :TODO: use findMessages, by uuid, limit=1 + static getMessageIdByUuid(uuid, cb) { + msgDb.get( + `SELECT message_id + FROM message + WHERE message_uuid = ? + LIMIT 1;`, + [ uuid ], + (err, row) => { + if(err) { + return cb(err); + } + const success = (row && row.message_id); - cb(success ? null : new Error('No match'), success ? row.message_id : null); - } - } - ); -}; - -Message.getMessageIdsByMetaValue = function(category, name, value, cb) { - msgDb.all( - `SELECT message_id - FROM message_meta - WHERE meta_category = ? AND meta_name = ? AND meta_value = ?;`, - [ category, name, value ], - (err, rows) => { - if(err) { - cb(err); - } else { - cb(null, rows.map(r => parseInt(r.message_id))); // return array of ID(s) - } - } - ); -}; - -Message.getMetaValuesByMessageId = function(messageId, category, name, cb) { - const sql = - `SELECT meta_value - FROM message_meta - WHERE message_id = ? AND meta_category = ? AND meta_name = ?;`; - - msgDb.all(sql, [ messageId, category, name ], (err, rows) => { - if(err) { - return cb(err); - } - - if(0 === rows.length) { - return cb(new Error('No value for category/name')); - } - - // single values are returned without an array - if(1 === rows.length) { - return cb(null, rows[0].meta_value); - } - - cb(null, rows.map(r => r.meta_value)); // map to array of values only - }); -}; - -Message.getMetaValuesByMessageUuid = function(uuid, category, name, cb) { - async.waterfall( - [ - function getMessageId(callback) { - Message.getMessageIdByUuid(uuid, (err, messageId) => { - callback(err, messageId); - }); - }, - function getMetaValues(messageId, callback) { - Message.getMetaValuesByMessageId(messageId, category, name, (err, values) => { - callback(err, values); - }); - } - ], - (err, values) => { - cb(err, values); - } - ); -}; - -Message.prototype.loadMeta = function(cb) { - /* - Example of loaded this.meta: - - meta: { - System: { - local_to_user_id: 1234, - }, - FtnProperty: { - ftn_seen_by: [ "1/102 103", "2/42 52 65" ] - } - } - */ - - const sql = - `SELECT meta_category, meta_name, meta_value - FROM message_meta - WHERE message_id = ?;`; - - let self = this; - msgDb.each(sql, [ this.messageId ], (err, row) => { - if(!(row.meta_category in self.meta)) { - self.meta[row.meta_category] = { }; - self.meta[row.meta_category][row.meta_name] = row.meta_value; - } else { - if(!(row.meta_name in self.meta[row.meta_category])) { - self.meta[row.meta_category][row.meta_name] = row.meta_value; - } else { - if(_.isString(self.meta[row.meta_category][row.meta_name])) { - self.meta[row.meta_category][row.meta_name] = [ self.meta[row.meta_category][row.meta_name] ]; - } - - self.meta[row.meta_category][row.meta_name].push(row.meta_value); - } - } - }, err => { - cb(err); - }); -}; - -Message.prototype.load = function(options, cb) { - assert(_.isString(options.uuid)); - - var self = this; - - async.series( - [ - function loadMessage(callback) { - msgDb.get( - '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=? ' + - 'LIMIT 1;', - [ options.uuid ], - (err, msgRow) => { - if(err) { - return callback(err); - } - if(!msgRow) { - return callback(new Error('Message (no longer) available')); - } - - self.messageId = msgRow.message_id; - self.areaTag = msgRow.area_tag; - self.messageUuid = msgRow.message_uuid; - self.replyToMsgId = msgRow.reply_to_message_id; - self.toUserName = msgRow.to_user_name; - self.fromUserName = msgRow.from_user_name; - self.subject = msgRow.subject; - self.message = msgRow.message; - self.modTimestamp = moment(msgRow.modified_timestamp); - self.viewCount = msgRow.view_count; - - callback(err); - } + return cb( + success ? null : Errors.DoesNotExist(`No message for UUID ${uuid}`), + success ? row.message_id : null ); - }, - function loadMessageMeta(callback) { - self.loadMeta(err => { - callback(err); - }); - }, - function loadHashTags(callback) { - // :TODO: - callback(null); - } - ], - function complete(err) { - cb(err); - } - ); -}; - -Message.prototype.persistMetaValue = function(category, name, value, transOrDb, cb) { - if(!_.isFunction(cb) && _.isFunction(transOrDb)) { - cb = transOrDb; - transOrDb = msgDb; - } - - const metaStmt = transOrDb.prepare( - `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) - VALUES (?, ?, ?, ?);`); - - if(!_.isArray(value)) { - value = [ value ]; - } - - let self = this; - - async.each(value, (v, next) => { - metaStmt.run(self.messageId, category, name, v, err => { - next(err); - }); - }, err => { - cb(err); - }); -}; - -Message.prototype.persist = function(cb) { - - if(!this.isValid()) { - return cb(new Error('Cannot persist invalid message!')); - } - - const self = this; - - async.waterfall( - [ - function beginTransaction(callback) { - return msgDb.beginTransaction(callback); - }, - function storeMessage(trans, callback) { - // generate a UUID for this message if required (general case) - const msgTimestamp = moment(); - if(!self.uuid) { - self.uuid = Message.createMessageUUID( - self.areaTag, - msgTimestamp, - self.subject, - self.message); - } - - trans.run( - `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, getISOTimestampString(msgTimestamp) ], - function inserted(err) { // use non-arrow function for 'this' scope - if(!err) { - self.messageId = this.lastID; - } - - return callback(err, trans); - } - ); - }, - function storeMeta(trans, callback) { - if(!self.meta) { - return callback(null, trans); - } - /* - Example of self.meta: - - meta: { - System: { - local_to_user_id: 1234, - }, - FtnProperty: { - ftn_seen_by: [ "1/102 103", "2/42 52 65" ] - } - } - */ - async.each(Object.keys(self.meta), (category, nextCat) => { - async.each(Object.keys(self.meta[category]), (name, nextName) => { - self.persistMetaValue(category, name, self.meta[category][name], trans, err => { - nextName(err); - }); - }, err => { - nextCat(err); - }); - - }, err => { - callback(err, trans); - }); - }, - function storeHashTags(trans, callback) { - // :TODO: hash tag support - return callback(null, trans); - } - ], - (err, trans) => { - if(trans) { - trans[err ? 'rollback' : 'commit'](transErr => { - return cb(err ? err : transErr, self.messageId); - }); - } else { - return cb(err); - } - } - ); -}; - -Message.prototype.getFTNQuotePrefix = function(source) { - source = source || 'fromUserName'; - - return ftnUtil.getQuotePrefix(this[source]); -}; - -Message.prototype.getTearLinePosition = function(input) { - const m = input.match(/^--- .+$(?![\s\S]*^--- .+$)/m); - return m ? m.index : -1; -}; - -Message.prototype.getQuoteLines = function(options, cb) { - if(!options.termWidth || !options.termHeight || !options.cols) { - return cb(Errors.MissingParam()); - } - - options.startCol = options.startCol || 1; - options.includePrefix = _.get(options, 'includePrefix', true); - options.ansiResetSgr = options.ansiResetSgr || ANSI.getSGRFromGraphicRendition( { fg : 39, bg : 49 }, true); - options.ansiFocusPrefixSgr = options.ansiFocusPrefixSgr || ANSI.getSGRFromGraphicRendition( { intensity : 'bold', fg : 39, bg : 49 } ); - options.isAnsi = options.isAnsi || isAnsi(this.message); // :TODO: If this.isAnsi, use that setting - - /* - Some long text that needs to be wrapped and quoted should look right after - doing so, don't ya think? yeah I think so - - Nu> Some long text that needs to be wrapped and quoted should look right - Nu> after doing so, don't ya think? yeah I think so - - Ot> Nu> Some long text that needs to be wrapped and quoted should look - Ot> Nu> right after doing so, don't ya think? yeah I think so - - */ - const quotePrefix = options.includePrefix ? this.getFTNQuotePrefix(options.prefixSource || 'fromUserName') : ''; - - function getWrapped(text, extraPrefix) { - extraPrefix = extraPrefix ? ` ${extraPrefix}` : ''; - - const wrapOpts = { - width : options.cols - (quotePrefix.length + extraPrefix.length), - tabHandling : 'expand', - tabWidth : 4, - }; - - return wordWrapText(text, wrapOpts).wrapped.map( (w, i) => { - return i === 0 ? `${quotePrefix}${w}` : `${quotePrefix}${extraPrefix}${w}`; - }); - } - - function getFormattedLine(line) { - // for pre-formatted text, we just append a line truncated to fit - let newLen; - const total = line.length + quotePrefix.length; - - if(total > options.cols) { - newLen = options.cols - total; - } else { - newLen = total; - } - - return `${quotePrefix}${line.slice(0, newLen)}`; - } - - if(options.isAnsi) { - ansiPrep( - this.message.replace(/\r?\n/g, '\r\n'), // normalized LF -> CRLF - { - termWidth : options.termWidth, - termHeight : options.termHeight, - cols : options.cols, - rows : 'auto', - startCol : options.startCol, - forceLineTerm : true, - }, - (err, prepped) => { - prepped = prepped || this.message; - - let lastSgr = ''; - const split = splitTextAtTerms(prepped); - - const quoteLines = []; - const focusQuoteLines = []; - - // - // Do not include quote prefixes (e.g. XX> ) on ANSI replies (and therefor quote builder) - // as while this works in ENiGMA, other boards such as Mystic, WWIV, etc. will try to - // strip colors, colorize the lines, etc. If we exclude the prefixes, this seems to do - // the trick and allow them to leave them alone! - // - split.forEach(l => { - quoteLines.push(`${lastSgr}${l}`); - - focusQuoteLines.push(`${options.ansiFocusPrefixSgr}>${lastSgr}${renderSubstr(l, 1, l.length - 1)}`); - lastSgr = (l.match(/(?:\x1b\x5b)[?=;0-9]*m(?!.*(?:\x1b\x5b)[?=;0-9]*m)/) || [])[0] || ''; // eslint-disable-line no-control-regex - }); - - quoteLines[quoteLines.length - 1] += options.ansiResetSgr; - - return cb(null, quoteLines, focusQuoteLines, true); } ); - } else { - const QUOTE_RE = /^ ((?:[A-Za-z0-9]{2}> )+(?:[A-Za-z0-9]{2}>)*) */; - const quoted = []; - const input = _.trimEnd(this.message).replace(/\b/g, ''); + } - // find *last* tearline - let tearLinePos = this.getTearLinePosition(input); - tearLinePos = -1 === tearLinePos ? input.length : tearLinePos; // we just want the index or the entire string + // :TODO: use findMessages + static getMessageIdsByMetaValue(category, name, value, cb) { + msgDb.all( + `SELECT message_id + FROM message_meta + WHERE meta_category = ? AND meta_name = ? AND meta_value = ?;`, + [ category, name, value ], + (err, rows) => { + if(err) { + return cb(err); + } + return cb(null, rows.map(r => parseInt(r.message_id))); // return array of ID(s) + } + ); + } - input.slice(0, tearLinePos).split(/\r\n\r\n|\n\n/).forEach(paragraph => { - // - // For each paragraph, a state machine: - // - New line - line - // - New (pre)quoted line - quote_line - // - Continuation of new/quoted line - // - // Also: - // - Detect pre-formatted lines & try to keep them as-is - // - let state; - let buf = ''; - let quoteMatch; + static getMetaValuesByMessageId(messageId, category, name, cb) { + const sql = + `SELECT meta_value + FROM message_meta + WHERE message_id = ? AND meta_category = ? AND meta_name = ?;`; - if(quoted.length > 0) { - // - // Preserve paragraph seperation. - // - // FSC-0032 states something about leaving blank lines fully blank - // (without a prefix) but it seems nicer (and more consistent with other systems) - // to put 'em in. - // - quoted.push(quotePrefix); + msgDb.all(sql, [ messageId, category, name ], (err, rows) => { + if(err) { + return cb(err); } - paragraph.split(/\r?\n/).forEach(line => { - if(0 === line.trim().length) { - // see blank line notes above - return quoted.push(quotePrefix); + if(0 === rows.length) { + return cb(Errors.DoesNotExist('No value for category/name')); + } + + // single values are returned without an array + if(1 === rows.length) { + return cb(null, rows[0].meta_value); + } + + return cb(null, rows.map(r => r.meta_value)); // map to array of values only + }); + } + + static getMetaValuesByMessageUuid(uuid, category, name, cb) { + async.waterfall( + [ + function getMessageId(callback) { + Message.getMessageIdByUuid(uuid, (err, messageId) => { + return callback(err, messageId); + }); + }, + function getMetaValues(messageId, callback) { + Message.getMetaValuesByMessageId(messageId, category, name, (err, values) => { + return callback(err, values); + }); + } + ], + (err, values) => { + return cb(err, values); + } + ); + } + + loadMeta(cb) { + /* + Example of loaded this.meta: + + meta: { + System: { + local_to_user_id: 1234, + }, + FtnProperty: { + ftn_seen_by: [ "1/102 103", "2/42 52 65" ] + } + } + */ + const sql = + `SELECT meta_category, meta_name, meta_value + FROM message_meta + WHERE message_id = ?;`; + + const self = this; // :TODO: not required - arrow functions below: + msgDb.each(sql, [ this.messageId ], (err, row) => { + if(!(row.meta_category in self.meta)) { + self.meta[row.meta_category] = { }; + self.meta[row.meta_category][row.meta_name] = row.meta_value; + } else { + if(!(row.meta_name in self.meta[row.meta_category])) { + self.meta[row.meta_category][row.meta_name] = row.meta_value; + } else { + if(_.isString(self.meta[row.meta_category][row.meta_name])) { + self.meta[row.meta_category][row.meta_name] = [ self.meta[row.meta_category][row.meta_name] ]; + } + + self.meta[row.meta_category][row.meta_name].push(row.meta_value); + } + } + }, err => { + return cb(err); + }); + } + + load(options, cb) { + assert(_.isString(options.uuid)); + + const self = this; + + async.series( + [ + function loadMessage(callback) { + msgDb.get( + `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=? + LIMIT 1;`, + [ options.uuid ], + (err, msgRow) => { + if(err) { + return callback(err); + } + + if(!msgRow) { + return callback(Errors.DoesNotExist('Message (no longer) available')); + } + + self.messageId = msgRow.message_id; + self.areaTag = msgRow.area_tag; + self.messageUuid = msgRow.message_uuid; + self.replyToMsgId = msgRow.reply_to_message_id; + self.toUserName = msgRow.to_user_name; + self.fromUserName = msgRow.from_user_name; + self.subject = msgRow.subject; + self.message = msgRow.message; + self.modTimestamp = moment(msgRow.modified_timestamp); + + return callback(err); + } + ); + }, + function loadMessageMeta(callback) { + self.loadMeta(err => { + return callback(err); + }); + }, + function loadHashTags(callback) { + // :TODO: + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + persistMetaValue(category, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = msgDb; + } + + const metaStmt = transOrDb.prepare( + `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) + VALUES (?, ?, ?, ?);`); + + if(!_.isArray(value)) { + value = [ value ]; + } + + const self = this; + + async.each(value, (v, next) => { + metaStmt.run(self.messageId, category, name, v, err => { + return next(err); + }); + }, err => { + return cb(err); + }); + } + + persist(cb) { + if(!this.isValid()) { + return cb(Errors.Invalid('Cannot persist invalid message!')); + } + + const self = this; + + async.waterfall( + [ + function beginTransaction(callback) { + return msgDb.beginTransaction(callback); + }, + function storeMessage(trans, callback) { + // generate a UUID for this message if required (general case) + const msgTimestamp = moment(); + if(!self.uuid) { + self.uuid = Message.createMessageUUID( + self.areaTag, + msgTimestamp, + self.subject, + self.message + ); + } + + trans.run( + `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, getISOTimestampString(msgTimestamp) ], + function inserted(err) { // use non-arrow function for 'this' scope + if(!err) { + self.messageId = this.lastID; + } + + return callback(err, trans); + } + ); + }, + function storeMeta(trans, callback) { + if(!self.meta) { + return callback(null, trans); + } + /* + Example of self.meta: + + meta: { + System: { + local_to_user_id: 1234, + }, + FtnProperty: { + ftn_seen_by: [ "1/102 103", "2/42 52 65" ] + } + } + */ + async.each(Object.keys(self.meta), (category, nextCat) => { + async.each(Object.keys(self.meta[category]), (name, nextName) => { + self.persistMetaValue(category, name, self.meta[category][name], trans, err => { + return nextName(err); + }); + }, err => { + return nextCat(err); + }); + + }, err => { + return callback(err, trans); + }); + }, + function storeHashTags(trans, callback) { + // :TODO: hash tag support + return callback(null, trans); + } + ], + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr, self.messageId); + }); + } else { + return cb(err); + } + } + ); + } + + // :TODO: FTN stuff doesn't have any business here + getFTNQuotePrefix(source) { + source = source || 'fromUserName'; + + return ftnUtil.getQuotePrefix(this[source]); + } + + getTearLinePosition(input) { + const m = input.match(/^--- .+$(?![\s\S]*^--- .+$)/m); + return m ? m.index : -1; + } + + getQuoteLines(options, cb) { + if(!options.termWidth || !options.termHeight || !options.cols) { + return cb(Errors.MissingParam()); + } + + options.startCol = options.startCol || 1; + options.includePrefix = _.get(options, 'includePrefix', true); + options.ansiResetSgr = options.ansiResetSgr || ANSI.getSGRFromGraphicRendition( { fg : 39, bg : 49 }, true); + options.ansiFocusPrefixSgr = options.ansiFocusPrefixSgr || ANSI.getSGRFromGraphicRendition( { intensity : 'bold', fg : 39, bg : 49 } ); + options.isAnsi = options.isAnsi || isAnsi(this.message); // :TODO: If this.isAnsi, use that setting + + /* + Some long text that needs to be wrapped and quoted should look right after + doing so, don't ya think? yeah I think so + + Nu> Some long text that needs to be wrapped and quoted should look right + Nu> after doing so, don't ya think? yeah I think so + + Ot> Nu> Some long text that needs to be wrapped and quoted should look + Ot> Nu> right after doing so, don't ya think? yeah I think so + + */ + const quotePrefix = options.includePrefix ? this.getFTNQuotePrefix(options.prefixSource || 'fromUserName') : ''; + + function getWrapped(text, extraPrefix) { + extraPrefix = extraPrefix ? ` ${extraPrefix}` : ''; + + const wrapOpts = { + width : options.cols - (quotePrefix.length + extraPrefix.length), + tabHandling : 'expand', + tabWidth : 4, + }; + + return wordWrapText(text, wrapOpts).wrapped.map( (w, i) => { + return i === 0 ? `${quotePrefix}${w}` : `${quotePrefix}${extraPrefix}${w}`; + }); + } + + function getFormattedLine(line) { + // for pre-formatted text, we just append a line truncated to fit + let newLen; + const total = line.length + quotePrefix.length; + + if(total > options.cols) { + newLen = options.cols - total; + } else { + newLen = total; + } + + return `${quotePrefix}${line.slice(0, newLen)}`; + } + + if(options.isAnsi) { + ansiPrep( + this.message.replace(/\r?\n/g, '\r\n'), // normalized LF -> CRLF + { + termWidth : options.termWidth, + termHeight : options.termHeight, + cols : options.cols, + rows : 'auto', + startCol : options.startCol, + forceLineTerm : true, + }, + (err, prepped) => { + prepped = prepped || this.message; + + let lastSgr = ''; + const split = splitTextAtTerms(prepped); + + const quoteLines = []; + const focusQuoteLines = []; + + // + // Do not include quote prefixes (e.g. XX> ) on ANSI replies (and therefor quote builder) + // as while this works in ENiGMA, other boards such as Mystic, WWIV, etc. will try to + // strip colors, colorize the lines, etc. If we exclude the prefixes, this seems to do + // the trick and allow them to leave them alone! + // + split.forEach(l => { + quoteLines.push(`${lastSgr}${l}`); + + focusQuoteLines.push(`${options.ansiFocusPrefixSgr}>${lastSgr}${renderSubstr(l, 1, l.length - 1)}`); + lastSgr = (l.match(/(?:\x1b\x5b)[?=;0-9]*m(?!.*(?:\x1b\x5b)[?=;0-9]*m)/) || [])[0] || ''; // eslint-disable-line no-control-regex + }); + + quoteLines[quoteLines.length - 1] += options.ansiResetSgr; + + return cb(null, quoteLines, focusQuoteLines, true); + } + ); + } else { + const QUOTE_RE = /^ ((?:[A-Za-z0-9]{2}> )+(?:[A-Za-z0-9]{2}>)*) */; + const quoted = []; + const input = _.trimEnd(this.message).replace(/\b/g, ''); + + // find *last* tearline + let tearLinePos = this.getTearLinePosition(input); + tearLinePos = -1 === tearLinePos ? input.length : tearLinePos; // we just want the index or the entire string + + input.slice(0, tearLinePos).split(/\r\n\r\n|\n\n/).forEach(paragraph => { + // + // For each paragraph, a state machine: + // - New line - line + // - New (pre)quoted line - quote_line + // - Continuation of new/quoted line + // + // Also: + // - Detect pre-formatted lines & try to keep them as-is + // + let state; + let buf = ''; + let quoteMatch; + + if(quoted.length > 0) { + // + // Preserve paragraph seperation. + // + // FSC-0032 states something about leaving blank lines fully blank + // (without a prefix) but it seems nicer (and more consistent with other systems) + // to put 'em in. + // + quoted.push(quotePrefix); } - quoteMatch = line.match(QUOTE_RE); + paragraph.split(/\r?\n/).forEach(line => { + if(0 === line.trim().length) { + // see blank line notes above + return quoted.push(quotePrefix); + } - switch(state) { - case 'line' : - if(quoteMatch) { - if(isFormattedLine(line)) { - quoted.push(getFormattedLine(line.replace(/\s/, ''))); + quoteMatch = line.match(QUOTE_RE); + + switch(state) { + case 'line' : + if(quoteMatch) { + if(isFormattedLine(line)) { + quoted.push(getFormattedLine(line.replace(/\s/, ''))); + } else { + quoted.push(...getWrapped(buf, quoteMatch[1])); + state = 'quote_line'; + buf = line; + } } else { - quoted.push(...getWrapped(buf, quoteMatch[1])); - state = 'quote_line'; + buf += ` ${line}`; + } + break; + + case 'quote_line' : + if(quoteMatch) { + const rem = line.slice(quoteMatch[0].length); + if(!buf.startsWith(quoteMatch[0])) { + quoted.push(...getWrapped(buf, quoteMatch[1])); + buf = rem; + } else { + buf += ` ${rem}`; + } + } else { + quoted.push(...getWrapped(buf)); buf = line; + state = 'line'; } - } else { - buf += ` ${line}`; - } - break; + break; - case 'quote_line' : - if(quoteMatch) { - const rem = line.slice(quoteMatch[0].length); - if(!buf.startsWith(quoteMatch[0])) { - quoted.push(...getWrapped(buf, quoteMatch[1])); - buf = rem; + default : + if(isFormattedLine(line)) { + quoted.push(getFormattedLine(line)); } else { - buf += ` ${rem}`; + state = quoteMatch ? 'quote_line' : 'line'; + buf = 'line' === state ? line : line.replace(/\s/, ''); // trim *first* leading space, if any } - } else { - quoted.push(...getWrapped(buf)); - buf = line; - state = 'line'; - } - break; + break; + } + }); - default : - if(isFormattedLine(line)) { - quoted.push(getFormattedLine(line)); - } else { - state = quoteMatch ? 'quote_line' : 'line'; - buf = 'line' === state ? line : line.replace(/\s/, ''); // trim *first* leading space, if any - } - break; - } + quoted.push(...getWrapped(buf, quoteMatch ? quoteMatch[1] : null)); }); - quoted.push(...getWrapped(buf, quoteMatch ? quoteMatch[1] : null)); - }); + input.slice(tearLinePos).split(/\r?\n/).forEach(l => { + quoted.push(...getWrapped(l)); + }); - input.slice(tearLinePos).split(/\r?\n/).forEach(l => { - quoted.push(...getWrapped(l)); - }); - - return cb(null, quoted, null, false); + return cb(null, quoted, null, false); + } } };