/* jslint node: true */ 'use strict'; // ENiGMA½ const MenuModule = require('./menu_module.js').MenuModule; const ansi = require('./ansi_term.js'); const FileEntry = require('./file_entry.js'); const stringFormat = require('./string_format.js'); const FileArea = require('./file_base_area.js'); const Errors = require('./enig_error.js').Errors; const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; const ArchiveUtil = require('./archive_util.js'); const Config = require('./config.js').get; const DownloadQueue = require('./download_queue.js'); const FileAreaWeb = require('./file_area_web.js'); const FileBaseFilters = require('./file_base_filter.js'); const resolveMimeType = require('./mime_util.js').resolveMimeType; const isAnsi = require('./string_util.js').isAnsi; const controlCodesToAnsi = require('./color_codes.js').controlCodesToAnsi; // deps const async = require('async'); const _ = require('lodash'); const moment = require('moment'); const paths = require('path'); exports.moduleInfo = { name: 'File Area List', desc: 'Lists contents of file an file area', author: 'NuSkooler', }; const FormIds = { browse: 0, details: 1, detailsGeneral: 2, detailsNfo: 3, detailsFileList: 4, }; const MciViewIds = { browse: { desc: 1, navMenu: 2, customRangeStart: 10, // 10+ = customs }, details: { navMenu: 1, infoXyTop: 2, // %XY starting position for info area infoXyBottom: 3, customRangeStart: 10, // 10+ = customs }, detailsGeneral: { customRangeStart: 10, // 10+ = customs }, detailsNfo: { nfo: 1, customRangeStart: 10, // 10+ = customs }, detailsFileList: { fileList: 1, customRangeStart: 10, // 10+ = customs }, }; exports.getModule = class FileAreaList extends MenuModule { constructor(options) { super(options); this.filterCriteria = _.get(options, 'extraArgs.filterCriteria'); this.fileList = _.get(options, 'extraArgs.fileList'); this.lastFileNextExit = _.get(options, 'extraArgs.lastFileNextExit', true); if (this.fileList) { // we'll need to adjust position as well! this.fileListPosition = 0; } this.dlQueue = new DownloadQueue(this.client); if (!this.filterCriteria) { this.filterCriteria = FileBaseFilters.getActiveFilter(this.client); } if (_.isString(this.filterCriteria)) { this.filterCriteria = JSON.parse(this.filterCriteria); } if (_.has(options, 'lastMenuResult.value')) { this.lastMenuResultValue = options.lastMenuResult.value; } this.menuMethods = { nextFile: (formData, extraArgs, cb) => { if (this.fileListPosition + 1 < this.fileList.length) { this.fileListPosition += 1; return this.displayBrowsePage(true, cb); // true=clerarScreen } if (this.lastFileNextExit) { return this.prevMenu(cb); } return cb(null); }, prevFile: (formData, extraArgs, cb) => { if (this.fileListPosition > 0) { --this.fileListPosition; return this.displayBrowsePage(true, cb); // true=clearScreen } return cb(null); }, viewDetails: (formData, extraArgs, cb) => { this.viewControllers.browse.setFocus(false); return this.displayDetailsPage(cb); }, detailsQuit: (formData, extraArgs, cb) => { ['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) => { this.dlQueue.toggle(this.currentFileEntry); this.updateQueueIndicator(); return cb(null); }, showWebDownloadLink: (formData, extraArgs, cb) => { return this.fetchAndDisplayWebDownloadLink(cb); }, displayHelp: (formData, extraArgs, cb) => { return this.displayHelpPage(cb); }, movementKeyPressed: (formData, extraArgs, cb) => { return this._handleMovementKeyPress(_.get(formData, 'key.name'), cb); }, }; } enter() { super.enter(); } leave() { super.leave(); } getSaveState() { return { fileList: this.fileList, fileListPosition: this.fileListPosition, }; } restoreSavedState(savedState) { if (savedState) { this.fileList = savedState.fileList; this.fileListPosition = savedState.fileListPosition; } } updateFileEntryWithMenuResult(cb) { if (!this.lastMenuResultValue) { return cb(null); } if (_.isNumber(this.lastMenuResultValue.rating)) { const fileId = this.fileList[this.fileListPosition]; FileEntry.persistUserRating( fileId, this.client.user.userId, this.lastMenuResultValue.rating, err => { if (err) { this.client.log.warn( { error: err.message, fileId: fileId }, 'Failed to persist file rating' ); } return cb(null); } ); } else { return cb(null); } } initSequence() { const self = this; async.series( [ function preInit(callback) { return self.updateFileEntryWithMenuResult(callback); }, function beforeArt(callback) { return self.beforeArt(callback); }, function display(callback) { return self.displayBrowsePage(false, err => { if (err) { self.gotoMenu( self.menuConfig.config.noResultsMenu || 'fileBaseListEntriesNoResults' ); } return callback(err); }); }, ], () => { self.finishedLoading(); } ); } populateCurrentEntryInfo(cb) { const config = this.menuConfig.config; const currEntry = this.currentFileEntry; const uploadTimestampFormat = config.uploadTimestampFormat || this.client.currentTheme.helpers.getDateFormat('short'); const area = FileArea.getFileAreaByTag(currEntry.areaTag); const hashTagsSep = config.hashTagsSep || ', '; const isQueuedIndicator = config.isQueuedIndicator || 'Y'; const isNotQueuedIndicator = config.isNotQueuedIndicator || 'N'; const entryInfo = (currEntry.entryInfo = { fileId: currEntry.fileId, areaTag: currEntry.areaTag, areaName: _.get(area, 'name') || 'N/A', areaDesc: _.get(area, 'desc') || 'N/A', fileSha256: currEntry.fileSha256, fileName: currEntry.fileName, desc: currEntry.desc || '', descLong: currEntry.descLong || '', userRating: currEntry.userRating, uploadTimestamp: moment(currEntry.uploadTimestamp).format( uploadTimestampFormat ), hashTags: Array.from(currEntry.hashTags).join(hashTagsSep), isQueued: this.dlQueue.isQueued(currEntry) ? isQueuedIndicator : isNotQueuedIndicator, webDlLink: '', // :TODO: fetch web any existing web d/l link webDlExpire: '', // :TODO: fetch web d/l link expire time }); // // We need the entry object to contain meta keys even if they are empty as // consumers may very likely attempt to use them // const metaValues = FileEntry.WellKnownMetaValues; metaValues.forEach(name => { const value = !_.isUndefined(currEntry.meta[name]) ? currEntry.meta[name] : 'N/A'; entryInfo[_.camelCase(name)] = value; }); if (entryInfo.archiveType) { const mimeType = resolveMimeType(entryInfo.archiveType); let desc; if (mimeType) { let fileType = _.get(Config(), ['fileTypes', mimeType]); if (Array.isArray(fileType)) { // further refine by extention fileType = fileType.find( ft => paths.extname(currEntry.fileName) === ft.ext ); } desc = fileType && fileType.desc; } entryInfo.archiveTypeDesc = desc || mimeType || entryInfo.archiveType; } else { entryInfo.archiveTypeDesc = 'N/A'; } entryInfo.uploadByUsername = entryInfo.uploadByUserName = entryInfo.uploadByUsername || 'N/A'; // may be imported entryInfo.hashTags = entryInfo.hashTags || '(none)'; // create a rating string, e.g. "**---" const userRatingTicked = config.userRatingTicked || '*'; const userRatingUnticked = config.userRatingUnticked || ''; entryInfo.userRating = ~~Math.round(entryInfo.userRating) || 0; // be safe! entryInfo.userRatingString = userRatingTicked.repeat(entryInfo.userRating); if (entryInfo.userRating < 5) { entryInfo.userRatingString += userRatingUnticked.repeat( 5 - entryInfo.userRating ); } FileAreaWeb.getExistingTempDownloadServeItem( this.client, this.currentFileEntry, (err, serveItem) => { if (err) { entryInfo.webDlExpire = ''; if (ErrNotEnabled === err.reasonCode) { entryInfo.webDlExpire = config.webDlLinkNoWebserver || 'Web server is not enabled'; } else { entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated'; } } else { const webDlExpireTimeFormat = config.webDlExpireTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat('short'); entryInfo.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format( webDlExpireTimeFormat ); } return cb(null); } ); } populateCustomLabels(category, startId) { return this.updateCustomViewTextsWithFilter( category, startId, this.currentFileEntry.entryInfo ); } displayArtDataPrepCallback(name, artData) { if (name === 'details') { try { this.detailsInfoArea = { top: artData.mciMap.XY2.position, bottom: artData.mciMap.XY3.position, }; } catch (e) { throw Errors.DoesNotExist( 'File listing details %XY2 and/or %XY3 MCI position indicators!' ); } } } displayBrowsePage(clearScreen, cb) { const self = this; async.series( [ function fetchEntryData(callback) { if (self.fileList) { return callback(null); } return self.loadFileIds(false, callback); // false=do not force }, function checkEmptyResults(callback) { if (0 === self.fileList.length) { return callback( Errors.General('No results for criteria', 'NORESULTS') ); } return callback(null); }, function prepArtAndViewController(callback) { return self.displayArtAndPrepViewController( 'browse', FormIds.browse, { clearScreen: clearScreen, artDataPrep: self.displayArtDataPrepCallback.bind(self), }, callback ); }, function loadCurrentFileInfo(callback) { self.currentFileEntry = new FileEntry(); self.currentFileEntry.load( self.fileList[self.fileListPosition], err => { if (err) { return callback(err); } return self.populateCurrentEntryInfo(callback); } ); }, function populateDesc(callback) { if (_.isString(self.currentFileEntry.desc)) { const descView = self.viewControllers.browse.getView( MciViewIds.browse.desc ); if (descView) { // // For descriptions we want to support as many color code systems // as we can for coverage of what is found in the while (e.g. Renegade // pipes, PCB @X##, etc.) // // MLTEV doesn't support all of this, so convert. If we produced ANSI // esc sequences, we'll proceed with specialization, else just treat // it as text. // const desc = controlCodesToAnsi(self.currentFileEntry.desc); if ( desc.length != self.currentFileEntry.desc.length || isAnsi(desc) ) { const opts = { prepped: false, forceLineTerm: true, }; // // if SAUCE states a term width, honor it else we may see // display corruption // const sauceTermWidth = _.get( self.currentFileEntry.meta, 'desc_sauce.Character.characterWidth' ); if (_.isNumber(sauceTermWidth)) { opts.termWidth = sauceTermWidth; } descView.setAnsi(desc, opts, () => { return callback(null); }); } else { descView.setText(self.currentFileEntry.desc); return callback(null); } } } else { return callback(null); } }, function populateAdditionalViews(callback) { self.updateQueueIndicator(); self.populateCustomLabels( 'browse', MciViewIds.browse.customRangeStart ); return callback(null); }, ], err => { if (cb) { return cb(err); } } ); } displayDetailsPage(cb) { const self = this; async.series( [ function prepArtAndViewController(callback) { return self.displayArtAndPrepViewController( 'details', FormIds.details, { clearScreen: true, artDataPrep: self.displayArtDataPrepCallback.bind(self), }, callback ); }, function populateViews(callback) { self.populateCustomLabels( 'details', MciViewIds.details.customRangeStart ); return callback(null); }, function prepSection(callback) { return self.displayDetailsSection('general', false, callback); }, function listenNavChanges(callback) { const navMenu = self.viewControllers.details.getView( MciViewIds.details.navMenu ); navMenu.setFocusItemIndex(0); navMenu.on('index update', index => { const sectionName = { 0: 'general', 1: 'nfo', 2: 'fileList', }[index]; if (sectionName) { self.displayDetailsSection(sectionName, true); } }); return callback(null); }, ], err => { return cb(err); } ); } displayHelpPage(cb) { this.displayAsset(this.menuConfig.config.art.help, { clearScreen: true }, () => { this.client.waitForKeyPress(() => { return this.displayBrowsePage(true, cb); }); }); } _handleMovementKeyPress(keyName, cb) { const descView = this.viewControllers.browse.getView(MciViewIds.browse.desc); if (!descView) { return cb(null); } switch (keyName) { case 'down arrow': descView.scrollDocumentUp(); break; case 'up arrow': descView.scrollDocumentDown(); break; case 'page up': descView.keyPressPageUp(); break; case 'page down': descView.keyPressPageDown(); break; } this.viewControllers.browse.switchFocus(MciViewIds.browse.navMenu); return cb(null); } fetchAndDisplayWebDownloadLink(cb) { const self = this; async.series( [ function generateLinkIfNeeded(callback) { if (self.currentFileEntry.webDlExpireTime < moment()) { return callback(null); } const expireTime = moment().add( Config().fileBase.web.expireMinutes, 'minutes' ); FileAreaWeb.createAndServeTempDownload( self.client, self.currentFileEntry, { expireTime: expireTime }, (err, url) => { if (err) { return callback(err); } self.currentFileEntry.webDlExpireTime = expireTime; const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; self.currentFileEntry.entryInfo.webDlLink = ansi.vtxHyperlink(self.client, url) + url; self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); return callback(null); } ); }, function updateActiveViews(callback) { self.updateCustomViewTextsWithFilter( 'browse', MciViewIds.browse.customRangeStart, self.currentFileEntry.entryInfo, { filter: ['{webDlLink}', '{webDlExpire}'] } ); return callback(null); }, ], err => { return cb(err); } ); } updateQueueIndicator() { const isQueuedIndicator = this.menuConfig.config.isQueuedIndicator || 'Y'; const isNotQueuedIndicator = this.menuConfig.config.isNotQueuedIndicator || 'N'; this.currentFileEntry.entryInfo.isQueued = stringFormat( this.dlQueue.isQueued(this.currentFileEntry) ? isQueuedIndicator : isNotQueuedIndicator ); this.updateCustomViewTextsWithFilter( 'browse', MciViewIds.browse.customRangeStart, this.currentFileEntry.entryInfo, { filter: ['{isQueued}'] } ); } cacheArchiveEntries(cb) { // check cache if (this.currentFileEntry.archiveEntries) { return cb(null, 'cache'); } const areaInfo = FileArea.getFileAreaByTag(this.currentFileEntry.areaTag); if (!areaInfo) { return cb(Errors.Invalid('Invalid area tag')); } const filePath = this.currentFileEntry.filePath; const archiveUtil = ArchiveUtil.getInstance(); archiveUtil.listEntries( filePath, this.currentFileEntry.entryInfo.archiveType, (err, entries) => { if (err) { return cb(err); } // assign and add standard "text" member for itemFormat this.currentFileEntry.archiveEntries = entries.map(e => Object.assign(e, { text: `${e.fileName} (${e.byteSize})` }) ); return cb(null, 're-cached'); } ); } setFileListNoListing(text) { const fileListView = this.viewControllers.detailsFileList.getView( MciViewIds.detailsFileList.fileList ); if (fileListView) { fileListView.complexItems = false; fileListView.setItems([text]); fileListView.redraw(); } } populateFileListing() { const fileListView = this.viewControllers.detailsFileList.getView( MciViewIds.detailsFileList.fileList ); if (this.currentFileEntry.entryInfo.archiveType) { this.cacheArchiveEntries((err, cacheStatus) => { if (err) { return this.setFileListNoListing('Failed to get file listing'); } if ('re-cached' === cacheStatus) { fileListView.setItems(this.currentFileEntry.archiveEntries); fileListView.redraw(); } }); } else { const notAnArchiveFileName = stringFormat( this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName: this.currentFileEntry.fileName } ); this.setFileListNoListing(notAnArchiveFileName); } } displayDetailsSection(sectionName, clearArea, cb) { const self = this; const name = `details${_.upperFirst(sectionName)}`; async.series( [ function detachPrevious(callback) { if (self.lastDetailsViewController) { self.lastDetailsViewController.detachClientEvents(); } return callback(null); }, function prepArtAndViewController(callback) { function gotoTopPos() { self.client.term.rawWrite( ansi.goto(self.detailsInfoArea.top[0], 1) ); } gotoTopPos(); if (clearArea) { self.client.term.rawWrite(ansi.reset()); let pos = self.detailsInfoArea.top[0]; const bottom = self.detailsInfoArea.bottom[0]; while (pos++ <= bottom) { self.client.term.rawWrite(ansi.eraseLine() + ansi.down()); } gotoTopPos(); } return self.displayArtAndPrepViewController( name, FormIds[name], { clearScreen: false, noInput: true, artDataPrep: self.displayArtDataPrepCallback.bind(self), viewOffsets: { col: 0, row: self.detailsInfoArea.top[0] - 1, }, }, callback ); }, function populateViews(callback) { self.lastDetailsViewController = self.viewControllers[name]; switch (sectionName) { case 'nfo': { const nfoView = self.viewControllers.detailsNfo.getView( MciViewIds.detailsNfo.nfo ); 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(); return callback(null); default: return callback(null); } }, function setLabels(callback) { self.populateCustomLabels(name, MciViewIds[name].customRangeStart); return callback(null); }, ], err => { if (cb) { return cb(err); } } ); } loadFileIds(force, cb) { if ( force || _.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition) ) { this.fileListPosition = 0; const filterCriteria = Object.assign({}, this.filterCriteria); if (!filterCriteria.areaTag) { filterCriteria.areaTag = FileArea.getAvailableFileAreaTags(this.client); } FileEntry.findFiles(filterCriteria, (err, fileIds) => { this.fileList = fileIds || []; return cb(err); }); } } };