diff --git a/core/archive_util.js b/core/archive_util.js index 81daf9c1..9e21f997 100644 --- a/core/archive_util.js +++ b/core/archive_util.js @@ -266,7 +266,7 @@ module.exports = class ArchiveUtil { while((m = entryMatchRe.exec(output))) { // :TODO: allow alternate ordering!!! entries.push({ - size : m[1], + byteSize : parseInt(m[1]), fileName : m[2], }); } diff --git a/core/config.js b/core/config.js index c94470bb..059051bc 100644 --- a/core/config.js +++ b/core/config.js @@ -239,24 +239,28 @@ function getDefaultConfig() { offset : 0, exts : [ 'zip' ], handler : '7Zip', + desc : 'ZIP Archive', }, '7z' : { sig : '377abcaf271c', offset : 0, exts : [ '7z' ], handler : '7Zip', + desc : '7-Zip Archive', }, arj : { sig : '60ea', offset : 0, exts : [ 'arj' ], handler : '7Zip', + desc : 'ARJ Archive', }, rar : { sig : '526172211a0700', offset : 0, exts : [ 'rar' ], handler : '7Zip', + desc : 'RAR Archive', } } }, @@ -339,7 +343,11 @@ function getDefaultConfig() { areaStoragePrefix : paths.join(__dirname, './../file_base/'), fileNamePatterns: { - shortDesc : [ '^FILE_ID\.DIZ$', '^DESC\.SDI$' ], + // These are NOT case sensitive + shortDesc : [ + '^FILE_ID\.DIZ$', '^DESC\.SDI$', '^DESCRIPT\.ION$', '^FILE\.DES$', '$FILE\.SDI$', '^DISK\.ID$' + ], + longDesc : [ '^.*\.NFO$', '^README\.1ST$', '^README\.TXT$' ], }, @@ -349,7 +357,7 @@ function getDefaultConfig() { // The year may be YY or YYYY // '[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})', // m/d/yyyy, mm-dd-yyyy, etc. - "\\B('[1789][0-9])\\b", + "\\B('[1789][0-9])\\b", // eslint-disable-line quotes // :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc. ], diff --git a/core/file_area.js b/core/file_area.js index 36d93078..d3884f26 100644 --- a/core/file_area.js +++ b/core/file_area.js @@ -20,7 +20,7 @@ const iconv = require('iconv-lite'); exports.getAvailableFileAreas = getAvailableFileAreas; exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas; -exports.getDefaultFileArea = getDefaultFileArea; +exports.getDefaultFileAreaTag = getDefaultFileAreaTag; exports.getFileAreaByTag = getFileAreaByTag; exports.changeFileAreaWithOptions = changeFileAreaWithOptions; //exports.addOrUpdateFileEntry = addOrUpdateFileEntry; @@ -45,18 +45,20 @@ function getAvailableFileAreas(client, options) { } function getSortedAvailableFileAreas(client, options) { - const areas = _.map(getAvailableFileAreas(client, options), (v, k) => { - return { + const areas = _.map(getAvailableFileAreas(client, options), (v, k) => { + const areaInfo = { areaTag : k, area : v }; + + return areaInfo; }); sortAreasOrConfs(areas, 'area'); return areas; } -function getDefaultFileArea(client, disableAcsCheck) { +function getDefaultFileAreaTag(client, disableAcsCheck) { let defaultArea = _.findKey(Config.fileAreas, o => o.default); if(defaultArea) { const area = Config.fileAreas.areas[defaultArea]; @@ -76,7 +78,8 @@ function getDefaultFileArea(client, disableAcsCheck) { function getFileAreaByTag(areaTag) { const areaInfo = Config.fileAreas.areas[areaTag]; if(areaInfo) { - areaInfo.areaTag = areaTag; // convienence! + areaInfo.areaTag = areaTag; // convienence! + areaInfo.storageDirectory = getAreaStorageDirectory(areaInfo); return areaInfo; } } @@ -177,7 +180,20 @@ function attemptSetEstimatedReleaseDate(fileEntry) { // const match = getMatch(fileEntry.desc) || getMatch(fileEntry.descLong); if(match && match[1]) { - const year = (2 === match[1].length) ? parseInt('19' + match[1]) : parseInt(match[1]); + let year; + if(2 === match[1].length) { + year = parseInt(match[1]); + if(year) { + if(year > 70) { + year += 1900; + } else { + year += 2000; + } + } + } else { + year = parseInt(match[1]); + } + if(year) { fileEntry.meta.est_release_year = year; } @@ -290,14 +306,18 @@ function addNewFileEntry(fileEntry, filePath, cb) { function populateInfo(callback) { archiveUtil.detectType(filePath, (err, archiveType) => { if(archiveType) { + // save this off + fileEntry.meta.archive_type = archiveType; + populateFileEntryWithArchive(fileEntry, filePath, archiveType, err => { if(err) { populateFileEntry(fileEntry, filePath, err => { // :TODO: log err return callback(null); // ignore err }); + } else { + return callback(null); } - return callback(null); }); } else { populateFileEntry(fileEntry, filePath, err => { @@ -310,7 +330,10 @@ function addNewFileEntry(fileEntry, filePath, cb) { function addNewDbRecord(callback) { return fileEntry.persist(callback); } - ] + ], + err => { + return cb(err); + } ); } @@ -371,7 +394,7 @@ function addOrUpdateFileEntry(areaInfo, fileName, options, cb) { return callback(err, existingEntries); }); }, - function addOrUpdate(callback, existingEntries) { + function addOrUpdate(existingEntries, callback) { if(existingEntries.length > 0) { } else { @@ -396,7 +419,7 @@ function scanFileAreaForChanges(areaInfo, cb) { return callback(err); } - async.each(files, (fileName, next) => { + async.eachSeries(files, (fileName, next) => { const fullPath = paths.join(areaPhysDir, fileName); fs.stat(fullPath, (err, stats) => { @@ -409,8 +432,8 @@ function scanFileAreaForChanges(areaInfo, cb) { return next(null); } - addOrUpdateFileEntry(areaInfo, fileName, err => { - + addOrUpdateFileEntry(areaInfo, fileName, { areaTag : areaInfo.areaTag }, err => { + return next(err); }); }); }, err => { diff --git a/core/file_entry.js b/core/file_entry.js index 25e6e81c..dd656442 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -25,6 +25,7 @@ const FILE_WELL_KNOWN_META = { dl_count : (d) => parseInt(d) || 0, byte_size : (b) => parseInt(b) || 0, user_rating : (r) => Math.min(parseInt(r) || 0, 5), + archive_type : null, }; module.exports = class FileEntry { @@ -33,8 +34,13 @@ module.exports = class FileEntry { this.fileId = options.fileId || 0; this.areaTag = options.areaTag || ''; - this.meta = {}; - this.hashTags = new Set(); + this.meta = options.meta || { + // values we always want + user_rating : 0, + dl_count : 0, + }; + + this.hashTags = options.hashTags || new Set(); this.fileName = options.fileName; } diff --git a/core/horizontal_menu_view.js b/core/horizontal_menu_view.js index 87c194ee..28f4c29d 100644 --- a/core/horizontal_menu_view.js +++ b/core/horizontal_menu_view.js @@ -113,30 +113,39 @@ HorizontalMenuView.prototype.setItems = function(items) { this.positionCacheExpired = true; }; +HorizontalMenuView.prototype.focusNext = function() { + if(this.items.length - 1 === this.focusedItemIndex) { + this.focusedItemIndex = 0; + } else { + this.focusedItemIndex++; + } + + // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes + this.redraw(); + + HorizontalMenuView.super_.prototype.focusNext.call(this); +}; + +HorizontalMenuView.prototype.focusPrevious = function() { + + if(0 === this.focusedItemIndex) { + this.focusedItemIndex = this.items.length - 1; + } else { + this.focusedItemIndex--; + } + + // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes + this.redraw(); + + HorizontalMenuView.super_.prototype.focusPrevious.call(this); +}; + HorizontalMenuView.prototype.onKeyPress = function(ch, key) { if(key) { - var prevFocusedItemIndex = this.focusedItemIndex; - if(this.isKeyMapped('left', key.name)) { - if(0 === this.focusedItemIndex) { - this.focusedItemIndex = this.items.length - 1; - } else { - this.focusedItemIndex--; - } - + this.focusPrevious(); } else if(this.isKeyMapped('right', key.name)) { - if(this.items.length - 1 === this.focusedItemIndex) { - this.focusedItemIndex = 0; - } else { - this.focusedItemIndex++; - } - } - - if(prevFocusedItemIndex !== this.focusedItemIndex) { - // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes - // if this is changed to allow scrolling - this.redraw(); - return; + this.focusNext(); } } diff --git a/core/view_controller.js b/core/view_controller.js index f2fbb366..cf90de3e 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -143,8 +143,10 @@ function ViewController(options) { var mci = mciMap[name]; var view = self.mciViewFactory.createFromMCI(mci); - if(view && false === self.noInput) { - view.on('action', self.viewActionListener); + if(view) { + if(false === self.noInput) { + view.on('action', self.viewActionListener); + } self.addView(view); } diff --git a/mods/file_area_list.js b/mods/file_area_list.js index ae1297d8..ab013d85 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -9,11 +9,15 @@ const theme = require('../core/theme.js'); const FileEntry = require('../core/file_entry.js'); const stringFormat = require('../core/string_format.js'); const FileArea = require('../core/file_area.js'); +const Errors = require('../core/enig_error.js').Errors; +const ArchiveUtil = require('../core/archive_util.js'); +const Config = require('../core/config.js').config; // deps const async = require('async'); const _ = require('lodash'); const moment = require('moment'); +const paths = require('path'); /* Misc TODO @@ -41,15 +45,35 @@ exports.moduleInfo = { }; const FormIds = { - browse : 0, - details : 1, + browse : 0, + details : 1, + detailsGeneral : 2, + detailsNfo : 3, + detailsFileList : 4, }; const MciViewIds = { browse : { desc : 1, navMenu : 2, - // 10+: customs + // 10+ = customs + }, + details : { + navMenu : 1, + infoXyTop : 2, // %XY starting position for info area + infoXyBottom : 3, + // 10+ = customs + }, + detailsGeneral : { + // 10+ = customs + }, + detailsNfo : { + nfo : 1, + // 10+ = customs + }, + detailsFileList : { + fileList : 1, + // 10+ = customs }, }; @@ -69,6 +93,39 @@ exports.getModule = class FileAreaList extends MenuModule { }; this.currentFileEntry = new FileEntry(); + + this.menuMethods = { + nextFile : (formData, extraArgs, cb) => { + if(this.fileListPosition + 1 < this.fileList.length) { + this.fileListPosition += 1; + + delete this.currentFileEntry.archiveEntries; + + return this.displayBrowsePage(true, cb); // true=clerarScreen + } + + return cb(null); + }, + prevFile : (formData, extraArgs, cb) => { + if(this.fileListPosition > 0) { + --this.fileListPosition; + + delete this.currentFileEntry.archiveEntries; + + 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) => { + this.viewControllers.details.setFocus(false); + return this.displayBrowsePage(true, cb); // true=clearScreen + }, + }; } enter() { @@ -97,19 +154,87 @@ exports.getModule = class FileAreaList extends MenuModule { ); } - displayBrowsePage(clearScreen, cb) { + populateCurrentEntryInfo() { + const config = this.menuConfig.config; + const currEntry = this.currentFileEntry; + + const uploadTimestampFormat = config.browseUploadTimestampFormat || config.uploadTimestampFormat || 'YYYY-MMM-DD'; + const area = FileArea.getFileAreaByTag(currEntry.areaTag); + const hashTagsSep = config.hashTagsSep || ', '; + + const entryInfo = this.currentFileEntry.entryInfo = { + fileId : currEntry.fileId, + areaTag : currEntry.areaTag, + areaName : area.name || 'N/A', + areaDesc : area.desc || 'N/A', + fileSha1 : currEntry.fileSha1, + fileName : currEntry.fileName, + desc : currEntry.desc || '', + descLong : currEntry.descLong || '', + uploadTimestamp : moment(currEntry.uploadTimestamp).format(uploadTimestampFormat), + hashTags : Array.from(currEntry.hashTags).join(hashTagsSep), + }; + + // + // 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.getWellKnownMetaValues(); + metaValues.forEach(name => { + const value = !_.isUndefined(currEntry.meta[name]) ? currEntry.meta[name] : ''; + entryInfo[_.camelCase(name)] = value; + }); + + if(entryInfo.archiveType) { + entryInfo.archiveTypeDesc = _.has(Config, [ 'archives', 'formats', entryInfo.archiveType, 'desc' ]) ? + Config.archives.formats[entryInfo.archiveType].desc : + entryInfo.archiveType; + } else { + entryInfo.archiveTypeDesc = 'N/A'; + } + + entryInfo.uploadByUsername = entryInfo.uploadByUsername || 'N/A'; // may be imported + + // create a rating string, e.g. "**---" + const userRatingTicked = config.userRatingTicked || '*'; + const userRatingUnticked = config.userRatingUnticked || ''; + entryInfo.userRating = entryInfo.userRating || 0; // be safe! + entryInfo.userRatingString = new Array(entryInfo.userRating + 1).join(userRatingTicked); + if(entryInfo.userRating < 5) { + entryInfo.userRatingString += new Array( (5 - entryInfo.userRating) + 1).join(userRatingUnticked); + } + } + + populateCustomLabels(category, startId) { + let textView; + let customMciId = startId; + const config = this.menuConfig.config; + + while( (textView = this.viewControllers[category].getView(customMciId)) ) { + const key = `${category}InfoFormat${customMciId}`; + const format = config[key]; + + if(format) { + textView.setText(stringFormat(format, this.currentFileEntry.entryInfo)); + } + + ++customMciId; + } + } + + displayArtAndPrepViewController(name, options, cb) { const self = this; const config = this.menuConfig.config; async.waterfall( [ - function clearAndDisplayArt(callback) { - if (clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); + function readyAndDisplayArt(callback) { + if(options.clearScreen) { + self.client.term.rawWrite(ansi.clearScreen()); } - + theme.displayThemedAsset( - config.art.browse, + config.art[name], self.client, { font : self.menuConfig.font, trailingLF : false }, (err, artData) => { @@ -118,31 +243,66 @@ exports.getModule = class FileAreaList extends MenuModule { ); }, function prepeareViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.browse)) { - const vc = self.addViewController( - 'browse', - new ViewController( { client : self.client, formId : FormIds.browse } ) - ); + if(_.isUndefined(self.viewControllers[name])) { + const vcOpts = { + client : self.client, + formId : FormIds[name], + }; + + if(!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = self.addViewController(name, new ViewController(vcOpts)); + + if('details' === name) { + try { + self.detailsInfoArea = { + top : artData.mciMap.XY2.position, + bottom : artData.mciMap.XY3.position, + }; + } catch(e) { + return callback(Errors.DoesNotExist('Missing XY2 and XY3 position indicators!')); + } + } const loadOpts = { callingMenu : self, mciMap : artData.mciMap, - formId : FormIds.browse, + formId : FormIds[name], }; return vc.loadFromMenuConfig(loadOpts, callback); } + + self.viewControllers[name].setFocus(true); + return callback(null); + + }, + ], + err => { + return cb(err); + } + ); + } - self.viewControllers.view.setFocus(true); - self.viewControllers.view.getView(MciViewIds.view.BBSList).redraw(); + displayBrowsePage(clearScreen, cb) { + const self = this; - return callback(null); + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('browse', { clearScreen : clearScreen }, callback); }, function fetchEntryData(callback) { + if(self.fileList) { + return callback(null); + } return self.loadFileIds(callback); }, function loadCurrentFileInfo(callback) { self.currentFileEntry.load( self.fileList[ self.fileListPosition ], err => { + self.populateCurrentEntryInfo(); return callback(err); }); }, @@ -153,58 +313,164 @@ exports.getModule = class FileAreaList extends MenuModule { descView.setText(self.currentFileEntry.desc); } } - - const currEntry = self.currentFileEntry; - const uploadTimestampFormat = config.browseUploadTimestampFormat || config.uploadTimestampFormat || 'YYYY-MMM-DD'; - const area = FileArea.getFileAreaByTag(currEntry.areaTag); - const hashTagsSep = config.hashTagsSep || ', '; - - const entryInfo = { - fileId : currEntry.fileId, - areaTag : currEntry.areaTag, - areaName : area.name || 'N/A', - areaDesc : area.desc || 'N/A', - fileSha1 : currEntry.fileSha1, - fileName : currEntry.fileName, - desc : currEntry.desc, - descLong : currEntry.descLong, - uploadByUsername : currEntry.uploadByUsername, - uploadTimestamp : moment(currEntry.uploadTimestamp).format(uploadTimestampFormat), - hashTags : Array.from(currEntry.hashTags).join(hashTagsSep), - }; - // - // 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.getWellKnownMetaValues(); - metaValues.forEach(name => { - const value = currEntry.meta[name] || ''; - entryInfo[_.camelCase(name)] = value; + self.populateCustomLabels('browse', 10); + + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayDetailsPage(cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('details', { clearScreen : true }, callback); + }, + function populateViews(callback) { + self.populateCustomLabels('details', 10); + 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); + } }); - const userRatingChar = config.userRatingChar ? config.userRatingChar[0] : '*'; - if(_.isNumber(entryInfo.userRating)) { - entryInfo.userRatingString = new Array(entryInfo.userRating).join(userRatingChar); - } else { - entryInfo.userRatingString = ''; + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + 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 = paths.join(areaInfo.storageDirectory, this.currentFileEntry.fileName); + const archiveUtil = ArchiveUtil.getInstance(); + + archiveUtil.listEntries(filePath, this.currentFileEntry.entryInfo.archiveType, (err, entries) => { + if(err) { + return cb(err); + } + + this.currentFileEntry.archiveEntries = entries; + return cb(null, 're-cached'); + }); + } + + populateFileListing() { + const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList); + + if(this.currentFileEntry.entryInfo.archiveType) { + this.cacheArchiveEntries( (err, cacheStatus) => { + if(err) { + // :TODO: Handle me!!! + fileListView.setItems( [ 'Failed getting file listing' ] ); // :TODO: make this not suck + return; + } + + if('re-cached' === cacheStatus) { + const fileListEntryFormat = this.menuConfig.config.fileListEntryFormat || '{fileName} {fileSize}'; + const focusFileListEntryFormat = this.menuConfig.config.focusFileListEntryFormat || fileListEntryFormat; + + fileListView.setItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(fileListEntryFormat, entry) ) ); + fileListView.setFocusItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(focusFileListEntryFormat, entry) ) ); + + fileListView.redraw(); + } + }); + } else { + fileListView.setItems( [ stringFormat(this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName : this.currentFileEntry.fileName } ) ] ); + } + } + + displayDetailsSection(sectionName, clearArea, cb) { + const self = this; + const name = `details${_.capitalize(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)); } - // 10+ are custom textviews - let textView; - let customMciId = 10; + gotoTopPos(); - while( (textView = self.viewControllers.browse.getView(customMciId)) ) { - const key = `browseInfoFormat${customMciId}`; - const format = config[key]; + if(clearArea) { + self.client.term.rawWrite(ansi.reset()); - if(format) { - textView.setText(stringFormat(format, entryInfo)); + let pos = self.detailsInfoArea.top[0]; + const bottom = self.detailsInfoArea.bottom[0]; + + while(pos++ <= bottom) { + self.client.term.rawWrite(ansi.eraseLine() + ansi.down()); } - ++customMciId; + gotoTopPos(); } + return self.displayArtAndPrepViewController(name, { clearScreen : false, noInput : true }, callback); + }, + function populateViews(callback) { + self.lastDetailsViewController = self.viewControllers[name]; + + switch(sectionName) { + case 'nfo' : + { + const nfoView = self.viewControllers.detailsNfo.getView(MciViewIds.detailsNfo.nfo); + if(nfoView) { + nfoView.setText(self.currentFileEntry.entryInfo.descLong); + } + } + break; + + case 'fileList' : + self.populateFileListing(); + break; + } + + self.populateCustomLabels(name, 10); return callback(null); } ],