
* Fix pause placement. Wait for all views ready before placing cursor such that the prompt will display in the right spot
555 lines
14 KiB
JavaScript
555 lines
14 KiB
JavaScript
/* jslint node: true */
|
|
'use strict';
|
|
|
|
var fs = require('fs');
|
|
var paths = require('path');
|
|
var assert = require('assert');
|
|
var iconv = require('iconv-lite');
|
|
var conf = require('./config.js');
|
|
var miscUtil = require('./misc_util.js');
|
|
var binary = require('binary');
|
|
var events = require('events');
|
|
var util = require('util');
|
|
var ansi = require('./ansi_term.js');
|
|
var aep = require('./ansi_escape_parser.js');
|
|
|
|
var _ = require('lodash');
|
|
|
|
exports.getArt = getArt;
|
|
exports.getArtFromPath = getArtFromPath;
|
|
exports.display = display;
|
|
exports.defaultEncodingFromExtension = defaultEncodingFromExtension;
|
|
|
|
var SAUCE_SIZE = 128;
|
|
var SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE'
|
|
var COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT'
|
|
|
|
// :TODO: Return MCI code information
|
|
// :TODO: process SAUCE comments
|
|
// :TODO: return font + font mapped information from SAUCE
|
|
|
|
var SUPPORTED_ART_TYPES = {
|
|
// :TODO: the defualt encoding are really useless if they are all the same ...
|
|
// perhaps .ansamiga and .ascamiga could be supported as well as overrides via conf
|
|
'.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a },
|
|
'.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a },
|
|
'.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a },
|
|
'.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a },
|
|
'.txt' : { name : 'Text', defaultEncoding : 'cp437', eof : 0x1a }, // :TODO: think about this more...
|
|
// :TODO: extentions for wwiv, renegade, celerity, syncronet, ...
|
|
// :TODO: extension for atari
|
|
// :TODO: extension for topaz ansi/ascii.
|
|
};
|
|
|
|
//
|
|
// See
|
|
// http://www.acid.org/info/sauce/sauce.htm
|
|
//
|
|
// :TODO: Move all SAUCE stuff to sauce.js
|
|
function readSAUCE(data, cb) {
|
|
if(data.length < SAUCE_SIZE) {
|
|
cb(new Error('No SAUCE record present'));
|
|
return;
|
|
}
|
|
|
|
var offset = data.length - SAUCE_SIZE;
|
|
var sauceRec = data.slice(offset);
|
|
|
|
binary.parse(sauceRec)
|
|
.buffer('id', 5)
|
|
.buffer('version', 2)
|
|
.buffer('title', 35)
|
|
.buffer('author', 20)
|
|
.buffer('group', 20)
|
|
.buffer('date', 8)
|
|
.word32lu('fileSize')
|
|
.word8('dataType')
|
|
.word8('fileType')
|
|
.word16lu('tinfo1')
|
|
.word16lu('tinfo2')
|
|
.word16lu('tinfo3')
|
|
.word16lu('tinfo4')
|
|
.word8('numComments')
|
|
.word8('flags')
|
|
.buffer('tinfos', 22) // SAUCE 00.5
|
|
.tap(function onVars(vars) {
|
|
|
|
if(!SAUCE_ID.equals(vars.id)) {
|
|
cb(new Error('No SAUCE record present'));
|
|
return;
|
|
}
|
|
|
|
var ver = vars.version.toString('cp437');
|
|
|
|
if('00' !== ver) {
|
|
cb(new Error('Unsupported SAUCE version: ' + ver));
|
|
return;
|
|
}
|
|
|
|
var sauce = {
|
|
id : vars.id.toString('cp437'),
|
|
version : vars.version.toString('cp437'),
|
|
title : vars.title.toString('cp437').trim(),
|
|
author : vars.author.toString('cp437').trim(),
|
|
group : vars.group.toString('cp437').trim(),
|
|
date : vars.date.toString('cp437').trim(),
|
|
fileSize : vars.fileSize,
|
|
dataType : vars.dataType,
|
|
fileType : vars.fileType,
|
|
tinfo1 : vars.tinfo1,
|
|
tinfo2 : vars.tinfo2,
|
|
tinfo3 : vars.tinfo3,
|
|
tinfo4 : vars.tinfo4,
|
|
numComments : vars.numComments,
|
|
flags : vars.flags,
|
|
tinfos : vars.tinfos,
|
|
};
|
|
|
|
var dt = SAUCE_DATA_TYPES[sauce.dataType];
|
|
if(dt && dt.parser) {
|
|
sauce[dt.name] = dt.parser(sauce);
|
|
}
|
|
|
|
cb(null, sauce);
|
|
});
|
|
}
|
|
|
|
// :TODO: These need completed:
|
|
var SAUCE_DATA_TYPES = {};
|
|
SAUCE_DATA_TYPES[0] = { name : 'None' };
|
|
SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE };
|
|
SAUCE_DATA_TYPES[2] = 'Bitmap';
|
|
SAUCE_DATA_TYPES[3] = 'Vector';
|
|
SAUCE_DATA_TYPES[4] = 'Audio';
|
|
SAUCE_DATA_TYPES[5] = 'BinaryText';
|
|
SAUCE_DATA_TYPES[6] = 'XBin';
|
|
SAUCE_DATA_TYPES[7] = 'Archive';
|
|
SAUCE_DATA_TYPES[8] = 'Executable';
|
|
|
|
var SAUCE_CHARACTER_FILE_TYPES = {};
|
|
SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII';
|
|
SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi';
|
|
SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation';
|
|
SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script';
|
|
SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard';
|
|
SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar';
|
|
SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML';
|
|
SAUCE_CHARACTER_FILE_TYPES[7] = 'Source';
|
|
SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw';
|
|
|
|
//
|
|
// Map of SAUCE font -> encoding hint
|
|
//
|
|
// Note that this is the same mapping that x84 uses. Be compatible!
|
|
//
|
|
var SAUCE_FONT_TO_ENCODING_HINT = {
|
|
'Amiga MicroKnight' : 'amiga',
|
|
'Amiga MicroKnight+' : 'amiga',
|
|
'Amiga mOsOul' : 'amiga',
|
|
'Amiga P0T-NOoDLE' : 'amiga',
|
|
'Amiga Topaz 1' : 'amiga',
|
|
'Amiga Topaz 1+' : 'amiga',
|
|
'Amiga Topaz 2' : 'amiga',
|
|
'Amiga Topaz 2+' : 'amiga',
|
|
'Atari ATASCII' : 'atari',
|
|
'IBM EGA43' : 'cp437',
|
|
'IBM EGA' : 'cp437',
|
|
'IBM VGA25G' : 'cp437',
|
|
'IBM VGA50' : 'cp437',
|
|
'IBM VGA' : 'cp437',
|
|
};
|
|
|
|
['437', '720', '737', '775', '819', '850', '852', '855', '857', '858',
|
|
'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) {
|
|
var codec = 'cp' + page;
|
|
SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec;
|
|
SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec;
|
|
SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec;
|
|
SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec;
|
|
SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec;
|
|
});
|
|
|
|
function parseCharacterSAUCE(sauce) {
|
|
var result = {};
|
|
|
|
result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown';
|
|
|
|
if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) {
|
|
// convience: create ansiFlags
|
|
sauce.ansiFlags = sauce.flags;
|
|
|
|
var i = 0;
|
|
while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) {
|
|
++i;
|
|
}
|
|
var fontName = sauce.tinfos.slice(0, i).toString('cp437');
|
|
if(fontName.length > 0) {
|
|
result.fontName = fontName;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function getFontNameFromSAUCE(sauce) {
|
|
if(sauce.Character) {
|
|
return sauce.Character.fontName;
|
|
}
|
|
}
|
|
|
|
function sliceAtEOF(data, eofMarker) {
|
|
var eof = data.length;
|
|
// :TODO: max scan back or other beter way of doing this?!
|
|
for(var i = data.length - 1; i > 0; i--) {
|
|
if(data[i] === eofMarker) {
|
|
eof = i;
|
|
break;
|
|
}
|
|
}
|
|
return data.slice(0, eof);
|
|
}
|
|
|
|
function getArtFromPath(path, options, cb) {
|
|
fs.readFile(path, function onData(err, data) {
|
|
if(err) {
|
|
cb(err);
|
|
return;
|
|
}
|
|
|
|
//
|
|
// Convert from encodedAs -> j
|
|
//
|
|
var ext = paths.extname(path).toLowerCase();
|
|
var encoding = options.encodedAs || defaultEncodingFromExtension(ext);
|
|
|
|
// :TODO: how are BOM's currently handled if present? Are they removed? Do we need to?
|
|
|
|
function sliceOfData() {
|
|
if(options.fullFile === true) {
|
|
return iconv.decode(data, encoding);
|
|
} else {
|
|
var eofMarker = defaultEofFromExtension(ext);
|
|
return iconv.decode(sliceAtEOF(data, eofMarker), encoding);
|
|
}
|
|
}
|
|
|
|
function getResult(sauce) {
|
|
var result = {
|
|
data : sliceOfData(),
|
|
fromPath : path,
|
|
};
|
|
|
|
if(sauce) {
|
|
result.sauce = sauce;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
if(options.readSauce === true) {
|
|
readSAUCE(data, function onSauce(err, sauce) {
|
|
if(err) {
|
|
cb(null, getResult());
|
|
} else {
|
|
//
|
|
// If a encoding was not provided & we have a mapping from
|
|
// the information provided by SAUCE, use that.
|
|
//
|
|
if(!options.encodedAs) {
|
|
/*
|
|
if(sauce.Character && sauce.Character.fontName) {
|
|
var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName];
|
|
if(enc) {
|
|
encoding = enc;
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
cb(null, getResult(sauce));
|
|
}
|
|
});
|
|
} else {
|
|
cb(null, getResult());
|
|
}
|
|
});
|
|
}
|
|
|
|
function getArt(name, options, cb) {
|
|
var ext = paths.extname(name);
|
|
|
|
options.basePath = miscUtil.valueWithDefault(options.basePath, conf.config.paths.art);
|
|
options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true);
|
|
|
|
// :TODO: make use of asAnsi option and convert from supported -> ansi
|
|
|
|
if('' !== ext) {
|
|
options.types = [ ext.toLowerCase() ];
|
|
} else {
|
|
if(_.isUndefined(options.types)) {
|
|
options.types = Object.keys(SUPPORTED_ART_TYPES);
|
|
} else if(_.isString(options.types)) {
|
|
options.types = [ options.types.toLowerCase() ];
|
|
}
|
|
}
|
|
|
|
// If an extension is provided, just read the file now
|
|
if('' !== ext) {
|
|
var directPath = paths.join(options.basePath, name);
|
|
getArtFromPath(directPath, options, cb);
|
|
return;
|
|
}
|
|
|
|
fs.readdir(options.basePath, function onFiles(err, files) {
|
|
if(err) {
|
|
cb(err);
|
|
return;
|
|
}
|
|
|
|
var filtered = files.filter(function onFile(file) {
|
|
//
|
|
// Ignore anything not allowed in |options.types|
|
|
//
|
|
var fext = paths.extname(file);
|
|
if(options.types.indexOf(fext.toLowerCase()) < 0) {
|
|
return false;
|
|
}
|
|
|
|
var bn = paths.basename(file, fext).toLowerCase();
|
|
if(options.random) {
|
|
var suppliedBn = paths.basename(name, fext).toLowerCase();
|
|
//
|
|
// Random selection enabled. We'll allow for
|
|
// basename1.ext, basename2.ext, ...
|
|
//
|
|
if(bn.indexOf(suppliedBn) !== 0) {
|
|
return false;
|
|
}
|
|
var num = bn.substr(suppliedBn.length);
|
|
if(num.length > 0) {
|
|
if(isNaN(parseInt(num, 10))) {
|
|
return false;
|
|
}
|
|
}
|
|
} else {
|
|
//
|
|
// We've already validated the extension (above). Must be an exact
|
|
// match to basename here
|
|
//
|
|
if(bn != paths.basename(name, fext).toLowerCase()) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if(filtered.length > 0) {
|
|
//
|
|
// We should now have:
|
|
// - Exactly (1) item in |filtered| if non-random
|
|
// - 1:n items in |filtered| to choose from if random
|
|
//
|
|
var readPath;
|
|
if(options.random) {
|
|
readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]);
|
|
} else {
|
|
assert(1 === filtered.length);
|
|
readPath = paths.join(options.basePath, filtered[0]);
|
|
}
|
|
|
|
getArtFromPath(readPath, options, cb);
|
|
} else {
|
|
cb(new Error('No matching art for supplied criteria'));
|
|
}
|
|
});
|
|
}
|
|
|
|
// :TODO: need a showArt()
|
|
// - center (if term width > 81)
|
|
// - interruptable
|
|
// - pausable: by user key and/or by page size (e..g term height)
|
|
|
|
|
|
function defaultEncodingFromExtension(ext) {
|
|
return SUPPORTED_ART_TYPES[ext.toLowerCase()].defaultEncoding;
|
|
}
|
|
|
|
function defaultEofFromExtension(ext) {
|
|
return SUPPORTED_ART_TYPES[ext.toLowerCase()].eof;
|
|
}
|
|
|
|
// :TODO: change to display(art, options, cb)
|
|
// cb(err, mci)
|
|
|
|
function display(options, cb) {
|
|
assert(_.isObject(options));
|
|
assert(_.isObject(options.client));
|
|
assert(!_.isUndefined(options.art));
|
|
|
|
if(0 === options.art.length) {
|
|
cb(new Error('Empty art'));
|
|
return;
|
|
}
|
|
|
|
// pause = none/off | end | termHeight | [ "key1", "key2", ... ]
|
|
|
|
var cancelKeys = miscUtil.valueWithDefault(options.cancelKeys, []);
|
|
var pauseKeys = miscUtil.valueWithDefault(options.pauseKeys, []);
|
|
var pauseAtTermHeight = miscUtil.valueWithDefault(options.pauseAtTermHeight, false);
|
|
var mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ' ');
|
|
|
|
var iceColors = options.iceColors;
|
|
if(_.isUndefined(options.iceColors)) {
|
|
// detect from SAUCE, if present
|
|
iceColors = false;
|
|
if(_.isObject(options.sauce) && _.isNumber(options.sauce.ansiFlags)) {
|
|
if(options.sauce.ansiFlags & (1 << 0)) {
|
|
iceColors = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
//var iceColors = miscUtil.valueWithDefault(options.iceColors, false);
|
|
|
|
// :TODO: support pause/cancel & pause @ termHeight
|
|
var canceled = false;
|
|
|
|
var parser = new aep.ANSIEscapeParser({
|
|
mciReplaceChar : mciReplaceChar,
|
|
termHeight : options.client.term.termHeight,
|
|
termWidth : options.client.term.termWidth,
|
|
omitTrailingLF : options.omitTrailingLF,
|
|
});
|
|
|
|
var mciMap = {};
|
|
var mciPosQueue = [];
|
|
var parseComplete = false;
|
|
|
|
var generatedId = 100;
|
|
|
|
var cprListener = function(pos) {
|
|
if(mciPosQueue.length > 0) {
|
|
var forMapItem = mciPosQueue.shift();
|
|
mciMap[forMapItem].position = pos;
|
|
|
|
if(parseComplete && 0 === mciPosQueue.length) {
|
|
completed();
|
|
}
|
|
}
|
|
};
|
|
|
|
function completed() {
|
|
options.client.removeListener('cursor position report', cprListener);
|
|
parser.removeAllListeners(); // :TODO: Necessary???
|
|
|
|
if(iceColors) {
|
|
// options.client.term.write(ansi.blinkNormal());
|
|
}
|
|
|
|
var extraInfo = {
|
|
height : parser.row - 1,
|
|
};
|
|
|
|
cb(null, mciMap, extraInfo);
|
|
}
|
|
|
|
options.client.on('cursor position report', cprListener);
|
|
|
|
options.pause = 'termHeight'; // :TODO: remove!!
|
|
var nextPauseTermHeight = options.client.term.termHeight;
|
|
var continous = false;
|
|
|
|
/*
|
|
parser.on('row update', function rowUpdate(row) {
|
|
if(row >= nextPauseTermHeight) {
|
|
if(!continous && 'termHeight' === options.pause) {
|
|
// :TODO: Must use new key type (ch, key)
|
|
options.client.waitForKeyPress(function kp(k) {
|
|
// :TODO: Allow for configurable key(s) here; or none
|
|
if('C' === k || 'c' == k) {
|
|
continous = true;
|
|
}
|
|
parser.parse();
|
|
});
|
|
parser.stop();
|
|
}
|
|
nextPauseTermHeight += options.client.term.termHeight;
|
|
}
|
|
});
|
|
*/
|
|
|
|
parser.on('mci', function mciEncountered(mciInfo) {
|
|
|
|
/*
|
|
if('PA' === mciInfo.mci) {
|
|
// :TODO: can't do this until this thing is pausable anyway...
|
|
options.client.waitForKeyPress(function kp(k) {
|
|
console.log('got a key: ' + k);
|
|
});
|
|
return;
|
|
}
|
|
*/
|
|
|
|
// :TODO: ensure generatedId's do not conflict with any |id|
|
|
// :TODO: Bug here - should only generate & increment ID's for the initial entry, not the "focus" version
|
|
var id = !_.isNumber(mciInfo.id) ? generatedId++ : mciInfo.id;
|
|
var mapKey = mciInfo.mci + id;
|
|
var mapEntry = mciMap[mapKey];
|
|
if(mapEntry) {
|
|
mapEntry.focusSGR = mciInfo.SGR;
|
|
mapEntry.focusArgs = mciInfo.args;
|
|
} else {
|
|
mciMap[mapKey] = {
|
|
args : mciInfo.args,
|
|
SGR : mciInfo.SGR,
|
|
code : mciInfo.mci,
|
|
id : id,
|
|
};
|
|
|
|
mciPosQueue.push(mapKey);
|
|
|
|
options.client.term.write(ansi.queryPos(), false);
|
|
}
|
|
});
|
|
|
|
parser.on('chunk', function onChunk(chunk) {
|
|
options.client.term.write(chunk, false);
|
|
});
|
|
|
|
parser.on('complete', function onComplete() {
|
|
parseComplete = true;
|
|
|
|
if(0 === mciPosQueue.length) {
|
|
completed();
|
|
}
|
|
});
|
|
|
|
// :TODO: If options.font, set the font via ANSI
|
|
// ...this should come from sauce, be passed in, or defaulted
|
|
|
|
var ansiFont = '';
|
|
if(options.font) {
|
|
// :TODO: how to set to ignore SAUCE?
|
|
ansiFont = ansi.setSyncTERMFont(options.font);
|
|
} else if(options.sauce) {
|
|
var fontName = getFontNameFromSAUCE(options.sauce);
|
|
if(fontName) {
|
|
fontName = ansi.getSyncTERMFontFromAlias(fontName);
|
|
}
|
|
|
|
if(fontName) {
|
|
ansiFont = ansi.setSyncTERMFont(fontName);
|
|
}
|
|
}
|
|
|
|
if(ansiFont.length > 1) {
|
|
options.client.term.write(ansiFont, false);
|
|
}
|
|
|
|
|
|
if(iceColors) {
|
|
options.client.term.write(ansi.blinkToBrightIntensity(), false);
|
|
}
|
|
|
|
parser.reset(options.art);
|
|
parser.parse();
|
|
} |