/* jslint node: true */ 'use strict'; // ENiGMA½ const Config = require('./config.js').get; const miscUtil = require('./misc_util.js'); const ansi = require('./ansi_term.js'); const aep = require('./ansi_escape_parser.js'); const sauce = require('./sauce.js'); const { Errors } = require('./enig_error.js'); // deps const fs = require('graceful-fs'); const paths = require('path'); const assert = require('assert'); const iconv = require('iconv-lite'); const _ = require('lodash'); exports.getArt = getArt; exports.getArtFromPath = getArtFromPath; exports.display = display; exports.defaultEncodingFromExtension = defaultEncodingFromExtension; // :TODO: Return MCI code information // :TODO: process SAUCE comments // :TODO: return font + font mapped information from SAUCE const 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 }, '.amiga': { name: 'Amiga', defaultEncoding: 'amiga', eof: 0x1a }, '.txt': { name: 'Amiga Text', defaultEncoding: 'cp437', eof: 0x1a }, // :TODO: extentions for wwiv, renegade, celerity, syncronet, ... // :TODO: extension for atari // :TODO: extension for topaz ansi/ascii. }; function getFontNameFromSAUCE(sauce) { if (sauce && sauce.Character) { return sauce.Character.fontName; } } function getWidthFromSAUCE(sauce) { if (sauce && sauce.Character) { let sauceWidth = _.toNumber(sauce.Character.characterWidth); if(!(_.isNaN(sauceWidth)) && sauceWidth > 0) { return sauceWidth; } } return null; } function sliceAtEOF(data, eofMarker) { let eof = data.length; const stopPos = Math.max(data.length - 256, 0); // 256 = 2 * sizeof(SAUCE) for (let i = eof - 1; i > stopPos; i--) { if (eofMarker === data[i]) { eof = i; break; } } if (eof === data.length) { return data; // nothing to do } // try to prevent goofs if (eof < 128 && 'SAUCE00' !== data.slice(eof + 1, eof + 8).toString()) { return data; } return data.slice(0, eof); } function getArtFromPath(path, options, cb) { fs.readFile(path, (err, data) => { if (err) { return cb(err); } // // Convert from encodedAs -> j // const ext = paths.extname(path).toLowerCase(); const 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 { const eofMarker = defaultEofFromExtension(ext); return iconv.decode( eofMarker ? sliceAtEOF(data, eofMarker) : data, encoding ); } } function getResult(sauce) { const result = { data: sliceOfData(), fromPath: path, }; if (sauce) { result.sauce = sauce; } return result; } if (options.readSauce === true) { sauce.readSAUCE(data, (err, sauce) => { if (err) { return cb(null, getResult()); } // // 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; } } */ } return cb(null, getResult(sauce)); }); } else { return cb(null, getResult()); } }); } function getArt(name, options, cb) { const ext = paths.extname(name); options.basePath = miscUtil.valueWithDefault(options.basePath, 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) { const directPath = paths.isAbsolute(name) ? name : paths.join(options.basePath, name); return getArtFromPath(directPath, options, cb); } fs.readdir(options.basePath, (err, files) => { if (err) { return cb(err); } const filtered = files.filter(file => { // // Ignore anything not allowed in |options.types| // const fext = paths.extname(file); 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.startsWith(suppliedBn)) { return false; } const 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 // let 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]); } return getArtFromPath(readPath, options, cb); } return cb(Errors.DoesNotExist(`No matching art for supplied criteria: ${name}`)); }); } function defaultEncodingFromExtension(ext) { const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; return artType ? artType.defaultEncoding : 'utf8'; } function defaultEofFromExtension(ext) { const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; if (artType) { return artType.eof; } } // :TODO: Implement the following // * Pause (disabled | termHeight | keyPress ) // * Cancel (disabled | ) // * Resume from pause -> continous (disabled | ) function display(client, art, options, cb) { if (_.isFunction(options) && !cb) { cb = options; options = {}; } if (!art || !art.length) { return cb(Errors.Invalid('No art supplied!')); } options.mciReplaceChar = options.mciReplaceChar || ' '; // :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 if (_.has(options, 'sauce.ansiFlags') && options.sauce.ansiFlags & (1 << 0)) { options.iceColors = true; } } const ansiParser = new aep.ANSIEscapeParser({ mciReplaceChar: options.mciReplaceChar, termHeight: client.term.termHeight, termWidth: client.term.termWidth, artWidth: getWidthFromSAUCE(options.sauce), trailingLF: options.trailingLF, startRow: options.startRow, }); const mciMap = {}; let generatedId = 100; ansiParser.on('mci', mciInfo => { // :TODO: ensure generatedId's do not conflict with any existing |id| const id = _.isNumber(mciInfo.id) ? mciInfo.id : generatedId; const mapKey = `${mciInfo.mci}${id}`; const mapEntry = mciMap[mapKey]; if (mapEntry) { mapEntry.focusSGR = mciInfo.SGR; mapEntry.focusArgs = mciInfo.args; } else { mciMap[mapKey] = { position: mciInfo.position, args: mciInfo.args, SGR: mciInfo.SGR, code: mciInfo.mci, id: id, }; if (!mciInfo.id) { ++generatedId; } } }); // Remove any MCI's that are in erased rows ansiParser.on('erase row', (startRow, endRow) => { _.forEach(mciMap, (mciInfo, mapKey) => { if (mciInfo.position[0] >= startRow && mciInfo.position[0] <= endRow) { delete mciMap[mapKey]; } }); }); // Remove any MCI's that are in erased columns ansiParser.on('erase columns', (row, startCol, endCol) => { _.forEach(mciMap, (mciInfo, mapKey) => { if ( mciInfo.position[0] === row && mciInfo.position[1] >= startCol && mciInfo.position[1] <= endCol ) { delete mciMap[mapKey]; } }); }); ansiParser.on('insert columns', (row, startCol, numCols) => { _.forEach(mciMap, (mciInfo, mapKey) => { if (mciInfo.position[0] === row && mciInfo.position[1] >= startCol) { mciInfo.position[1] += numCols; if(mciInfo.position[1] > client.term.termWidth) { delete mciMap[mapKey]; } } }); }); // Clear the screen, removing any MCI's ansiParser.on('clear screen', () => { _.forEach(mciMap, (mciInfo, mapKey) => { delete mciMap[mapKey]; }); }); ansiParser.on('scroll', (scrollY) => { _.forEach(mciMap, (mciInfo) => { mciInfo.position[0] -= scrollY; }); }); ansiParser.on('insert line', (row, numLines) => { _.forEach(mciMap, (mciInfo) => { if (mciInfo.position[0] >= row) { mciInfo.position[0] += numLines; } }); }); ansiParser.on('delete line', (row, numLines) => { _.forEach(mciMap, (mciInfo, mapKey) => { if (mciInfo.position[0] >= row) { if(mciInfo.position[0] < row + numLines) { // unlike scrolling, the rows are actually gone, // so we need to delete any MCI's that are in them delete mciMap[mapKey]; } else { mciInfo.position[0] -= numLines; } } }); }); ansiParser.on('literal', literal => client.term.write(literal, false)); ansiParser.on('control', control => client.term.rawWrite(control)); ansiParser.on('complete', () => { ansiParser.removeAllListeners(); const extraInfo = { height: ansiParser.row - 1, }; return cb(null, mciMap, extraInfo); }); let initSeq = ''; if (client.term.syncTermFontsEnabled) { if (options.font) { initSeq = ansi.setSyncTermFontWithAlias(options.font); } else if (options.sauce) { let fontName = getFontNameFromSAUCE(options.sauce); if (fontName) { fontName = ansi.getSyncTermFontFromAlias(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 (options.iceColors) { initSeq += ansi.blinkToBrightIntensity(); } if (initSeq) { client.term.rawWrite(initSeq); } ansiParser.reset(art); return ansiParser.parse(); }