1150 lines
39 KiB
JavaScript
1150 lines
39 KiB
JavaScript
/* jslint node: true */
|
|
'use strict';
|
|
|
|
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 Errors = require('./enig_error.js').Errors;
|
|
const ANSI = require('./ansi_term.js');
|
|
const { sanitizeString, getISOTimestampString } = require('./database.js');
|
|
|
|
const { isCP437Encodable } = require('./cp437util');
|
|
const { containsNonLatinCodepoints } = require('./string_util');
|
|
|
|
const {
|
|
isAnsi,
|
|
isFormattedLine,
|
|
splitTextAtTerms,
|
|
renderSubstr,
|
|
} = require('./string_util.js');
|
|
|
|
const ansiPrep = require('./ansi_prep.js');
|
|
|
|
// deps
|
|
const uuidParse = require('uuid-parse');
|
|
const async = require('async');
|
|
const _ = require('lodash');
|
|
const assert = require('assert');
|
|
const moment = require('moment');
|
|
const iconvEncode = require('iconv-lite').encode;
|
|
|
|
const ENIGMA_MESSAGE_UUID_NAMESPACE = uuidParse.parse(
|
|
'154506df-1df8-46b9-98f8-ebb5815baaf8'
|
|
);
|
|
|
|
const WELL_KNOWN_AREA_TAGS = {
|
|
Invalid: '',
|
|
Private: 'private_mail',
|
|
Bulletin: 'local_bulletin',
|
|
};
|
|
|
|
const WellKnownMetaCategories = {
|
|
System: 'System',
|
|
FtnProperty: 'FtnProperty',
|
|
FtnKludge: 'FtnKludge',
|
|
QwkProperty: 'QwkProperty',
|
|
QwkKludge: 'QwkKludge',
|
|
ActivityPub: 'ActivityPub',
|
|
};
|
|
|
|
// Category: WellKnownMetaCategories.System ("System")
|
|
const SYSTEM_META_NAMES = {
|
|
LocalToUserID: 'local_to_user_id',
|
|
LocalFromUserID: 'local_from_user_id',
|
|
StateFlags0: 'state_flags0', // See Message.StateFlags0
|
|
ExplicitEncoding: 'explicit_encoding', // Explicitly set encoding when exporting/etc.
|
|
ExternalFlavor: 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.AddressFlavor
|
|
RemoteToUser: 'remote_to_user', // Opaque value depends on external system, e.g. FTN address
|
|
RemoteFromUser: 'remote_from_user', // Opaque value depends on external system, e.g. FTN address
|
|
};
|
|
|
|
// Types for Message.SystemMetaNames.ExternalFlavor meta
|
|
const ADDRESS_FLAVOR = {
|
|
Local: 'local', // local / non-remote addressing
|
|
FTN: 'ftn', // FTN style
|
|
Email: 'email', // From email
|
|
QWK: 'qwk', // QWK packet
|
|
NNTP: 'nntp', // NNTP article POST; often a email address
|
|
ActivityPub: 'activitypub', // ActivityPub, Mastodon, etc.
|
|
};
|
|
|
|
const STATE_FLAGS0 = {
|
|
None: 0x00000000,
|
|
Imported: 0x00000001, // imported from foreign system
|
|
Exported: 0x00000002, // exported to foreign system
|
|
};
|
|
|
|
// :TODO: these should really live elsewhere...
|
|
// Category: WellKnownMetaCategories.FtnProperty ("FtnProperty")
|
|
const FTN_PROPERTY_NAMES = {
|
|
// packet header oriented
|
|
FtnOrigNode: 'ftn_orig_node',
|
|
FtnDestNode: 'ftn_dest_node',
|
|
// :TODO: rename these to ftn_*_net vs network - ensure things won't break, may need mapping
|
|
FtnOrigNetwork: 'ftn_orig_network',
|
|
FtnDestNetwork: 'ftn_dest_network',
|
|
FtnAttrFlags: 'ftn_attr_flags',
|
|
FtnCost: 'ftn_cost',
|
|
FtnOrigZone: 'ftn_orig_zone',
|
|
FtnDestZone: 'ftn_dest_zone',
|
|
FtnOrigPoint: 'ftn_orig_point',
|
|
FtnDestPoint: 'ftn_dest_point',
|
|
|
|
// message header oriented
|
|
FtnMsgOrigNode: 'ftn_msg_orig_node',
|
|
FtnMsgDestNode: 'ftn_msg_dest_node',
|
|
FtnMsgOrigNet: 'ftn_msg_orig_net',
|
|
FtnMsgDestNet: 'ftn_msg_dest_net',
|
|
|
|
FtnAttribute: 'ftn_attribute',
|
|
|
|
FtnTearLine: 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001
|
|
FtnOrigin: 'ftn_origin', // http://ftsc.org/docs/fts-0004.001
|
|
FtnArea: 'ftn_area', // http://ftsc.org/docs/fts-0004.001
|
|
FtnSeenBy: 'ftn_seen_by', // http://ftsc.org/docs/fts-0004.001
|
|
};
|
|
|
|
// Category: WellKnownMetaCategories.QwkProperty
|
|
const QWKPropertyNames = {
|
|
MessageNumber: 'qwk_msg_num',
|
|
MessageStatus: 'qwk_msg_status', // See http://wiki.synchro.net/ref:qwk for a decent list
|
|
ConferenceNumber: 'qwk_conf_num',
|
|
InReplyToNum: 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available
|
|
};
|
|
|
|
// Category: WellKnownMetaCategories.ActivityPub
|
|
const ActivityPubPropertyNames = {
|
|
ActivityId: 'activitypub_activity_id', // Activity ID; FK to AP table entries
|
|
InReplyTo: 'activitypub_in_reply_to', // Activity ID from 'inReplyTo' field
|
|
};
|
|
|
|
// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)!
|
|
const MESSAGE_ROW_MAP = {
|
|
reply_to_message_id: 'replyToMsgId',
|
|
modified_timestamp: 'modTimestamp',
|
|
};
|
|
|
|
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.messageUuid = uuid;
|
|
this.replyToMsgId = replyToMsgId;
|
|
this.toUserName = toUserName;
|
|
this.fromUserName = fromUserName;
|
|
this.subject = subject;
|
|
this.message = message;
|
|
|
|
if (_.isDate(modTimestamp) || _.isString(modTimestamp)) {
|
|
modTimestamp = moment(modTimestamp);
|
|
}
|
|
|
|
this.modTimestamp = modTimestamp || moment();
|
|
|
|
this.meta = {};
|
|
_.defaultsDeep(this.meta, { System: {} }, meta);
|
|
|
|
this.hashTags = hashTags;
|
|
}
|
|
|
|
get uuid() {
|
|
// deprecated, will be removed in the near future
|
|
return this.messageUuid;
|
|
}
|
|
|
|
isValid() {
|
|
return true;
|
|
} // :TODO: obviously useless; look into this or remove it
|
|
|
|
static isPrivateAreaTag(areaTag) {
|
|
return areaTag.toLowerCase() === Message.WellKnownAreaTags.Private;
|
|
}
|
|
|
|
isPrivate() {
|
|
return Message.isPrivateAreaTag(this.areaTag);
|
|
}
|
|
|
|
isFromRemoteUser() {
|
|
return null !== _.get(this, 'meta.System.remote_from_user', null);
|
|
}
|
|
|
|
isCP437Encodable() {
|
|
return (
|
|
isCP437Encodable(this.toUserName) &&
|
|
isCP437Encodable(this.fromUserName) &&
|
|
isCP437Encodable(this.subject) &&
|
|
isCP437Encodable(this.message)
|
|
);
|
|
}
|
|
|
|
containsNonLatinCodepoints() {
|
|
return (
|
|
containsNonLatinCodepoints(this.toUserName) ||
|
|
containsNonLatinCodepoints(this.fromUserName) ||
|
|
containsNonLatinCodepoints(this.subject) ||
|
|
containsNonLatinCodepoints(this.message)
|
|
);
|
|
}
|
|
|
|
/*
|
|
:TODO: finish me
|
|
static checkUserHasDeleteRights(user, messageIdOrUuid, cb) {
|
|
const isMessageId = _.isNumber(messageIdOrUuid);
|
|
const getMetaName = isMessageId ? 'getMetaValuesByMessageId' : 'getMetaValuesByMessageUuid';
|
|
|
|
Message[getMetaName](messageIdOrUuid, 'System', Message.SystemMetaNames.LocalToUserID, (err, localUserId) => {
|
|
if(err) {
|
|
return cb(err);
|
|
}
|
|
|
|
// expect single value
|
|
if(!_.isString(localUserId)) {
|
|
return cb(Errors.Invalid(`Invalid ${Message.SystemMetaNames.LocalToUserID} value: ${localUserId}`));
|
|
}
|
|
|
|
localUserId = parseInt(localUserId);
|
|
});
|
|
}
|
|
*/
|
|
|
|
userHasDeleteRights(user) {
|
|
const messageLocalUserId = parseInt(
|
|
this.meta.System[Message.SystemMetaNames.LocalToUserID]
|
|
);
|
|
return (this.isPrivate() && user.userId === messageLocalUserId) || user.isSysOp();
|
|
}
|
|
|
|
static get WellKnownMetaCategories() {
|
|
return WellKnownMetaCategories;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
static get QWKPropertyNames() {
|
|
return QWKPropertyNames;
|
|
}
|
|
|
|
static get ActivityPubPropertyNames() {
|
|
return ActivityPubPropertyNames;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
getLocalFromUserId() {
|
|
let id = _.get(this, 'meta.System.local_from_user_id', 0);
|
|
return parseInt(id);
|
|
}
|
|
|
|
setRemoteToUser(remoteTo) {
|
|
this.meta.System = this.meta.System || {};
|
|
this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo;
|
|
}
|
|
|
|
getRemoteToUser() {
|
|
return _.get(this, 'meta.System.remote_to_user');
|
|
}
|
|
|
|
setExternalFlavor(flavor) {
|
|
this.meta.System = this.meta.System || {};
|
|
this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor;
|
|
}
|
|
|
|
getAddressFlavor() {
|
|
return _.get(this, 'meta.System.external_flavor', Message.AddressFlavor.Local);
|
|
}
|
|
|
|
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])
|
|
)
|
|
);
|
|
}
|
|
|
|
static getMessageFromRow(row) {
|
|
const msg = {};
|
|
_.each(row, (v, k) => {
|
|
// :TODO: see notes around MESSAGE_ROW_MAP -- clean this up so we can just _camelCase()!
|
|
k = MESSAGE_ROW_MAP[k] || _.camelCase(k);
|
|
msg[k] = v;
|
|
});
|
|
return msg;
|
|
}
|
|
|
|
/*
|
|
Find message IDs or UUIDs by filter. Available filters/options:
|
|
|
|
filter.uuids - use with resultType='id'
|
|
filter.ids - use with resultType='uuid'
|
|
filter.toUserName - string|Array(string)
|
|
filter.fromUserName - string|Array(string)
|
|
filter.replyToMessageId
|
|
|
|
filter.operator = (AND)|OR
|
|
|
|
filter.newerThanTimestamp - may not be used with |date|
|
|
filter.date - moment object - may not be used with |newerThanTimestamp|
|
|
|
|
filter.newerThanMessageId
|
|
filter.areaTag - note if you want by conf, send in all areas for a conf
|
|
filter.metaTuples - [ {category, name, value} ]
|
|
|
|
filter.terms - FTS search
|
|
|
|
filter.sort = modTimestamp | messageId
|
|
filter.order = ascending | (descending)
|
|
|
|
filter.limit
|
|
filter.resultType = (id) | uuid | count | messageList
|
|
filter.extraFields = []
|
|
|
|
filter.privateTagUserId = <userId> - if set, only private messages belonging to <userId> are processed
|
|
- areaTags filter ignored
|
|
- if NOT present, private areas are skipped
|
|
|
|
*=NYI
|
|
*/
|
|
static findMessages(filter, cb) {
|
|
filter = filter || {};
|
|
|
|
filter.resultType = filter.resultType || 'id';
|
|
filter.extraFields = filter.extraFields || [];
|
|
filter.operator = filter.operator || 'AND';
|
|
|
|
if ('messageList' === filter.resultType) {
|
|
filter.extraFields = _.uniq(
|
|
filter.extraFields.concat([
|
|
'area_tag',
|
|
'message_uuid',
|
|
'reply_to_message_id',
|
|
'to_user_name',
|
|
'from_user_name',
|
|
'subject',
|
|
'modified_timestamp',
|
|
])
|
|
);
|
|
}
|
|
|
|
const field = 'uuid' === filter.resultType ? 'message_uuid' : 'message_id';
|
|
|
|
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, op) {
|
|
if (sqlWhere) {
|
|
sqlWhere += ` ${op || filter.operator} `;
|
|
} 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 {
|
|
if (filter.areaTag && filter.areaTag.length > 0) {
|
|
if (!Array.isArray(filter.areaTag)) {
|
|
filter.areaTag = [filter.areaTag];
|
|
}
|
|
|
|
const areaList = filter.areaTag
|
|
.filter(t => t !== Message.WellKnownAreaTags.Private)
|
|
.map(t => `"${t}"`)
|
|
.join(', ');
|
|
if (areaList.length > 0) {
|
|
appendWhereClause(`m.area_tag IN(${areaList})`);
|
|
} else {
|
|
// nothing to do; no areas remain
|
|
return cb(null, []);
|
|
}
|
|
} else {
|
|
// explicit exclude of Private
|
|
appendWhereClause(
|
|
`m.area_tag != "${Message.WellKnownAreaTags.Private}"`,
|
|
'AND'
|
|
);
|
|
}
|
|
}
|
|
|
|
if (_.isNumber(filter.replyToMessageId)) {
|
|
appendWhereClause(`m.reply_to_message_id=${filter.replyToMessageId}`);
|
|
}
|
|
|
|
['toUserName', 'fromUserName'].forEach(field => {
|
|
let val = filter[field];
|
|
if (!val) {
|
|
return; // next item
|
|
}
|
|
if (_.isString(val)) {
|
|
val = [val];
|
|
}
|
|
if (Array.isArray(val)) {
|
|
val =
|
|
'(' +
|
|
val
|
|
.map(v => {
|
|
return `m.${_.snakeCase(field)} LIKE "${sanitizeString(v)}"`;
|
|
})
|
|
.join(' OR ') +
|
|
')';
|
|
appendWhereClause(val);
|
|
}
|
|
});
|
|
|
|
if (
|
|
_.isString(filter.newerThanTimestamp) &&
|
|
filter.newerThanTimestamp.length > 0
|
|
) {
|
|
// :TODO: should be using "localtime" here?
|
|
appendWhereClause(
|
|
`DATETIME(m.modified_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`
|
|
);
|
|
} else if (moment.isMoment(filter.date)) {
|
|
appendWhereClause(
|
|
`DATE(m.modified_timestamp, "localtime") = DATE("${filter.date.format(
|
|
'YYYY-MM-DD'
|
|
)}")`
|
|
);
|
|
}
|
|
|
|
if (_.isNumber(filter.newerThanMessageId)) {
|
|
appendWhereClause(`m.message_id > ${filter.newerThanMessageId}`);
|
|
}
|
|
|
|
if (filter.terms && filter.terms.length > 0) {
|
|
// note the ':' in MATCH expr., see https://www.sqlite.org/cvstrac/wiki?p=FullTextIndex
|
|
appendWhereClause(
|
|
`m.message_id IN (
|
|
SELECT rowid
|
|
FROM message_fts
|
|
WHERE message_fts MATCH ":${sanitizeString(filter.terms)}"
|
|
)`
|
|
);
|
|
}
|
|
|
|
if (Array.isArray(filter.metaTuples)) {
|
|
let sub = [];
|
|
filter.metaTuples.forEach(mt => {
|
|
sub.push(
|
|
`(meta_category = "${mt.category}" AND meta_name = "${
|
|
mt.name
|
|
}" AND meta_value = "${sanitizeString(mt.value)}")`
|
|
);
|
|
});
|
|
sub = sub.join(` ${filter.operator} `);
|
|
appendWhereClause(
|
|
`m.message_id IN (
|
|
SELECT message_id
|
|
FROM message_meta
|
|
WHERE ${sub}
|
|
)`
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
const rowConv =
|
|
'messageList' === filter.resultType
|
|
? Message.getMessageFromRow
|
|
: row => row;
|
|
|
|
msgDb.each(
|
|
sql,
|
|
(err, row) => {
|
|
if (_.isObject(row)) {
|
|
matches.push(extra ? rowConv(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;
|
|
return cb(
|
|
success ? null : Errors.DoesNotExist(`No message for UUID ${uuid}`),
|
|
success ? row.message_id : null
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
// :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)
|
|
}
|
|
);
|
|
}
|
|
|
|
static getMetaValuesByMessageId(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(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(loadWith, cb) {
|
|
assert(_.isString(loadWith.uuid) || _.isNumber(loadWith.messageId));
|
|
|
|
const self = this;
|
|
|
|
async.series(
|
|
[
|
|
function loadMessage(callback) {
|
|
const whereField = loadWith.uuid ? 'message_uuid' : 'message_id';
|
|
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 ${whereField} = ?
|
|
LIMIT 1;`,
|
|
[loadWith.uuid || loadWith.messageId],
|
|
(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;
|
|
|
|
// We use parseZone() to *preserve* the time zone information
|
|
self.modTimestamp = moment.parseZone(
|
|
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)
|
|
if (!self.messageUuid) {
|
|
self.messageUuid = Message.createMessageUUID(
|
|
self.areaTag,
|
|
self.modTimestamp,
|
|
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.messageUuid,
|
|
self.replyToMsgId,
|
|
self.toUserName,
|
|
self.fromUserName,
|
|
self.subject,
|
|
self.message,
|
|
getISOTimestampString(self.modTimestamp),
|
|
],
|
|
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);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
deleteMessage(requestingUser, cb) {
|
|
if (!this.userHasDeleteRights(requestingUser)) {
|
|
return cb(
|
|
Errors.AccessDenied('User does not have rights to delete this message')
|
|
);
|
|
}
|
|
|
|
msgDb.run(
|
|
`DELETE FROM message
|
|
WHERE message_uuid = ?;`,
|
|
[this.messageUuid],
|
|
err => {
|
|
return cb(err);
|
|
}
|
|
);
|
|
}
|
|
|
|
// :TODO: FTN stuff doesn't have any business here
|
|
getFTNQuotePrefix(source) {
|
|
source = source || 'fromUserName';
|
|
|
|
return ftnUtil.getQuotePrefix(this[source]);
|
|
}
|
|
|
|
static 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]{1,2}> )+(?:[A-Za-z0-9]{1,2}>)*) */;
|
|
const quoted = [];
|
|
const input = _.trimEnd(this.message).replace(/\x08/g, ''); // eslint-disable-line no-control-regex
|
|
|
|
// find *last* tearline
|
|
let tearLinePos = Message.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 separation.
|
|
//
|
|
// 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);
|
|
}
|
|
|
|
paragraph.split(/\r?\n/).forEach(line => {
|
|
if (0 === line.trim().length) {
|
|
// see blank line notes above
|
|
return quoted.push(quotePrefix);
|
|
}
|
|
|
|
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 {
|
|
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';
|
|
}
|
|
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));
|
|
});
|
|
|
|
input
|
|
.slice(tearLinePos)
|
|
.split(/\r?\n/)
|
|
.forEach(l => {
|
|
quoted.push(...getWrapped(l));
|
|
});
|
|
|
|
return cb(null, quoted, null, false);
|
|
}
|
|
}
|
|
};
|