diff --git a/README.md b/README.md
index 1fe19081..39ee0561 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# ENiGMA½ BBS Software
-![alt text](http://i325.photobucket.com/albums/k361/request4spam/enigma.ans_zps05w2ey4s.png "ENiGMA½ BBS")
+![alt text](docs/images/enigma-bbs.png "ENiGMA½ BBS")
ENiGMA½ is a modern BBS software with a nostalgic flair!
@@ -9,26 +9,25 @@ ENiGMA½ is a modern BBS software with a nostalgic flair!
* Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows)
* Unlimited multi node support (for all those BBS "callers"!)
* **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods
- * MCI support for lightbars, toggles, input areas, and so on plus many other other bells and whistles
- * Telnet & **SSH** access built in. Additional servers are easy to implement
+ * [MCI support](docs/mci.md) for lightbars, toggles, input areas, and so on plus many other other bells and whistles
+ * Telnet, **SSH**, and both secure and non-secure [WebSocket](https://en.wikipedia.org/wiki/WebSocket) access built in! Additional servers are easy to implement
* [CP437](http://www.ascii-codes.com/) and UTF-8 output
* [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior
- * [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
+ * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
* Renegade style pipe color codes
* [SQLite](http://sqlite.org/) storage of users, message areas, and so on
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption
- * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), and [DoorParty](http://forums.throwbackbbs.com/) support!
+ * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), and [Exodus](https://oddnetwork.org/exodus/) support!
* [Bunyan](https://github.com/trentm/node-bunyan) logging
* [Message networks](docs/msg_networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export
* [Gazelle](https://github.com/WhatCD/Gazelle) inspirted File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/web_server.md). Legacy X/Y/Z modem also supported!
* Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more!
+ * ANSI support in the Full Screen Editor (FSE), file descriptions, and so on
## In the Works
* More ES6+ usage, and **documentation**!
* More ACS support coverage
* SysOp dashboard (ye ol' WFC)
-* Missing functionality such as message FTS, user coloring of messages in the FST, etc.
-* String localization
* A lot more! Feel free to request features via [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues)
## Known Issues
@@ -40,19 +39,20 @@ See [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) for more
* Use [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues)
* **Discussion on a ENiGMA BBS!** (see Boards below)
* IRC: **#enigma-bbs** on **chat.freenode.net**
+* Discussion on [fsxNet](http://bbs.geek.nz/#fsxNet) available on many boards
* Email: bryan -at- l33t.codes
* [Facebook ENiGMA½ group](https://www.facebook.com/groups/enigmabbs/)
-* ENiGMA discussion on [fsxNet](http://bbs.geek.nz/#fsxNet)
## Terminal Clients
ENiGMA has been tested with many terminals. However, the following are suggested for BBSing:
+* [VTX](https://github.com/codewar65/VTX_ClientServer) (Try [Xibalba using VTX](https://l33t.codes/vtx/xibalba.html)!)
* [SyncTERM](http://syncterm.bbsdev.net/)
* [EtherTerm](https://github.com/M-griffin/EtherTerm)
* [NetRunner](http://mysticbbs.com/downloads.html)
## Boards
-* WQH: :skull: Xibalba :skull: (**telnet://xibalba.l33t.codes:44510**)
-* Exotica: (**telnet://andrew.homeunix.org:2023**)
+* WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**telnet://xibalba.l33t.codes:44510** or via SSH secure on port 44511)
+* [Exotica](https://exoticabbs.com/): (**telnet://exoticabbs.com:8888**)
* [force9](http://bbs.force9.org/): (**telnet://bbs.force9.org**)
@@ -60,10 +60,11 @@ ENiGMA has been tested with many terminals. However, the following are suggested
```
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh | bash
```
-
-(See the [Quickstart](docs/index.md#quickstart) for more information)
+
+Please see the [Quickstart](docs/index.md) for more information.
## Special Thanks
+* [Daniel Mecklenburg Jr.](https://github.com/codewar65) for the awesome VTX terminal and general coding talk
* [M. Brutman](http://www.brutman.com/), author of [mTCP](http://www.brutman.com/mTCP/mTCP.html) (Interwebs for DOS!)
* [M. Griffin](https://github.com/M-griffin), author of [Enthral BBS](https://github.com/M-griffin/Enthral), [Oblivion/2 XRM](https://github.com/M-griffin/Oblivion2-XRM) and [EtherTerm](https://github.com/M-griffin/EtherTerm)!
* [Caphood](http://www.reddit.com/user/Caphood), supreme SysOp of [BLACK ƒlag](http://www.bbsnexus.com/directory/listing/blackflag.html) BBS
diff --git a/core/ansi_escape_parser.js b/core/ansi_escape_parser.js
index fb7ea732..7e777618 100644
--- a/core/ansi_escape_parser.js
+++ b/core/ansi_escape_parser.js
@@ -44,14 +44,10 @@ function ANSIEscapeParser(options) {
self.row += rows;
self.column = Math.max(self.column, 1);
- self.column = Math.min(self.column, self.termWidth);
- self.row = Math.max(self.row, 1);
- self.row = Math.min(self.row, self.termHeight);
-
-// self.emit('move cursor', self.column, self.row);
+ self.column = Math.min(self.column, self.termWidth); // can't move past term width
+ self.row = Math.max(self.row, 1);
self.positionUpdated();
- //self.rowUpdated();
};
self.saveCursorPosition = function() {
@@ -85,30 +81,27 @@ function ANSIEscapeParser(options) {
};
function literal(text) {
- let charCode;
- let pos;
- let start = 0;
const len = text.length;
+ let pos = 0;
+ let start = 0;
+ let charCode;
- function emitLiteral() {
- self.emit('literal', text.slice(start, pos));
- start = pos;
- }
-
- for(pos = 0; pos < len; ++pos) {
- charCode = text.charCodeAt(pos) & 0xff; // ensure 8bit clean
+ while(pos < len) {
+ charCode = text.charCodeAt(pos) & 0xff; // 8bit clean
switch(charCode) {
- case CR :
- emitLiteral();
-
- self.column = 1;
+ case CR :
+ self.emit('literal', text.slice(start, pos));
+ start = pos;
+ self.column = 1;
+
self.positionUpdated();
break;
case LF :
- emitLiteral();
+ self.emit('literal', text.slice(start, pos));
+ start = pos;
self.row += 1;
@@ -116,73 +109,37 @@ function ANSIEscapeParser(options) {
break;
default :
- if(self.column > self.termWidth) {
- //
- // Emit data up to this point so it can be drawn before the postion update
- //
- emitLiteral();
+ if(self.column === self.termWidth) {
+ self.emit('literal', text.slice(start, pos + 1));
+ start = pos + 1;
self.column = 1;
self.row += 1;
-
- self.positionUpdated();
-
+ self.positionUpdated();
} else {
self.column += 1;
}
break;
}
+
+ ++pos;
}
- self.emit('literal', text.slice(start));
-
+ //
+ // Finalize this chunk
+ //
if(self.column > self.termWidth) {
self.column = 1;
self.row += 1;
+
self.positionUpdated();
}
- }
- function literal2(text) {
- var charCode;
-
- var len = text.length;
- for(var i = 0; i < len; i++) {
- charCode = text.charCodeAt(i) & 0xff; // ensure 8 bit
- switch(charCode) {
- case CR :
- self.column = 1;
- break;
-
- case LF :
- self.row++;
- self.positionUpdated();
- //self.rowUpdated();
- break;
-
- default :
- // wrap
- if(self.column > self.termWidth) {
- self.column = 1;
- self.row++;
- //self.rowUpdated();
- self.positionUpdated();
- } else {
- self.column += 1;
- }
- break;
- }
-
- if(self.row === self.termHeight) {
- self.scrollBack += 1;
- self.row -= 1;
-
- self.positionUpdated();
- }
+ const rem = text.slice(start);
+ if(rem) {
+ self.emit('literal', rem);
}
-
- self.emit('literal', text);
}
function getProcessedMCI(mci) {
@@ -238,10 +195,10 @@ function ANSIEscapeParser(options) {
});
if(self.mciReplaceChar.length > 0) {
- //self.emit('chunk', ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase));
const sgrCtrl = ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase);
+
self.emit('control', sgrCtrl, 'm', sgrCtrl.slice(2).split(/[\;m]/).slice(0, 3));
- //self.emit('control', ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase));
+
literal(new Array(match[0].length + 1).join(self.mciReplaceChar));
} else {
literal(match[0]);
@@ -436,6 +393,8 @@ function ANSIEscapeParser(options) {
// set graphic rendition
case 'm' :
+ self.graphicRendition.reset = false;
+
for(let i = 0, len = args.length; i < len; ++i) {
arg = args[i];
@@ -453,8 +412,12 @@ function ANSIEscapeParser(options) {
delete self.graphicRendition.negative;
delete self.graphicRendition.invisible;
- self.graphicRendition.fg = 39;
- self.graphicRendition.bg = 49;
+ delete self.graphicRendition.fg;
+ delete self.graphicRendition.bg;
+
+ self.graphicRendition.reset = true;
+ //self.graphicRendition.fg = 39;
+ //self.graphicRendition.bg = 49;
break;
case 1 :
@@ -490,6 +453,8 @@ function ANSIEscapeParser(options) {
}
}
}
+
+ self.emit('sgr update', self.graphicRendition);
break; // m
// :TODO: s, u, K
diff --git a/core/ansi_prep.js b/core/ansi_prep.js
new file mode 100644
index 00000000..29bd5ee4
--- /dev/null
+++ b/core/ansi_prep.js
@@ -0,0 +1,212 @@
+/* jslint node: true */
+'use strict';
+
+// ENiGMA½
+const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser;
+const ANSI = require('./ansi_term.js');
+const {
+ splitTextAtTerms,
+ renderStringLength
+} = require('./string_util.js');
+
+// deps
+const _ = require('lodash');
+
+module.exports = function ansiPrep(input, options, cb) {
+ if(!input) {
+ return cb(null, '');
+ }
+
+ options.termWidth = options.termWidth || 80;
+ options.termHeight = options.termHeight || 25;
+ options.cols = options.cols || options.termWidth || 80;
+ options.rows = options.rows || options.termHeight || 'auto';
+ options.startCol = options.startCol || 1;
+ options.exportMode = options.exportMode || false;
+
+ // in auto we start out at 25 rows, but can always expand for more
+ const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) );
+ const parser = new ANSIEscapeParser( { termHeight : options.termHeight, termWidth : options.termWidth } );
+
+ const state = {
+ row : 0,
+ col : 0,
+ };
+
+ let lastRow = 0;
+
+ function ensureRow(row) {
+ if(canvas[row]) {
+ return;
+ }
+
+ canvas[row] = Array.from( { length : options.cols}, () => new Object() );
+ }
+
+ parser.on('position update', (row, col) => {
+ state.row = row - 1;
+ state.col = col - 1;
+
+ if(0 === state.col) {
+ state.initialSgr = state.lastSgr;
+ }
+
+ lastRow = Math.max(state.row, lastRow);
+ });
+
+ parser.on('literal', literal => {
+ //
+ // CR/LF are handled for 'position update'; we don't need the chars themselves
+ //
+ literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, '');
+
+ for(let c of literal) {
+ if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) {
+ ensureRow(state.row);
+
+ if(0 === state.col) {
+ canvas[state.row][state.col].initialSgr = state.initialSgr;
+ }
+
+ canvas[state.row][state.col].char = c;
+
+ if(state.sgr) {
+ canvas[state.row][state.col].sgr = _.clone(state.sgr);
+ state.lastSgr = canvas[state.row][state.col].sgr;
+ state.sgr = null;
+ }
+ }
+
+ state.col += 1;
+ }
+ });
+
+ parser.on('sgr update', sgr => {
+ ensureRow(state.row);
+
+ if(state.col < options.cols) {
+ canvas[state.row][state.col].sgr = _.clone(sgr);
+ state.lastSgr = canvas[state.row][state.col].sgr;
+ } else {
+ state.sgr = sgr;
+ }
+ });
+
+ function getLastPopulatedColumn(row) {
+ let col = row.length;
+ while(--col > 0) {
+ if(row[col].char || row[col].sgr) {
+ break;
+ }
+ }
+ return col;
+ }
+
+ parser.on('complete', () => {
+ let output = '';
+ let line;
+ let sgr;
+
+ canvas.slice(0, lastRow + 1).forEach(row => {
+ const lastCol = getLastPopulatedColumn(row) + 1;
+
+ let i;
+ line = '';
+ for(i = 0; i < lastCol; ++i) {
+ const col = row[i];
+
+ sgr = 0 === i ?
+ col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' :
+ '';
+
+ if(col.sgr) {
+ sgr += ANSI.getSGRFromGraphicRendition(col.sgr);
+ }
+
+ line += `${sgr}${col.char || ' '}`;
+ }
+
+ output += line;
+
+ if(i < row.length) {
+ output += `${ANSI.blackBG()}${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`;
+ }
+
+ if(options.startCol + i < options.termWidth || options.forceLineTerm) {
+ output += '\r\n';
+ }
+ });
+
+ if(options.exportMode) {
+ //
+ // If we're in export mode, we do some additional hackery:
+ //
+ // * Hard wrap ALL lines at <= 79 *characters* (not visible columns)
+ // if a line must wrap early, we'll place a ESC[A ESC[C where
+ // represents chars to get back to the position we were previously at
+ //
+ // * Replace contig spaces with ESC[C as well to save... space.
+ //
+ // :TODO: this would be better to do as part of the processing above, but this will do for now
+ const MAX_CHARS = 79 - 8; // 79 max, - 8 for max ESC seq's we may prefix a line with
+ let exportOutput = '';
+
+ let m;
+ let afterSeq;
+ let wantMore;
+ let renderStart;
+
+ splitTextAtTerms(output).forEach(fullLine => {
+ renderStart = 0;
+
+ while(fullLine.length > 0) {
+ let splitAt;
+ const ANSI_REGEXP = ANSI.getFullMatchRegExp();
+ wantMore = true;
+
+ while((m = ANSI_REGEXP.exec(fullLine))) {
+ afterSeq = m.index + m[0].length;
+
+ if(afterSeq < MAX_CHARS) {
+ // after current seq
+ splitAt = afterSeq;
+ } else {
+ if(m.index < MAX_CHARS) {
+ // before last found seq
+ splitAt = m.index;
+ wantMore = false; // can't eat up any more
+ }
+
+ break; // seq's beyond this point are >= MAX_CHARS
+ }
+ }
+
+ if(splitAt) {
+ if(wantMore) {
+ splitAt = Math.min(fullLine.length, MAX_CHARS - 1);
+ }
+ } else {
+ splitAt = Math.min(fullLine.length, MAX_CHARS - 1);
+ }
+
+ const part = fullLine.slice(0, splitAt);
+ fullLine = fullLine.slice(splitAt);
+ renderStart += renderStringLength(part);
+ exportOutput += `${part}\r\n`;
+
+ if(fullLine.length > 0) { // more to go for this line?
+ exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`;
+ } else {
+ exportOutput += ANSI.up();
+ }
+ }
+ });
+
+ return cb(null, exportOutput);
+ }
+
+ return cb(null, output);
+ });
+
+ parser.parse(input);
+};
diff --git a/core/ansi_term.js b/core/ansi_term.js
index c9902c7d..8b4094f1 100644
--- a/core/ansi_term.js
+++ b/core/ansi_term.js
@@ -17,12 +17,22 @@
// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt
// * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm
//
+// VTX
+// * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
+//
// General
// * http://en.wikipedia.org/wiki/ANSI_escape_code
// * http://www.inwap.com/pdp10/ansicode.txt
//
// Other Implementations
// * https://github.com/chjj/term.js/blob/master/src/term.js
+//
+//
+// For a board, we need to support the semi-standard ANSI-BBS "spec" which
+// is bastardized mix of DOS ANSI.SYS, cterm.txt, bansi.txt and a little other.
+// This gives us NetRunner, SyncTERM, EtherTerm, most *nix terminals, compatibilitiy
+// with legit oldschool DOS terminals, and so on.
+//
// ENiGMA½
const miscUtil = require('./misc_util.js');
@@ -31,6 +41,7 @@ const miscUtil = require('./misc_util.js');
const assert = require('assert');
const _ = require('lodash');
+exports.getFullMatchRegExp = getFullMatchRegExp;
exports.getFGColorValue = getFGColorValue;
exports.getBGColorValue = getBGColorValue;
exports.sgr = sgr;
@@ -172,6 +183,12 @@ const SGRValues = {
whiteBG : 47,
};
+function getFullMatchRegExp(flags = 'g') {
+ // :TODO: expand this a bit - see strip-ansi/etc.
+ // :TODO: \u009b ?
+ return new RegExp(/[\u001b][[()#;?]*([0-9]{1,4}(?:;[0-9]{0,4})*)?([0-9A-ORZcf-npqrsuy=><])/, flags); // eslint-disable-line no-control-regex
+}
+
function getFGColorValue(name) {
return SGRValues[name];
}
@@ -289,7 +306,6 @@ const FONT_ALIAS_TO_SYNCTERM_MAP = {
'amiga_microknight' : 'microknight',
'amiga_microknight+' : 'microknight_plus',
-
'atari' : 'atari',
'atarist' : 'atari',
@@ -399,10 +415,6 @@ function getSGRFromGraphicRendition(graphicRendition, initialReset) {
}
});
- if(!styleCount) {
- sgrSeq.push(0);
- }
-
if(graphicRendition.fg) {
sgrSeq.push(graphicRendition.fg);
}
@@ -411,7 +423,7 @@ function getSGRFromGraphicRendition(graphicRendition, initialReset) {
sgrSeq.push(graphicRendition.bg);
}
- if(initialReset) {
+ if(0 === styleCount || initialReset) {
sgrSeq.unshift(0);
}
@@ -473,4 +485,3 @@ function setEmulatedBaudRate(rate) {
}[rate] || 0;
return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed);
}
-
diff --git a/core/art.js b/core/art.js
index b07665a7..28657fc0 100644
--- a/core/art.js
+++ b/core/art.js
@@ -157,18 +157,19 @@ function getArt(name, options, cb) {
// Ignore anything not allowed in |options.types|
//
const fext = paths.extname(file);
- if(options.types.indexOf(fext.toLowerCase()) < 0) {
+ if(!options.types.includes(fext.toLowerCase())) {
return false;
}
const bn = paths.basename(file, fext).toLowerCase();
if(options.random) {
const suppliedBn = paths.basename(name, fext).toLowerCase();
+
//
// Random selection enabled. We'll allow for
// basename1.ext, basename2.ext, ...
//
- if(bn.indexOf(suppliedBn) !== 0) {
+ if(!bn.startsWith(suppliedBn)) {
return false;
}
@@ -239,7 +240,11 @@ function display(client, art, options, cb) {
}
options.mciReplaceChar = options.mciReplaceChar || ' ';
- options.disableMciCache = options.disableMciCache || false;
+ options.disableMciCache = options.disableMciCache || false;
+
+ // :TODO: this is going to be broken into two approaches controlled via options:
+ // 1) Standard - use internal tracking of locations for MCI -- no CPR's/etc.
+ // 2) CPR driven
if(!_.isBoolean(options.iceColors)) {
// try to detect from SAUCE
@@ -282,7 +287,6 @@ function display(client, art, options, cb) {
return cb(null, mciMap, extraInfo);
}
-
if(!options.disableMciCache) {
artHash = farmhash.hash32(art);
@@ -335,14 +339,14 @@ function display(client, art, options, cb) {
}
mciCprQueue.push(mapKey);
- client.term.write(ansi.queryPos(), false);
+ client.term.rawWrite(ansi.queryPos());
}
});
}
ansiParser.on('literal', literal => client.term.write(literal, false) );
- ansiParser.on('control', control => client.term.write(control, false) );
+ ansiParser.on('control', control => client.term.rawWrite(control) );
ansiParser.on('complete', () => {
parseComplete = true;
@@ -352,29 +356,35 @@ function display(client, art, options, cb) {
}
});
- let ansiFontSeq;
+ let initSeq = '';
if(options.font) {
- ansiFontSeq = ansi.setSyncTermFontWithAlias(options.font);
+ initSeq = ansi.setSyncTermFontWithAlias(options.font);
} else if(options.sauce) {
let fontName = getFontNameFromSAUCE(options.sauce);
if(fontName) {
fontName = ansi.getSyncTERMFontFromAlias(fontName);
}
- // don't set default (CP437) from SAUCE
- if(fontName && 'cp437' !== fontName) {
- ansiFontSeq = ansi.setSyncTERMFont(fontName);
+ //
+ // Set SyncTERM font if we're switching only. Most terminals
+ // that support this ESC sequence can only show *one* font
+ // at a time. This applies to detection only (e.g. SAUCE).
+ // If explicit, we'll set it no matter what (above)
+ //
+ if(fontName && client.term.currentSyncFont != fontName) {
+ client.term.currentSyncFont = fontName;
+ initSeq = ansi.setSyncTERMFont(fontName);
}
}
- if(ansiFontSeq) {
- client.term.write(ansiFontSeq, false);
+ if(options.iceColors) {
+ initSeq += ansi.blinkToBrightIntensity();
}
- if(options.iceColors) {
- client.term.write(ansi.blinkToBrightIntensity(), false);
+ if(initSeq) {
+ client.term.rawWrite(initSeq);
}
ansiParser.reset(art);
- ansiParser.parse();
+ return ansiParser.parse();
}
diff --git a/core/bbs.js b/core/bbs.js
index ea351480..c43d63a3 100644
--- a/core/bbs.js
+++ b/core/bbs.js
@@ -9,7 +9,6 @@
const conf = require('./config.js');
const logger = require('./logger.js');
const database = require('./database.js');
-const clientConns = require('./client_connections.js');
const resolvePath = require('./misc_util.js').resolvePath;
// deps
@@ -27,7 +26,7 @@ exports.main = main;
const initServices = {};
const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby';
-const HELP =
+const HELP =
`${ENIGMA_COPYRIGHT}
usage: main.js
@@ -60,7 +59,7 @@ function main() {
conf.init(resolvePath(configPath), function configInit(err) {
//
- // If the user supplied a path and we can't read/parse it
+ // If the user supplied a path and we can't read/parse it
// then it's a fatal error
//
if(err) {
@@ -84,15 +83,15 @@ function main() {
}
return callback(err);
});
- },
+ }
],
function complete(err) {
// note this is escaped:
fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => {
console.info(ENIGMA_COPYRIGHT);
- if(!err) {
+ if(!err) {
console.info(banner);
- }
+ }
console.info('System started!');
});
@@ -111,11 +110,12 @@ function shutdownSystem() {
async.series(
[
function closeConnections(callback) {
- const activeConnections = clientConns.getActiveConnections();
+ const ClientConns = require('./client_connections.js');
+ const activeConnections = ClientConns.getActiveConnections();
let i = activeConnections.length;
while(i--) {
activeConnections[i].term.write('\n\nServer is shutting down NOW! Disconnecting...\n\n');
- clientConns.removeClient(activeConnections[i]);
+ ClientConns.removeClient(activeConnections[i]);
}
callback(null);
},
@@ -140,7 +140,7 @@ function shutdownSystem() {
},
function stopMsgNetwork(callback) {
require('./msg_network.js').shutdown(callback);
- }
+ }
],
() => {
console.info('Goodbye!');
@@ -173,12 +173,15 @@ function initialize(cb) {
process.on('SIGINT', shutdownSystem);
require('later').date.localTime(); // use local times for later.js/scheduling
-
+
return callback(null);
- },
+ },
function initDatabases(callback) {
return database.initializeDatabases(callback);
},
+ function initMimeTypes(callback) {
+ return require('./mime_util.js').startup(callback);
+ },
function initStatLog(callback) {
return require('./stat_log.js').init(callback);
},
@@ -195,7 +198,7 @@ function initialize(cb) {
// * Makes this accessible for MCI codes, easy non-blocking access, etc.
// * We do this every time as the op is free to change this information just
// like any other user
- //
+ //
const User = require('./user.js');
async.waterfall(
@@ -223,7 +226,7 @@ function initialize(cb) {
opProps.username = opUserName;
_.each(opProps, (v, k) => {
- StatLog.setNonPeristentSystemStat(`sysop_${k}`, v);
+ StatLog.setNonPeristentSystemStat(`sysop_${k}`, v);
});
}
@@ -235,7 +238,10 @@ function initialize(cb) {
return require('./predefined_mci.js').init(callback);
},
function readyMessageNetworkSupport(callback) {
- return require('./msg_network.js').startup(callback);
+ return require('./msg_network.js').startup(callback);
+ },
+ function readyEvents(callback) {
+ return require('./events.js').startup(callback);
},
function listenConnections(callback) {
return require('./listening_server.js').startup(callback);
diff --git a/core/client.js b/core/client.js
index 02b225b8..4501396d 100644
--- a/core/client.js
+++ b/core/client.js
@@ -120,6 +120,7 @@ function Client(input, output) {
// * Irssi ConnectBot (Android)
//
'63;1;2' : 'arctel',
+ '50;86;84;88' : 'vtx',
}[deviceAttr];
if(!termClient) {
@@ -491,3 +492,13 @@ Client.prototype.defaultHandlerMissingMod = function(err) {
return handler;
};
+Client.prototype.terminalSupports = function(query) {
+ switch(query) {
+ case 'vtx_audio' :
+ // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
+ return this.termClient === 'vtx';
+
+ default :
+ return false;
+ }
+};
diff --git a/core/client_connections.js b/core/client_connections.js
index 5cbeb3b9..7e74e29d 100644
--- a/core/client_connections.js
+++ b/core/client_connections.js
@@ -3,6 +3,7 @@
// ENiGMA½
const logger = require('./logger.js');
+const Events = require('./events.js');
// deps
const _ = require('lodash');
@@ -22,7 +23,7 @@ function getActiveConnections() { return clientConnections; }
function getActiveNodeList(authUsersOnly) {
if(!_.isBoolean(authUsersOnly)) {
- authUsersOnly = true;
+ authUsersOnly = true;
}
const now = moment();
@@ -30,7 +31,7 @@ function getActiveNodeList(authUsersOnly) {
const activeConnections = getActiveConnections().filter(ac => {
return ((authUsersOnly && ac.user.isAuthenticated()) || !authUsersOnly);
});
-
+
return _.map(activeConnections, ac => {
const entry = {
node : ac.node,
@@ -41,7 +42,7 @@ function getActiveNodeList(authUsersOnly) {
//
// There may be a connection, but not a logged in user as of yet
- //
+ //
if(ac.user.isAuthenticated()) {
entry.userName = ac.user.username;
entry.realName = ac.user.properties.real_name;
@@ -49,7 +50,7 @@ function getActiveNodeList(authUsersOnly) {
entry.affils = ac.user.properties.affiliation;
const diff = now.diff(moment(ac.user.properties.last_login_timestamp), 'minutes');
- entry.timeOn = moment.duration(diff, 'minutes');
+ entry.timeOn = moment.duration(diff, 'minutes');
}
return entry;
});
@@ -59,7 +60,7 @@ function addNewClient(client, clientSock) {
const id = client.session.id = clientConnections.push(client) - 1;
const remoteAddress = client.remoteAddress = clientSock.remoteAddress;
- // Create a client specific logger
+ // Create a client specific logger
// Note that this will be updated @ login with additional information
client.log = logger.log.child( { clientId : id } );
@@ -76,6 +77,8 @@ function addNewClient(client, clientSock) {
client.log.info(connInfo, 'Client connected');
+ Events.emit('codes.l33t.enigma.system.connected', { client : client, connectionCount : clientConnections.length } );
+
return id;
}
@@ -85,17 +88,19 @@ function removeClient(client) {
const i = clientConnections.indexOf(client);
if(i > -1) {
clientConnections.splice(i, 1);
-
+
logger.log.info(
- {
+ {
connectionCount : clientConnections.length,
- clientId : client.session.id
- },
+ clientId : client.session.id
+ },
'Client disconnected'
);
+
+ Events.emit('codes.l33t.enigma.system.disconnected', { client : client, connectionCount : clientConnections.length } );
}
}
function getConnectionByUserId(userId) {
return getActiveConnections().find( ac => userId === ac.user.userId );
-}
\ No newline at end of file
+}
diff --git a/core/client_term.js b/core/client_term.js
index c9a9e66f..9992f9f3 100644
--- a/core/client_term.js
+++ b/core/client_term.js
@@ -32,6 +32,8 @@ function ClientTerminal(output) {
var termWidth = 0;
var termClient = 'unknown';
+ this.currentSyncFont = 'not_set';
+
// Raw values set by e.g. telnet NAWS, ENVIRONMENT, etc.
this.env = {};
@@ -169,7 +171,7 @@ ClientTerminal.prototype.pipeWrite = function(s, spec, cb) {
var conv = {
enigma : enigmaToAnsi,
renegade : renegadeToAnsi,
- }[spec] || enigmaToAnsi;
+ }[spec] || renegadeToAnsi;
this.write(conv(s, this), null, cb); // null = use default for |convertLineFeeds|
};
@@ -183,3 +185,4 @@ ClientTerminal.prototype.encode = function(s, convertLineFeeds) {
return iconv.encode(s, this.outputEncoding);
};
+
diff --git a/core/color_codes.js b/core/color_codes.js
index 92a8650e..4dc8da99 100644
--- a/core/color_codes.js
+++ b/core/color_codes.js
@@ -23,6 +23,8 @@ exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi;
// * fromWWIV(): <0-7>
// * fromSyncronet():
// See http://wiki.synchro.net/custom:colors
+
+// :TODO: rid of enigmaToAnsi() -- never really use. Instead, create bbsToAnsi() that supports renegade, PCB, WWIV, etc...
function enigmaToAnsi(s, client) {
if(-1 == s.indexOf('|')) {
return s; // no pipe codes present
@@ -31,7 +33,7 @@ function enigmaToAnsi(s, client) {
var result = '';
var re = /\|([A-Z\d]{2}|\|)/g;
var m;
- var lastIndex = 0;
+ var lastIndex = 0;
while((m = re.exec(s))) {
var val = m[1];
@@ -65,18 +67,18 @@ function enigmaToAnsi(s, client) {
}
result += s.substr(lastIndex, m.index - lastIndex) + attr;
- }
+ }
- lastIndex = re.lastIndex;
+ lastIndex = re.lastIndex;
}
- result = (0 === result.length ? s : result + s.substr(lastIndex));
+ result = (0 === result.length ? s : result + s.substr(lastIndex));
- return result;
+ return result;
}
function stripEnigmaCodes(s) {
- return s.replace(/\|[A-Z\d]{2}/g, '');
+ return s.replace(/\|[A-Z\d]{2}/g, '');
}
function enigmaStrLen(s) {
diff --git a/core/config.js b/core/config.js
index 25e409f4..449a5c98 100644
--- a/core/config.js
+++ b/core/config.js
@@ -100,6 +100,11 @@ function init(configPath, options, cb) {
],
function complete(err, mergedConfig) {
exports.config = mergedConfig;
+
+ exports.config.get = function(path) {
+ return _.get(exports.config, path);
+ };
+
return cb(err);
}
);
@@ -316,6 +321,24 @@ function getDefaultConfig() {
longDescUtil : 'Exiftool',
},
//
+ // Video
+ //
+ 'video/mp4' : {
+ desc : 'MPEG Video',
+ shortDescUtil : 'Exiftool2Desc',
+ longDescUtil : 'Exiftool',
+ },
+ 'video/x-matroska ' : {
+ desc : 'Matroska Video',
+ shortDescUtil : 'Exiftool2Desc',
+ longDescUtil : 'Exiftool',
+ },
+ 'video/x-msvideo' : {
+ desc : 'Audio Video Interleave',
+ shortDescUtil : 'Exiftool2Desc',
+ longDescUtil : 'Exiftool',
+ },
+ //
// Images
//
'image/jpeg' : {
@@ -347,6 +370,12 @@ function getDefaultConfig() {
offset : 0,
archiveHandler : '7Zip',
},
+ /*
+ 'application/x-cbr' : {
+ desc : 'Comic Book Archive',
+ sig : '504b0304',
+ },
+ */
'application/x-arj' : {
desc : 'ARJ Archive',
sig : '60ea',
@@ -363,7 +392,7 @@ function getDefaultConfig() {
desc : 'Gzip Archive',
sig : '1f8b',
offset : 0,
- archiveHandler : '7Zip',
+ archiveHandler : 'TarGz',
},
// :TODO: application/x-bzip
'application/x-bzip2' : {
@@ -474,6 +503,22 @@ function getDefaultConfig() {
cmd : 'unrar',
args : [ 'e', '{archivePath}', '{extractPath}', '{fileList}' ],
}
+ },
+
+ TarGz : {
+ decompress : {
+ cmd : 'tar',
+ args : [ '-xf', '{archivePath}', '-C', '{extractPath}', '--strip-components=1' ],
+ },
+ list : {
+ cmd : 'tar',
+ args : [ '-tvf', '{archivePath}' ],
+ entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$',
+ },
+ extract : {
+ cmd : 'tar',
+ args : [ '-xvf', '{archivePath}', '-C', '{extractPath}', '{fileList}' ],
+ }
}
},
},
@@ -582,8 +627,10 @@ function getDefaultConfig() {
// Actual sizes may be slightly larger when we must place a full
// PKT contents *somewhere*
//
- packetTargetByteSize : 512000, // 512k, before placing messages in a new pkt
- bundleTargetByteSize : 2048000, // 2M, before creating another archive
+ packetTargetByteSize : 512000, // 512k, before placing messages in a new pkt
+ bundleTargetByteSize : 2048000, // 2M, before creating another archive
+ packetMsgEncoding : 'utf8', // default packet encoding. Override per node if desired.
+ packetAnsiMsgEncoding : 'cp437', // packet encoding for *ANSI ART* messages
tic : {
secureInOnly : true, // only bring in from secure inbound (|secInbound| path, password protected)
diff --git a/core/connect.js b/core/connect.js
index ffd28a0c..b94fa586 100644
--- a/core/connect.js
+++ b/core/connect.js
@@ -3,6 +3,7 @@
// ENiGMA½
const ansi = require('./ansi_term.js');
+const Events = require('./events.js');
// deps
const async = require('async');
@@ -82,7 +83,7 @@ function ansiQueryTermSizeIfNeeded(client, cb) {
//
if(h < 10 || w < 10) {
client.log.warn(
- { height : h, width : w },
+ { height : h, width : w },
'Ignoring ANSI CPR screen size query response due to very small values');
return done(new Error('Term size <= 10 considered invalid'));
}
@@ -91,11 +92,11 @@ function ansiQueryTermSizeIfNeeded(client, cb) {
client.term.termWidth = w;
client.log.debug(
- {
- termWidth : client.term.termWidth,
- termHeight : client.term.termHeight,
- source : 'ANSI CPR'
- },
+ {
+ termWidth : client.term.termWidth,
+ termHeight : client.term.termHeight,
+ source : 'ANSI CPR'
+ },
'Window size updated'
);
@@ -109,7 +110,7 @@ function ansiQueryTermSizeIfNeeded(client, cb) {
return done(new Error('No term size established by CPR within timeout'));
}, 2000);
- // Start the process: Query for CPR
+ // Start the process: Query for CPR
client.term.rawWrite(ansi.queryScreenSize());
}
@@ -123,7 +124,7 @@ function displayBanner(term) {
// note: intentional formatting:
term.pipeWrite(`
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN
-|06Copyright (c) 2014-2017 Bryan Ashby |14- |12http://l33t.codes/
+|06Copyright (c) 2014-2017 Bryan Ashby |14- |12http://l33t.codes/
|06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|00`
);
@@ -153,11 +154,11 @@ function connectEntry(client, nextMenu) {
if(0 === term.termHeight || 0 === term.termWidth) {
//
// We still don't have something good for term height/width.
- // Default to DOS size 80x25.
+ // Default to DOS size 80x25.
//
- // :TODO: Netrunner is currenting hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing???
+ // :TODO: Netrunner is currenting hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing???
client.log.warn( { reason : err.message }, 'Failed to negotiate term size; Defaulting to 80x25!');
-
+
term.termHeight = 25;
term.termWidth = 80;
}
@@ -165,8 +166,8 @@ function connectEntry(client, nextMenu) {
return callback(null);
});
- },
- ],
+ },
+ ],
() => {
prepareTerminal(term);
@@ -175,6 +176,9 @@ function connectEntry(client, nextMenu) {
//
displayBanner(term);
+ // fire event
+ Events.emit('codes.l33t.enigma.system.term_detected', { client : client } );
+
setTimeout( () => {
return client.menuStack.goto(nextMenu);
}, 500);
diff --git a/core/database.js b/core/database.js
index e3f8fc8a..d4fb4795 100644
--- a/core/database.js
+++ b/core/database.js
@@ -71,9 +71,13 @@ function initializeDatabases(cb) {
});
}
+function enableForeignKeys(db) {
+ db.run('PRAGMA foreign_keys = ON;');
+}
+
const DB_INIT_TABLE = {
system : (cb) => {
- dbs.system.run('PRAGMA foreign_keys = ON;');
+ enableForeignKeys(dbs.system);
// Various stat/event logging - see stat_log.js
dbs.system.run(
@@ -110,7 +114,7 @@ const DB_INIT_TABLE = {
},
user : (cb) => {
- dbs.user.run('PRAGMA foreign_keys = ON;');
+ enableForeignKeys(dbs.user);
dbs.user.run(
`CREATE TABLE IF NOT EXISTS user (
@@ -152,7 +156,7 @@ const DB_INIT_TABLE = {
},
message : (cb) => {
- dbs.message.run('PRAGMA foreign_keys = ON;');
+ enableForeignKeys(dbs.message);
dbs.message.run(
`CREATE TABLE IF NOT EXISTS message (
@@ -260,7 +264,7 @@ const DB_INIT_TABLE = {
},
file : (cb) => {
- dbs.file.run('PRAGMA foreign_keys = ON;');
+ enableForeignKeys(dbs.file);
dbs.file.run(
// :TODO: should any of this be unique -- file_sha256 unless dupes are allowed on the system
diff --git a/core/descript_ion_file.js b/core/descript_ion_file.js
new file mode 100644
index 00000000..8f2bc1b3
--- /dev/null
+++ b/core/descript_ion_file.js
@@ -0,0 +1,72 @@
+/* jslint node: true */
+'use strict';
+
+// deps
+const fs = require('graceful-fs');
+const iconv = require('iconv-lite');
+const async = require('async');
+
+module.exports = class DescriptIonFile {
+ constructor() {
+ this.entries = new Map();
+ }
+
+ get(fileName) {
+ return this.entries.get(fileName);
+ }
+
+ getDescription(fileName) {
+ const entry = this.get(fileName);
+ if(entry) {
+ return entry.desc;
+ }
+ }
+
+ static createFromFile(path, cb) {
+ fs.readFile(path, (err, descData) => {
+ if(err) {
+ return cb(err);
+ }
+
+ const descIonFile = new DescriptIonFile();
+
+ // DESCRIPT.ION entries are terminated with a CR and/or LF
+ const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g);
+
+ async.each(lines, (entryData, nextLine) => {
+ //
+ // We allow quoted (long) filenames or non-quoted filenames.
+ // FILENAMEDESC<0x04>
+ //
+ const parts = entryData.match(/^(?:(?:"([^"]+)" )|(?:([^ ]+) ))([^\x04]+)\x04(.)[^\r\n]*$/); // eslint-disable-line no-control-regex
+ if(!parts) {
+ return nextLine(null);
+ }
+
+ const fileName = parts[1] || parts[2];
+
+ //
+ // Un-escape CR/LF's
+ // - escapped \r and/or \n
+ // - BBBS style @n - See https://www.bbbs.net/sysop.html
+ //
+ const desc = parts[3].replace(/\\r\\n|\\n|[^@]@n/g, '\r\n');
+
+ descIonFile.entries.set(
+ fileName,
+ {
+ desc : desc,
+ programId : parts[4],
+ programData : parts[5],
+ }
+ );
+
+ return nextLine(null);
+ },
+ () => {
+ return cb(null, descIonFile);
+ });
+ });
+ }
+};
+
diff --git a/core/door_party.js b/core/door_party.js
index 1766f5ff..762f626b 100644
--- a/core/door_party.js
+++ b/core/door_party.js
@@ -96,6 +96,10 @@ exports.getModule = class DoorPartyModule extends MenuModule {
});
});
});
+
+ sshClient.on('error', err => {
+ self.client.log.info(`DoorParty SSH client error: ${err.message}`);
+ });
sshClient.on('close', () => {
restorePipe();
diff --git a/core/events.js b/core/events.js
new file mode 100644
index 00000000..8e16a374
--- /dev/null
+++ b/core/events.js
@@ -0,0 +1,73 @@
+/* jslint node: true */
+'use strict';
+
+const paths = require('path');
+const events = require('events');
+const Log = require('./logger.js').log;
+
+// deps
+const _ = require('lodash');
+const async = require('async');
+const glob = require('glob');
+
+module.exports = new class Events extends events.EventEmitter {
+ constructor() {
+ super();
+ }
+
+ addListener(event, listener) {
+ Log.trace( { event : event }, 'Registering event listener');
+ return super.addListener(event, listener);
+ }
+
+ emit(event, ...args) {
+ Log.trace( { event : event }, 'Emitting event');
+ return super.emit(event, args);
+ }
+
+ on(event, listener) {
+ Log.trace( { event : event }, 'Registering event listener');
+ return super.on(event, listener);
+ }
+
+ once(event, listener) {
+ Log.trace( { event : event }, 'Registering single use event listener');
+ return super.once(event, listener);
+ }
+
+ removeListener(event, listener) {
+ Log.trace( { event : event }, 'Removing listener');
+ return super.removeListener(event, listener);
+ }
+
+ startup(cb) {
+ async.each(require('./module_util.js').getModulePaths(), (modulePath, nextPath) => {
+ glob('*{.js,/*.js}', { cwd : modulePath }, (err, files) => {
+ if(err) {
+ return nextPath(err);
+ }
+
+ async.each(files, (moduleName, nextModule) => {
+ modulePath = paths.join(modulePath, moduleName);
+
+ try {
+ const mod = require(modulePath);
+
+ if(_.isFunction(mod.registerEvents)) {
+ // :TODO: ... or just systemInit() / systemShutdown() & mods could call Events.on() / Events.removeListener() ?
+ mod.registerEvents(this);
+ }
+ } catch(e) {
+
+ }
+
+ return nextModule(null);
+ }, err => {
+ return nextPath(err);
+ });
+ });
+ }, err => {
+ return cb(err);
+ });
+ }
+};
diff --git a/core/exodus.js b/core/exodus.js
new file mode 100644
index 00000000..e77183ee
--- /dev/null
+++ b/core/exodus.js
@@ -0,0 +1,231 @@
+/* jslint node: true */
+'use strict';
+
+// ENiGMA½
+const MenuModule = require('../core/menu_module.js').MenuModule;
+const resetScreen = require('../core/ansi_term.js').resetScreen;
+const Config = require('./config.js').config;
+const Errors = require('./enig_error.js').Errors;
+const Log = require('./logger.js').log;
+const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent;
+
+// deps
+const async = require('async');
+const _ = require('lodash');
+const joinPath = require('path').join;
+const crypto = require('crypto');
+const moment = require('moment');
+const https = require('https');
+const querystring = require('querystring');
+const fs = require('fs');
+const SSHClient = require('ssh2').Client;
+
+/*
+ Configuration block:
+
+
+ someDoor: {
+ module: exodus
+ config: {
+ // defaults
+ ticketHost: oddnetwork.org
+ ticketPort: 1984
+ ticketPath: /exodus
+ rejectUnauthorized: false // set to true to allow untrusted CA's (dangerous!)
+ sshHost: oddnetwork.org
+ sshPort: 22
+ sshUser: exodus
+ sshKeyPem: /path/to/enigma-bbs/misc/exodus.id_rsa
+
+ // optional
+ caPem: /path/to/cacerts.pem // see https://curl.haxx.se/docs/caextract.html
+
+ // required
+ board: XXXX
+ key: XXXX
+ door: some_door
+ }
+ }
+*/
+
+exports.moduleInfo = {
+ name : 'Exodus',
+ desc : 'Exodus Door Server Access Module - https://oddnetwork.org/exodus/',
+ author : 'NuSkooler',
+};
+
+exports.getModule = class ExodusModule extends MenuModule {
+ constructor(options) {
+ super(options);
+
+ this.config = options.menuConfig.config || {};
+ this.config.ticketHost = this.config.ticketHost || 'oddnetwork.org';
+ this.config.ticketPort = this.config.ticketPort || 1984,
+ this.config.ticketPath = this.config.ticketPath || '/exodus';
+ this.config.rejectUnauthorized = _.get(this.config, 'rejectUnauthorized', true);
+ this.config.sshHost = this.config.sshHost || this.config.ticketHost;
+ this.config.sshPort = this.config.sshPort || 22;
+ this.config.sshUser = this.config.sshUser || 'exodus_server';
+ this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config.paths.misc, 'exodus.id_rsa');
+ }
+
+ initSequence() {
+
+ const self = this;
+ let clientTerminated = false;
+
+ async.waterfall(
+ [
+ function validateConfig(callback) {
+ // very basic validation on optionals
+ async.each( [ 'board', 'key', 'door' ], (key, next) => {
+ return _.isString(self.config[key]) ? next(null) : next(Errors.MissingConfig(`Config requires "${key}"!`));
+ }, callback);
+ },
+ function loadCertAuthorities(callback) {
+ if(!_.isString(self.config.caPem)) {
+ return callback(null, null);
+ }
+
+ fs.readFile(self.config.caPem, (err, certAuthorities) => {
+ return callback(err, certAuthorities);
+ });
+ },
+ function getTicket(certAuthorities, callback) {
+ const now = moment.utc().unix();
+ const sha256 = crypto.createHash('sha256').update(`${self.config.key}${now}`).digest('hex');
+ const token = `${sha256}|${now}`;
+
+ const postData = querystring.stringify({
+ token : token,
+ board : self.config.board,
+ user : self.client.user.username,
+ door : self.config.door,
+ });
+
+ const reqOptions = {
+ hostname : self.config.ticketHost,
+ port : self.config.ticketPort,
+ path : self.config.ticketPath,
+ rejectUnauthorized : self.config.rejectUnauthorized,
+ method : 'POST',
+ headers : {
+ 'Content-Type' : 'application/x-www-form-urlencoded',
+ 'Content-Length' : postData.length,
+ 'User-Agent' : getEnigmaUserAgent(),
+ }
+ };
+
+ if(certAuthorities) {
+ reqOptions.ca = certAuthorities;
+ }
+
+ let ticket = '';
+ const req = https.request(reqOptions, res => {
+ res.on('data', data => {
+ ticket += data;
+ });
+
+ res.on('end', () => {
+ if(ticket.length !== 36) {
+ return callback(Errors.Invalid(`Invalid Exodus ticket: ${ticket}`));
+ }
+
+ return callback(null, ticket);
+ });
+ });
+
+ req.on('error', err => {
+ return callback(Errors.General(`Exodus error: ${err.message}`));
+ });
+
+ req.write(postData);
+ req.end();
+ },
+ function loadPrivateKey(ticket, callback) {
+ fs.readFile(self.config.sshKeyPem, (err, privateKey) => {
+ return callback(err, ticket, privateKey);
+ });
+ },
+ function establishSecureConnection(ticket, privateKey, callback) {
+
+ let pipeRestored = false;
+ let pipedStream;
+
+ function restorePipe() {
+ if(pipedStream && !pipeRestored && !clientTerminated) {
+ self.client.term.output.unpipe(pipedStream);
+ self.client.term.output.resume();
+ }
+ }
+
+ self.client.term.write(resetScreen());
+ self.client.term.write('Connecting to Exodus server, please wait...\n');
+
+ const sshClient = new SSHClient();
+
+ const window = {
+ rows : self.client.term.termHeight,
+ cols : self.client.term.termWidth,
+ width : 0,
+ height : 0,
+ term : 'vt100', // Want to pass |self.client.term.termClient| here, but we end up getting hung up on :(
+ };
+
+ const options = {
+ env : {
+ exodus : ticket,
+ },
+ };
+
+ sshClient.on('ready', () => {
+ self.client.once('end', () => {
+ self.client.log.info('Connection ended. Terminating Exodus connection');
+ clientTerminated = true;
+ return sshClient.end();
+ });
+
+ sshClient.shell(window, options, (err, stream) => {
+ pipedStream = stream; // :TODO: ewwwwwwwww hack
+ self.client.term.output.pipe(stream);
+
+ stream.on('data', d => {
+ return self.client.term.rawWrite(d);
+ });
+
+ stream.on('close', () => {
+ restorePipe();
+ return sshClient.end();
+ });
+
+ stream.on('error', err => {
+ Log.warn( { error : err.message }, 'Exodus SSH client stream error');
+ });
+ });
+ });
+
+ sshClient.on('close', () => {
+ restorePipe();
+ return callback(null);
+ });
+
+ sshClient.connect({
+ host : self.config.sshHost,
+ port : self.config.sshPort,
+ username : self.config.sshUser,
+ privateKey : privateKey,
+ });
+ }
+ ],
+ err => {
+ if(err) {
+ self.client.log.warn( { error : err.message }, 'Exodus error');
+ }
+
+ if(!clientTerminated) {
+ self.prevMenu();
+ }
+ }
+ );
+ }
+};
diff --git a/core/file_area_web.js b/core/file_area_web.js
index b4d93d81..bac85de7 100644
--- a/core/file_area_web.js
+++ b/core/file_area_web.js
@@ -284,7 +284,7 @@ class FileAreaWebAccess {
resp.on('finish', () => {
// transfer completed fully
- this.updateDownloadStatsForUserId(servedItem.userId, stats.size);
+ this.updateDownloadStatsForUserIdAndSystemAndSystem(servedItem.userId, stats.size);
});
const headers = {
@@ -301,7 +301,7 @@ class FileAreaWebAccess {
});
}
- updateDownloadStatsForUserId(userId, dlBytes, cb) {
+ updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) {
async.waterfall(
[
function fetchActiveUser(callback) {
diff --git a/core/file_base_filter.js b/core/file_base_filter.js
index 801ae47e..fadd41fd 100644
--- a/core/file_base_filter.js
+++ b/core/file_base_filter.js
@@ -1,8 +1,9 @@
/* jslint node: true */
'use strict';
-const _ = require('lodash');
-const uuidV4 = require('uuid/v4');
+// deps
+const _ = require('lodash');
+const uuidV4 = require('uuid/v4');
module.exports = class FileBaseFilters {
constructor(client) {
@@ -65,14 +66,14 @@ module.exports = class FileBaseFilters {
let filtersProperty = this.client.user.properties.file_base_filters;
let defaulted;
if(!filtersProperty) {
- filtersProperty = JSON.stringify(FileBaseFilters.getDefaultFilters());
+ filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters());
defaulted = true;
}
try {
this.filters = JSON.parse(filtersProperty);
} catch(e) {
- this.filters = FileBaseFilters.getDefaultFilters(); // something bad happened; reset everything back to defaults :(
+ this.filters = FileBaseFilters.getBuiltInSystemFilters(); // something bad happened; reset everything back to defaults :(
defaulted = true;
this.client.log.error( { error : e.message, property : filtersProperty }, 'Failed parsing file base filters property' );
}
@@ -107,18 +108,20 @@ module.exports = class FileBaseFilters {
return false;
}
- static getDefaultFilters() {
- const filters = {};
-
- const uuid = uuidV4();
- filters[uuid] = {
- name : 'Default',
- areaTag : '', // all
- terms : '', // *
- tags : '', // *
- order : 'descending',
- sort : 'upload_timestamp',
- uuid : uuid,
+ static getBuiltInSystemFilters() {
+ const U_LATEST = '7458b09d-40ab-4f9b-a0d7-0cf866646329';
+
+ const filters = {
+ [ U_LATEST ] : {
+ name : 'By Date Added',
+ areaTag : '', // all
+ terms : '', // *
+ tags : '', // *
+ order : 'descending',
+ sort : 'upload_timestamp',
+ uuid : U_LATEST,
+ system : true,
+ }
};
return filters;
@@ -127,4 +130,20 @@ module.exports = class FileBaseFilters {
static getActiveFilter(client) {
return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid);
}
+
+ static getFileBaseLastViewedFileIdByUser(user) {
+ return parseInt((user.properties.user_file_base_last_viewed || 0));
+ }
+
+ static setFileBaseLastViewedFileIdForUser(user, fileId, cb) {
+ const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user);
+ if(fileId < current) {
+ if(cb) {
+ cb(null);
+ }
+ return;
+ }
+
+ return user.persistProperty('user_file_base_last_viewed', fileId, cb);
+ }
};
diff --git a/core/file_entry.js b/core/file_entry.js
index 06fb6fe1..b88d41a0 100644
--- a/core/file_entry.js
+++ b/core/file_entry.js
@@ -510,6 +510,14 @@ module.exports = class FileEntry {
);
}
+ if(_.isString(filter.newerThanTimestamp) && filter.newerThanTimestamp.length > 0) {
+ appendWhereClause(`DATETIME(f.upload_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`);
+ }
+
+ if(_.isNumber(filter.newerThanFileId)) {
+ appendWhereClause(`f.file_id > ${filter.newerThanFileId}`);
+ }
+
sql += `${sqlWhere} ${sqlOrderBy};`;
const matchingFileIds = [];
diff --git a/core/fse.js b/core/fse.js
index 7b93e800..ed9d3b13 100644
--- a/core/fse.js
+++ b/core/fse.js
@@ -10,10 +10,11 @@ const Message = require('./message.js');
const updateMessageAreaLastReadId = require('./message_area.js').updateMessageAreaLastReadId;
const getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag;
const User = require('./user.js');
-const cleanControlCodes = require('./string_util.js').cleanControlCodes;
const StatLog = require('./stat_log.js');
const stringFormat = require('./string_format.js');
const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher;
+const { isAnsi, cleanControlCodes, insert } = require('./string_util.js');
+const Config = require('./config.js').config;
// deps
const async = require('async');
@@ -168,7 +169,6 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
}
cb(newFocusViewId);
},
-
headerSubmit : function(formData, extraArgs, cb) {
self.switchToBody();
return cb(null);
@@ -210,21 +210,24 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
},
appendQuoteEntry: function(formData, extraArgs, cb) {
// :TODO: Dont' use magic # ID's here
- var quoteMsgView = self.viewControllers.quoteBuilder.getView(1);
-
+ const quoteMsgView = self.viewControllers.quoteBuilder.getView(1);
+
if(self.newQuoteBlock) {
self.newQuoteBlock = false;
+
+ // :TODO: If replying to ANSI, add a blank sepration line here
+
quoteMsgView.addText(self.getQuoteByHeader());
}
- var quoteText = self.viewControllers.quoteBuilder.getView(3).getItem(formData.value.quote);
+ const quoteText = self.viewControllers.quoteBuilder.getView(3).getItem(formData.value.quote);
quoteMsgView.addText(quoteText);
//
// If this is *not* the last item, advance. Otherwise, do nothing as we
// don't want to jump back to the top and repeat already quoted lines
//
- var quoteListView = self.viewControllers.quoteBuilder.getView(3);
+ const quoteListView = self.viewControllers.quoteBuilder.getView(3);
if(quoteListView.getData() !== quoteListView.getCount() - 1) {
quoteListView.focusNext();
} else {
@@ -316,22 +319,38 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
}
}
- buildMessage() {
+ buildMessage(cb) {
const headerValues = this.viewControllers.header.getFormData().value;
- var msgOpts = {
+ const msgOpts = {
areaTag : this.messageAreaTag,
toUserName : headerValues.to,
fromUserName : this.client.user.username,
subject : headerValues.subject,
- message : this.viewControllers.body.getFormData().value.message,
+ // :TODO: don't hard code 1 here:
+ message : this.viewControllers.body.getView(1).getData( { forceLineTerms : this.replyIsAnsi } ),
};
if(this.isReply()) {
msgOpts.replyToMsgId = this.replyToMessage.messageId;
+
+ if(this.replyIsAnsi) {
+ //
+ // Ensure first characters indicate ANSI for detection down
+ // the line (other boards/etc.). We also set explicit_encoding
+ // to packetAnsiMsgEncoding (generally cp437) as various boards
+ // really don't like ANSI messages in UTF-8 encoding (they should!)
+ //
+ msgOpts.meta = { System : { 'explicit_encoding' : Config.scannerTossers.ftn_bso.packetAnsiMsgEncoding || 'cp437' } };
+ // :TODO: change to \r\nESC[A
+ //msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}${msgOpts.message}`;
+ msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}\r\n${ansi.up()}${msgOpts.message}`;
+ }
}
this.message = new Message(msgOpts);
+
+ return cb(null);
}
setMessage(message) {
@@ -344,9 +363,35 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
this.initHeaderViewMode();
this.initFooterViewMode();
- var bodyMessageView = this.viewControllers.body.getView(1);
+ const bodyMessageView = this.viewControllers.body.getView(1);
+ let msg = this.message.message;
+
if(bodyMessageView && _.has(this, 'message.message')) {
- bodyMessageView.setText(cleanControlCodes(this.message.message));
+ //
+ // We handle ANSI messages differently than standard messages -- this is required as
+ // we don't want to do things like word wrap ANSI, but instead, trust that it's formatted
+ // how the author wanted it
+ //
+ if(isAnsi(msg)) {
+ //
+ // Find tearline - we want to color it differently.
+ //
+ const tearLinePos = this.message.getTearLinePosition(msg);
+
+ if(tearLinePos > -1) {
+ msg = insert(msg, tearLinePos, bodyMessageView.getSGRFor('text'));
+ }
+
+ bodyMessageView.setAnsi(
+ msg.replace(/\r?\n/g, '\r\n'), // messages are stored with CRLF -> LF
+ {
+ prepped : false,
+ forceLineTerm : true,
+ }
+ );
+ } else {
+ bodyMessageView.setText(cleanControlCodes(msg));
+ }
}
}
}
@@ -360,9 +405,10 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
[
function buildIfNecessary(callback) {
if(self.isEditMode()) {
- self.buildMessage(); // creates initial self.message
+ return self.buildMessage(callback); // creates initial self.message
}
- callback(null);
+
+ return callback(null);
},
function populateLocalUserInfo(callback) {
if(self.isLocalEmail()) {
@@ -659,16 +705,18 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
break;
case 'edit' :
- const fromView = self.viewControllers.header.getView(1);
- const area = getMessageAreaByTag(self.messageAreaTag);
- if(area && area.realNames) {
- fromView.setText(self.client.user.properties.real_name || self.client.user.username);
- } else {
- fromView.setText(self.client.user.username);
- }
-
- if(self.replyToMessage) {
- self.initHeaderReplyEditMode();
+ {
+ const fromView = self.viewControllers.header.getView(1);
+ const area = getMessageAreaByTag(self.messageAreaTag);
+ if(area && area.realNames) {
+ fromView.setText(self.client.user.properties.real_name || self.client.user.username);
+ } else {
+ fromView.setText(self.client.user.username);
+ }
+
+ if(self.replyToMessage) {
+ self.initHeaderReplyEditMode();
+ }
}
break;
}
@@ -848,9 +896,31 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
}
},
function loadQuoteLines(callback) {
- var quoteView = self.viewControllers.quoteBuilder.getView(3);
- quoteView.setItems(self.replyToMessage.getQuoteLines(quoteView.dimens.width));
- callback(null);
+ const quoteView = self.viewControllers.quoteBuilder.getView(3);
+ const bodyView = self.viewControllers.body.getView(1);
+
+ self.replyToMessage.getQuoteLines(
+ {
+ termWidth : self.client.term.termWidth,
+ termHeight : self.client.term.termHeight,
+ cols : quoteView.dimens.width,
+ startCol : quoteView.position.col,
+ ansiResetSgr : bodyView.styleSGR1,
+ ansiFocusPrefixSgr : quoteView.styleSGR2,
+ },
+ (err, quoteLines, focusQuoteLines, replyIsAnsi) => {
+ if(err) {
+ return callback(err);
+ }
+
+ self.replyIsAnsi = replyIsAnsi;
+
+ quoteView.setItems(quoteLines);
+ quoteView.setFocusItems(focusQuoteLines);
+
+ return callback(null);
+ }
+ );
},
function setViewFocus(callback) {
self.viewControllers.quoteBuilder.getView(1).setFocus(false);
@@ -922,14 +992,17 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
quoteBuilderFinalize() {
// :TODO: fix magic #'s
- var quoteMsgView = this.viewControllers.quoteBuilder.getView(1);
- var msgView = this.viewControllers.body.getView(1);
-
- var quoteLines = quoteMsgView.getData();
+ const quoteMsgView = this.viewControllers.quoteBuilder.getView(1);
+ const msgView = this.viewControllers.body.getView(1);
+
+ let quoteLines = quoteMsgView.getData();
if(quoteLines.trim().length > 0) {
- msgView.addText(quoteMsgView.getData() + '\n');
-
+ if(this.replyIsAnsi) {
+ const bodyMessageView = this.viewControllers.body.getView(1);
+ quoteLines += `${ansi.normal()}${bodyMessageView.getSGRFor('text')}`;
+ }
+ msgView.addText(`${quoteLines}\n`);
}
quoteMsgView.setText('');
diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js
index 5e0bf581..095628ea 100644
--- a/core/ftn_mail_packet.js
+++ b/core/ftn_mail_packet.js
@@ -7,6 +7,7 @@ const sauce = require('./sauce.js');
const Address = require('./ftn_address.js');
const strUtil = require('./string_util.js');
const Log = require('./logger.js').log;
+const ansiPrep = require('./ansi_prep.js');
const _ = require('lodash');
const assert = require('assert');
@@ -480,8 +481,8 @@ function Packet(options) {
Log.debug( { encoding : encoding, error : e.toString() }, 'Error decoding. Falling back to ASCII');
decoded = iconv.decode(messageBodyBuffer, 'ascii');
}
- //const messageLines = iconv.decode(messageBodyBuffer, encoding).replace(/\xec/g, '').split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
- const messageLines = decoded.replace(/\xec/g, '').split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
+
+ const messageLines = strUtil.splitTextAtTerms(decoded.replace(/\xec/g, ''));
let endOfMessage = false;
messageLines.forEach(line => {
@@ -636,108 +637,137 @@ function Packet(options) {
});
};
- this.getMessageEntryBuffer = function(message, options) {
- let basicHeader = new Buffer(34);
+ this.getMessageEntryBuffer = function(message, options, cb) {
- 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.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10);
- basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12);
-
- const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0');
- dateTimeBuffer.copy(basicHeader, 14);
-
- // toUserName & fromUserName: up to 36 bytes in length, NULL term'd
- // :TODO: DRY...
- let toUserNameBuf = iconv.encode(message.toUserName + '\0', 'CP437').slice(0, 36);
- toUserNameBuf[toUserNameBuf.length - 1] = '\0'; // ensure it's null term'd
-
- let fromUserNameBuf = iconv.encode(message.fromUserName + '\0', 'CP437').slice(0, 36);
- fromUserNameBuf[fromUserNameBuf.length - 1] = '\0'; // ensure it's null term'd
-
- // subject: up to 72 bytes in length, NULL term'd
- let subjectBuf = iconv.encode(message.subject + '\0', 'CP437').slice(0, 72);
- subjectBuf[subjectBuf.length - 1] = '\0'; // ensure it's null term'd
-
- //
- // 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) {
+ function getAppendMeta(k, m) {
+ let append = '';
if(m) {
let a = m;
if(!_.isArray(a)) {
a = [ a ];
}
a.forEach(v => {
- msgBody += `${k}: ${v}\r`;
+ append += `${k}: ${v}\r`;
});
}
+ return append;
}
+
+ async.waterfall(
+ [
+ function prepareHeaderAndKludges(callback) {
+ const basicHeader = new Buffer(34);
- //
- // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001
- // AREA:CONFERENCE
- // Should be first line in a message
- //
- if(message.meta.FtnProperty.ftn_area) {
- msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01)
- }
-
- Object.keys(message.meta.FtnKludge).forEach(k => {
- // we want PATH to be last
- if('PATH' !== k) {
- appendMeta(`\x01${k}`, message.meta.FtnKludge[k]);
+ 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.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10);
+ basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12);
+
+ const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0');
+ dateTimeBuffer.copy(basicHeader, 14);
+
+ //
+ // To, from, and subject must be NULL term'd and have max lengths as per spec.
+ //
+ const toUserNameBuf = strUtil.stringToNullTermBuffer(message.toUserName, { encoding : 'cp437', maxBufLen : 36 } );
+ const fromUserNameBuf = strUtil.stringToNullTermBuffer(message.fromUserName, { encoding : 'cp437', maxBufLen : 36 } );
+ const subjectBuf = strUtil.stringToNullTermBuffer(message.subject, { encoding : 'cp437', maxBufLen : 72 } );
+
+ //
+ // message: unbound length, NULL term'd
+ //
+ // We need to build in various special lines - kludges, area,
+ // seen-by, etc.
+ //
+ let msgBody = '';
+
+ //
+ // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001
+ // AREA:CONFERENCE
+ // Should be first line in a message
+ //
+ if(message.meta.FtnProperty.ftn_area) {
+ msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01)
+ }
+
+ Object.keys(message.meta.FtnKludge).forEach(k => {
+ // we want PATH to be last
+ if('PATH' !== k) {
+ msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]);
+ }
+ });
+
+ return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody);
+ },
+ function prepareAnsiMessageBody(basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, callback) {
+ if(!strUtil.isAnsi(message.message)) {
+ return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, message.message);
+ }
+
+ ansiPrep(
+ message.message,
+ {
+ cols : 80,
+ rows : 'auto',
+ forceLineTerm : true,
+ exportMode : true,
+ },
+ (err, preppedMsg) => {
+ return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, preppedMsg || message.message);
+ }
+ );
+ },
+ function addMessageBody(basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, preppedMsg, callback) {
+ msgBody += preppedMsg + '\r';
+
+ //
+ // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001
+ // Tear line should be near the bottom of a message
+ //
+ if(message.meta.FtnProperty.ftn_tear_line) {
+ msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`;
+ }
+
+ //
+ // Origin line should be near the bottom of a message
+ //
+ if(message.meta.FtnProperty.ftn_origin) {
+ msgBody += `${message.meta.FtnProperty.ftn_origin}\r`;
+ }
+
+ //
+ // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001
+ // SEEN-BY and PATH should be the last lines of a message
+ //
+ msgBody += getAppendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01)
+ msgBody += getAppendMeta('\x01PATH', message.meta.FtnKludge['PATH']);
+
+ let msgBodyEncoded;
+ try {
+ msgBodyEncoded = iconv.encode(msgBody + '\0', options.encoding);
+ } catch(e) {
+ msgBodyEncoded = iconv.encode(msgBody + '\0', 'ascii');
+ }
+
+ return callback(
+ null,
+ Buffer.concat( [
+ basicHeader,
+ toUserNameBuf,
+ fromUserNameBuf,
+ subjectBuf,
+ msgBodyEncoded
+ ])
+ );
+ }
+ ],
+ (err, msgEntryBuffer) => {
+ return cb(err, msgEntryBuffer);
}
- });
-
- msgBody += message.message + '\r';
-
- //
- // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001
- // Tear line should be near the bottom of a message
- //
- if(message.meta.FtnProperty.ftn_tear_line) {
- msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`;
- }
-
- //
- // Origin line should be near the bottom of a message
- //
- if(message.meta.FtnProperty.ftn_origin) {
- msgBody += `${message.meta.FtnProperty.ftn_origin}\r`;
- }
-
- //
- // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001
- // SEEN-BY and PATH should be the last lines of a message
- //
- appendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01)
-
- appendMeta('\x01PATH', message.meta.FtnKludge['PATH']);
-
- let msgBodyEncoded;
- try {
- msgBodyEncoded = iconv.encode(msgBody + '\0', options.encoding);
- } catch(e) {
- msgBodyEncoded = iconv.encode(msgBody + '\0', 'ascii');
- }
-
- return Buffer.concat( [
- basicHeader,
- toUserNameBuf,
- fromUserNameBuf,
- subjectBuf,
- msgBodyEncoded
- ]);
+ );
};
this.writeMessage = function(message, ws, options) {
diff --git a/core/ftn_util.js b/core/ftn_util.js
index c48cc222..4d779c4a 100644
--- a/core/ftn_util.js
+++ b/core/ftn_util.js
@@ -1,17 +1,18 @@
/* jslint node: true */
'use strict';
-let Config = require('./config.js').config;
-let Address = require('./ftn_address.js');
-let FNV1a = require('./fnv1a.js');
+let Config = require('./config.js').config;
+let Address = require('./ftn_address.js');
+let FNV1a = require('./fnv1a.js');
+const getCleanEnigmaVersion = require('./misc_util.js').getCleanEnigmaVersion;
-let _ = require('lodash');
-let iconv = require('iconv-lite');
-let moment = require('moment');
+let _ = require('lodash');
+let iconv = require('iconv-lite');
+let moment = require('moment');
//let uuid = require('node-uuid');
-let os = require('os');
+let os = require('os');
-let packageJson = require('../package.json');
+let packageJson = require('../package.json');
// :TODO: Remove "Ftn" from most of these -- it's implied in the module
exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer;
@@ -146,11 +147,7 @@ function getMessageIdentifier(message, address) {
// in which (; ; ) is used instead
//
function getProductIdentifier() {
- const version = packageJson.version
- .replace(/\-/g, '.')
- .replace(/alpha/,'a')
- .replace(/beta/,'b');
-
+ const version = getCleanEnigmaVersion();
const nodeVer = process.version.substr(1); // remove 'v' prefix
return `ENiGMA1/2 ${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`;
@@ -166,10 +163,23 @@ function getUTCTimeZoneOffset() {
return moment().format('ZZ').replace(/\+/, '');
}
-// Get a FSC-0032 style quote prefixes
+//
+// Get a FSC-0032 style quote prefix
+// http://ftsc.org/docs/fsc-0032.001
+//
function getQuotePrefix(name) {
- // :TODO: Add support for real names (e.g. with spaces) -> initials
- return ' ' + name[0].toUpperCase() + name[1].toLowerCase() + '> ';
+ let initials;
+
+ const parts = name.split(' ');
+ if(parts.length > 1) {
+ // First & Last initials - (Bryan Ashby -> BA)
+ initials = `${parts[0].slice(0, 1)}${parts[parts.length - 1].slice(0, 1)}`.toUpperCase();
+ } else {
+ // Just use the first two - (NuSkooler -> Nu)
+ initials = _.capitalize(name.slice(0, 2));
+ }
+
+ return ` ${initials}> `;
}
//
diff --git a/core/logger.js b/core/logger.js
index 9ea283fd..3b0b47e2 100644
--- a/core/logger.js
+++ b/core/logger.js
@@ -62,7 +62,7 @@ module.exports = class Log {
// Use a regexp -- we don't know how nested fields we want to seek and destroy may be
//
return JSON.parse(
- JSON.stringify(obj).replace(/"(password)"\s?:\s?"([^"]+)"/, (match, valueName) => {
+ JSON.stringify(obj).replace(/"(password|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => {
return `"${valueName}":"********"`;
})
);
diff --git a/core/menu_module.js b/core/menu_module.js
index 1f350564..884389ca 100644
--- a/core/menu_module.js
+++ b/core/menu_module.js
@@ -325,11 +325,14 @@ exports.MenuModule = class MenuModule extends PluginModule {
formId : formId,
};
- return vc.loadFromMenuConfig(loadOpts, cb);
+ return vc.loadFromMenuConfig(loadOpts, err => {
+ return cb(err, vc);
+ });
}
this.viewControllers[name].setFocus(true);
- return cb(null);
+
+ return cb(null, this.viewControllers[name]);
}
prepViewControllerWithArt(name, formId, options, cb) {
diff --git a/core/menu_view.js b/core/menu_view.js
index 07d19e8a..41f1302f 100644
--- a/core/menu_view.js
+++ b/core/menu_view.js
@@ -113,6 +113,14 @@ MenuView.prototype.focusPrevious = function() {
this.emit('index update', this.focusedItemIndex);
};
+MenuView.prototype.focusNextPageItem = function() {
+ this.emit('index update', this.focusedItemIndex);
+};
+
+MenuView.prototype.focusPreviousPageItem = function() {
+ this.emit('index update', this.focusedItemIndex);
+};
+
MenuView.prototype.setFocusItemIndex = function(index) {
this.focusedItemIndex = index;
};
diff --git a/core/message.js b/core/message.js
index 19d13e61..710444ce 100644
--- a/core/message.js
+++ b/core/message.js
@@ -6,6 +6,16 @@ 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 {
+ isAnsi, isFormattedLine,
+ splitTextAtTerms,
+ renderSubstr
+} = require('./string_util.js');
+
+const ansiPrep = require('./ansi_prep.js');
// deps
const uuidParse = require('uuid-parse');
@@ -35,7 +45,7 @@ function Message(options) {
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)) {
@@ -82,6 +92,7 @@ Message.SystemMetaNames = {
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.
};
Message.StateFlags0 = {
@@ -429,47 +440,192 @@ Message.prototype.getFTNQuotePrefix = function(source) {
return ftnUtil.getQuotePrefix(this[source]);
};
-Message.prototype.getQuoteLines = function(width, options) {
- // :TODO: options.maxBlankLines = 1
-
- options = options || {};
-
- //
- // Include FSC-0032 style quote prefixes?
- //
- // See http://ftsc.org/docs/fsc-0032.001
- //
- if(!_.isBoolean(options.includePrefix)) {
- options.includePrefix = true;
- }
-
- var quoteLines = [];
-
- var origLines = this.message
- .trim()
- .replace(/\b/g, '')
- .split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
-
- var quotePrefix = ''; // we need this init even if blank
- if(options.includePrefix) {
- quotePrefix = this.getFTNQuotePrefix(options.prefixSource || 'fromUserName');
- }
-
- var wrapOpts = {
- width : width - quotePrefix.length,
- tabHandling : 'expand',
- tabWidth : 4,
- };
-
- function addPrefix(l) {
- return quotePrefix + l;
- }
-
- var wrapped;
- for(var i = 0; i < origLines.length; ++i) {
- wrapped = wordWrapText(origLines[i], wrapOpts).wrapped;
- Array.prototype.push.apply(quoteLines, _.map(wrapped, addPrefix));
- }
-
- return quoteLines;
+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
+
+ 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);
+ }
+
+ 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);
+ }
};
diff --git a/core/mime_util.js b/core/mime_util.js
index ca7ab50e..bdb88a53 100644
--- a/core/mime_util.js
+++ b/core/mime_util.js
@@ -1,10 +1,38 @@
/* jslint node: true */
'use strict';
+// deps
+const _ = require('lodash');
+
const mimeTypes = require('mime-types');
+exports.startup = startup;
exports.resolveMimeType = resolveMimeType;
+function startup(cb) {
+ //
+ // Add in types (not yet) supported by mime-db -- and therefor, mime-types
+ //
+ const ADDITIONAL_EXT_MIMETYPES = {
+ arj : 'application/x-arj',
+ ans : 'text/x-ansi',
+ gz : 'application/gzip', // not in mime-types 2.1.15 :(
+ };
+
+ _.forEach(ADDITIONAL_EXT_MIMETYPES, (mimeType, ext) => {
+ // don't override any entries
+ if(!_.isString(mimeTypes.types[ext])) {
+ mimeTypes[ext] = mimeType;
+ }
+
+ if(!mimeTypes.extensions[mimeType]) {
+ mimeTypes.extensions[mimeType] = [ ext ];
+ }
+ });
+
+ return cb(null);
+}
+
function resolveMimeType(query) {
if(mimeTypes.extensions[query]) {
return query; // alreaed a mime-type
diff --git a/core/misc_util.js b/core/misc_util.js
index 1f3a11df..afe33dee 100644
--- a/core/misc_util.js
+++ b/core/misc_util.js
@@ -1,12 +1,17 @@
/* jslint node: true */
'use strict';
-var paths = require('path');
+const paths = require('path');
-exports.isProduction = isProduction;
-exports.isDevelopment = isDevelopment;
-exports.valueWithDefault = valueWithDefault;
-exports.resolvePath = resolvePath;
+const os = require('os');
+const packageJson = require('../package.json');
+
+exports.isProduction = isProduction;
+exports.isDevelopment = isDevelopment;
+exports.valueWithDefault = valueWithDefault;
+exports.resolvePath = resolvePath;
+exports.getCleanEnigmaVersion = getCleanEnigmaVersion;
+exports.getEnigmaUserAgent = getEnigmaUserAgent;
function isProduction() {
var env = process.env.NODE_ENV || 'dev';
@@ -27,4 +32,21 @@ function resolvePath(path) {
path = (process.env.HOME || mswCombined || process.env.HOMEPATH || process.env.HOMEDIR || process.cwd()) + path.substr(1);
}
return paths.resolve(path);
+}
+
+function getCleanEnigmaVersion() {
+ return packageJson.version
+ .replace(/\-/g, '.')
+ .replace(/alpha/,'a')
+ .replace(/beta/,'b')
+ ;
+}
+
+// See also ftn_util.js getTearLine() & getProductIdentifier()
+function getEnigmaUserAgent() {
+ // can't have 1/2 or ½ in User-Agent according to RFC 1945 :(
+ const version = getCleanEnigmaVersion();
+ const nodeVer = process.version.substr(1); // remove 'v' prefix
+
+ return `ENiGMA-BBS/${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`;
}
\ No newline at end of file
diff --git a/core/module_util.js b/core/module_util.js
index 1cd4bed3..67e87306 100644
--- a/core/module_util.js
+++ b/core/module_util.js
@@ -15,6 +15,7 @@ const async = require('async');
exports.loadModuleEx = loadModuleEx;
exports.loadModule = loadModule;
exports.loadModulesForCategory = loadModulesForCategory;
+exports.getModulePaths = getModulePaths;
function loadModuleEx(options, cb) {
assert(_.isObject(options));
@@ -25,7 +26,7 @@ function loadModuleEx(options, cb) {
if(_.isObject(modConfig) && false === modConfig.enabled) {
const err = new Error(`Module "${options.name}" is disabled`);
- err.code = 'EENIGMODDISABLED';
+ err.code = 'EENIGMODDISABLED';
return cb(err);
}
@@ -36,7 +37,7 @@ function loadModuleEx(options, cb) {
//
let mod;
let modPath = paths.join(options.path, `${options.name}.js`); // general case first
- try {
+ try {
mod = require(modPath);
} catch(e) {
if('MODULE_NOT_FOUND' === e.code) {
@@ -48,7 +49,7 @@ function loadModuleEx(options, cb) {
}
} else {
return cb(e);
- }
+ }
}
if(!_.isObject(mod.moduleInfo)) {
@@ -75,7 +76,7 @@ function loadModule(name, category, cb) {
}
function loadModulesForCategory(category, iterator, complete) {
-
+
fs.readdir(Config.paths[category], (err, files) => {
if(err) {
return iterator(err);
@@ -97,3 +98,12 @@ function loadModulesForCategory(category, iterator, complete) {
});
});
}
+
+function getModulePaths() {
+ return [
+ Config.paths.mods,
+ Config.paths.loginServers,
+ Config.paths.contentServers,
+ Config.paths.scannerTossers,
+ ];
+}
diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js
index 94bd2f77..9d46e8a1 100644
--- a/core/multi_line_edit_text_view.js
+++ b/core/multi_line_edit_text_view.js
@@ -6,6 +6,7 @@ const strUtil = require('./string_util.js');
const ansi = require('./ansi_term.js');
const colorCodes = require('./color_codes.js');
const wordWrapText = require('./word_wrap.js').wordWrapText;
+const ansiPrep = require('./ansi_prep.js');
const assert = require('assert');
const _ = require('lodash');
@@ -62,7 +63,7 @@ const _ = require('lodash');
// *
-var SPECIAL_KEY_MAP_DEFAULT = {
+const SPECIAL_KEY_MAP_DEFAULT = {
'line feed' : [ 'return' ],
exit : [ 'esc' ],
backspace : [ 'backspace' ],
@@ -165,43 +166,50 @@ function MultiLineEditTextView(options) {
return self.textLines.length;
};
+ this.toggleTextCursor = function(action) {
+ self.client.term.rawWrite(`${self.getSGRFor('text')}${'hide' === action ? ansi.hideCursor() : ansi.showCursor()}`);
+ };
+
this.redrawRows = function(startRow, endRow) {
- self.client.term.rawWrite(self.getSGRFor('text') + ansi.hideCursor());
+ self.toggleTextCursor('hide');
- var startIndex = self.getTextLinesIndex(startRow);
- var endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length);
- var absPos = self.getAbsolutePosition(startRow, 0);
+ const startIndex = self.getTextLinesIndex(startRow);
+ const endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length);
+ const absPos = self.getAbsolutePosition(startRow, 0);
- for(var i = startIndex; i < endIndex; ++i) {
- self.client.term.write(
- ansi.goto(absPos.row++, absPos.col) +
- self.getRenderText(i), false);
+ for(let i = startIndex; i < endIndex; ++i) {
+ //${self.getSGRFor('text')}
+ self.client.term.write(
+ `${ansi.goto(absPos.row++, absPos.col)}${self.getRenderText(i)}`,
+ false // convertLineFeeds
+ );
}
- self.client.term.rawWrite(ansi.showCursor());
+ self.toggleTextCursor('show');
return absPos.row - self.position.row; // row we ended on
};
this.eraseRows = function(startRow, endRow) {
- self.client.term.rawWrite(self.getSGRFor('text') + ansi.hideCursor());
+ self.toggleTextCursor('hide');
- var absPos = self.getAbsolutePosition(startRow, 0);
- var absPosEnd = self.getAbsolutePosition(endRow, 0);
- var eraseFiller = new Array(self.dimens.width).join(' ');
+ const absPos = self.getAbsolutePosition(startRow, 0);
+ const absPosEnd = self.getAbsolutePosition(endRow, 0);
+ const eraseFiller = ' '.repeat(self.dimens.width);//new Array(self.dimens.width).join(' ');
while(absPos.row < absPosEnd.row) {
self.client.term.write(
- ansi.goto(absPos.row++, absPos.col) +
- eraseFiller, false);
+ `${ansi.goto(absPos.row++, absPos.col)}${eraseFiller}`,
+ false // convertLineFeeds
+ );
}
- self.client.term.rawWrite(ansi.showCursor());
+ self.toggleTextCursor('show');
};
this.redrawVisibleArea = function() {
assert(self.topVisibleIndex <= self.textLines.length);
- var lastRow = self.redrawRows(0, self.dimens.height);
+ const lastRow = self.redrawRows(0, self.dimens.height);
self.eraseRows(lastRow, self.dimens.height);
/*
@@ -255,11 +263,14 @@ function MultiLineEditTextView(options) {
};
this.getRenderText = function(index) {
- var text = self.getVisibleText(index);
- var remain = self.dimens.width - text.length;
+ let text = self.getVisibleText(index);
+ const remain = self.dimens.width - text.length;
+
if(remain > 0) {
- text += new Array(remain + 1).join(' ');
+ text += ' '.repeat(remain + 1);
+// text += new Array(remain + 1).join(' ');
}
+
return text;
};
@@ -273,14 +284,15 @@ function MultiLineEditTextView(options) {
return lines;
};
- this.getOutputText = function(startIndex, endIndex, eolMarker) {
- let lines = self.getTextLines(startIndex, endIndex);
- let text = '';
- var re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g');
+ this.getOutputText = function(startIndex, endIndex, eolMarker, options) {
+ const lines = self.getTextLines(startIndex, endIndex);
+ let text = '';
+ const re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g');
lines.forEach(line => {
text += line.text.replace(re, '\t');
- if(eolMarker && line.eol) {
+
+ if(options.forceLineTerms || (eolMarker && line.eol)) {
text += eolMarker;
}
});
@@ -491,25 +503,90 @@ function MultiLineEditTextView(options) {
return new Array(self.getRemainingTabWidth(col)).join(expandChar);
};
- this.getStringLength = function(s) {
- return self.isPreviewMode() ? colorCodes.enigmaStrLen(s) : s.length;
- };
-
this.wordWrapSingleLine = function(s, tabHandling, width) {
if(!_.isNumber(width)) {
width = self.dimens.width;
}
return wordWrapText(
- s, {
+ s,
+ {
width : width,
tabHandling : tabHandling || 'expand',
tabWidth : self.tabWidth,
tabChar : '\t',
- });
+ }
+ );
+ };
+
+ this.setTextLines = function(lines, index, termWithEol) {
+ if(0 === index && (0 === self.textLines.length || (self.textLines.length === 1 && '' === self.textLines[0].text) )) {
+ // quick path: just set the things
+ self.textLines = lines.slice(0, -1).map(l => {
+ return { text : l };
+ }).concat( { text : lines[lines.length - 1], eol : termWithEol } );
+ } else {
+ // insert somewhere in textLines...
+ if(index > self.textLines.length) {
+ // fill with empty
+ self.textLines.splice(
+ self.textLines.length,
+ 0,
+ ...Array.from( { length : index - self.textLines.length } ).map( () => { return { text : '' }; } )
+ );
+ }
+
+ const newLines = lines.slice(0, -1).map(l => {
+ return { text : l };
+ }).concat( { text : lines[lines.length - 1], eol : termWithEol } );
+
+ self.textLines.splice(
+ index,
+ 0,
+ ...newLines
+ );
+ }
+ };
+
+ this.setAnsiWithOptions = function(ansi, options, cb) {
+
+ function setLines(text) {
+ text = strUtil.splitTextAtTerms(text);
+
+ let index = 0;
+
+ text.forEach(line => {
+ self.setTextLines( [ line ], index, true); // true=termWithEol
+ index += 1;
+ });
+
+ self.cursorStartOfDocument();
+
+ if(cb) {
+ return cb(null);
+ }
+ }
+
+ if(options.prepped) {
+ return setLines(ansi);
+ }
+
+ ansiPrep(
+ ansi,
+ {
+ termWidth : this.client.term.termWidth,
+ termHeight : this.client.term.termHeight,
+ cols : this.dimens.width,
+ rows : 'auto',
+ startCol : this.position.col,
+ forceLineTerm : options.forceLineTerm,
+ },
+ (err, preppedAnsi) => {
+ return setLines(err ? ansi : preppedAnsi);
+ }
+ );
};
- // :TODO: rename to insertRawText()
this.insertRawText = function(text, index, col) {
//
// Perform the following on |text|:
@@ -542,23 +619,19 @@ function MultiLineEditTextView(options) {
index = self.textLines.length;
}
- text = text
- .replace(/\b/g, '')
- .split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
+ text = strUtil.splitTextAtTerms(text);
let wrapped;
-
- for(let i = 0; i < text.length; ++i) {
+ text.forEach(line => {
wrapped = self.wordWrapSingleLine(
- text[i], // input
+ line, // line to wrap
'expand', // tabHandling
- self.dimens.width).wrapped;
+ self.dimens.width
+ ).wrapped;
- for(let j = 0; j < wrapped.length - 1; ++j) {
- self.textLines.splice(index++, 0, { text : wrapped[j] } );
- }
- self.textLines.splice(index++, 0, { text : wrapped[wrapped.length - 1], eol : true } );
- }
+ self.setTextLines(wrapped, index, true); // true=termWithEol
+ index += wrapped.length;
+ });
};
this.getAbsolutePosition = function(row, col) {
@@ -996,8 +1069,6 @@ MultiLineEditTextView.prototype.setFocus = function(focused) {
};
MultiLineEditTextView.prototype.setText = function(text) {
- //text = require('graceful-fs').readFileSync('/home/nuskooler/Downloads/test_text.txt', { encoding : 'utf-8'});
-
this.textLines = [ ];
this.addText(text);
/*this.insertRawText(text);
@@ -1009,6 +1080,11 @@ MultiLineEditTextView.prototype.setText = function(text) {
}*/
};
+MultiLineEditTextView.prototype.setAnsi = function(ansi, options = { prepped : false }, cb) {
+ this.textLines = [ ];
+ return this.setAnsiWithOptions(ansi, options, cb);
+};
+
MultiLineEditTextView.prototype.addText = function(text) {
this.insertRawText(text);
@@ -1019,8 +1095,8 @@ MultiLineEditTextView.prototype.addText = function(text) {
}
};
-MultiLineEditTextView.prototype.getData = function() {
- return this.getOutputText(0, this.textLines.length, '\r\n');
+MultiLineEditTextView.prototype.getData = function(options = { forceLineTerms : false } ) {
+ return this.getOutputText(0, this.textLines.length, '\r\n', options);
};
MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) {
@@ -1032,7 +1108,9 @@ MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) {
}
break;
- case 'autoScroll' : this.autoScroll = value; break;
+ case 'autoScroll' :
+ this.autoScroll = value;
+ break;
case 'tabSwitchesView' :
this.tabSwitchesView = value;
diff --git a/core/new_scan.js b/core/new_scan.js
index 0483e8d1..3c3f1371 100644
--- a/core/new_scan.js
+++ b/core/new_scan.js
@@ -6,6 +6,9 @@ const msgArea = require('./message_area.js');
const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController;
const stringFormat = require('./string_format.js');
+const FileEntry = require('./file_entry.js');
+const FileBaseFilters = require('./file_base_filter.js');
+const Errors = require('./enig_error.js').Errors;
// deps
const _ = require('lodash');
@@ -30,13 +33,21 @@ const MciCodeIds = {
ScanStatusList : 2, // VM2 (appends)
};
+const Steps = {
+ MessageConfs : 'messageConferences',
+ FileBase : 'fileBase',
+
+ Finished : 'finished',
+};
+
exports.getModule = class NewScanModule extends MenuModule {
constructor(options) {
super(options);
- this.newScanFullExit = _.has(options, 'lastMenuResult.fullExit') ? options.lastMenuResult.fullExit : false;
- this.currentStep = 'messageConferences';
+ this.newScanFullExit = _.get(options, 'lastMenuResult.fullExit', false);
+
+ this.currentStep = Steps.MessageConfs;
this.currentScanAux = {};
// :TODO: Make this conf/area specific:
@@ -49,13 +60,6 @@ exports.getModule = class NewScanModule extends MenuModule {
updateScanStatus(statusText) {
this.setViewText('allViews', MciCodeIds.ScanStatusLabel, statusText);
-
- /*
- view = vc.getView(MciCodeIds.ScanStatusList);
- // :TODO: MenuView needs appendItem()
- if(view) {
- }
- */
}
newScanMessageConference(cb) {
@@ -87,32 +91,19 @@ exports.getModule = class NewScanModule extends MenuModule {
this.currentScanAux.area = this.currentScanAux.area || 0;
}
- const currentConf = this.sortedMessageConfs[this.currentScanAux.conf];
- const self = this;
-
- async.series(
- [
- function scanArea(callback) {
- //self.currentScanAux.area = self.currentScanAux.area || 0;
-
- self.newScanMessageArea(currentConf, () => {
- 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'));
- }
- });
- }
- ],
- err => {
- return cb(err);
+ const currentConf = this.sortedMessageConfs[this.currentScanAux.conf];
+
+ this.newScanMessageArea(currentConf, () => {
+ if(this.sortedMessageConfs.length > this.currentScanAux.conf + 1) {
+ this.currentScanAux.conf += 1;
+ this.currentScanAux.area = 0;
+
+ return this.newScanMessageConference(cb); // recursive to next conf
}
- );
+
+ this.updateScanStatus(this.scanCompleteMsg);
+ return cb(Errors.DoesNotExist('No more conferences'));
+ });
}
newScanMessageArea(conf, cb) {
@@ -134,7 +125,7 @@ exports.getModule = class NewScanModule extends MenuModule {
return callback(null);
} else {
self.updateScanStatus(self.scanCompleteMsg);
- return callback(new Error('No more areas')); // this will stop our scan
+ return callback(Errors.DoesNotExist('No more areas')); // this will stop our scan
}
},
function updateStatusScanStarted(callback) {
@@ -173,6 +164,28 @@ exports.getModule = class NewScanModule extends MenuModule {
);
}
+ newScanFileBase(cb) {
+ // :TODO: add in steps
+ FileEntry.findFiles(
+ { newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user) },
+ (err, fileIds) => {
+ if(err || 0 === fileIds.length) {
+ return cb(err ? err : Errors.DoesNotExist('No more new files'));
+ }
+
+ FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[0] );
+
+ const menuOpts = {
+ extraArgs : {
+ fileList : fileIds,
+ },
+ };
+
+ return this.gotoMenu(this.menuConfig.config.newScanFileBaseList || 'newScanFileBaseList', menuOpts);
+ }
+ );
+ }
+
getSaveState() {
return {
currentStep : this.currentStep,
@@ -185,6 +198,26 @@ exports.getModule = class NewScanModule extends MenuModule {
this.currentScanAux = savedState.currentScanAux;
}
+ performScanCurrentStep(cb) {
+ switch(this.currentStep) {
+ case Steps.MessageConfs :
+ this.newScanMessageConference( () => {
+ this.currentStep = Steps.FileBase;
+ return this.performScanCurrentStep(cb);
+ });
+ break;
+
+ case Steps.FileBase :
+ this.newScanFileBase( () => {
+ this.currentStep = Steps.Finished;
+ return this.performScanCurrentStep(cb);
+ });
+ break;
+
+ default : return cb(null);
+ }
+ }
+
mciReady(mciData, cb) {
if(this.newScanFullExit) {
// user has canceled the entire scan @ message list view
@@ -213,15 +246,7 @@ exports.getModule = class NewScanModule extends MenuModule {
vc.loadFromMenuConfig(loadOpts, callback);
},
function performCurrentStepScan(callback) {
- switch(self.currentStep) {
- case 'messageConferences' :
- self.newScanMessageConference( () => {
- callback(null); // finished
- });
- break;
-
- default : return callback(null);
- }
+ return self.performScanCurrentStep(callback);
}
],
err => {
diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js
index 9b376e7c..5cbe10e3 100644
--- a/core/oputil/oputil_file_base.js
+++ b/core/oputil/oputil_file_base.js
@@ -34,10 +34,25 @@ exports.handleFileBaseCommand = handleFileBaseCommand;
let fileArea; // required during init
-function finalizeEntryAndPersist(fileEntry, cb) {
+function finalizeEntryAndPersist(fileEntry, descHandler, cb) {
async.series(
[
- function getDescIfNeeded(callback) {
+ function getDescFromHandlerIfNeeded(callback) {
+ if((fileEntry.desc && fileEntry.desc.length > 0 ) && !argv['desc-file']) {
+ return callback(null); // we have a desc already and are NOT overriding with desc file
+ }
+
+ if(!descHandler) {
+ return callback(null); // not much we can do!
+ }
+
+ const desc = descHandler.getDescription(fileEntry.fileName);
+ if(desc) {
+ fileEntry.desc = desc;
+ }
+ return callback(null);
+ },
+ function getDescFromUserIfNeeded(callback) {
if(false === argv.prompt || ( fileEntry.desc && fileEntry.desc.length > 0 ) ) {
return callback(null);
}
@@ -70,6 +85,18 @@ function finalizeEntryAndPersist(fileEntry, cb) {
);
}
+const SCAN_EXCLUDE_FILENAMES = [ 'DESCRIPT.ION', 'FILES.BBS' ];
+
+function loadDescHandler(path, cb) {
+ const DescIon = require('../../core/descript_ion_file.js');
+
+ // :TODO: support FILES.BBS also
+
+ DescIon.createFromFile(path, (err, descHandler) => {
+ return cb(err, descHandler);
+ });
+}
+
function scanFileAreaForChanges(areaInfo, options, cb) {
const storageLocations = fileArea.getAreaStorageLocations(areaInfo).filter(sl => {
@@ -79,9 +106,18 @@ function scanFileAreaForChanges(areaInfo, options, cb) {
});
async.eachSeries(storageLocations, (storageLoc, nextLocation) => {
- async.series(
+ async.waterfall(
[
- function scanPhysFiles(callback) {
+ function initDescFile(callback) {
+ if(options.descFileHandler) {
+ return callback(null, options.descFileHandler); // we're going to use the global handler
+ }
+
+ loadDescHandler(paths.join(storageLoc.dir, 'DESCRIPT.ION'), (err, descHandler) => {
+ return callback(null, descHandler);
+ });
+ },
+ function scanPhysFiles(descHandler, callback) {
const physDir = storageLoc.dir;
fs.readdir(physDir, (err, files) => {
@@ -92,6 +128,11 @@ function scanFileAreaForChanges(areaInfo, options, cb) {
async.eachSeries(files, (fileName, nextFile) => {
const fullPath = paths.join(physDir, fileName);
+ if(SCAN_EXCLUDE_FILENAMES.includes(fileName.toUpperCase())) {
+ console.info(`Excluding ${fullPath}`);
+ return nextFile(null);
+ }
+
fs.stat(fullPath, (err, stats) => {
if(err) {
// :TODO: Log me!
@@ -115,9 +156,7 @@ function scanFileAreaForChanges(areaInfo, options, cb) {
// :TODO: Log me!!!
console.info(`Error: ${err.message}`);
return nextFile(null); // try next anyway
- }
-
-
+ }
if(dupeEntries.length > 0) {
// :TODO: Handle duplidates -- what to do here???
@@ -131,7 +170,7 @@ function scanFileAreaForChanges(areaInfo, options, cb) {
});
}
- finalizeEntryAndPersist(fileEntry, err => {
+ finalizeEntryAndPersist(fileEntry, descHandler, err => {
return nextFile(err);
});
}
@@ -304,6 +343,8 @@ function scanFileAreas() {
options.tags = tags.split(',');
}
+ options.descFile = argv['desc-file']; // --desc-file or --desc-file PATH
+
options.areaAndStorageInfo = getAreaAndStorage(argv._.slice(2));
async.series(
@@ -311,6 +352,21 @@ function scanFileAreas() {
function init(callback) {
return initConfigAndDatabases(callback);
},
+ function initGlobalDescHandler(callback) {
+ //
+ // If options.descFile is a String, it represents a FILE|PATH. We'll init
+ // the description handler now. Else, we'll attempt to look for a description
+ // file in each storage location.
+ //
+ if(!_.isString(options.descFile)) {
+ return callback(null);
+ }
+
+ loadDescHandler(options.descFile, (err, descHandler) => {
+ options.descFileHandler = descHandler;
+ return callback(null);
+ });
+ },
function scanAreas(callback) {
fileArea = require('../../core/file_base_area.js');
diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js
index 0344145b..5c20e3a5 100644
--- a/core/oputil/oputil_help.js
+++ b/core/oputil/oputil_help.js
@@ -61,6 +61,10 @@ actions:
scan args:
--tags TAG1,TAG2,... specify tag(s) to assign to discovered entries
+ --desc-file [PATH] prefer file descriptions from DESCRIPT.ION file over
+ other sources such as FILE_ID.DIZ.
+ if PATH is specified, use DESCRIPT.ION at PATH instead
+ of looking in specific storage locations
info args:
--show-desc display short description, if any
diff --git a/core/plugin_module.js b/core/plugin_module.js
index aeac4950..31ba6f01 100644
--- a/core/plugin_module.js
+++ b/core/plugin_module.js
@@ -4,4 +4,4 @@
exports.PluginModule = PluginModule;
function PluginModule(options) {
-}
\ No newline at end of file
+}
diff --git a/core/predefined_mci.js b/core/predefined_mci.js
index 34cb6a5c..afebd24c 100644
--- a/core/predefined_mci.js
+++ b/core/predefined_mci.js
@@ -37,7 +37,7 @@ function setNextRandomRumor(cb) {
});
}
-function getRatio(client, propA, propB) {
+function getUserRatio(client, propA, propB) {
const a = StatLog.getUserStatNum(client.user, propA);
const b = StatLog.getUserStatNum(client.user, propB);
const ratio = ~~((a / b) * 100);
@@ -48,6 +48,10 @@ function userStatAsString(client, statName, defaultValue) {
return (StatLog.getUserStat(client.user, statName) || defaultValue).toString();
}
+function sysStatAsString(statName, defaultValue) {
+ return (StatLog.getSystemStat(statName) || defaultValue).toString();
+}
+
const PREDEFINED_MCI_GENERATORS = {
//
// Board
@@ -84,7 +88,7 @@ const PREDEFINED_MCI_GENERATORS = {
UT : function themeId(client) { return userStatAsString(client, 'theme_id', ''); },
UC : function loginCount(client) { return userStatAsString(client, 'login_count', 0); },
ND : function connectedNode(client) { return client.node.toString(); },
- IP : function clientIpAddress(client) { return client.remoteAddress; },
+ IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version
ST : function serverName(client) { return client.session.serverName; },
FN : function activeFileBaseFilterName(client) {
const activeFilter = FileBaseFilters.getActiveFilter(client);
@@ -101,15 +105,15 @@ const PREDEFINED_MCI_GENERATORS = {
return formatByteSize(byteSize, true); // true=withAbbr
},
NR : function userUpDownRatio(client) { // Obv/2
- return getRatio(client, 'ul_total_count', 'dl_total_count');
+ return getUserRatio(client, 'ul_total_count', 'dl_total_count');
},
KR : function userUpDownByteRatio(client) { // Obv/2 uses KR=upload/download Kbyte ratio
- return getRatio(client, 'ul_total_bytes', 'dl_total_bytes');
+ return getUserRatio(client, 'ul_total_bytes', 'dl_total_bytes');
},
MS : function accountCreatedclient(client) { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); },
PS : function userPostCount(client) { return userStatAsString(client, 'post_count', 0); },
- PC : function userPostCallRatio(client) { return getRatio(client, 'post_count', 'login_count'); },
+ PC : function userPostCallRatio(client) { return getUserRatio(client, 'post_count', 'login_count'); },
MD : function currentMenuDescription(client) {
return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : '';
@@ -187,7 +191,16 @@ const PREDEFINED_MCI_GENERATORS = {
//
// :TODO: DD - Today's # of downloads (iNiQUiTY)
//
- // :TODO: System stat log for total ul/dl, total ul/dl bytes
+ SD : function systemNumDownloads() { return sysStatAsString('dl_total_count', 0); },
+ SO : function systemByteDownload() {
+ const byteSize = StatLog.getSystemStatNum('dl_total_bytes');
+ return formatByteSize(byteSize, true); // true=withAbbr
+ },
+ SU : function systemNumUploads() { return sysStatAsString('ul_total_count', 0); },
+ SP : function systemByteUpload() {
+ const byteSize = StatLog.getSystemStatNum('ul_total_bytes');
+ return formatByteSize(byteSize, true); // true=withAbbr
+ },
// :TODO: PT - Messages posted *today* (Obv/2)
// -> Include FTN/etc.
diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js
index 645c4b5f..039caac7 100644
--- a/core/scanner_tossers/ftn_bso.js
+++ b/core/scanner_tossers/ftn_bso.js
@@ -293,14 +293,14 @@ function FTNMessageScanTossModule() {
async.detectSeries(EXT_SUFFIXES, (suffix, callback) => {
const checkFileName = fileName + suffix;
fs.stat(paths.join(basePath, checkFileName), err => {
- callback((err && 'ENOENT' === err.code) ? true : false);
+ callback(null, (err && 'ENOENT' === err.code) ? true : false);
});
- }, finalSuffix => {
+ }, (err, finalSuffix) => {
if(finalSuffix) {
- cb(null, paths.join(basePath, fileName + finalSuffix));
- } else {
- cb(new Error('Could not acquire a bundle filename!'));
+ return cb(null, paths.join(basePath, fileName + finalSuffix));
}
+
+ return cb(new Error('Could not acquire a bundle filename!'));
});
};
@@ -390,11 +390,14 @@ function FTNMessageScanTossModule() {
message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier();
//
- // Determine CHRS and actual internal encoding name
- // Try to preserve anything already here
+ // Determine CHRS and actual internal encoding name. If the message has an
+ // explicit encoding set, use it. Otherwise, try to preserve any CHRS/encoding already set.
//
- let encoding = options.nodeConfig.encoding || 'utf8';
- if(message.meta.FtnKludge.CHRS) {
+ let encoding = options.nodeConfig.encoding || Config.scannerTossers.ftn_bso.packetMsgEncoding || 'utf8';
+ const explicitEncoding = _.get(message.meta, 'System.explicit_encoding');
+ if(explicitEncoding) {
+ encoding = explicitEncoding;
+ } else if(message.meta.FtnKludge.CHRS) {
const encFromChars = ftnUtil.getEncodingFromCharacterSetIdentifier(message.meta.FtnKludge.CHRS);
if(encFromChars) {
encoding = encFromChars;
@@ -605,16 +608,22 @@ function FTNMessageScanTossModule() {
callback(null);
},
function appendMessage(callback) {
- const msgBuf = packet.getMessageEntryBuffer(message, exportOpts);
- currPacketSize += msgBuf.length;
+ packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => {
+ if(err) {
+ return callback(err);
+ }
+
+ currPacketSize += msgBuf.length;
- if(currPacketSize >= self.moduleConfig.packetTargetByteSize) {
- remainMessageBuf = msgBuf; // save for next packet
- remainMessageId = message.messageId;
- } else {
- ws.write(msgBuf);
- }
- callback(null);
+ if(currPacketSize >= self.moduleConfig.packetTargetByteSize) {
+ remainMessageBuf = msgBuf; // save for next packet
+ remainMessageId = message.messageId;
+ } else {
+ ws.write(msgBuf);
+ }
+
+ return callback(null);
+ });
},
function storeStateFlags0Meta(callback) {
message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), err => {
diff --git a/core/servers/content/web.js b/core/servers/content/web.js
index 9ec194a0..31c617e2 100644
--- a/core/servers/content/web.js
+++ b/core/servers/content/web.js
@@ -116,7 +116,7 @@ exports.getModule = class WebServerModule extends ServerModule {
// additional options
Object.assign(options, Config.contentServers.web.https.options || {} );
- this.httpsServer = https.createServer(options, this.routeRequest);
+ this.httpsServer = https.createServer(options, (req, resp) => this.routeRequest(req, resp) );
}
}
diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js
index 5c471473..4377f029 100644
--- a/core/servers/login/telnet.js
+++ b/core/servers/login/telnet.js
@@ -6,12 +6,12 @@ const baseClient = require('../../client.js');
const Log = require('../../logger.js').log;
const LoginServerModule = require('../../login_server_module.js');
const Config = require('../../config.js').config;
+const EnigAssert = require('../../enigma_assert.js');
// deps
const net = require('net');
const buffers = require('buffers');
const binary = require('binary');
-const assert = require('assert');
const util = require('util');
//var debug = require('debug')('telnet');
@@ -184,6 +184,10 @@ const OPTION_NAMES = Object.keys(OPTIONS).reduce(function(names, name) {
return names;
}, {});
+function unknownOption(bufs, i, event) {
+ Log.warn( { bufs : bufs, i : i, event : event }, 'Unknown Telnet option');
+}
+
const OPTION_IMPLS = {};
// :TODO: fill in the rest...
OPTION_IMPLS.NO_ARGS =
@@ -224,10 +228,10 @@ OPTION_IMPLS[OPTIONS.TERMINAL_TYPE] = function(bufs, i, event) {
.word8('ttype')
.word8('is')
.tap(function(vars) {
- assert(vars.iac1 === COMMANDS.IAC);
- assert(vars.sb === COMMANDS.SB);
- assert(vars.ttype === OPTIONS.TERMINAL_TYPE);
- assert(vars.is === SB_COMMANDS.IS);
+ EnigAssert(vars.iac1 === COMMANDS.IAC);
+ EnigAssert(vars.sb === COMMANDS.SB);
+ EnigAssert(vars.ttype === OPTIONS.TERMINAL_TYPE);
+ EnigAssert(vars.is === SB_COMMANDS.IS);
});
// eat up the rest
@@ -275,11 +279,11 @@ OPTION_IMPLS[OPTIONS.WINDOW_SIZE] = function(bufs, i, event) {
.word8('iac2')
.word8('se')
.tap(function(vars) {
- assert(vars.iac1 == COMMANDS.IAC);
- assert(vars.sb == COMMANDS.SB);
- assert(vars.naws == OPTIONS.WINDOW_SIZE);
- assert(vars.iac2 == COMMANDS.IAC);
- assert(vars.se == COMMANDS.SE);
+ EnigAssert(vars.iac1 == COMMANDS.IAC);
+ EnigAssert(vars.sb == COMMANDS.SB);
+ EnigAssert(vars.naws == OPTIONS.WINDOW_SIZE);
+ EnigAssert(vars.iac2 == COMMANDS.IAC);
+ EnigAssert(vars.se == COMMANDS.SE);
event.cols = event.columns = event.width = vars.width;
event.rows = event.height = vars.height;
@@ -322,10 +326,10 @@ OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT] = function(bufs, i, event) {
.word8('newEnv')
.word8('isOrInfo') // initial=IS, updates=INFO
.tap(function(vars) {
- assert(vars.iac1 === COMMANDS.IAC);
- assert(vars.sb === COMMANDS.SB);
- assert(vars.newEnv === OPTIONS.NEW_ENVIRONMENT || vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP);
- assert(vars.isOrInfo === SB_COMMANDS.IS || vars.isOrInfo === SB_COMMANDS.INFO);
+ EnigAssert(vars.iac1 === COMMANDS.IAC);
+ EnigAssert(vars.sb === COMMANDS.SB);
+ EnigAssert(vars.newEnv === OPTIONS.NEW_ENVIRONMENT || vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP);
+ EnigAssert(vars.isOrInfo === SB_COMMANDS.IS || vars.isOrInfo === SB_COMMANDS.INFO);
event.type = vars.isOrInfo;
@@ -394,8 +398,8 @@ OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT] = function(bufs, i, event) {
const MORE_DATA_REQUIRED = 0xfeedface;
function parseBufs(bufs) {
- assert(bufs.length >= 2);
- assert(bufs.get(0) === COMMANDS.IAC);
+ EnigAssert(bufs.length >= 2);
+ EnigAssert(bufs.get(0) === COMMANDS.IAC);
return parseCommand(bufs, 1, {});
}
@@ -421,7 +425,9 @@ function parseOption(bufs, i, event) {
const option = bufs.get(i); // :TODO: fix deprecation... [i] is not the same
event.optionCode = option;
event.option = OPTION_NAMES[option];
- return OPTION_IMPLS[option](bufs, i + 1, event);
+
+ const handler = OPTION_IMPLS[option];
+ return handler ? handler(bufs, i + 1, event) : unknownOption(bufs, i + 1, event);
}
@@ -433,6 +439,8 @@ function TelnetClient(input, output) {
let bufs = buffers();
this.bufs = bufs;
+ this.sentDont = {}; // DON'T's we've already sent
+
this.setInputOutput(input, output);
this.negotiationsComplete = false; // are we in the 'negotiation' phase?
@@ -453,6 +461,11 @@ function TelnetClient(input, output) {
};
this.dataHandler = function(b) {
+ if(!Buffer.isBuffer(b)) {
+ EnigAssert(false, `Cannot push non-buffer ${typeof b}`);
+ return;
+ }
+
bufs.push(b);
let i;
@@ -466,7 +479,7 @@ function TelnetClient(input, output) {
break;
}
- assert(bufs.length > (i + 1));
+ EnigAssert(bufs.length > (i + 1));
if(i > 0) {
self.emit('data', bufs.splice(0, i).toBuffer());
@@ -476,7 +489,7 @@ function TelnetClient(input, output) {
if(MORE_DATA_REQUIRED === i) {
break;
- } else {
+ } else if(i) {
if(i.option) {
self.emit(i.option, i); // "transmit binary", "echo", ...
}
@@ -505,15 +518,26 @@ function TelnetClient(input, output) {
});
this.input.on('error', err => {
- self.log.debug( { err : err }, 'Socket error');
- self.emit('end');
+ this.connectionDebug( { err : err }, 'Socket error' );
+ return self.emit('end');
});
- this.connectionDebug = (info, msg) => {
+ this.connectionTrace = (info, msg) => {
if(Config.loginServers.telnet.traceConnections) {
- self.log.trace(info, 'Telnet: ' + msg);
+ const logger = self.log || Log;
+ return logger.trace(info, `Telnet: ${msg}`);
}
};
+
+ this.connectionDebug = (info, msg) => {
+ const logger = self.log || Log;
+ return logger.debug(info, `Telnet: ${msg}`);
+ };
+
+ this.connectionWarn = (info, msg) => {
+ const logger = self.log || Log;
+ return logger.warn(info, `Telnet: ${msg}`);
+ };
}
util.inherits(TelnetClient, baseClient.Client);
@@ -522,6 +546,11 @@ util.inherits(TelnetClient, baseClient.Client);
// Telnet Command/Option handling
///////////////////////////////////////////////////////////////////////////////
TelnetClient.prototype.handleTelnetEvent = function(evt) {
+
+ if(!evt.command) {
+ return this.connectionWarn( { evt : evt }, 'No command for event');
+ }
+
// handler name e.g. 'handleWontCommand'
const handlerName = `handle${evt.command.charAt(0).toUpperCase()}${evt.command.substr(1)}Command`;
@@ -547,15 +576,21 @@ TelnetClient.prototype.handleWillCommand = function(evt) {
this.requestNewEnvironment();
} else {
// :TODO: temporary:
- this.connectionDebug(evt, 'WILL');
+ this.connectionTrace(evt, 'WILL');
}
};
TelnetClient.prototype.handleWontCommand = function(evt) {
- if('new environment' === evt.option) {
+ if(this.sentDont[evt.option]) {
+ return this.connectionTrace(evt, 'WONT - DON\'T already sent');
+ }
+
+ this.sentDont[evt.option] = true;
+
+ if('new environment' === evt.option) {
this.dont.new_environment();
} else {
- this.connectionDebug(evt, 'WONT');
+ this.connectionTrace(evt, 'WONT');
}
};
@@ -574,12 +609,12 @@ TelnetClient.prototype.handleDoCommand = function(evt) {
this.wont.encrypt();
} else {
// :TODO: temporary:
- this.connectionDebug(evt, 'DO');
+ this.connectionTrace(evt, 'DO');
}
};
TelnetClient.prototype.handleDontCommand = function(evt) {
- this.connectionDebug(evt, 'DONT');
+ this.connectionTrace(evt, 'DONT');
};
TelnetClient.prototype.handleSbCommand = function(evt) {
@@ -613,24 +648,26 @@ TelnetClient.prototype.handleSbCommand = function(evt) {
} else if('COLUMNS' === name && 0 === self.term.termWidth) {
self.term.termWidth = parseInt(evt.envVars[name]);
self.clearMciCache(); // term size changes = invalidate cache
- self.log.debug({ termWidth : self.term.termWidth, source : 'NEW-ENVIRON'}, 'Window width updated');
+ self.connectionDebug({ termWidth : self.term.termWidth, source : 'NEW-ENVIRON'}, 'Window width updated');
} else if('ROWS' === name && 0 === self.term.termHeight) {
self.term.termHeight = parseInt(evt.envVars[name]);
self.clearMciCache(); // term size changes = invalidate cache
- self.log.debug({ termHeight : self.term.termHeight, source : 'NEW-ENVIRON'}, 'Window height updated');
+ self.connectionDebug({ termHeight : self.term.termHeight, source : 'NEW-ENVIRON'}, 'Window height updated');
} else {
if(name in self.term.env) {
- assert(
- SB_COMMANDS.INFO === evt.type || SB_COMMANDS.IS === evt.type,
- 'Unexpected type: ' + evt.type);
- self.log.warn(
+ EnigAssert(
+ SB_COMMANDS.INFO === evt.type || SB_COMMANDS.IS === evt.type,
+ 'Unexpected type: ' + evt.type
+ );
+
+ self.connectionWarn(
{ varName : name, value : evt.envVars[name], existingValue : self.term.env[name] },
- 'Environment variable already exists');
+ 'Environment variable already exists'
+ );
} else {
self.term.env[name] = evt.envVars[name];
- self.log.debug(
- { varName : name, value : evt.envVars[name] }, 'New environment variable');
+ self.connectionDebug( { varName : name, value : evt.envVars[name] }, 'New environment variable' );
}
}
});
@@ -653,9 +690,9 @@ TelnetClient.prototype.handleSbCommand = function(evt) {
self.clearMciCache(); // term size changes = invalidate cache
- self.log.debug({ termWidth : evt.width , termHeight : evt.height, source : 'NAWS' }, 'Window size updated');
+ self.connectionDebug({ termWidth : evt.width , termHeight : evt.height, source : 'NAWS' }, 'Window size updated');
} else {
- self.log(evt, 'SB');
+ self.connectionDebug(evt, 'SB');
}
};
@@ -666,7 +703,7 @@ const IGNORED_COMMANDS = [];
TelnetClient.prototype.handleMiscCommand = function(evt) {
- assert(evt.command !== 'undefined' && evt.command.length > 0);
+ EnigAssert(evt.command !== 'undefined' && evt.command.length > 0);
//
// See:
diff --git a/core/servers/login/websocket.js b/core/servers/login/websocket.js
index 6f826cc9..378af7ef 100644
--- a/core/servers/login/websocket.js
+++ b/core/servers/login/websocket.js
@@ -13,7 +13,7 @@ const WebSocketServer = require('ws').Server;
const http = require('http');
const https = require('https');
const fs = require('graceful-fs');
-const EventEmitter = require('events');
+const Writable = require('stream');
const ModuleInfo = exports.moduleInfo = {
name : 'WebSocket',
@@ -34,20 +34,31 @@ function WebSocketClient(ws, req, serverType) {
// This bridge makes accessible various calls that client sub classes
// want to access on I/O socket
//
- this.socketBridge = new class SocketBridge extends EventEmitter {
+ this.socketBridge = new class SocketBridge extends Writable {
constructor(ws) {
super();
this.ws = ws;
}
end() {
- return ws.terminate();
+ return ws.close();
}
write(data, cb) {
+ cb = cb || ( () => { /* eat it up */} ); // handle data writes after close
+
return this.ws.send(data, { binary : true }, cb);
}
+ // we need to fake some streaming work
+ unpipe() {
+ Log.trace('WebSocket SocketBridge unpipe()');
+ }
+
+ resume() {
+ Log.trace('WebSocket SocketBridge resume()');
+ }
+
get remoteAddress() {
// Support X-Forwarded-For and X-Real-IP headers for proxied connections
return (self.proxied && (req.headers['x-forwarded-for'] || req.headers['x-real-ip'])) || req.connection.remoteAddress;
diff --git a/core/stat_log.js b/core/stat_log.js
index 41c4ceb5..d6a53d28 100644
--- a/core/stat_log.js
+++ b/core/stat_log.js
@@ -92,6 +92,10 @@ class StatLog {
getSystemStat(statName) { return this.systemStats[statName]; }
+ getSystemStatNum(statName) {
+ return parseInt(this.getSystemStat(statName)) || 0;
+ }
+
incrementSystemStat(statName, incrementBy, cb) {
incrementBy = incrementBy || 1;
diff --git a/core/string_util.js b/core/string_util.js
index 05cdd0d7..ed6df231 100644
--- a/core/string_util.js
+++ b/core/string_util.js
@@ -8,20 +8,26 @@ const ANSI = require('./ansi_term.js');
// deps
const iconv = require('iconv-lite');
+const _ = require('lodash');
exports.stylizeString = stylizeString;
exports.pad = pad;
+exports.insert = insert;
exports.replaceAt = replaceAt;
exports.isPrintable = isPrintable;
exports.stripAllLineFeeds = stripAllLineFeeds;
exports.debugEscapedString = debugEscapedString;
exports.stringFromNullTermBuffer = stringFromNullTermBuffer;
+exports.stringToNullTermBuffer = stringToNullTermBuffer;
exports.renderSubstr = renderSubstr;
exports.renderStringLength = renderStringLength;
exports.formatByteSizeAbbr = formatByteSizeAbbr;
exports.formatByteSize = formatByteSize;
exports.cleanControlCodes = cleanControlCodes;
-exports.createCleanAnsi = createCleanAnsi;
+exports.isAnsi = isAnsi;
+exports.isAnsiLine = isAnsiLine;
+exports.isFormattedLine = isFormattedLine;
+exports.splitTextAtTerms = splitTextAtTerms;
// :TODO: create Unicode verison of this
const VOWELS = [ 'a', 'e', 'i', 'o', 'u' ];
@@ -168,11 +174,16 @@ function pad(s, len, padChar, dir, stringSGR, padSGR, useRenderLen) {
return stringSGR + s;
}
+function insert(s, index, substr) {
+ return `${s.slice(0, index)}${substr}${s.slice(index)}`;
+}
+
function replaceAt(s, n, t) {
return s.substring(0, n) + t + s.substring(n + 1);
}
-const RE_NON_PRINTABLE = /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/;
+const RE_NON_PRINTABLE =
+ /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/; // eslint-disable-line no-control-regex
function isPrintable(s) {
//
@@ -207,9 +218,16 @@ function stringFromNullTermBuffer(buf, encoding) {
return iconv.decode(buf.slice(0, nullPos), encoding || 'utf-8');
}
+function stringToNullTermBuffer(s, options = { encoding : 'utf8', maxBufLen : -1 } ) {
+ let buf = iconv.encode( `${s}\0`, options.encoding ).slice(0, options.maxBufLen);
+ buf[buf.length - 1] = '\0'; // make abs sure we null term even if truncated
+ return buf;
+}
+
const PIPE_REGEXP = /(\|[A-Z\d]{2})/g;
-const ANSI_REGEXP = /[\u001b\u009b][[()#;?]*([0-9]{1,4}(?:;[0-9]{0,4})*)?([0-9A-ORZcf-npqrsuy=><])/g;
-const ANSI_OR_PIPE_REGEXP = new RegExp(PIPE_REGEXP.source + '|' + ANSI_REGEXP.source, 'g');
+//const ANSI_REGEXP = /[\u001b\u009b][[()#;?]*([0-9]{1,4}(?:;[0-9]{0,4})*)?([0-9A-ORZcf-npqrsuy=><])/g;
+//const ANSI_OR_PIPE_REGEXP = new RegExp(PIPE_REGEXP.source + '|' + ANSI_REGEXP.source, 'g');
+const ANSI_OR_PIPE_REGEXP = new RegExp(PIPE_REGEXP.source + '|' + ANSI.getFullMatchRegExp().source, 'g');
//
// Similar to substr() but works with ANSI/Pipe code strings
@@ -322,18 +340,13 @@ function formatByteSize(byteSize, withAbbr, decimals) {
// :TODO: See notes in word_wrap.js about need to consolidate the various ANSI related RegExp's
//const REGEXP_ANSI_CONTROL_CODES = /(\x1b\x5b)([\?=;0-9]*?)([0-9A-ORZcf-npsu=><])/g;
-const REGEXP_ANSI_CONTROL_CODES = /(?:\x1b\x5b)([\?=;0-9]*?)([A-ORZcf-npsu=><])/g;
+const REGEXP_ANSI_CONTROL_CODES = /(?:\x1b\x5b)([\?=;0-9]*?)([A-ORZcf-npsu=><])/g; // eslint-disable-line no-control-regex
const ANSI_OPCODES_ALLOWED_CLEAN = [
- 'A', 'B', // up, down
- 'C', 'D', // right, left
+ //'A', 'B', // up, down
+ //'C', 'D', // right, left
'm', // color
];
-const AnsiSpecialOpCodes = {
- positioning : [ 'A', 'B', 'C', 'D' ], // up, down, right, left
- style : [ 'm' ] // color
-};
-
function cleanControlCodes(input, options) {
let m;
let pos;
@@ -373,147 +386,249 @@ function cleanControlCodes(input, options) {
return cleaned;
}
-function createCleanAnsi(input, options, cb) {
-
+function prepAnsi(input, options, cb) {
if(!input) {
- return cb('');
+ return cb(null, '');
}
- options.width = options.width || 80;
- options.height = options.height || 25;
-
- const canvas = new Array(options.height);
- for(let i = 0; i < options.height; ++i) {
- canvas[i] = new Array(options.width);
- for(let j = 0; j < options.width; ++j) {
- canvas[i][j] = {};
- }
- }
+ options.termWidth = options.termWidth || 80;
+ options.termHeight = options.termHeight || 25;
+ options.cols = options.cols || options.termWidth || 80;
+ options.rows = options.rows || options.termHeight || 'auto';
+ options.startCol = options.startCol || 1;
+ options.exportMode = options.exportMode || false;
- const parserOpts = {
- termHeight : options.height,
- termWidth : options.width,
+ const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) );
+ const parser = new ANSIEscapeParser( { termHeight : options.termHeight, termWidth : options.termWidth } );
+
+ const state = {
+ row : 0,
+ col : 0,
};
- const parser = new ANSIEscapeParser(parserOpts);
+ let lastRow = 0;
- const canvasPos = {
- col : 0,
- row : 0,
- };
-
- let sgr;
-
- function ensureCell() {
- // we've pre-allocated a matrix, but allow for > supplied dimens up front. They will be trimmed @ finalize
- if(!canvas[canvasPos.row]) {
- canvas[canvasPos.row] = new Array(options.width);
- for(let j = 0; j < options.width; ++j) {
- canvas[canvasPos.row][j] = {};
- }
+ function ensureRow(row) {
+ if(Array.isArray(canvas[row])) {
+ return;
}
- canvas[canvasPos.row][canvasPos.col] = canvas[canvasPos.row][canvasPos.col] || {};
- //canvas[canvasPos.row][0].width = Math.max(canvas[canvasPos.row][0].width || 0, canvasPos.col);
+
+ canvas[row] = Array.from( { length : options.cols}, () => new Object() );
}
+ parser.on('position update', (row, col) => {
+ state.row = row - 1;
+ state.col = col - 1;
+
+ lastRow = Math.max(state.row, lastRow);
+ });
+
parser.on('literal', literal => {
//
// CR/LF are handled for 'position update'; we don't need the chars themselves
//
literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, '');
- for(let i = 0; i < literal.length; ++i) {
- const c = literal.charAt(i);
+ for(let c of literal) {
+ if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) {
+ ensureRow(state.row);
- ensureCell();
+ canvas[state.row][state.col].char = c;
- canvas[canvasPos.row][canvasPos.col].char = c;
-
- if(sgr) {
- canvas[canvasPos.row][canvasPos.col].sgr = sgr;
- sgr = null;
+ if(state.sgr) {
+ canvas[state.row][state.col].sgr = state.sgr;
+ state.sgr = null;
+ }
}
- canvasPos.col += 1;
+ state.col += 1;
}
});
parser.on('control', (match, opCode) => {
- if('m' !== opCode) {
- return; // don't care'
+ //
+ // Movement is handled via 'position update', so we really only care about
+ // display opCodes
+ //
+ switch(opCode) {
+ case 'm' :
+ state.sgr = (state.sgr || '') + match;
+ break;
+
+ default :
+ break;
}
- sgr = match;
});
- parser.on('position update', (row, col) => {
- canvasPos.row = row - 1;
- canvasPos.col = Math.min(col - 1, options.width);
- });
+ function getLastPopulatedColumn(row) {
+ let col = row.length;
+ while(--col > 0) {
+ if(row[col].char || row[col].sgr) {
+ break;
+ }
+ }
+ return col;
+ }
parser.on('complete', () => {
- for(let row = 0; row < options.height; ++row) {
- let col = 0;
+ let output = '';
+ let lastSgr = '';
+ let line;
- //while(col <= canvas[row][0].width) {
- while(col < options.width) {
- if(!canvas[row][col].char) {
- canvas[row][col].char = ' ';
- if(!canvas[row][col].sgr) {
- // :TODO: fix duplicate SGR's in a row here - we just need one per sequence
- canvas[row][col].sgr = ANSI.reset();
- }
+ canvas.slice(0, lastRow + 1).forEach(row => {
+ const lastCol = getLastPopulatedColumn(row) + 1;
+
+ let i;
+ line = '';
+ for(i = 0; i < lastCol; ++i) {
+ const col = row[i];
+ if(col.sgr) {
+ lastSgr = col.sgr;
}
-
- col += 1;
+ line += `${col.sgr || ''}${col.char || ' '}`;
}
- // :TODO: end *all* with CRLF - general usage will be width : 79 - prob update defaults
+ output += line;
- if(col <= options.width) {
- canvas[row][col] = canvas[row][col] || {};
-
- canvas[row][col].char = '\r\n';
- canvas[row][col].sgr = ANSI.reset();
-
- // :TODO: don't splice, just reset + fill with ' ' till end
- for(let fillCol = col; fillCol <= options.width; ++fillCol) {
- canvas[row][fillCol].char = ' ';
- }
-
- //canvas[row] = canvas[row].splice(0, col + 1);
- //canvas[row][options.width - 1].char = '\r\n';
-
-
- } else {
- canvas[row] = canvas[row].splice(0, options.width + 1);
+ if(i < row.length) {
+ output += `${ANSI.blackBG()}${row.slice(i).map( () => ' ').join('')}${lastSgr}`;
}
-
- }
- let out = '';
- for(let row = 0; row < options.height; ++row) {
- out += canvas[row].map( col => {
- let c = col.sgr || '';
- c += col.char;
- return c;
- }).join('');
+ //if(options.startCol + options.cols < options.termWidth || options.forceLineTerm) {
+ if(options.startCol + i < options.termWidth || options.forceLineTerm) {
+ output += '\r\n';
+ }
+ });
+
+ if(options.exportMode) {
+ //
+ // If we're in export mode, we do some additional hackery:
+ //
+ // * Hard wrap ALL lines at <= 79 *characters* (not visible columns)
+ // if a line must wrap early, we'll place a ESC[A ESC[C where
+ // represents chars to get back to the position we were previously at
+ //
+ // * Replace contig spaces with ESC[C as well to save... space.
+ //
+ // :TODO: this would be better to do as part of the processing above, but this will do for now
+ const MAX_CHARS = 79 - 8; // 79 max, - 8 for max ESC seq's we may prefix a line with
+ let exportOutput = '';
+ let m;
+ let afterSeq;
+ let wantMore;
+ let renderStart;
+
+ splitTextAtTerms(output).forEach(fullLine => {
+ renderStart = 0;
+
+ while(fullLine.length > 0) {
+ let splitAt;
+ const ANSI_REGEXP = ANSI.getFullMatchRegExp();
+ wantMore = true;
+
+ while((m = ANSI_REGEXP.exec(fullLine))) {
+ afterSeq = m.index + m[0].length;
+
+ if(afterSeq < MAX_CHARS) {
+ // after current seq
+ splitAt = afterSeq;
+ } else {
+ if(m.index < MAX_CHARS) {
+ // before last found seq
+ splitAt = m.index;
+ wantMore = false; // can't eat up any more
+ }
+
+ break; // seq's beyond this point are >= MAX_CHARS
+ }
+ }
+
+ if(splitAt) {
+ if(wantMore) {
+ splitAt = Math.min(fullLine.length, MAX_CHARS - 1);
+ }
+ } else {
+ splitAt = Math.min(fullLine.length, MAX_CHARS - 1);
+ }
+
+ const part = fullLine.slice(0, splitAt);
+ fullLine = fullLine.slice(splitAt);
+ renderStart += renderStringLength(part);
+ exportOutput += `${part}\r\n`;
+
+ if(fullLine.length > 0) { // more to go for this line?
+ exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`;
+ } else {
+ exportOutput += ANSI.up();
+ }
+ }
+ });
+
+ return cb(null, exportOutput);
}
- // :TODO: finalize: @ any non-char cell, reset sgr & set to ' '
- // :TODO: finalize: after sgr established, omit anything > supplied dimens
- return cb(out);
+ return cb(null, output);
});
parser.parse(input);
}
-/*
-const fs = require('graceful-fs');
-let data = fs.readFileSync('/home/nuskooler/Downloads/art3.ans');
-data = iconv.decode(data, 'cp437');
-createCleanAnsi(data, { width : 79, height : 25 }, (out) => {
- out = iconv.encode(out, 'cp437');
- fs.writeFileSync('/home/nuskooler/Downloads/art4.ans', out);
-});
-*/
\ No newline at end of file
+function isAnsiLine(line) {
+ return isAnsi(line);// || renderStringLength(line) < line.length;
+}
+
+//
+// Returns true if the line is considered "formatted". A line is
+// considered formatted if it contains:
+// * ANSI
+// * Pipe codes
+// * Extended (CP437) ASCII - https://www.ascii-codes.com/
+// * Tabs
+// * Contigous 3+ spaces before the end of the line
+//
+function isFormattedLine(line) {
+ if(renderStringLength(line) < line.length) {
+ return true; // ANSI or Pipe Codes
+ }
+
+ if(line.match(/[\t\x00-\x1f\x80-\xff]/)) { // eslint-disable-line no-control-regex
+ return true;
+ }
+
+ if(_.trimEnd(line).match(/[ ]{3,}/)) {
+ return true;
+ }
+
+ return false;
+}
+
+function isAnsi(input) {
+ //
+ // * ANSI found - limited, just colors
+ // * Full ANSI art
+ // *
+ //
+ // FULL ANSI art:
+ // * SAUCE present & reports as ANSI art
+ // * ANSI clear screen within first 2-3 codes
+ // * ANSI movement codes (goto, right, left, etc.)
+ //
+ // *
+ /*
+ readSAUCE(input, (err, sauce) => {
+ if(!err && ('ANSi' === sauce.fileType || 'ANSiMation' === sauce.fileType)) {
+ return cb(null, 'ansi');
+ }
+ });
+ */
+
+ // :TODO: if a similar method is kept, use exec() until threshold
+ const ANSI_DET_REGEXP = /(?:\x1b\x5b)[\?=;0-9]*?[ABCDEFGHJKLMSTfhlmnprsu]/g; // eslint-disable-line no-control-regex
+ const m = input.match(ANSI_DET_REGEXP) || [];
+ return m.length >= 4; // :TODO: do this reasonably, e.g. a percent or soemthing
+}
+
+function splitTextAtTerms(s) {
+ return s.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
+}
diff --git a/core/theme.js b/core/theme.js
index 6359d461..c5b90233 100644
--- a/core/theme.js
+++ b/core/theme.js
@@ -10,6 +10,7 @@ const getFullConfig = require('./config_util.js').getFullConfig;
const asset = require('./asset.js');
const ViewController = require('./view_controller.js').ViewController;
const Errors = require('./enig_error.js').Errors;
+const ErrorReasons = require('./enig_error.js').ErrorReasons;
const fs = require('graceful-fs');
const paths = require('path');
@@ -80,24 +81,27 @@ function refreshThemeHelpers(theme) {
function loadTheme(themeID, cb) {
- var path = paths.join(Config.paths.themes, themeID, 'theme.hjson');
+ const path = paths.join(Config.paths.themes, themeID, 'theme.hjson');
- configCache.getConfigWithOptions( { filePath : path, forceReCache : true }, function loaded(err, theme) {
+ configCache.getConfigWithOptions( { filePath : path, forceReCache : true }, (err, theme) => {
if(err) {
- cb(err);
- } else {
- if(!_.isObject(theme.info) ||
- !_.isString(theme.info.name) ||
- !_.isString(theme.info.author))
- {
- cb(new Error('Invalid or missing "info" section!'));
- return;
- }
-
- refreshThemeHelpers(theme);
-
- cb(null, theme, path);
+ return cb(err);
}
+
+ if(!_.isObject(theme.info) ||
+ !_.isString(theme.info.name) ||
+ !_.isString(theme.info.author))
+ {
+ return cb(Errors.Invalid('Invalid or missing "info" section'));
+ }
+
+ if(false === _.get(theme, 'info.enabled')) {
+ return cb(Errors.General('Theme is not enalbed', ErrorReasons.ErrNotEnabled));
+ }
+
+ refreshThemeHelpers(theme);
+
+ return cb(null, theme, path);
});
}
@@ -261,69 +265,72 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
}
function initAvailableThemes(cb) {
- var menuConfig;
- var promptConfig;
async.waterfall(
[
function loadMenuConfig(callback) {
- getFullConfig(Config.general.menuFile, function gotConfig(err, mc) {
- menuConfig = mc;
- callback(err);
+ getFullConfig(Config.general.menuFile, (err, menuConfig) => {
+ return callback(err, menuConfig);
});
},
- function loadPromptConfig(callback) {
- getFullConfig(Config.general.promptFile, function gotConfig(err, pc) {
- promptConfig = pc;
- callback(err);
+ function loadPromptConfig(menuConfig, callback) {
+ getFullConfig(Config.general.promptFile, (err, promptConfig) => {
+ return callback(err, menuConfig, promptConfig);
});
},
- function getDir(callback) {
- fs.readdir(Config.paths.themes, function dirRead(err, files) {
- callback(err, files);
- });
- },
- function filterFiles(files, callback) {
- var filtered = files.filter(function filter(file) {
- return fs.statSync(paths.join(Config.paths.themes, file)).isDirectory();
- });
- callback(null, filtered);
- },
- function populateAvailable(filtered, callback) {
- // :TODO: this is a bit broken with callback placement and configCache.on() handler
-
- filtered.forEach(function themeEntry(themeId) {
- loadTheme(themeId, function themeLoaded(err, theme, themePath) {
- if(!err) {
- availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme);
+ function getThemeDirectories(menuConfig, promptConfig, callback) {
+ fs.readdir(Config.paths.themes, (err, files) => {
+ if(err) {
+ return callback(err);
+ }
- configCache.on('recached', function recached(path) {
- if(themePath === path) {
- loadTheme(themeId, function reloaded(err, reloadedTheme) {
- Log.debug( { info : theme.info }, 'Theme recached' );
+ return callback(
+ null,
+ menuConfig,
+ promptConfig,
+ files.filter( f => {
+ // sync normally not allowed -- initAvailableThemes() is a startup-only method, however
+ return fs.statSync(paths.join(Config.paths.themes, f)).isDirectory();
+ })
+ );
+ });
+ },
+ function populateAvailable(menuConfig, promptConfig, themeDirectories, callback) {
+ async.each(themeDirectories, (themeId, nextThemeDir) => { // theme dir = theme ID
+ loadTheme(themeId, (err, theme, themePath) => {
+ if(err) {
+ if(ErrorReasons.NotEnabled !== err.reasonCode) {
+ Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme');
+ }
- availableThemes[themeId] = reloadedTheme;
- });
- }
- });
-
- Log.debug( { info : theme.info }, 'Theme loaded');
- } else {
- Log.warn( { themeId : themeId, error : err.toString() }, 'Failed to load theme');
+ return nextThemeDir(null); // try next
}
- });
+ availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme);
+
+ configCache.on('recached', recachedPath => {
+ if(themePath === recachedPath) {
+ loadTheme(themeId, (err, reloadedTheme) => {
+ if(!err) {
+ // :TODO: This is still broken - Need to reapply *latest* menu config and prompt configs to theme at very least
+ Log.debug( { info : theme.info }, 'Theme recached' );
+ availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, reloadedTheme);
+ } else if(ErrorReasons.NotEnabled === err.reasonCode) {
+ // :TODO: we need to disable this theme -- users may be using it! We'll need to re-assign them if so
+ }
+ });
+ }
+ });
+
+ return nextThemeDir(null);
+ });
+ }, err => {
+ return callback(err);
});
- callback(null);
}
],
- function onComplete(err) {
- if(err) {
- cb(err);
- return;
- }
-
- cb(null, availableThemes.length);
+ err => {
+ return cb(err, availableThemes ? availableThemes.length : 0);
}
);
}
@@ -340,17 +347,24 @@ function getRandomTheme() {
}
function setClientTheme(client, themeId) {
- var desc;
+ let logMsg;
- try {
- client.currentTheme = getAvailableThemes()[themeId];
- desc = 'Set client theme';
- } catch(e) {
- client.currentTheme = getAvailableThemes()[Config.defaults.theme];
- desc = 'Failed setting theme by supplied ID; Using default';
+ const availThemes = getAvailableThemes();
+
+ client.currentTheme = availThemes[themeId];
+ if(client.currentTheme) {
+ logMsg = 'Set client theme';
+ } else {
+ client.currentTheme = availThemes[Config.defaults.theme];
+ if(client.currentTheme) {
+ logMsg = 'Failed setting theme by supplied ID; Using default';
+ } else {
+ client.currentTheme = availThemes[Object.keys(availThemes)[0]];
+ logMsg = 'Failed setting theme by system default ID; Using the first one we can find';
+ }
}
-
- client.log.debug( { themeId : themeId, info : client.currentTheme.info }, desc);
+
+ client.log.debug( { themeId : themeId, info : client.currentTheme.info }, logMsg);
}
function getThemeArt(options, cb) {
@@ -374,8 +388,8 @@ function getThemeArt(options, cb) {
// :TODO: replace asAnsi stuff with something like retrieveAs = 'ansi' | 'pipe' | ...
// :TODO: Some of these options should only be set if not provided!
options.asAnsi = true; // always convert to ANSI
- options.readSauce = true; // read SAUCE, if avail
- options.random = _.isBoolean(options.random) ? options.random : true; // FILENAME.EXT support
+ options.readSauce = true; // read SAUCE, if avail
+ options.random = _.get(options, 'random', true); // FILENAME.EXT support
//
// We look for themed art in the following order:
@@ -450,34 +464,20 @@ function displayThemeArt(options, cb) {
assert(_.isObject(options.client));
assert(_.isString(options.name));
- getThemeArt(options, function themeArt(err, artInfo) {
+ getThemeArt(options, (err, artInfo) => {
if(err) {
- cb(err);
- } else {
- // :TODO: just use simple merge of options -> displayOptions
- /*
- var dispOptions = {
- art : artInfo.data,
- sauce : artInfo.sauce,
- client : options.client,
- font : options.font,
- trailingLF : options.trailingLF,
- };
-
- art.display(dispOptions, function displayed(err, mciMap, extraInfo) {
- cb(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
- });
- */
- const displayOpts = {
- sauce : artInfo.sauce,
- font : options.font,
- trailingLF : options.trailingLF,
- };
-
- art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
- return cb(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
- });
+ return cb(err);
}
+ // :TODO: just use simple merge of options -> displayOptions
+ const displayOpts = {
+ sauce : artInfo.sauce,
+ font : options.font,
+ trailingLF : options.trailingLF,
+ };
+
+ art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
+ return cb(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
+ });
});
}
@@ -566,8 +566,10 @@ function displayThemedPrompt(name, client, options, cb) {
//
// If we did *not* clear the screen, don't let the font change
- // as it will mess with the output of the existing art displayed in a terminal
- //
+ // doing so messes things up -- most terminals that support font
+ // changing can only display a single font at at time.
+ //
+ // :TODO: We can use term detection to do nifty things like avoid this kind of kludge:
const dispOptions = Object.assign( {}, promptConfig.options );
if(!options.clearScreen) {
dispOptions.font = 'not_really_a_font!'; // kludge :)
diff --git a/core/tic_file_info.js b/core/tic_file_info.js
index 730155e6..d2216d66 100644
--- a/core/tic_file_info.js
+++ b/core/tic_file_info.js
@@ -17,6 +17,7 @@ const crypto = require('crypto');
//
// Class to read and hold information from a TIC file
//
+// * FTS-5006.001 @ http://www.filegate.net/ftsc/FTS-5006.001
// * FSP-1039.001 @ http://ftsc.org/docs/old/fsp-1039.001
// * FSC-0087.001 @ http://ftsc.org/docs/fsc-0087.001
//
diff --git a/core/user_login.js b/core/user_login.js
index 76b01598..4bd9176c 100644
--- a/core/user_login.js
+++ b/core/user_login.js
@@ -37,14 +37,16 @@ function userLogin(client, username, password, cb) {
});
if(existingClientConnection) {
- client.log.info( {
- existingClientId : existingClientConnection.session.id,
- username : user.username,
- userId : user.userId },
+ client.log.info(
+ {
+ existingClientId : existingClientConnection.session.id,
+ username : user.username,
+ userId : user.userId
+ },
'Already logged in'
);
- var existingConnError = new Error('Already logged in as supplied user');
+ const existingConnError = new Error('Already logged in as supplied user');
existingConnError.existingConn = true;
// :TODO: We should use EnigError & pass existing connection as second param
@@ -61,24 +63,24 @@ function userLogin(client, username, password, cb) {
[
function setTheme(callback) {
setClientTheme(client, user.properties.theme_id);
- callback(null);
+ return callback(null);
},
function updateSystemLoginCount(callback) {
- StatLog.incrementSystemStat('login_count', 1, callback);
+ return StatLog.incrementSystemStat('login_count', 1, callback);
},
function recordLastLogin(callback) {
- StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback);
+ return StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback);
},
function updateUserLoginCount(callback) {
- StatLog.incrementUserStat(user, 'login_count', 1, callback);
+ return StatLog.incrementUserStat(user, 'login_count', 1, callback);
},
function recordLoginHistory(callback) {
const LOGIN_HISTORY_MAX = 200; // history of up to last 200 callers
- StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback);
+ return StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback);
}
],
- function complete(err) {
- cb(err);
+ err => {
+ return cb(err);
}
);
});
diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js
index 445f5f4a..2cc2ad7a 100644
--- a/core/vertical_menu_view.js
+++ b/core/vertical_menu_view.js
@@ -5,10 +5,10 @@
const MenuView = require('./menu_view.js').MenuView;
const ansi = require('./ansi_term.js');
const strUtil = require('./string_util.js');
-const colorCodes = require('./color_codes.js');
// deps
const util = require('util');
+const _ = require('lodash');
exports.VerticalMenuView = VerticalMenuView;
@@ -20,6 +20,14 @@ function VerticalMenuView(options) {
const self = this;
+ // we want page up/page down by default
+ if(!_.isObject(options.specialKeyMap)) {
+ Object.assign(this.specialKeyMap, {
+ 'page up' : [ 'page up' ],
+ 'page down' : [ 'page down' ],
+ });
+ }
+
this.performAutoScale = function() {
if(this.autoScale.height) {
this.dimens.height = (self.items.length * (self.itemSpacing + 1)) - (self.itemSpacing);
@@ -99,7 +107,7 @@ VerticalMenuView.prototype.redraw = function() {
let row = this.position.row + 1;
const endRow = (row + this.oldDimens.height) - 2;
- while(row < endRow) {
+ while(row <= endRow) {
seq += ansi.goto(row, this.position.col) + blank;
row += 1;
}
@@ -160,6 +168,10 @@ VerticalMenuView.prototype.onKeyPress = function(ch, key) {
this.focusPrevious();
} else if(this.isKeyMapped('down', key.name)) {
this.focusNext();
+ } else if(this.isKeyMapped('page up', key.name)) {
+ this.focusPreviousPageItem();
+ } else if( this.isKeyMapped('page down', key.name)) {
+ this.focusNextPageItem();
}
}
@@ -243,6 +255,54 @@ VerticalMenuView.prototype.focusPrevious = function() {
VerticalMenuView.super_.prototype.focusPrevious.call(this);
};
+VerticalMenuView.prototype.focusPreviousPageItem = function() {
+ //
+ // Jump to current - up to page size or top
+ // If already at the top, jump to bottom
+ //
+ if(0 === this.focusedItemIndex) {
+ return this.focusPrevious(); // will jump to bottom
+ }
+
+ const index = Math.max(this.focusedItemIndex - this.dimens.height, 0);
+
+ if(index < this.viewWindow.top) {
+ this.oldDimens = Object.assign({}, this.dimens);
+ }
+
+ this.setFocusItemIndex(index);
+
+ return VerticalMenuView.super_.prototype.focusPreviousPageItem.call(this);
+};
+
+VerticalMenuView.prototype.focusNextPageItem = function() {
+ //
+ // Jump to current + up to page size or bottom
+ // If already at the bottom, jump to top
+ //
+ if(this.items.length - 1 === this.focusedItemIndex) {
+ return this.focusNext(); // will jump to top
+ }
+
+ const index = Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length - 1);
+
+ if(index > this.viewWindow.bottom) {
+ this.oldDimens = Object.assign({}, this.dimens);
+
+ this.focusedItemIndex = index;
+
+ this.viewWindow = {
+ top : this.focusedItemIndex,
+ bottom : Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length) - 1
+ };
+
+ this.redraw();
+ } else {
+ this.setFocusItemIndex(index);
+ }
+
+ return VerticalMenuView.super_.prototype.focusNextPageItem.call(this);
+};
VerticalMenuView.prototype.setFocusItems = function(items) {
VerticalMenuView.super_.prototype.setFocusItems.call(this, items);
diff --git a/core/word_wrap.js b/core/word_wrap.js
index f3c3c5d5..0a4b122d 100644
--- a/core/word_wrap.js
+++ b/core/word_wrap.js
@@ -16,50 +16,6 @@ const SPACE_CHARS = [
const REGEXP_WORD_WRAP = new RegExp(`\t|[${SPACE_CHARS.join('')}]`, 'g');
-/*
-//
-// ANSI & pipe codes we indend to strip
-//
-// See also https://github.com/chalk/ansi-regex/blob/master/index.js
-//
-// :TODO: Consolidate this, regexp's in ansi_escape_parser, and strutil. Need more complete set that includes common standads, bansi, and cterm.txt
-// renderStringLength() from strUtil does not account for ESC[C (e.g. go forward)
-const REGEXP_CONTROL_CODES = /(\|[\d]{2})|(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g;
-
-function getRenderLength(s) {
- let m;
- let pos;
- let len = 0;
-
- REGEXP_CONTROL_CODES.lastIndex = 0; // reset
-
- //
- // Loop counting only literal (non-control) sequences
- // paying special attention to ESC[C which means forward
- //
- do {
- pos = REGEXP_CONTROL_CODES.lastIndex;
- m = REGEXP_CONTROL_CODES.exec(s);
-
- if(null !== m) {
- if(m.index > pos) {
- len += s.slice(pos, m.index).length;
- }
-
- if('C' === m[3]) { // ESC[C is foward/right
- len += parseInt(m[2], 10) || 0;
- }
- }
- } while(0 !== REGEXP_CONTROL_CODES.lastIndex);
-
- if(pos < s.length) {
- len += s.slice(pos).length;
- }
-
- return len;
-}
-*/
-
function wordWrapText2(text, options) {
assert(_.isObject(options));
assert(_.isNumber(options.width));
@@ -68,7 +24,13 @@ function wordWrapText2(text, options) {
options.tabWidth = options.tabWidth || 4;
options.tabChar = options.tabChar || ' ';
- const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}`, 'g');
+ //const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}`, 'g');
+ //
+ // For a given word, match 0->options.width chars -- alwasy include a full trailing ESC
+ // sequence if present!
+ //
+ // :TODO: Need to create ansi.getMatchRegex or something - this is used all over
+ const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}\\x1b\\[[\\?=;0-9]*[ABCDEFGHJKLMSTfhlmnprsu]|.{0,${options.width}}`, 'g');
let m;
let word;
diff --git a/docs/images/enigma-bbs.png b/docs/images/enigma-bbs.png
new file mode 100644
index 00000000..85e30f69
Binary files /dev/null and b/docs/images/enigma-bbs.png differ
diff --git a/docs/index.md b/docs/index.md
index 4ceb6449..1147c578 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -2,7 +2,7 @@
ENiGMA½ is a modern from scratch BBS package written in Node.js.
# Quickstart
-TL;DR? This should get you started...
+Unless you have a compelling reason to do otherwise, please use **The Easy Way** below.
## The Easy Way
Under most Linux/UNIX like environments (Linux, BSD, OS X, ...) new users can simply execute the `install.sh` script to get everything up and running. Simply cut + paste the following into your terminal:
@@ -13,7 +13,7 @@ curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/inst
For other environments such as Windows, see **The Manual Way** below.
-## The Manual Way
+## The Manual Way (aka Advanced)
For Windows environments or if you simply like to do things manually, read on...
### Prerequisites
@@ -61,7 +61,7 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson`
`oputil.js` can be utilized to generate your **initial** configuration. **This is the recommended way for all new users**:
```bash
-./oputil.js config --new
+./oputil.js config new
```
(You wil be asked a series of basic questions)
diff --git a/docs/mci.md b/docs/mci.md
index 28d424fd..61ffee04 100644
--- a/docs/mci.md
+++ b/docs/mci.md
@@ -74,6 +74,11 @@ There are many predefined MCI codes that can be used anywhere on the system (pla
* `AN`: Current active node count
* `TC`: Total login/calls to system
* `RR`: Displays a random rumor
+* `SD`: Total downloads, system wide
+* `SO`: Total downloaded amount, system wide (formatted to appropriate bytes/megs/etc.)
+* `SU`: Total uploads, system wide
+* `SP`: Total uploaded amount, system wide (formatted to appropriate bytes/megs/etc.)
+
A special `XY` MCI code may also be utilized for placement identification when creating menus.
diff --git a/misc/exodus.id_rsa b/misc/exodus.id_rsa
new file mode 100644
index 00000000..356aba63
--- /dev/null
+++ b/misc/exodus.id_rsa
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAmpwn/vJ1CAIkVnQGDZumvEDsMyFSHioGO5RM/T2Id6XLX91r
+feJI6w48yqLV+HgKLUK7eeTOzb/l4VShH9AzOqTbAxwfZ6fgzV2cI/wZxO0S6QpS
+7IrwcVK1Bm7Wu45Kp7LcGHB66nHSb+wqIYkZobIc8Z9arClJxV4AzgaUxjJrk0wT
+hW81r5TicbTG7zm+bOMLO/mln+HA/EOtx/yfKDcfkl+mLzzbMpojor+KdwuKJUb1
++r4PhPVl6pZgOuQIl37Qh5SPY3mMjwyXW/tUe+ZmPpfOm3CKf/pTLsA45QzUbNBY
+GPLjbEcMJ4R5T3c2LXCKR+Wi9/pCkeZT7/1BbQIDAQABAoIBAQCCasrKIddahAQG
+8SPSAsQo9FLJ5oeQbj6Hr1cqHueola/yE6KCs4hyzrW08JqxVwCuoSXncnyHziGp
+a2vmnAc6pqkf/G75TwEv+pClQhiyppBXB6Bfa+vai7ury39TAnoy74r9CpSEgrLS
+OlJnq3B1lvsXTiZ8Ju/Vjq/7Gk4QyFOVPugbmjhUtuCiyRXV9V2o/HUzZGtaXDp5
+n+XOfb90mLtPhtIRC6wmgMkhlRPpGir+NN0DWQ1oBWZO+TockIFusVInOTEXY4ui
+V+JJ3KRwfaogzJMnDcqkiCck6bMT8E85ucRScsJjpENsUyEjFAoRV2grbguc/rdx
+dgG5BMx5AoGBAMgCDFGwCctHfRvRXIac5goxYuTkVYjEh4yxj8d/Y+0HmDJiH5HM
+tiUAtsgq/KYKJKM9U0PJWdPW3DPJa+wDVPQSlIqUOiXEpwLA+yhXuAvTqia9chuI
+vaP1Ze/4yfW2eQg+3Ji0vC9VEr1eoRnAwJI+fDE3fRCvoPohlT4+zOhvAoGBAMXk
+ksy5DdtUOvt0wss7R030dEtHP/Hs+qheQJOhl+GLlQt5BKP6NsdM3OKXyXYLddOc
+xrKSWdjtiWOtap0D7o7cBFv44EmgzSvM2QltYxF4phPaNn2zPC/Mkvs1EaYnMtw4
+boKNDWbwixpCapheAE+lfA96DfqU/KyVaXls9MnjAoGAaL+B2ipbBsZ7BF2imrGD
+XOU+iOf4z/c1kn7P8UiLefEXSZPQOti+sCRulejFhuQbCg8tE3xZejO2Ab1Es0eP
+b4BnoSg+R9d1LGELaLaAIlmJbF6da0QzJbJ437QpeXFGdAYQHD3TrOpeNSVhNA6a
+DD2DZ3dLHbkNktKRyhaz1CsCgYBMJbIfOK4OUZEIpVs3XK4JXyFIvjfq3aduFiZ/
+KFULIuzNJ1oTxvpBImB0iLeqxqomLVN/7zTHdk/BnT9C//pR2nOK+G9FpayNSBvT
+ttXCKUyuou8I22kzc2Kzay5JYxf9CXHspl4b2D+OcTQXQUSZYTIlum+alq3LswqN
+ANIIxQKBgHauoT79sViuB/wHcp2W/mek0p9aLkgQKt+riPJ4vKXc8DtapTgQzXkk
+6yQCOSD8T9DcVGBcap9n6T21NOyDQwM0gg+DoHVeYqBrAa93jufOi7EY3MFrkjH6
+tC0crKBcUkxu43zhY4DkHLxId5btSPH57U+lhrJGjKXdvlJrGGOM
+-----END RSA PRIVATE KEY-----
diff --git a/mods/file_area_filter_edit.js b/mods/file_area_filter_edit.js
index 00d13a2e..cb3322f9 100644
--- a/mods/file_area_filter_edit.js
+++ b/mods/file_area_filter_edit.js
@@ -61,7 +61,6 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule {
this.menuMethods = {
saveFilter : (formData, extraArgs, cb) => {
return this.saveCurrentFilter(formData, cb);
-
},
prevFilter : (formData, extraArgs, cb) => {
this.currentFilterIndex -= 1;
@@ -93,7 +92,15 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule {
return cb(null);
},
deleteFilter : (formData, extraArgs, cb) => {
- const filterUuid = this.filtersArray[this.currentFilterIndex].uuid;
+ const selectedFilter = this.filtersArray[this.currentFilterIndex];
+ const filterUuid = selectedFilter.uuid;
+
+ // cannot delete built-in/system filters
+ if(true === selectedFilter.system) {
+ this.showError('Cannot delete built in filters!');
+ return cb(null);
+ }
+
this.filtersArray.splice(this.currentFilterIndex, 1); // remove selected entry
// remove from stored properties
@@ -143,6 +150,17 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule {
},
};
}
+
+ showError(errMsg) {
+ const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error);
+ if(errorView) {
+ if(errMsg) {
+ errorView.setText(errMsg);
+ } else {
+ errorView.clearText();
+ }
+ }
+ }
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
diff --git a/mods/file_area_list.js b/mods/file_area_list.js
index 9dacc23d..076d2a98 100644
--- a/mods/file_area_list.js
+++ b/mods/file_area_list.js
@@ -8,7 +8,6 @@ const ansi = require('../core/ansi_term.js');
const theme = require('../core/theme.js');
const FileEntry = require('../core/file_entry.js');
const stringFormat = require('../core/string_format.js');
-const createCleanAnsi = require('../core/string_util.js').createCleanAnsi;
const FileArea = require('../core/file_base_area.js');
const Errors = require('../core/enig_error.js').Errors;
const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled;
@@ -18,8 +17,7 @@ const DownloadQueue = require('../core/download_queue.js');
const FileAreaWeb = require('../core/file_area_web.js');
const FileBaseFilters = require('../core/file_base_filter.js');
const resolveMimeType = require('../core/mime_util.js').resolveMimeType;
-
-const cleanControlCodes = require('../core/string_util.js').cleanControlCodes;
+const isAnsi = require('../core/string_util.js').isAnsi;
// deps
const async = require('async');
@@ -74,8 +72,12 @@ exports.getModule = class FileAreaList extends MenuModule {
constructor(options) {
super(options);
- if(options.extraArgs) {
- this.filterCriteria = options.extraArgs.filterCriteria;
+ this.filterCriteria = _.get(options, 'extraArgs.filterCriteria');
+ this.fileList = _.get(options, 'extraArgs.fileList');
+
+ if(this.fileList) {
+ // we'll need to adjust position as well!
+ this.fileListPosition = 0;
}
this.dlQueue = new DownloadQueue(this.client);
@@ -116,7 +118,13 @@ exports.getModule = class FileAreaList extends MenuModule {
return this.displayDetailsPage(cb);
},
detailsQuit : (formData, extraArgs, cb) => {
- this.viewControllers.details.setFocus(false);
+ [ 'detailsNfo', 'detailsFileList', 'details' ].forEach(n => {
+ const vc = this.viewControllers[n];
+ if(vc) {
+ vc.detachClientEvents();
+ }
+ });
+
return this.displayBrowsePage(true, cb); // true=clearScreen
},
toggleQueue : (formData, extraArgs, cb) => {
@@ -212,8 +220,8 @@ exports.getModule = class FileAreaList extends MenuModule {
const entryInfo = currEntry.entryInfo = {
fileId : currEntry.fileId,
areaTag : currEntry.areaTag,
- areaName : area.name || 'N/A',
- areaDesc : area.desc || 'N/A',
+ areaName : _.get(area, 'name') || 'N/A',
+ areaDesc : _.get(area, 'desc') || 'N/A',
fileSha256 : currEntry.fileSha256,
fileName : currEntry.fileName,
desc : currEntry.desc || '',
@@ -250,9 +258,9 @@ exports.getModule = class FileAreaList extends MenuModule {
const userRatingTicked = config.userRatingTicked || '*';
const userRatingUnticked = config.userRatingUnticked || '';
entryInfo.userRating = ~~Math.round(entryInfo.userRating) || 0; // be safe!
- entryInfo.userRatingString = new Array(entryInfo.userRating + 1).join(userRatingTicked);
+ entryInfo.userRatingString = userRatingTicked.repeat(entryInfo.userRating);
if(entryInfo.userRating < 5) {
- entryInfo.userRatingString += new Array( (5 - entryInfo.userRating) + 1).join(userRatingUnticked);
+ entryInfo.userRatingString += userRatingUnticked.repeat( (5 - entryInfo.userRating) );
}
FileAreaWeb.getExistingTempDownloadServeItem(this.client, this.currentFileEntry, (err, serveItem) => {
@@ -348,7 +356,7 @@ exports.getModule = class FileAreaList extends MenuModule {
async.series(
[
function fetchEntryData(callback) {
- if(self.fileList) {
+ if(self.fileList) {
return callback(null);
}
return self.loadFileIds(false, callback); // false=do not force
@@ -373,31 +381,34 @@ exports.getModule = class FileAreaList extends MenuModule {
return self.populateCurrentEntryInfo(callback);
});
},
- function populateViews(callback) {
+ function populateDesc(callback) {
if(_.isString(self.currentFileEntry.desc)) {
const descView = self.viewControllers.browse.getView(MciViewIds.browse.desc);
- if(descView) {
- createCleanAnsi(
- self.currentFileEntry.desc,
- { height : self.client.termHeight, width : descView.dimens.width },
- cleanDesc => {
- // :TODO: use cleanDesc -- need to finish createCleanAnsi() !!
- //descView.setText(cleanDesc);
- descView.setText( self.currentFileEntry.desc );
-
- self.updateQueueIndicator();
- self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart);
-
- return callback(null);
- }
- );
+ if(descView) {
+ if(isAnsi(self.currentFileEntry.desc)) {
+ descView.setAnsi(
+ self.currentFileEntry.desc,
+ {
+ prepped : false,
+ forceLineTerm : true
+ },
+ () => {
+ return callback(null);
+ }
+ );
+ } else {
+ descView.setText(self.currentFileEntry.desc);
+ return callback(null);
+ }
}
} else {
- self.updateQueueIndicator();
- self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart);
-
return callback(null);
}
+ },
+ function populateAdditionalViews(callback) {
+ self.updateQueueIndicator();
+ self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart);
+ return callback(null);
}
],
err => {
@@ -618,17 +629,37 @@ exports.getModule = class FileAreaList extends MenuModule {
case 'nfo' :
{
const nfoView = self.viewControllers.detailsNfo.getView(MciViewIds.detailsNfo.nfo);
- if(nfoView) {
+ if(!nfoView) {
+ return callback(null);
+ }
+
+ if(isAnsi(self.currentFileEntry.entryInfo.descLong)) {
+ nfoView.setAnsi(
+ self.currentFileEntry.entryInfo.descLong,
+ {
+ prepped : false,
+ forceLineTerm : true,
+ },
+ () => {
+ return callback(null);
+ }
+ );
+ } else {
nfoView.setText(self.currentFileEntry.entryInfo.descLong);
+ return callback(null);
}
}
break;
case 'fileList' :
self.populateFileListing();
- break;
- }
+ return callback(null);
+ default :
+ return callback(null);
+ }
+ },
+ function setLabels(callback) {
self.populateCustomLabels(name, MciViewIds[name].customRangeStart);
return callback(null);
}
diff --git a/mods/file_base_area_select.js b/mods/file_base_area_select.js
new file mode 100644
index 00000000..5eef583b
--- /dev/null
+++ b/mods/file_base_area_select.js
@@ -0,0 +1,84 @@
+/* jslint node: true */
+'use strict';
+
+// enigma-bbs
+const MenuModule = require('../core/menu_module.js').MenuModule;
+const Config = require('../core/config.js').config;
+const stringFormat = require('../core/string_format.js');
+const ViewController = require('../core/view_controller.js').ViewController;
+const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas;
+
+// deps
+const async = require('async');
+const _ = require('lodash');
+
+exports.moduleInfo = {
+ name : 'File Area Selector',
+ desc : 'Select from available file areas',
+ author : 'NuSkooler',
+};
+
+const MciViewIds = {
+ areaList : 1,
+};
+
+exports.getModule = class FileAreaSelectModule extends MenuModule {
+ constructor(options) {
+ super(options);
+
+ this.config = this.menuConfig.config || {};
+
+ this.loadAvailAreas();
+
+ this.menuMethods = {
+ selectArea : (formData, extraArgs, cb) => {
+ const area = this.availAreas[formData.value.areaSelect] || 0;
+
+ const filterCriteria = {
+ areaTag : area.areaTag,
+ };
+
+ const menuOpts = {
+ extraArgs : {
+ filterCriteria : filterCriteria,
+ },
+ menuFlags : [ 'noHistory' ],
+ };
+
+ return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
+ }
+ };
+ }
+
+ loadAvailAreas() {
+ this.availAreas = getSortedAvailableFileAreas(this.client);
+ }
+
+ mciReady(mciData, cb) {
+ super.mciReady(mciData, err => {
+ if(err) {
+ return cb(err);
+ }
+
+ this.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => {
+ if(err) {
+ return cb(err);
+ }
+
+ const areaListView = vc.getView(MciViewIds.areaList);
+
+ const areaListFormat = this.config.areaListFormat || '{name}';
+
+ areaListView.setItems(this.availAreas.map(a => stringFormat(areaListFormat, a) ) );
+
+ if(this.config.areaListFocusFormat) {
+ areaListView.setFocusItems(this.availAreas.map(a => stringFormat(this.config.areaListFocusFormat, a) ) );
+ }
+
+ areaListView.redraw();
+
+ return cb(null);
+ });
+ });
+ }
+};
diff --git a/mods/menu.hjson b/mods/menu.hjson
index 5852ad28..d1822cfc 100644
--- a/mods/menu.hjson
+++ b/mods/menu.hjson
@@ -472,7 +472,7 @@
0: {
mci: {
TL1: {
- argName: from
+ argName: from
}
ET2: {
argName: to
@@ -721,7 +721,7 @@
}
newScanMessageList: {
- desc: Viewing New Message List
+ desc: New Messages
module: msg_list
art: NEWMSGS
config: {
@@ -761,6 +761,166 @@
}
}
+ newScanFileBaseList: {
+ module: file_area_list
+ desc: New Files
+ config: {
+ art: {
+ browse: FNEWBRWSE
+ details: FDETAIL
+ detailsGeneral: FDETGEN
+ detailsNfo: FDETNFO
+ detailsFileList: FDETLST
+ help: FBHELP
+ }
+ }
+ form: {
+ 0: {
+ mci: {
+ MT1: {
+ mode: preview
+ ansiView: true
+ }
+
+ HM2: {
+ focus: true
+ submit: true
+ argName: navSelect
+ items: [
+ "prev", "next", "details", "toggle queue", "rate", "help", "quit"
+ ]
+ focusItemIndex: 1
+ }
+
+ // :TODO: these can be removed once the hack is not required:
+ TL10: {}
+ TL11: {}
+ TL12: {}
+ TL13: {}
+ TL14: {}
+ TL15: {}
+ TL16: {}
+ TL17: {}
+ TL18: {}
+ }
+
+ submit: {
+ *: [
+ {
+ value: { navSelect: 0 }
+ action: @method:prevFile
+ }
+ {
+ value: { navSelect: 1 }
+ action: @method:nextFile
+ }
+ {
+ value: { navSelect: 2 }
+ action: @method:viewDetails
+ }
+ {
+ value: { navSelect: 3 }
+ action: @method:toggleQueue
+ }
+ {
+ value: { navSelect: 4 }
+ action: @menu:fileBaseGetRatingForSelectedEntry
+ }
+ {
+ value: { navSelect: 5 }
+ action: @method:displayHelp
+ }
+ {
+ value: { navSelect: 6 }
+ action: @systemMethod:prevMenu
+ }
+ ]
+ }
+
+ actionKeys: [
+ {
+ keys: [ "w", "shift + w" ]
+ action: @method:showWebDownloadLink
+ }
+ {
+ keys: [ "escape", "q", "shift + q" ]
+ action: @systemMethod:prevMenu
+ }
+ {
+ keys: [ "t", "shift + t" ]
+ action: @method:toggleQueue
+ }
+ {
+ keys: [ "v", "shift + v" ]
+ action: @method:viewDetails
+ }
+ {
+ keys: [ "r", "shift + r" ]
+ action: @menu:fileBaseGetRatingForSelectedEntry
+ }
+ {
+ keys: [ "?" ]
+ action: @method:displayHelp
+ }
+ ]
+ }
+
+ 1: {
+ mci: {
+ HM1: {
+ focus: true
+ submit: true
+ argName: navSelect
+ items: [
+ "general", "nfo/readme", "file listing"
+ ]
+ }
+
+ // :TODO: these can be removed once the hack is not required:
+ TL10: {}
+ TL11: {}
+ TL12: {}
+ TL13: {}
+ TL14: {}
+ TL15: {}
+ TL16: {}
+ TL17: {}
+ TL18: {}
+ }
+
+ actionKeys: [
+ {
+ keys: [ "escape", "q", "shift + q" ]
+ action: @method:detailsQuit
+ }
+ ]
+ }
+
+ 2: {
+ // details - general
+ mci: {}
+ }
+
+ 3: {
+ // details - nfo/readme
+ mci: {
+ MT1: {
+ mode: preview
+ }
+ }
+ }
+
+ 4: {
+ // details - file listing
+ mci: {
+ VM1: {
+
+ }
+ }
+ }
+ }
+ }
+
///////////////////////////////////////////////////////////////////////
// Main Menu
///////////////////////////////////////////////////////////////////////
@@ -1916,50 +2076,49 @@
}
}
- submit: {
- *: [
- {
- value: { 1: 0 }
- action: @method:editModeMenuSave
- }
- {
- value: { 1: 1 }
- action: @systemMethod:prevMenu
- }
- {
- value: { 1: 2 },
- action: @method:editModeMenuQuote
- }
- {
- value: { 1: 3 }
- action: @method:editModeMenuHelp
- }
- ]
- }
-
- actionKeys: [
+ submit: {
+ *: [
{
- keys: [ "escape" ]
- action: @method:editModeEscPressed
- }
- {
- keys: [ "s", "shift + s" ]
+ value: { 1: 0 }
action: @method:editModeMenuSave
}
{
- keys: [ "d", "shift + d" ]
+ value: { 1: 1 }
action: @systemMethod:prevMenu
}
{
- keys: [ "q", "shift + q" ]
+ value: { 1: 2 },
action: @method:editModeMenuQuote
}
{
- keys: [ "?" ]
+ value: { 1: 3 }
action: @method:editModeMenuHelp
}
]
}
+
+ actionKeys: [
+ {
+ keys: [ "escape" ]
+ action: @method:editModeEscPressed
+ }
+ {
+ keys: [ "s", "shift + s" ]
+ action: @method:editModeMenuSave
+ }
+ {
+ keys: [ "d", "shift + d" ]
+ action: @systemMethod:prevMenu
+ }
+ {
+ keys: [ "q", "shift + q" ]
+ action: @method:editModeMenuQuote
+ }
+ {
+ keys: [ "?" ]
+ action: @method:editModeMenuHelp
+ }
+ ]
}
// Quote builder
@@ -2313,9 +2472,13 @@
prompt: fileMenuCommand
submit: [
{
- value: { menuOption: "B" }
+ value: { menuOption: "L" }
action: @menu:fileBaseListEntries
}
+ {
+ value: { menuOption: "B" }
+ action: @menu:fileBaseBrowseByAreaSelect
+ }
{
value: { menuOption: "F" }
action: @menu:fileAreaFilterEditor
@@ -2510,6 +2673,38 @@
}
}
+ fileBaseBrowseByAreaSelect: {
+ desc: Browsing File Areas
+ module: file_base_area_select
+ art: FAREASEL
+ form: {
+ 0: {
+ mci: {
+ VM1: {
+ focus: true
+ argName: areaSelect
+ }
+ }
+
+ submit: {
+ *: [
+ {
+ value: { areaSelect: null }
+ action: @method:selectArea
+ }
+ ]
+ }
+
+ actionKeys: [
+ {
+ keys: [ "escape" ]
+ action: @systemMethod:prevMenu
+ }
+ ]
+ }
+ }
+ }
+
fileBaseGetRatingForSelectedEntry: {
desc: Rating a File
prompt: fileBaseRateEntryPrompt
diff --git a/mods/msg_area_view_fse.js b/mods/msg_area_view_fse.js
index 129476ae..de4657f1 100644
--- a/mods/msg_area_view_fse.js
+++ b/mods/msg_area_view_fse.js
@@ -22,8 +22,9 @@ exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule {
this.editorMode = 'view';
if(_.isObject(options.extraArgs)) {
- this.messageList = options.extraArgs.messageList;
- this.messageIndex = options.extraArgs.messageIndex;
+ this.messageList = options.extraArgs.messageList;
+ this.messageIndex = options.extraArgs.messageIndex;
+ this.lastMessageNextExit = options.extraArgs.lastMessageNextExit;
}
this.messageList = this.messageList || [];
@@ -41,6 +42,12 @@ exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule {
return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
}
+ // auto-exit if no more to go?
+ if(self.lastMessageNextExit) {
+ self.lastMessageReached = true;
+ return self.prevMenu(cb);
+ }
+
return cb(null);
},
@@ -120,6 +127,9 @@ exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule {
}
getMenuResult() {
- return this.messageIndex;
+ return {
+ messageIndex : this.messageIndex,
+ lastMessageReached : this.lastMessageReached,
+ };
}
};
diff --git a/mods/msg_list.js b/mods/msg_list.js
index 498d8976..bc80e27b 100644
--- a/mods/msg_list.js
+++ b/mods/msg_list.js
@@ -49,6 +49,8 @@ exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(
this.messageAreaTag = config.messageAreaTag;
+ this.lastMessageReachedExit = _.get(options, 'lastMenuResult.lastMessageReached', false);
+
if(options.extraArgs) {
//
// |extraArgs| can override |messageAreaTag| provided by config
@@ -73,6 +75,7 @@ exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(
messageAreaTag : self.messageAreaTag,
messageList : self.messageList,
messageIndex : formData.value.message,
+ lastMessageNextExit : true,
}
};
@@ -107,6 +110,10 @@ exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(
}
enter() {
+ if(this.lastMessageReachedExit) {
+ return this.prevMenu();
+ }
+
super.enter();
//
diff --git a/mods/themes/luciano_blocktronics/FAREASEL.ANS b/mods/themes/luciano_blocktronics/FAREASEL.ANS
new file mode 100755
index 00000000..9377a3f2
Binary files /dev/null and b/mods/themes/luciano_blocktronics/FAREASEL.ANS differ
diff --git a/mods/themes/luciano_blocktronics/FMENU.ANS b/mods/themes/luciano_blocktronics/FMENU.ANS
index 5869f815..a187491b 100644
Binary files a/mods/themes/luciano_blocktronics/FMENU.ANS and b/mods/themes/luciano_blocktronics/FMENU.ANS differ
diff --git a/mods/themes/luciano_blocktronics/FNEWBRWSE.ANS b/mods/themes/luciano_blocktronics/FNEWBRWSE.ANS
new file mode 100644
index 00000000..52db95e0
Binary files /dev/null and b/mods/themes/luciano_blocktronics/FNEWBRWSE.ANS differ
diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/mods/themes/luciano_blocktronics/theme.hjson
index 3dfe67e6..d553ed83 100644
--- a/mods/themes/luciano_blocktronics/theme.hjson
+++ b/mods/themes/luciano_blocktronics/theme.hjson
@@ -3,6 +3,7 @@
name: Mystery Skull
author: Luciano Ayres
group: blocktronics
+ enabled: true
}
customization: {
@@ -590,6 +591,124 @@
}
}
+ newScanFileBaseList: {
+ config: {
+ hashTagsSep: "|08, |07"
+ browseInfoFormat10: "|00|10{fileName} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}"
+ browseInfoFormat11: "|00|15{areaName}"
+ browseInfoFormat12: "|00|07{hashTags}"
+ browseInfoFormat13: "|00|07{estReleaseYear}"
+ browseInfoFormat14: "|00|07{dlCount}"
+ browseInfoFormat15: "{userRatingString}"
+ browseInfoFormat16: "{isQueued}"
+ browseInfoFormat17: "{webDlLink}{webDlExpire}"
+
+ webDlExpireTimeFormat: " [|08- |07exp] ddd, MMM Do @ h:mm a"
+ webDlLinkNeedsGenerated: "|08(|07press |10W |07to generate link|08)"
+
+ isQueuedIndicator: "|00|10YES"
+ isNotQueuedIndicator: "|00|07no"
+
+ userRatingTicked: "|00|15*"
+ userRatingUnticked: "|00|07-"
+
+ detailsGeneralInfoFormat10: "{fileName}"
+ detailsGeneralInfoFormat11: "|00|07{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08(|03{byteSize:,} |11B|08)"
+ detailsGeneralInfoFormat12: "|00|07{hashTags}"
+ detailsGeneralInfoFormat13: "{estReleaseYear}"
+ detailsGeneralInfoFormat14: "{dlCount}"
+ detailsGeneralInfoFormat15: "{userRatingString}"
+ detailsGeneralInfoFormat16: "{fileCrc32}"
+ detailsGeneralInfoFormat17: "{fileMd5}"
+ detailsGeneralInfoFormat18: "{fileSha1}"
+ detailsGeneralInfoFormat19: "{fileSha256}"
+ detailsGeneralInfoFormat20: "{uploadByUsername}"
+ detailsGeneralInfoFormat21: "{uploadTimestamp}"
+ detailsGeneralInfoFormat22: "{archiveTypeDesc}"
+
+ fileListEntryFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
+ focusFileListEntryFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
+
+ notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)"
+ }
+
+ 0: {
+ mci: {
+ MT1: {
+ height: 16
+ width: 45
+ }
+ HM2: {
+ focusTextStyle: first lower
+ }
+
+ TL11: {
+ width: 21
+ textOverflow: ...
+ }
+
+ TL12: {
+ width: 21
+ textOverflow: ...
+ }
+ TL13: { width: 21 }
+ TL14: { width: 21 }
+ TL15: { width: 21 }
+ TL16: { width: 21 }
+ TL17: { width: 73 }
+
+ }
+ }
+
+ 1: {
+ mci: {
+ HM1: {
+ focusTextStyle: first lower
+ }
+ }
+ }
+
+ 2: {
+
+ }
+
+ 3: {
+ // details - nfo/readme
+ mci: {
+ MT1: {
+ height: 19
+ width: 79
+ }
+ }
+ }
+
+ 4: {
+ mci: {
+ VM1: {
+ height: 17
+ width: 79
+ }
+ }
+ }
+ }
+
+ fileBaseBrowseByAreaSelect: {
+ config: {
+ protListFormat: "|00|03{name}"
+ protListFocusFormat: "|00|19|15{name}"
+ }
+
+ 0: {
+ mci: {
+ VM1: {
+ height: 15
+ width: 30
+ focusTextStyle: first lower
+ }
+ }
+ }
+ }
+
fileBaseSearch: {
mci: {
ET1: {
diff --git a/mods/upload.js b/mods/upload.js
index d8887193..5c5fd5b2 100644
--- a/mods/upload.js
+++ b/mods/upload.js
@@ -15,7 +15,6 @@ const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTe
const Log = require('../core/logger.js').log;
const Errors = require('../core/enig_error.js').Errors;
const FileEntry = require('../core/file_entry.js');
-const enigmaToAnsi = require('../core/color_codes.js').enigmaToAnsi;
// deps
const async = require('async');
diff --git a/package.json b/package.json
index c983edd5..ec508841 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "enigma-bbs",
- "version": "0.0.6-alpha",
+ "version": "0.0.7-alpha",
"description": "ENiGMA½ Bulletin Board System",
"author": "Bryan Ashby ",
"license": "BSD-2-Clause",
@@ -39,13 +39,14 @@
"ptyw.js": "NuSkooler/ptyw.js",
"sanitize-filename": "^1.6.1",
"sqlite3": "^3.1.1",
- "ssh2": "^0.5.1",
+ "ssh2": "^0.5.5",
"temptmp": "^1.0.0",
"uuid": "^3.0.1",
"uuid-parse": "^1.0.0",
"ws" : "^3.0.0",
"graceful-fs" : "^4.1.11",
- "exiftool" : "^0.0.3"
+ "exiftool" : "^0.0.3",
+ "node-glob" : "^1.2.0"
},
"devDependencies": {},
"engines": {
diff --git a/util/exiftool2desc.js b/util/exiftool2desc.js
index d94f5d75..210c800d 100755
--- a/util/exiftool2desc.js
+++ b/util/exiftool2desc.js
@@ -17,6 +17,7 @@ const FILETYPE_HANDLERS = {};
[ 'AIFF', 'APE', 'FLAC', 'OGG', 'MP3' ].forEach(ext => FILETYPE_HANDLERS[ext] = audioFile);
[ 'PDF', 'DOC', 'DOCX', 'DOCM', 'ODB', 'ODC', 'ODF', 'ODG', 'ODI', 'ODP', 'ODS', 'ODT' ].forEach(ext => FILETYPE_HANDLERS[ext] = documentFile);
[ 'PNG', 'JPEG', 'GIF', 'WEBP', 'XCF' ].forEach(ext => FILETYPE_HANDLERS[ext] = imageFile);
+[ 'MP4', 'MOV', 'AVI', 'MKV', 'MPG', 'MPEG', 'M4V', 'WMV' ].forEach(ext => FILETYPE_HANDLERS[ext] = videoFile);
function audioFile(metadata) {
// nothing if we don't know at least the author or title
@@ -32,6 +33,10 @@ function audioFile(metadata) {
return desc;
}
+function videoFile(metadata) {
+ return `${metadata.fileType} video(${metadata.imageSize}px, ${metadata.duration}, ${metadata.audioBitsPerSample}/${metadata.audioSampleRate} audio)`;
+}
+
function documentFile(metadata) {
// nothing if we don't know at least the author or title
if(!metadata.author && !metadata.title) {