diff --git a/core/database.js b/core/database.js index 76c4d72b..6dca0101 100644 --- a/core/database.js +++ b/core/database.js @@ -214,6 +214,7 @@ const DB_INIT_TABLE = { );` ); + // :TODO: need SQL to ensure cleaned up if delete from message? /* dbs.message.run( @@ -335,6 +336,16 @@ const DB_INIT_TABLE = { );` ); + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_user_rating ( + file_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + rating INTEGER NOT NULL, + + UNIQUE(file_id, user_id) + );` + ); + dbs.file.run( `CREATE TABLE IF NOT EXISTS file_web_serve ( hash_id VARCHAR NOT NULL PRIMARY KEY, diff --git a/core/file_base_filter.js b/core/file_base_filter.js index 75938a26..801ae47e 100644 --- a/core/file_base_filter.js +++ b/core/file_base_filter.js @@ -12,7 +12,7 @@ module.exports = class FileBaseFilters { } static get OrderByValues() { - return [ 'ascending', 'descending' ]; + return [ 'descending', 'ascending' ]; } static get SortByValues() { @@ -116,7 +116,7 @@ module.exports = class FileBaseFilters { areaTag : '', // all terms : '', // * tags : '', // * - order : 'ascending', + order : 'descending', sort : 'upload_timestamp', uuid : uuid, }; diff --git a/core/file_entry.js b/core/file_entry.js index cb208008..57276807 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -26,7 +26,6 @@ const FILE_WELL_KNOWN_META = { est_release_year : (y) => parseInt(y) || new Date().getFullYear(), dl_count : (d) => parseInt(d) || 0, byte_size : (b) => parseInt(b) || 0, - user_rating : (r) => Math.min(parseInt(r) || 0, 5), archive_type : null, }; @@ -38,50 +37,61 @@ module.exports = class FileEntry { this.areaTag = options.areaTag || ''; this.meta = options.meta || { // values we always want - user_rating : 0, dl_count : 0, }; - + this.hashTags = options.hashTags || new Set(); this.fileName = options.fileName; this.storageTag = options.storageTag; } + static loadBasicEntry(fileId, dest, cb) { + if(!cb && _.isFunction(dest)) { + cb = dest; + dest = this; + } + + fileDb.get( + `SELECT ${FILE_TABLE_MEMBERS.join(', ')} + FROM file + WHERE file_id=? + LIMIT 1;`, + [ fileId ], + (err, file) => { + if(err) { + return cb(err); + } + + if(!file) { + return cb(Errors.DoesNotExist('No file is available by that ID')); + } + + // assign props from |file| + FILE_TABLE_MEMBERS.forEach(prop => { + dest[_.camelCase(prop)] = file[prop]; + }); + + return cb(null); + } + ); + } + load(fileId, cb) { const self = this; async.series( [ function loadBasicEntry(callback) { - fileDb.get( - `SELECT ${FILE_TABLE_MEMBERS.join(', ')} - FROM file - WHERE file_id=? - LIMIT 1;`, - [ fileId ], - (err, file) => { - if(err) { - return callback(err); - } - - if(!file) { - return callback(Errors.DoesNotExist('No file is available by that ID')); - } - - // assign props from |file| - FILE_TABLE_MEMBERS.forEach(prop => { - self[_.camelCase(prop)] = file[prop]; - }); - - return callback(null); - } - ); + FileEntry.loadBasicEntry(fileId, self, callback); }, function loadMeta(callback) { return self.loadMeta(callback); }, function loadHashTags(callback) { return self.loadHashTags(callback); + }, + function loadUserRating(callback) { + return self.loadRating(callback); } ], err => { @@ -156,10 +166,19 @@ module.exports = class FileEntry { return paths.join(storageDir, this.fileName); } + static persistUserRating(fileId, userId, rating, cb) { + return fileDb.run( + `REPLACE INTO file_user_rating (file_id, user_id, rating) + VALUES (?, ?, ?);`, + [ fileId, userId, rating ], + cb + ); + } + static persistMetaValue(fileId, name, value, cb) { - fileDb.run( + return fileDb.run( `REPLACE INTO file_meta (file_id, meta_name, meta_value) - VALUES(?, ?, ?);`, + VALUES (?, ?, ?);`, [ fileId, name, value ], cb ); @@ -243,6 +262,23 @@ module.exports = class FileEntry { ); } + loadRating(cb) { + fileDb.get( + `SELECT AVG(fur.rating) AS avg_rating + FROM file_user_rating fur + INNER JOIN file f + ON f.file_id = fur.file_id + AND f.file_id = ?`, + [ this.fileId ], + (err, result) => { + if(result) { + this.userRating = result.avg_rating; + } + return cb(err); + } + ); + } + setHashTags(hashTags) { if(_.isString(hashTags)) { this.hashTags = new Set(hashTags.split(/[\s,]+/)); @@ -264,7 +300,7 @@ module.exports = class FileEntry { const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; function getOrderByWithCast(ob) { - if( [ 'dl_count', 'user_rating', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) { + if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) { return `ORDER BY CAST(${ob} AS INTEGER)`; } @@ -290,11 +326,24 @@ module.exports = class FileEntry { sqlOrderBy = `${getOrderByWithCast('m.meta_value')} ${sqlOrderDir}`; } else { - sql = - `SELECT f.file_id, f.${filter.sort} - FROM file f`; + // additional special treatment for user ratings: we need to average them + if('user_rating' === filter.sort) { + sql = + `SELECT f.file_id, + (SELECT IFNULL(AVG(rating), 0) rating + FROM file_user_rating + WHERE file_id = f.file_id) + AS avg_rating + FROM file f`; + + sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`; + } else { + sql = + `SELECT f.file_id, f.${filter.sort} + FROM file f`; - sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir; + sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir; + } } } else { sql = diff --git a/core/menu_module.js b/core/menu_module.js index 2553335b..dfcf0589 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -166,7 +166,8 @@ exports.MenuModule = class MenuModule extends PluginModule { } getMenuResult() { - // nothing in base + // default to the formData that was provided @ a submit, if any + return this.submitFormData; } nextMenu(cb) { @@ -345,22 +346,42 @@ exports.MenuModule = class MenuModule extends PluginModule { ); } - pausePrompt(position, cb) { - if(!cb && _.isFunction(position)) { - cb = position; - position = null; - } - + optionalMoveToPosition(position) { if(position) { position.x = position.row || position.x || 1; position.y = position.col || position.y || 1; this.client.term.rawWrite(ansi.goto(position.x, position.y)); } - - return theme.displayThemedPause( { client : this.client }, cb); } + pausePrompt(position, cb) { + if(!cb && _.isFunction(position)) { + cb = position; + position = null; + } + + this.optionalMoveToPosition(position); + + return theme.displayThemedPause(this.client, cb); + } + + /* + :TODO: this needs quite a bit of work - but would be nice: promptForInput(..., (err, formData) => ... ) + promptForInput(formName, name, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + + options.viewController = this.viewControllers[formName]; + + this.optionalMoveToPosition(options.position); + + return theme.displayThemedPrompt(name, this.client, options, cb); + } + */ + setViewText(formName, mciId, text, appendMultiLine) { const view = this.viewControllers[formName].getView(mciId); if(!view) { diff --git a/core/string_util.js b/core/string_util.js index 5d953711..860b78d4 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -301,13 +301,17 @@ function renderStringLength(s) { const SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) function formatByteSizeAbbr(byteSize) { + if(0 === byteSize) { + return SIZE_ABBRS[0]; // B + } + return SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; } function formatByteSize(byteSize, withAbbr, decimals) { withAbbr = withAbbr || false; decimals = decimals || 3; - const i = Math.floor(Math.log(byteSize) / Math.log(1024)); + const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); if(withAbbr) { result += ` ${SIZE_ABBRS[i]}`; diff --git a/core/system_menu_method.js b/core/system_menu_method.js index 95a47a96..2439d7df 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -63,9 +63,15 @@ function logoff(callingMenu, formData, extraArgs, cb) { } function prevMenu(callingMenu, formData, extraArgs, cb) { + + // :TODO: this is a pretty big hack -- need the whole key map concep there like other places + if(formData.key && 'return' === formData.key.name) { + callingMenu.submitFormData = formData; + } + callingMenu.prevMenu( err => { if(err) { - callingMenu.client.log.error( { error : err.toString() }, 'Error attempting to fallback!'); + callingMenu.client.log.error( { error : err.message }, 'Error attempting to fallback!'); } return cb(err); }); @@ -74,7 +80,7 @@ function prevMenu(callingMenu, formData, extraArgs, cb) { function nextMenu(callingMenu, formData, extraArgs, cb) { callingMenu.nextMenu( err => { if(err) { - callingMenu.client.log.error( { error : err.toString() }, 'Error attempting to go to next menu!'); + callingMenu.client.log.error( { error : err.message}, 'Error attempting to go to next menu!'); } return cb(err); }); diff --git a/core/theme.js b/core/theme.js index 19ed9ff4..7dd9aa0a 100644 --- a/core/theme.js +++ b/core/theme.js @@ -9,6 +9,7 @@ const configCache = require('./config_cache.js'); 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 fs = require('fs'); const paths = require('path'); @@ -23,6 +24,7 @@ exports.setClientTheme = setClientTheme; exports.initAvailableThemes = initAvailableThemes; exports.displayThemeArt = displayThemeArt; exports.displayThemedPause = displayThemedPause; +exports.displayThemedPrompt = displayThemedPrompt; exports.displayThemedAsset = displayThemedAsset; function refreshThemeHelpers(theme) { @@ -484,110 +486,187 @@ function displayThemeArt(options, cb) { }); } +/* +function displayThemedPrompt(name, client, options, cb) { + + async.waterfall( + [ + function loadConfig(callback) { + configCache.getModConfig('prompt.hjson', (err, promptJson) => { + if(err) { + return callback(err); + } + + if(_.has(promptJson, [ 'prompts', name ] )) { + return callback(Errors.DoesNotExist(`Prompt "${name}" does not exist`)); + } + + const promptConfig = promptJson.prompts[name]; + if(!_.isObject(promptConfig)) { + return callback(Errors.Invalid(`Prompt "${name} is invalid`)); + } + + return callback(null, promptConfig); + }); + }, + function display(promptConfig, callback) { + if(options.clearScreen) { + client.term.rawWrite(ansi.clearScreen()); + } + + // + // If we did not clear the screen, don't let the font change + // + const dispOptions = Object.assign( {}, promptConfig.options ); + if(!options.clearScreen) { + dispOptions.font = 'not_really_a_font!'; + } + + displayThemedAsset( + promptConfig.art, + client, + dispOptions, + (err, artData) => { + if(err) { + return callback(err); + } + + return callback(null, promptConfig, artData.mciMap); + } + ); + }, + function prepViews(promptConfig, mciMap, callback) { + vc = new ViewController( { client : client } ); + + const loadOpts = { + promptName : name, + mciMap : mciMap, + config : promptConfig, + }; + + vc.loadFromPromptConfig(loadOpts, err => { + callback(null); + }); + } + ] + ); +} +*/ + +function displayThemedPrompt(name, client, options, cb) { + + const useTempViewController = _.isUndefined(options.viewController); + + async.waterfall( + [ + function display(callback) { + const promptConfig = client.currentTheme.prompts[name]; + if(!promptConfig) { + return callback(Errors.DoesNotExist(`Missing "${name}" prompt configuration!`)); + } + + if(options.clearScreen) { + client.term.rawWrite(ansi.clearScreen()); + } + + // + // 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 + // + const dispOptions = Object.assign( {}, promptConfig.options ); + if(!options.clearScreen) { + dispOptions.font = 'not_really_a_font!'; // kludge :) + } + + displayThemedAsset( + promptConfig.art, + client, + dispOptions, + (err, artInfo) => { + return callback(err, promptConfig, artInfo); + } + ); + }, + function discoverCursorPosition(promptConfig, artInfo, callback) { + if(!options.clearPrompt) { + // no need to query cursor - we're not gonna use it + return callback(null, promptConfig, artInfo); + } + + client.once('cursor position report', pos => { + artInfo.startRow = pos[0] - artInfo.height; + return callback(null, promptConfig, artInfo); + }); + + client.term.rawWrite(ansi.queryPos()); + }, + function createMCIViews(promptConfig, artInfo, callback) { + const tempViewController = useTempViewController ? new ViewController( { client : client } ) : options.viewController; + + const loadOpts = { + promptName : name, + mciMap : artInfo.mciMap, + config : promptConfig, + }; + + tempViewController.loadFromPromptConfig(loadOpts, () => { + return callback(null, artInfo, tempViewController); + }); + }, + function pauseForUserInput(artInfo, tempViewController, callback) { + if(!options.pause) { + return callback(null, artInfo, tempViewController); + } + + client.waitForKeyPress( () => { + return callback(null, artInfo, tempViewController); + }); + }, + function clearPauseArt(artInfo, tempViewController, callback) { + if(options.clearPrompt) { + if(artInfo.startRow && artInfo.height) { + client.term.rawWrite(ansi.goto(artInfo.startRow, 1)); + + // Note: Does not work properly in NetRunner < 2.0b17: + client.term.rawWrite(ansi.deleteLine(artInfo.height)); + } else { + client.term.rawWrite(ansi.eraseLine(1)); + } + } + + return callback(null, tempViewController); + } + ], + (err, tempViewController) => { + if(err) { + client.log.warn( { error : err.message }, `Failed displaying "${name}" prompt` ); + } + + if(tempViewController && useTempViewController) { + tempViewController.detachClientEvents(); + } + + return cb(null); + } + ); +} + // // Pause prompts are a special prompt by the name 'pause'. // -function displayThemedPause(options, cb) { - // - // options.client - // options clearPrompt - // - assert(_.isObject(options.client)); +function displayThemedPause(client, options, cb) { + + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } if(!_.isBoolean(options.clearPrompt)) { options.clearPrompt = true; } - // :TODO: Support animated pause prompts. Probably via MCI with AnimatedView - - var artInfo; - var vc; - var promptConfig; - - async.series( - [ - function loadPromptJSON(callback) { - configCache.getModConfig('prompt.hjson', function loaded(err, promptJson) { - if(err) { - callback(err); - } else { - if(_.has(promptJson, [ 'prompts', 'pause' ] )) { - promptConfig = promptJson.prompts.pause; - callback(_.isObject(promptConfig) ? null : new Error('Invalid prompt config block!')); - } else { - callback(new Error('Missing standard \'pause\' prompt')); - } - } - }); - }, - function displayPausePrompt(callback) { - // - // Override .font so it doesn't change from current setting - // - var dispOptions = promptConfig.options; - dispOptions.font = 'not_really_a_font!'; - - displayThemedAsset( - promptConfig.art, - options.client, - dispOptions, - function displayed(err, artData) { - artInfo = artData; - callback(err); - } - ); - }, - function discoverCursorPosition(callback) { - options.client.once('cursor position report', function cpr(pos) { - artInfo.startRow = pos[0] - artInfo.height; - callback(null); - }); - options.client.term.rawWrite(ansi.queryPos()); - }, - function createMCIViews(callback) { - vc = new ViewController( { client : options.client, noInput : true } ); - vc.loadFromPromptConfig( { promptName : 'pause', mciMap : artInfo.mciMap, config : promptConfig }, function loaded(err) { - callback(null); - }); - }, - function pauseForUserInput(callback) { - options.client.waitForKeyPress(function keyPressed() { - callback(null); - }); - }, - function clearPauseArt(callback) { - if(options.clearPrompt) { - if(artInfo.startRow && artInfo.height) { - options.client.term.rawWrite(ansi.goto(artInfo.startRow, 1)); - - // Note: Does not work properly in NetRunner < 2.0b17: - options.client.term.rawWrite(ansi.deleteLine(artInfo.height)); - } else { - options.client.term.rawWrite(ansi.eraseLine(1)) - } - } - callback(null); - } - /* - , function debugPause(callback) { - setTimeout(function to() { - callback(null); - }, 4000); - } - */ - ], - function complete(err) { - if(err) { - Log.error(err); - } - - if(vc) { - vc.detachClientEvents(); - } - - cb(); - } - ); + const promptOptions = Object.assign( {}, options, { pause : true } ); + return displayThemedPrompt('pause', client, promptOptions, cb); } function displayThemedAsset(assetSpec, client, options, cb) { diff --git a/core/view_controller.js b/core/view_controller.js index 51c63b3a..ecf36be5 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -184,52 +184,52 @@ function ViewController(options) { propAsset = asset.getViewPropertyAsset(conf[propName]); if(propAsset) { switch(propAsset.type) { - case 'config' : - propValue = asset.resolveConfigAsset(conf[propName]); - break; - - case 'sysStat' : - propValue = asset.resolveSystemStatAsset(conf[propName]); - break; + case 'config' : + propValue = asset.resolveConfigAsset(conf[propName]); + break; + + case 'sysStat' : + propValue = asset.resolveSystemStatAsset(conf[propName]); + break; - // :TODO: handle @art (e.g. text : @art ...) + // :TODO: handle @art (e.g. text : @art ...) - case 'method' : - case 'systemMethod' : - if('validate' === propName) { - // :TODO: handle propAsset.location for @method script specification - if('systemMethod' === propAsset.type) { - // :TODO: implementation validation @systemMethod handling! - var methodModule = require(paths.join(__dirname, 'system_view_validate.js')); - if(_.isFunction(methodModule[propAsset.asset])) { - propValue = methodModule[propAsset.asset]; - } - } else { - if(_.isFunction(self.client.currentMenuModule.menuMethods[propAsset.asset])) { - propValue = self.client.currentMenuModule.menuMethods[propAsset.asset]; - } - } - } else { - if(_.isString(propAsset.location)) { - - } else { + case 'method' : + case 'systemMethod' : + if('validate' === propName) { + // :TODO: handle propAsset.location for @method script specification if('systemMethod' === propAsset.type) { - // :TODO: + // :TODO: implementation validation @systemMethod handling! + var methodModule = require(paths.join(__dirname, 'system_view_validate.js')); + if(_.isFunction(methodModule[propAsset.asset])) { + propValue = methodModule[propAsset.asset]; + } } else { - // local to current module - var currentModule = self.client.currentMenuModule; - if(_.isFunction(currentModule.menuMethods[propAsset.asset])) { - // :TODO: Fix formData & extraArgs... this all needs general processing - propValue = currentModule.menuMethods[propAsset.asset]({}, {});//formData, conf.extraArgs); + if(_.isFunction(self.client.currentMenuModule.menuMethods[propAsset.asset])) { + propValue = self.client.currentMenuModule.menuMethods[propAsset.asset]; + } + } + } else { + if(_.isString(propAsset.location)) { + + } else { + if('systemMethod' === propAsset.type) { + // :TODO: + } else { + // local to current module + var currentModule = self.client.currentMenuModule; + if(_.isFunction(currentModule.menuMethods[propAsset.asset])) { + // :TODO: Fix formData & extraArgs... this all needs general processing + propValue = currentModule.menuMethods[propAsset.asset]({}, {});//formData, conf.extraArgs); + } } } } - } - break; + break; - default : - propValue = propValue = conf[propName]; - break; + default : + propValue = propValue = conf[propName]; + break; } } else { propValue = conf[propName]; @@ -601,6 +601,33 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) { callback(null); }, + function loadActionKeys(callback) { + if(!_.isObject(promptConfig) || !_.isArray(promptConfig.actionKeys)) { + return callback(null); + } + + promptConfig.actionKeys.forEach(ak => { + // + // * 'keys' must be present and be an array of key names + // * If 'viewId' is present, key(s) will focus & submit on behalf + // of the specified view. + // * If 'action' is present, that action will be procesed when + // triggered by key(s) + // + // Ultimately, create a map of key -> { action block } + // + if(!_.isArray(ak.keys)) { + return; + } + + ak.keys.forEach(kn => { + self.actionKeyMap[kn] = ak; + }); + + }); + + return callback(null); + }, function drawAllViews(callback) { self.redrawAll(initialFocusId); callback(null); diff --git a/mods/abracadabra.js b/mods/abracadabra.js index 4b9afdbf..a84d2c63 100644 --- a/mods/abracadabra.js +++ b/mods/abracadabra.js @@ -99,8 +99,7 @@ exports.getModule = class AbracadabraModule extends MenuModule { if(_.isString(self.config.tooManyArt)) { theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() { - // :TODO: Use MenuModule.pausePrompt() - theme.displayThemedPause( { client : self.client }, function keyPressed() { + self.pausePrompt( () => { callback(new Error('Too many active instances')); }); }); @@ -108,7 +107,7 @@ exports.getModule = class AbracadabraModule extends MenuModule { self.client.term.write('\nToo many active instances. Try again later.\n'); // :TODO: Use MenuModule.pausePrompt() - theme.displayThemedPause( { client : self.client }, function keyPressed() { + self.pausePrompt( () => { callback(new Error('Too many active instances')); }); } diff --git a/mods/file_area_filter_edit.js b/mods/file_area_filter_edit.js index 5cb211ac..00d13a2e 100644 --- a/mods/file_area_filter_edit.js +++ b/mods/file_area_filter_edit.js @@ -89,7 +89,7 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule { }, newFilter : (formData, extraArgs, cb) => { this.currentFilterIndex = this.filtersArray.length; // next avail slot - this.clearForm(true); // true=reset focus + this.clearForm(MciViewIds.editor.searchTerms); return cb(null); }, deleteFilter : (formData, extraArgs, cb) => { @@ -115,10 +115,12 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule { } // update UI + this.updateActiveLabel(); + if(this.filtersArray.length > 0) { this.loadDataForFilter(this.currentFilterIndex); } else { - this.clearForm(true); // true=reset focus + this.clearForm(); } return cb(null); }); @@ -203,7 +205,7 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule { } } - clearForm(setFocus) { + clearForm(newFocusId) { [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => { this.setText(mciId, ''); }); @@ -212,7 +214,9 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule { this.setFocusItemIndex(mciId, 0); }); - if(setFocus) { + if(newFocusId) { + this.viewControllers.editor.switchFocus(newFocusId); + } else { this.viewControllers.editor.resetInitialFocus(); } } diff --git a/mods/file_area_list.js b/mods/file_area_list.js index b946497b..2f590a1f 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -78,9 +78,17 @@ exports.getModule = class FileAreaList extends MenuModule { this.dlQueue = new DownloadQueue(this.client); - this.filterCriteria = this.filterCriteria || { - // :TODO: set area tag - all in current area by default - }; + 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) => { @@ -116,7 +124,7 @@ exports.getModule = class FileAreaList extends MenuModule { }, showWebDownloadLink : (formData, extraArgs, cb) => { return this.fetchAndDisplayWebDownloadLink(cb); - }, + } }; } @@ -128,11 +136,46 @@ exports.getModule = class FileAreaList extends MenuModule { 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); }, @@ -165,11 +208,12 @@ exports.getModule = class FileAreaList extends MenuModule { 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(this.currentFileEntry) ? isQueuedIndicator : isNotQueuedIndicator, webDlLink : '', // :TODO: fetch web any existing web d/l link - webDlExpire : '', // :TODO: fetch web d/l link expire time + webDlExpire : '', // :TODO: fetch web d/l link expire time }; // @@ -196,7 +240,7 @@ exports.getModule = class FileAreaList extends MenuModule { // create a rating string, e.g. "**---" const userRatingTicked = config.userRatingTicked || '*'; const userRatingUnticked = config.userRatingUnticked || ''; - entryInfo.userRating = entryInfo.userRating || 0; // be safe! + entryInfo.userRating = ~~Math.round(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); @@ -297,7 +341,7 @@ exports.getModule = class FileAreaList extends MenuModule { if(self.fileList) { return callback(null); } - return self.loadFileIds(callback); + return self.loadFileIds(false, callback); // false=do not force }, function loadCurrentFileInfo(callback) { self.currentFileEntry = new FileEntry(); @@ -566,14 +610,13 @@ exports.getModule = class FileAreaList extends MenuModule { ); } - loadFileIds(cb) { - this.fileListPosition = 0; - const activeFilter = FileBaseFilters.getActiveFilter(this.client); - - FileEntry.findFiles(activeFilter, (err, fileIds) => { - this.fileList = fileIds; - return cb(err); - }); + loadFileIds(force, cb) { + if(force || (_.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition))) { + this.fileListPosition = 0; + FileEntry.findFiles(this.filterCriteria, (err, fileIds) => { + this.fileList = fileIds; + return cb(err); + }); + } } - }; diff --git a/mods/file_base_search.js b/mods/file_base_search.js new file mode 100644 index 00000000..87c127cd --- /dev/null +++ b/mods/file_base_search.js @@ -0,0 +1,119 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('../core/menu_module.js').MenuModule; +const ViewController = require('../core/view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('../core/file_base_filter.js'); + +// deps +const async = require('async'); + +exports.moduleInfo = { + name : 'File Base Search', + desc : 'Module for quickly searching the file base', + author : 'NuSkooler', +}; + +const MciViewIds = { + search : { + searchTerms : 1, + search : 2, + tags : 3, + area : 4, + orderBy : 5, + sort : 6, + advSearch : 7, + } +}; + +exports.getModule = class FileBaseSearch extends MenuModule { + constructor(options) { + super(options); + + this.menuMethods = { + search : (formData, extraArgs, cb) => { + const isAdvanced = formData.submitId === MciViewIds.search.advSearch; + return this.searchNow(formData, isAdvanced, cb); + }, + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'search', new ViewController( { client : this.client } ) ); + + async.series( + [ + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function populateAreas(callback) { + self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); + + const areasView = vc.getView(MciViewIds.search.area); + if(areasView) { + areasView.setItems( self.availAreas.map( a => a.name ) ); + } + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getSelectedAreaTag(index) { + if(0 === index) { + return ''; // -ALL- + } + const area = this.availAreas[index]; + if(!area) { + return ''; + } + return area.areaTag; + } + + getOrderBy(index) { + return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; + } + + getSortBy(index) { + return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; + } + + getFilterValuesFromFormData(formData, isAdvanced) { + const areaIndex = isAdvanced ? formData.value.areaIndex : 0; + const orderByIndex = isAdvanced ? formData.value.orderByIndex : 0; + const sortByIndex = isAdvanced ? formData.value.sortByIndex : 0; + + return { + areaTag : this.getSelectedAreaTag(areaIndex), + terms : formData.value.searchTerms, + tags : isAdvanced ? formData.value.tags : '', + order : this.getOrderBy(orderByIndex), + sort : this.getSortBy(sortByIndex), + }; + } + + searchNow(formData, isAdvanced, cb) { + const filterCriteria = this.getFilterValuesFromFormData(formData, isAdvanced); + + const menuOpts = { + extraArgs : { + filterCriteria : filterCriteria, + } + }; + + return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); + } +}; diff --git a/mods/msg_area_list.js b/mods/msg_area_list.js index cb8d14bb..a6a0df4c 100644 --- a/mods/msg_area_list.js +++ b/mods/msg_area_list.js @@ -6,7 +6,6 @@ const MenuModule = require('../core/menu_module.js').MenuModule; const ViewController = require('../core/view_controller.js').ViewController; const messageArea = require('../core/message_area.js'); const displayThemeArt = require('../core/theme.js').displayThemeArt; -const displayThemedPause = require('../core/theme.js').displayThemedPause; const resetScreen = require('../core/ansi_term.js').resetScreen; const stringFormat = require('../core/string_format.js'); @@ -76,8 +75,7 @@ exports.getModule = class MessageAreaListModule extends MenuModule { if(_.has(area, 'options.pause') && false === area.options.pause) { return self.prevMenuOnTimeout(1000, cb); } else { - // :TODO: Use MenuModule.pausePrompt() - displayThemedPause( { client : self.client }, () => { + self.pausePrompt( () => { return self.prevMenu(cb); }); } diff --git a/mods/msg_conf_list.js b/mods/msg_conf_list.js index 0d6b6202..91c24de4 100644 --- a/mods/msg_conf_list.js +++ b/mods/msg_conf_list.js @@ -6,7 +6,6 @@ const MenuModule = require('../core/menu_module.js').MenuModule; const ViewController = require('../core/view_controller.js').ViewController; const messageArea = require('../core/message_area.js'); const displayThemeArt = require('../core/theme.js').displayThemeArt; -const displayThemedPause = require('../core/theme.js').displayThemedPause; const resetScreen = require('../core/ansi_term.js').resetScreen; const stringFormat = require('../core/string_format.js'); @@ -63,8 +62,7 @@ exports.getModule = class MessageConfListModule extends MenuModule { if(_.has(conf, 'options.pause') && false === conf.options.pause) { return self.prevMenuOnTimeout(1000, cb); } else { - // :TODO: Use MenuModule.pausePrompt() - displayThemedPause( { client : self.client }, () => { + self.pausePrompt( () => { return self.prevMenu(cb); }); } diff --git a/mods/prompt.hjson b/mods/prompt.hjson index 3dac7326..9083f5d4 100644 --- a/mods/prompt.hjson +++ b/mods/prompt.hjson @@ -1,8 +1,34 @@ { + /* + ./\/\.' ENiGMA½ Prompt Configuration -/--/-------- - -- - + + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + + ------------------------------------------------------------------------------- + + This configuration is in HJSON (http://hjson.org/) format. Strict to-spec + JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON. + + See http://hjson.org/ for more information and syntax. + + + If you haven't yet, copy the conents of this file to something like + sick_board_prompt.hjson. Point to it via config.hjson using the + 'general.promptFile' key: + + general: { promptFile: "sick_board_prompt.hjson" } + + */ // :TODO: this entire file needs cleaned up a LOT // :TODO: Convert all of this to HJSON - "prompts" : { - "userCredentials" : { + prompts: { + userCredentials: { "art" : "usercred", "mci" : { "ET1" : { @@ -106,6 +132,25 @@ } } }, + + // File Base Related + fileBaseRateEntryPrompt: { + art: RATEFILE + mci: { + SM1: { + argName: rating + items: [ "-----", "*----", "**---", "***--", "****-", "*****" ] + } + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + /////////////////////////////////////////////////////////////////////// // Standard / Required /////////////////////////////////////////////////////////////////////// diff --git a/mods/upload.js b/mods/upload.js index d06b1fa6..f2bbe888 100644 --- a/mods/upload.js +++ b/mods/upload.js @@ -7,11 +7,14 @@ const stringFormat = require('../core/string_format.js'); const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; const getAreaDefaultStorageDirectory = require('../core/file_base_area.js').getAreaDefaultStorageDirectory; const scanFile = require('../core/file_base_area.js').scanFile; +const getFileAreaByTag = require('../core/file_base_area.js').getFileAreaByTag; const ansiGoto = require('../core/ansi_term.js').goto; const moveFileWithCollisionHandling = require('../core/file_util.js').moveFileWithCollisionHandling; const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTerminatingSeparator; 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'); @@ -30,7 +33,7 @@ const FormIds = { options : 0, processing : 1, fileDetails : 2, - + dupes : 3, }; const MciViewIds = { @@ -56,6 +59,10 @@ const MciViewIds = { estYear : 3, accept : 4, // accept fields & continue customRangeStart : 10, // 10+ = customs + }, + + dupes : { + dupeList : 1, } }; @@ -161,17 +168,6 @@ exports.getModule = class UploadModule extends MenuModule { } } - leave() { - // remove any temp files - only do this when - if(this.isFileTransferComplete()) { - temptmp.cleanup( paths => { - Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); - }); - } - - super.leave(); - } - performUpload(cb) { temptmp.mkdir( { prefix : 'enigul-' }, (err, tempRecvDirectory) => { if(err) { @@ -341,6 +337,12 @@ exports.getModule = class UploadModule extends MenuModule { }); } + cleanupTempFiles() { + temptmp.cleanup( paths => { + Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); + }); + } + moveAndPersistUploadsToDatabase(newEntries) { const areaStorageDir = getAreaDefaultStorageDirectory(this.areaInfo); @@ -370,6 +372,11 @@ exports.getModule = class UploadModule extends MenuModule { return nextEntry(null); // still try next file }); }); + }, () => { + // + // Finally, we can remove any temp files that we may have created + // + self.cleanupTempFiles(); }); } @@ -401,6 +408,75 @@ exports.getModule = class UploadModule extends MenuModule { }); } + displayDupesPage(dupes, cb) { + // + // If we have custom art to show, use it - else just dump basic info. + // Pause at the end in either case. + // + const self = this; + + async.waterfall( + [ + function prepArtAndViewController(callback) { + self.prepViewControllerWithArt( + 'dupes', + FormIds.dupes, + { clearScreen : true, trailingLF : false }, + err => { + if(err) { + self.client.term.pipeWrite('|00|07Duplicate upload(s) found:\n'); + return callback(null, null); + } + + const dupeListView = self.viewControllers.dupes.getView(MciViewIds.dupes.dupeList); + return callback(null, dupeListView); + } + ); + }, + function prepDupeObjects(dupeListView, callback) { + // update dupe objects with additional info that can be used for formatString() and the like + async.each(dupes, (dupe, nextDupe) => { + FileEntry.loadBasicEntry(dupe.fileId, dupe, err => { + if(err) { + return nextDupe(err); + } + + const areaInfo = getFileAreaByTag(dupe.areaTag); + if(areaInfo) { + dupe.areaName = areaInfo.name; + dupe.areaDesc = areaInfo.desc; + } + return nextDupe(null); + }); + }, err => { + return callback(err, dupeListView); + }); + }, + function populateDupeInfo(dupeListView, callback) { + const dupeInfoFormat = self.menuConfig.config.dupeInfoFormat || '{fileName} @ {areaName}'; + + dupes.forEach(dupe => { + const formatted = stringFormat(dupeInfoFormat, dupe); + if(dupeListView) { + // dupesInfoFormatX + dupeListView.addText(enigmaToAnsi(formatted)); + } else { + self.client.term.pipeWrite(`${formatted}\n`); + } + }); + + return callback(null); + }, + function pause(callback) { + return self.pausePrompt( { row : self.client.term.termHeight }, callback); + } + ], + err => { + return cb(err); + } + ); + } + processUploadedFiles() { // // For each file uploaded, we need to process & gather information @@ -437,15 +513,16 @@ exports.getModule = class UploadModule extends MenuModule { self.pausePrompt( () => { return callback(null, scanResults); - }); + }); }, function displayDupes(scanResults, callback) { if(0 === scanResults.dupes.length) { return callback(null, scanResults); } - // :TODO: display dupe info - return callback(null, scanResults); + return self.displayDupesPage(scanResults.dupes, () => { + return callback(null, scanResults); + }); }, function prepDetails(scanResults, callback) { return self.prepDetailsForUpload(scanResults, callback); @@ -463,6 +540,7 @@ exports.getModule = class UploadModule extends MenuModule { err => { if(err) { self.client.log.warn('File upload error encountered', { error : err.message } ); + self.cleanupTempFiles(); // normally called after moveAndPersistUploadsToDatabase() is completed. } return self.prevMenu(); @@ -565,13 +643,16 @@ exports.getModule = class UploadModule extends MenuModule { tagsView.setText( Array.from(fileEntry.hashTags).join(',') ); // :TODO: optional 'hashTagsSep' like file list/browse yearView.setText(fileEntry.meta.est_release_year || ''); - + if(self.fileEntryHasDetectedDesc(fileEntry)) { - descView.setText(fileEntry.desc); descView.setPropertyValue('mode', 'preview'); - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.tags); + descView.setText(fileEntry.desc); descView.acceptsFocus = false; + self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.tags); } else { + descView.setPropertyValue('mode', 'edit'); + descView.setText(''); + descView.acceptsFocus = true; self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.desc); }