diff --git a/xtrn/ddfilelister/ddfilelister.cfg b/xtrn/ddfilelister/ddfilelister.cfg index 82ffbec134ad9dc1ee6e83e5f4ea06b6aae55862..76d0aa70cacd6230e6581a21d67912ee6d63ff09 100644 --- a/xtrn/ddfilelister/ddfilelister.cfg +++ b/xtrn/ddfilelister/ddfilelister.cfg @@ -27,5 +27,9 @@ pauseAfterViewingFile=true ; won't display it after the lister exits blankNFilesListedStrIfLoadableModule=true +; Whether or not to display uploader avatars in extended +; information for files +displayUserAvatars=true + ; The name of the color theme configuration file themeFilename=defaultTheme.cfg diff --git a/xtrn/ddfilelister/ddfilelister.js b/xtrn/ddfilelister/ddfilelister.js index 544cca82b85287f335ca75dbb00d0e63d9a4682e..fe7c524fee59976489dfa5f9e977c36625a49cba 100644 --- a/xtrn/ddfilelister/ddfilelister.js +++ b/xtrn/ddfilelister/ddfilelister.js @@ -132,21 +132,24 @@ * 2024-10-29 Eric Oulashin Version 2.24a * When doing a file search, don't call console.pause() between directories. * This is a fix for issue 806 (reported by nelgin). + * 2024-10-30 Eric Oulashin Version 2.25 Beta + * Made 'view file' (FL_VIEW) work when used as a loadable module. + * Refactored some stuff in the process. + * 2024-10-31 Eric Oulashin Version 2.25 + * Finished up the 'view file' update. Also refactored the way + * file extended info is displayed - Added information to match + * Synchronet's stock lister, and display the uploader's avatar + * if available */ "use strict"; -//js.on_exit("console.ctrlkey_passthru = " + console.ctrlkey_passthru); -//console.ctrlkey_passthru |= (1<<3); // Ctrl-C -//console.ctrlkey_passthru = "+C"; -//console.ctrlkey_passthru = "-C"; - // If the search action has been aborted, then return -1 if (console.aborted) exit(-1); -// This script requires Synchronet version 3.20 or newer (only for bbs.Text.<value>); -// Synchronet 3.19 added filebase support in JS. +// This script requires Synchronet version 3.20 or newer (for bbs.Text.<value> and +// user.stats.download_cps); Synchronet 3.19 added filebase support in JS. // If the Synchronet version is below the minimum, then exit. if (system.version_num < 32000) { @@ -174,11 +177,13 @@ require("frame.js", "Frame"); require("scrollbar.js", "ScrollBar"); require("mouse_getkey.js", "mouse_getkey"); require("attr_conv.js", "convertAttrsToSyncPerSysCfg"); +require("file_size.js", "file_size_str"); +var gAvatar = load({}, "avatar_lib.js"); -// Lister version information -var LISTER_VERSION = "2.24a"; -var LISTER_DATE = "2024-10-29"; +// Version information +var LISTER_VERSION = "2.25"; +var LISTER_DATE = "2024-10-31"; /////////////////////////////////////////////////////////////////////////////// @@ -202,6 +207,9 @@ var BYTES_PER_GB = 1073741824; var BYTES_PER_MB = 1048576; var BYTES_PER_KB = 1024; +// Decimal precision for file_size_str() +var FILE_SIZE_PRECISION = 2; + // File list column indexes (0-based). The end indexes are one past the last index. // These defaults assume an 80-character wide terminal. var gListIdxes = { @@ -275,6 +283,7 @@ var MODE_LIST_DIR = 1; var MODE_SEARCH_FILENAME = 2; var MODE_SEARCH_DESCRIPTION = 3; var MODE_NEW_FILE_SEARCH = 4; +var MODE_VIEWING_FILES = 5; // Sort orders (not included in FileBase.SORT) var SORT_PER_DIR_CFG = 50; // Sort according to the file directory configuration @@ -314,6 +323,10 @@ var gUseLightbarInterface = true; // file lister instead of ddfilelister var gTraditionalUseSyncStock = false; +// Whether or not to display user avatars for the uploader in extended +// file information +var gDispayUserAvatars = true; + /////////////////////////////////////////////////////////////////////////////// // Script execution code @@ -354,6 +367,19 @@ if ((!gUseLightbarInterface || !console.term_supports(USER_ANSI)) && gTraditiona exit(exitCode); } +// Date/time format string (depends on system settings for date format & military time) +const gTimeFormatStr = getTimeFormatStr(); + +// If we are to view file(s), then do so for this file directory +// and exit +if (Boolean(gListBehavior & FL_VIEW)) +{ + exit(doFileView(gDirCode, gFilespec)); +} + +/////////////////////////////////////////// +// The following assumes normal file listing mode & other modes (not FL_VIEW) + // This array will contain file metadata objects var gFileList = []; @@ -1050,11 +1076,6 @@ function showFileInfo_ANSI(pFileMetadata) if (pFileMetadata == undefined || typeof(pFileMetadata) !== "object") return retObj; - // The width of the frame to display the file info (including borders). This - // is declared early so that it can be used for string length adjustment. - //var frameWidth = pFileListMenu.size.width - 4; // TODO: Remove? - var frameWidth = console.screen_columns - 4; - // pFileList[pFileListMenu.selectedItemIdx] has a file metadata object without // extended information. Get a metadata object with extended information so we // can display the extended description. @@ -1073,45 +1094,145 @@ function showFileInfo_ANSI(pFileMetadata) else fileMetadata = pFileMetadata; } - // Build a string with the file information - // Make sure the displayed filename isn't too crazy long - var frameInnerWidth = frameWidth - 2; // Without borders - var adjustedFilename = shortenFilename(fileMetadata.name, frameInnerWidth, false); - var fileInfoStr = "\x01n\x01wFilename"; - if (adjustedFilename.length < fileMetadata.name.length) - fileInfoStr += " (shortened)"; - fileInfoStr += ":\r\n"; - fileInfoStr += gColors.filename + adjustedFilename + "\x01n\x01w\r\n"; - // Note: File size can also be retrieved by calling a FileBase's get_size(fileMetadata.name) - // TODO: Shouldn't need the max length here - fileInfoStr += "Size: " + gColors.fileSize + getFileSizeStr(fileMetadata.size, 99999) + "\x01n\x01w\r\n"; - fileInfoStr += "Timestamp: " + gColors.fileTimestamp + strftime("%Y-%m-%d %H:%M:%S", fileMetadata.time) + "\x01n\x01w\r\n"; - fileInfoStr += "\r\n"; + // The width of the frame to display the file info (including borders). This + // is declared early so that it can be used for string length adjustment. + //var frameWidth = pFileListMenu.size.width - 4; // TODO: Remove? + var frameWidth = console.screen_columns - 4; + var frameInnerWidth = frameWidth - 2; // Without borders + // Build a string with the file information // File library/directory information var libIdx = file_area.dir[dirCode].lib_index; var dirIdx = file_area.dir[dirCode].index; var libDesc = file_area.lib_list[libIdx].description; var dirDesc = file_area.dir[dirCode].description; - fileInfoStr += format("\x01c\x01h%s\x01g: \x01n\x01c%s\x01n\x01w\r\n", "Lib", libDesc.substr(0, frameInnerWidth-5)); + var fileInfoStr = format("\x01c\x01h%s\x01g: \x01n\x01c%s\x01n\x01w\r\n", "Lib", libDesc.substr(0, frameInnerWidth-5)); fileInfoStr += format("\x01c\x01h%s\x01g: \x01n\x01c%s\x01n\x01w\r\n", "Dir", dirDesc.substr(0, frameInnerWidth-5)); fileInfoStr += "\r\n"; - - // fileMetadata should have extdDesc, but check just in case - var fileDesc = ""; - if (fileMetadata.hasOwnProperty("extdesc") && fileMetadata.extdesc.length > 0) - fileDesc = fileMetadata.extdesc; + // Make sure the displayed filename isn't too crazy long + var adjustedFilename = shortenFilename(fileMetadata.name, frameInnerWidth, false); + fileInfoStr += "\x01n\x01wFilename"; + if (adjustedFilename.length < fileMetadata.name.length) + fileInfoStr += " (shortened)"; + fileInfoStr += ":\r\n"; + fileInfoStr += gColors.filename + adjustedFilename + "\x01n\x01w\r\n"; + // Note: File size can also be retrieved by calling a FileBase's get_size(fileMetadata.name) + var fileSizeStr = format("%s (%s) bytes", numberWithCommas(fileMetadata.size), file_size_str(fileMetadata.size, null, FILE_SIZE_PRECISION)); + fileInfoStr += "Size: " + gColors.fileSize + fileSizeStr + "\x01n\x01w"; + // Credit value + var fieldFormatStr = "\r\n\x01n\x01c\x01h%s\x01g:\x01n\x01c %s"; + var dirFilesAreFree = Boolean(file_area.dir[dirCode].settings & DIR_FREE); + var creditStr = dirFilesAreFree || fileMetadata.cost == 0 ? "FREE" : fileMetadata.cost.toString(); + fileInfoStr += format(fieldFormatStr, "Credit value", lfexpand(word_wrap(creditStr, frameInnerWidth)).replace(/\r\n$/, "")); + // CRC-32, MD5, SHA-1 + if (fileMetadata.hasOwnProperty("crc32")) + { + var str = lfexpand(word_wrap(fileMetadata.crc32, frameInnerWidth)).replace(/\r\n$/, ""); + fileInfoStr += format(fieldFormatStr, "File CRC32", str); + } + if (fileMetadata.hasOwnProperty("md5")) + { + str = lfexpand(word_wrap(fileMetadata.md5, frameInnerWidth)).replace(/\r\n$/, ""); + fileInfoStr += format(fieldFormatStr, "File MD5", str); + } + if (fileMetadata.hasOwnProperty("sha1")) + { + str = lfexpand(word_wrap(fileMetadata.sha1, frameInnerWidth)).replace(/\r\n$/, ""); + fileInfoStr += format(fieldFormatStr, "File SHA-1", str); + } + // Short description + str = lfexpand(word_wrap(fileMetadata.desc, frameInnerWidth)).replace(/\r\n$/, ""); + fileInfoStr += format(fieldFormatStr, "Description", str); + // Author, group + var authorStr = fileMetadata.hasOwnProperty("author") ? fileMetadata.author : ""; + fileInfoStr += format(fieldFormatStr, "Author", lfexpand(word_wrap(authorStr, frameInnerWidth)).replace(/\r\n$/, "")); + var groupStr = fileMetadata.hasOwnProperty("author_org") ? fileMetadata.author_org : ""; + fileInfoStr += format(fieldFormatStr, "Group", lfexpand(word_wrap(groupStr, frameInnerWidth)).replace(/\r\n$/, "")); + + // Some data to possibly write alongside the uploader's avatar + // Uploaded by + var uploadedByStr = fileMetadata.from; + if (fileMetadata.hasOwnProperty("from_protocol")) + uploadedByStr += " via " + fileMetadata.from_protocol; + // Date added + var uploadedDateStr = strftime(gTimeFormatStr, fileMetadata.added); + // File date + //var fileDateStr = gColors.fileTimestamp + strftime(gTimeFormatStr, fileMetadata.time) + "\x01n\x01w"; + var fileDateStr = strftime(gTimeFormatStr, fileMetadata.time); + // Last downloaded date + var lastDownloadedDateStr = strftime(gTimeFormatStr, fileMetadata.last_downloaded); + // # times downloaded + var timesDownloadedStr = fileMetadata.hasOwnProperty("times_downloaded") ? fileMetadata.times_downloaded : 0; + // Time to download + var timeToDownloadStr = secondsToTimeStr(calcDownloadTimeInSeconds(fileMetadata.size)); + // If enabled, get the uploader's avatar, if available. If there + // is an avatar for the uploader, display it to the right, + // alongside the next file information lines. Otherwise, just + // display the file information lines without the avatar. + var userAvatarArray = [] + if (gDispayUserAvatars) + userAvatarArray = getAvatarArray(fileMetadata.from); + if (userAvatarArray.length > 0) + { + // infoToDisplay is an array containing labels and values for the next + // lines of file information to be displayed with the user's avatar on + // the right + var infoToDisplay = [{ label: "Uploaded by", value: uploadedByStr }, + { label: "Uploaded on", value: uploadedDateStr }, + { label: "File date", value: fileDateStr } + ]; + if (fileMetadata.hasOwnProperty("last_downloaded")) + infoToDisplay.push({ label: "Last downloaded", value: lastDownloadedDateStr }); + infoToDisplay.push({ label: "Times downloaded", value: timesDownloadedStr }); + infoToDisplay.push({ label: "Time to download", value: timeToDownloadStr }); + // Go through and display each file info line with the user's + // avatar to the right + var arrayIdx = 0; + for (; arrayIdx < infoToDisplay.length; ++arrayIdx) + { + if (arrayIdx < userAvatarArray.length) + { + //var valueLen = frameInnerWidth - console.strlen(userAvatarArray[arrayIdx]) - infoToDisplay[arrayIdx].label.length - 3; + var valueLen = frameInnerWidth - gAvatar.defs.width - infoToDisplay[arrayIdx].label.length - 3; + var valueStr = format("%-" + valueLen + "s", infoToDisplay[arrayIdx].value.toString().substr(0, valueLen)); + fileInfoStr += format(fieldFormatStr, infoToDisplay[arrayIdx].label, valueStr); + fileInfoStr += " " + userAvatarArray[arrayIdx]; + } + else + { + // Just the file info without avatar line component + fileInfoStr += format(fieldFormatStr, infoToDisplay[arrayIdx].label, infoToDisplay[arrayIdx].value.toString().substr(0, frameInnerWidth)); + } + } + // In case the user avatar still has more lines, display them + for (; arrayIdx < userAvatarArray.length; ++arrayIdx) + { + var widthBeforeAvatarLine = frameInnerWidth - console.strlen(userAvatarArray[arrayIdx]); + fileInfoStr += format("%*s", widthBeforeAvatarLine, "") + userAvatarArray[arrayIdx]; + } + } else - fileDesc = fileMetadata.desc; - - // It's possible for fileDesc to be undefined (due to extDesc or desc being undefined), - // so make sure it's a string. - // Also, if it's a string, reformat certain types of strings that don't look good in a - // Frame object - if (typeof(fileDesc) === "string") + { + // Uploaded by + fileInfoStr += format(fieldFormatStr, "Uploaded by", uploadedByStr.substr(0, frameInnerWidth)); + // Uploaded On (date) + fileInfoStr += format(fieldFormatStr, "Uploaded on", uploadedDateStr.substr(0, frameInnerWidth)); + // File date + fileInfoStr += format(fieldFormatStr, "File date", fileDateStr.substr(0, frameInnerWidth)); + //fileInfoStr += "\r\n"; + // Last downloaded + if (fileMetadata.hasOwnProperty("last_downloaded")) + fileInfoStr += format(fieldFormatStr, "Last downloaded", lastDownloadedDateStr); + // # times downloaded + fileInfoStr += format(fieldFormatStr, "Times downloaded", timesDownloadedStr); + // Time to download + fileInfoStr += format(fieldFormatStr, "Time to download", timeToDownloadStr); + } + // Extended description, if available + if (fileMetadata.hasOwnProperty("extdesc") && fileMetadata.extdesc.length > 0) { // Remove/replace any cursor movement characters, as they can corrupt the display - fileDesc = removeOrReplaceSyncCursorMovementChars(fileDesc); + var fileDesc = removeOrReplaceSyncCursorMovementChars(fileMetadata.extdesc); // Check to see if it starts with a normal attribute and remove if so, // since that seems to cause problems with displaying the description in a Frame object. This @@ -1119,56 +1240,15 @@ function showFileInfo_ANSI(pFileMetadata) fileDesc = fileDesc.replace(/^\x01[nN]/, ""); // Fix line endings if necessary fileDesc = lfexpand(fileDesc); - } - else - fileDesc = ""; - // This might be overkill, but just in case, convert any non-Synchronet - // attribute codes to Synchronet attribute codes in the description. - if (!fileMetadata.hasOwnProperty("attrsConverted")) - { + // Convert any non-Synchronet attribute codes to Synchronet attribute codes + // in the description. fileDesc = convertAttrsToSyncPerSysCfg(fileDesc); - fileMetadata.attrsConverted = true; - if (fileMetadata.hasOwnProperty("extdesc")) - fileMetadata.extdesc = fileDesc; - else - fileMetadata.desc = fileDesc; + fileInfoStr += "\r\n\r\n" + gColors.desc; + fileInfoStr += fileDesc; } - fileInfoStr += gColors.desc; - if (fileDesc.length > 0) - fileInfoStr += "Description:\r\n" + fileDesc; // Don't want to use strip_ctrl(fileDesc) - else - fileInfoStr += "No description available"; - fileInfoStr += "\r\n"; - // # of times downloaded and last downloaded date/time - var fieldFormatStr = "\r\n\x01n\x01c\x01h%s\x01g:\x01n\x01c %s"; - var timesDownloaded = fileMetadata.hasOwnProperty("times_downloaded") ? fileMetadata.times_downloaded : 0; - fileInfoStr += format(fieldFormatStr, "Times downloaded", timesDownloaded); - if (fileMetadata.hasOwnProperty("last_downloaded")) - fileInfoStr += format(fieldFormatStr, "Last downloaded", strftime("%Y-%m-%d %H:%M", fileMetadata.last_downloaded)); - // Some more fields for the sysop - if (user.is_sysop) - { - var sysopFields = [ "from", "cost", "added" ]; - for (var sI = 0; sI < sysopFields.length; ++sI) - { - var prop = sysopFields[sI]; - if (fileMetadata.hasOwnProperty(prop)) - { - if (typeof(fileMetadata[prop]) === "string" && fileMetadata[prop].length == 0) - continue; - var propName = prop.charAt(0).toUpperCase() + prop.substr(1); - var infoValue = ""; - if (prop == "added") - infoValue = strftime("%Y-%m-%d %H:%M:%S", fileMetadata.added); - else - infoValue = fileMetadata[prop].toString().substr(0, frameInnerWidth); - fileInfoStr += format(fieldFormatStr, propName, infoValue); - //fileInfoStr += "\x01n\x01w"; - } - } - } - // Append a final CR & LF so that all lines can be split on those + // Append a final CR & LF - This seems to be needed in order to get all + // description lines by spltiting on \r\n fileInfoStr += "\r\n"; //fileInfoStr += "\x01n\x01w"; @@ -1195,13 +1275,20 @@ function showFileInfo_ANSI(pFileMetadata) // Construct the file list redraw info. Note that the X and Y are relative // to the file list menu, not absolute screen coordinates. retObj.fileListPartialRedrawInfo = { - startX: frameUpperLeftX - gFileListMenu.pos.x + 1, // Relative to the file menu - startY: frameUpperLeftY - gFileListMenu.pos.y + 1, // Relative to the file menu + startX: 1, + startY: 1, absStartX: frameUpperLeftX, absStartY: frameUpperLeftY, width: frameWidth, height: frameHeight }; + // If gFileListMenu is defined, then set startX and startY + // relative to the file menu + if (typeof(gFileListMenu) === "object") + { + retObj.fileListPartialRedrawInfo.startX = frameUpperLeftX - gFileListMenu.pos.x + 1; + retObj.fileListPartialRedrawInfo.startY = frameUpperLeftY - gFileListMenu.pos.y + 1; + } return retObj; } @@ -1218,113 +1305,90 @@ function showFileInfo_noANSI(pFileMetadata) if (pFileMetadata == undefined || typeof(pFileMetadata) !== "object") return retObj; - // pFileList[pFileListMenu.selectedItemIdx] has a file metadata object without - // extended information. Get a metadata object with extended information so we - // can display the extended description. - // The metadata object in pFileList should have a dirCode added by this script. + // The metadata object in pFileList could have a dirCode added by this script. var dirCode = gDirCode; if (pFileMetadata.hasOwnProperty("dirCode")) dirCode = pFileMetadata.dirCode; - var fileMetadata = null; - if (extendedDescEnabled()) - fileMetadata = pFileMetadata; - else - { - var tmpFileMetadata = getFileInfoFromFilebase(dirCode, pFileMetadata.name, FileBase.DETAIL.EXTENDED); - if (tmpFileMetadata != null) - fileMetadata = tmpFileMetadata; - else - fileMetadata = pFileMetadata; - } - - console.print("\x01n\x01wFilename:\r\n"); - console.print(gColors.filename + fileMetadata.name + "\x01n\x01w\r\n"); - console.print("Size: " + gColors.fileSize + getFileSizeStr(fileMetadata.size, 99999) + "\x01n\x01w\r\n"); - console.print("Timestamp: " + gColors.fileTimestamp + strftime("%Y-%m-%d %H:%M:%S", fileMetadata.time) + "\x01n\x01w\r\n"); - console.crlf(); - - // File library/directory information var libIdx = file_area.dir[dirCode].lib_index; - var dirIdx = file_area.dir[dirCode].index; - var libDesc = file_area.lib_list[libIdx].description; - var dirDesc = file_area.dir[dirCode].description; - console.print("\x01c\x01hLib\x01g: \x01n\x01c" + libDesc + "\x01n\x01w\r\n"); - console.print("\x01c\x01hDir\x01g: \x01n\x01c" + dirDesc + "\x01n\x01w\r\n"); - console.crlf(); + //var dirIdx = file_area.dir[dirCode].index; - // fileMetadata should have extdDesc, but check just in case - var fileDesc = ""; - if (fileMetadata.hasOwnProperty("extdesc") && fileMetadata.extdesc.length > 0) - fileDesc = fileMetadata.extdesc; - else - fileDesc = fileMetadata.desc; - // It's possible for fileDesc to be undefined (due to extDesc or desc being undefined), - // so make sure it's a string. - // Also, if it's a string, reformat certain types of strings that don't look good in a - // Frame object - if (typeof(fileDesc) === "string") - { - // We could remove/replace any cursor movement characters, but probably don't need to - // for the traditional/non-ANSI interface, as those things shouldn't corrupt the display - //fileDesc = removeOrReplaceSyncCursorMovementChars(fileDesc); + // Ensure the metadata object has extended information + var fileMetadata = pFileMetadata; + if (!fileMetadata.hasOwnProperty("extdesc")) + fileMetadata = getFileInfoFromFilebase(dirCode, fileMetadata.name, FileBase.DETAIL.EXTENDED); - // Check to see if it starts with a normal attribute and remove if so, - // since that seems to cause problems with displaying the description in a Frame object. This - // may be a kludge, and perhaps there's a better solution.. - fileDesc = fileDesc.replace(/^\x01[nN]/, ""); - // Fix line endings if necessary - fileDesc = lfexpand(fileDesc); + var labelLen = 16; + var lblSep = " : "; + var valueLen = console.screen_columns - labelLen - console.strlen(lblSep) - 1; + var generalFormatStr = "\x01n\x01g%-" + labelLen + "s\x01h" + lblSep + "%-" + valueLen + "s\x01n\r\n"; + + // File library & directory + var libDesc = format("(%d) %s", file_area.lib_list[libIdx].index+1, file_area.lib_list[libIdx].description); + var dirDesc = format("(%d) %s", file_area.dir[dirCode].index+1, file_area.dir[dirCode].description); + console.crlf(); + printf(generalFormatStr, "Library", libDesc.substr(0, valueLen)); + printf(generalFormatStr, "Directory", dirDesc.substr(0, valueLen)); + var formatStr = "\x01n\x01g%-" + labelLen + "s\x01h" + lblSep + "\x01n" + gColors.filename + "%-" + valueLen + "s\x01n\r\n"; + printf(formatStr, "Filename", fileMetadata.name.substr(0, valueLen)); + // File size + formatStr = "\x01n\x01g%-" + labelLen + "s\x01h" + lblSep + "\x01n" + gColors.fileSize + "%-" + valueLen + "s\x01n\r\n"; + var fileSizeStr = format(gColors.fileSize + "%s (%s) bytes", numberWithCommas(fileMetadata.size), file_size_str(fileMetadata.size, null, FILE_SIZE_PRECISION).substr(0, valueLen)); + printf(formatStr, "File size", fileSizeStr.substr(0, valueLen)); + // Credit value + var dirFilesAreFree = Boolean(file_area.dir[dirCode].settings & DIR_FREE); + var creditStr = dirFilesAreFree || fileMetadata.cost == 0 ? "FREE" : fileMetadata.cost.toString(); + printf(generalFormatStr, "Credit value", creditStr.substr(0, valueLen)); + if (fileMetadata.hasOwnProperty("crc32")) + printf(generalFormatStr, "File CRC-32", format("%x", fileMetadata.crc32).substr(0, valueLen)); + if (fileMetadata.hasOwnProperty("md5")) + printf(generalFormatStr, "File MD5", fileMetadata.md5.substr(0, valueLen)); + if (fileMetadata.hasOwnProperty("sha1")) + printf(generalFormatStr, "File SHA-1", fileMetadata.sha1.substr(0, valueLen)); + formatStr = "\x01n\x01g%-" + labelLen + "s\x01h" + lblSep + "\x01n" + gColors.desc + "%-" + valueLen + "s\x01n\r\n"; + printf(formatStr, "Description", fileMetadata.desc.substr(0, valueLen)); + var authorStr = fileMetadata.hasOwnProperty("author") ? fileMetadata.author : ""; + printf(generalFormatStr, "Author", authorStr.substr(0, valueLen)); + var groupStr = fileMetadata.hasOwnProperty("author_org") ? fileMetadata.author_org : ""; + printf(generalFormatStr, "Group", groupStr.substr(0, valueLen)); + var uploadedByStr = fileMetadata.from; + if (fileMetadata.hasOwnProperty("from_protocol")) + uploadedByStr += " via " + fileMetadata.from_protocol; + printf(generalFormatStr, "Uploaded by", uploadedByStr.substr(0, valueLen)); + // If enabled, and if possible, show the avatar of the user who uploaded the file + if (gDispayUserAvatars && gAvatar != null && fileMetadata.from != "") + { + var userNum = system.matchuser(fileMetadata.from); + if (userNum > 0) + { + gAvatar.draw(userNum, fileMetadata.from, /*netaddr*/null, /* above: */true, /* right-justified: */true); + console.attributes = 0; // Clear the background attribute as the next line might scroll, filling with BG attribute + } } - else - fileDesc = ""; - // This might be overkill, but just in case, convert any non-Synchronet - // attribute codes to Synchronet attribute codes in the description. - if (!fileMetadata.hasOwnProperty("attrsConverted")) + // Uploaded on + formatStr = "\x01n\x01g%-" + labelLen + "s\x01h" + lblSep + "\x01n" + gColors.fileTimestamp + "%-" + valueLen + "s\x01n\r\n"; + var timeStr = strftime(gTimeFormatStr, fileMetadata.added); + printf(formatStr, "Uploaded on", timeStr.substr(0, valueLen)); + // File date + timeStr = strftime(gTimeFormatStr, fileMetadata.time); + printf(formatStr, "File date", timeStr.substr(0, valueLen)); + if (fileMetadata.hasOwnProperty("last_downloaded")) { - fileDesc = convertAttrsToSyncPerSysCfg(fileDesc); - fileMetadata.attrsConverted = true; - if (fileMetadata.hasOwnProperty("extdesc")) - fileMetadata.extdesc = fileDesc; - else - fileMetadata.desc = fileDesc; + timeStr = strftime(gTimeFormatStr, fileMetadata.last_downloaded); + printf(generalFormatStr, "Last downloaded", timeStr.substr(0, valueLen)); } - - console.print(gColors.desc); - if (fileDesc.length > 0) - console.print("Description:\r\n" + fileDesc); // Don't want to use strip_ctrl(fileDesc) - else - console.print("No description available"); - console.crlf(); - // # of times downloaded and last downloaded date/time - var fieldFormatStr = "\x01n\x01c\x01h%s\x01g:\x01n\x01c %s\x01n\r\n"; + // Times downloaded, time to download var timesDownloaded = fileMetadata.hasOwnProperty("times_downloaded") ? fileMetadata.times_downloaded : 0; - printf(fieldFormatStr, "Times downloaded", timesDownloaded); - if (fileMetadata.hasOwnProperty("last_downloaded")) - printf(fieldFormatStr, "Last downloaded", strftime("%Y-%m-%d %H:%M", fileMetadata.last_downloaded)); - // Some more fields for the sysop - if (user.is_sysop) + printf(generalFormatStr, "Times downloaded", timesDownloaded); + printf(generalFormatStr, "Time to download", secondsToTimeStr(calcDownloadTimeInSeconds(fileMetadata.size))); + // Extended description (if available) + console.attributes = "N"; + console.print(gColors.desc); + if (fileMetadata.hasOwnProperty("extdesc")) { - var sysopFields = [ "from", "cost", "added" ]; - for (var sI = 0; sI < sysopFields.length; ++sI) - { - var prop = sysopFields[sI]; - if (fileMetadata.hasOwnProperty(prop)) - { - if (typeof(fileMetadata[prop]) === "string" && fileMetadata[prop].length == 0) - continue; - var propName = prop.charAt(0).toUpperCase() + prop.substr(1); - var infoValue = ""; - if (prop == "added") - infoValue = strftime("%Y-%m-%d %H:%M:%S", fileMetadata.added); - else - infoValue = fileMetadata[prop].toString(); - printf(fieldFormatStr, propName, infoValue); - console.attributes = "NW"; - } - } + console.crlf(); + console.print(fileMetadata.extdesc); + console.crlf(); } - console.attributes = "NW"; - console.crlf(); // Construct the file list redraw info. Note that the X and Y are relative // to the file list menu, not absolute screen coordinates. @@ -1432,7 +1496,7 @@ function addSelectedFilesToBatchDLQueue(pFileMetadata, pFileList) // then just return now. var filenames = []; var metadataObjects = []; - if (gFileListMenu.numSelectedItemIndexes() > 0) + if (typeof(gFileListMenu) === "object" && gFileListMenu.numSelectedItemIndexes() > 0 && Array.isArray(pFileList)) { for (var idx in gFileListMenu.selectedItemIndexes) { @@ -1448,7 +1512,7 @@ function addSelectedFilesToBatchDLQueue(pFileMetadata, pFileList) } // Note that confirmFileActionWithUser() will re-draw the parts of the file // list menu that are necessary. - var addFilesConfirmed = addFilesConfirmed = confirmFileActionWithUser(filenames, "Batch DL add", false); + var addFilesConfirmed = confirmFileActionWithUser(filenames, "Batch DL add", false); retObj.refreshedSelectedFilesAlready = true; if (addFilesConfirmed) { @@ -3286,9 +3350,10 @@ function createFileListMenu(pQuitKeys) var desc = (typeof(gFileList[pIdx].desc) === "string" ? gFileList[pIdx].desc : ""); // Remove/replace any cursor movement codes in the description, which can corrupt the display desc = removeOrReplaceSyncCursorMovementChars(desc, false); + var fileSizeStr = file_size_str(gFileList[pIdx].size, null, FILE_SIZE_PRECISION); menuItemObj.text = format(this.fileFormatStr, filename, - getFileSizeStr(gFileList[pIdx].size, this.fileSizeLen), + fileSizeStr.substr(0, this.fileSizeLen), desc.substr(0, this.shortDescLen)); return menuItemObj; } @@ -3570,94 +3635,6 @@ function getLargestNumFilesInLibDirs(pLibIdx) return largestNumFiles; } -// Returns a formatted string representation of a file size. Tries -// to put a size designation at the end if possible. -// -// Parameters: -// pFileSize: The size of the file in bytes -// pMaxLen: Optional - The maximum length of the string -// -// Return value: A formatted string representation of the file size -function getFileSizeStr(pFileSize, pMaxLen) -{ - var fileSizeStr = "?"; - if (typeof(pFileSize) !== "number" || pFileSize < 0) - return fileSizeStr; - - // TODO: Improve - if (pFileSize >= BYTES_PER_GB) // Gigabytes - { - fileSizeStr = format("%.02fG", +(pFileSize / BYTES_PER_GB)); - if (typeof(pMaxLen) === "number" && pMaxLen > 0 && fileSizeStr.length > pMaxLen) - { - fileSizeStr = format("%.1fG", +(pFileSize / BYTES_PER_GB)); - if (fileSizeStr.length > pMaxLen) - { - // If there's a decimal point, then put the size designation after it - var dotIdx = fileSizeStr.lastIndexOf("."); - if (dotIdx > 0) - { - if (/\.$/.test(fileSizeStr)) - fileSizeStr = fileSizeStr.substr(0, dotIdx) + "G"; - else - fileSizeStr = fileSizeStr.substr(0, fileSizeStr.length-1) + "G"; - } - } - fileSizeStr = fileSizeStr.substr(0, pMaxLen); - } - } - else if (pFileSize >= BYTES_PER_MB) // Megabytes - { - fileSizeStr = format("%.02fM", +(pFileSize / BYTES_PER_MB)); - if (typeof(pMaxLen) === "number" && pMaxLen > 0 && fileSizeStr.length > pMaxLen) - { - fileSizeStr = format("%.1fM", +(pFileSize / BYTES_PER_MB)); - if (fileSizeStr.length > pMaxLen) - { - // If there's a decimal point, then put the size designation after it - var dotIdx = fileSizeStr.lastIndexOf("."); - if (dotIdx > 0) - { - if (/\.$/.test(fileSizeStr)) - fileSizeStr = fileSizeStr.substr(0, dotIdx) + "M"; - else - fileSizeStr = fileSizeStr.substr(0, fileSizeStr.length-1) + "M"; - } - } - fileSizeStr = fileSizeStr.substr(0, pMaxLen); - } - } - else if (pFileSize >= BYTES_PER_KB) // Kilobytes - { - fileSizeStr = format("%.02fK", +(pFileSize / BYTES_PER_KB)); - if (typeof(pMaxLen) === "number" && pMaxLen > 0 && fileSizeStr.length > pMaxLen) - { - fileSizeStr = format("%.1fK", +(pFileSize / BYTES_PER_KB)); - if (fileSizeStr.length > pMaxLen) - { - // If there's a decimal point, then put the size designation after it - var dotIdx = fileSizeStr.lastIndexOf("."); - if (dotIdx > 0) - { - if (/\.$/.test(fileSizeStr)) - fileSizeStr = fileSizeStr.substr(0, dotIdx) + "K"; - else - fileSizeStr = fileSizeStr.substr(0, fileSizeStr.length-1) + "K"; - } - } - fileSizeStr = fileSizeStr.substr(0, pMaxLen); - } - } - else - { - fileSizeStr = pFileSize.toString(); - if (typeof(pMaxLen) === "number" && pMaxLen > 0 && fileSizeStr.length > pMaxLen) - fileSizeStr = fileSizeStr.substr(0, pMaxLen); - } - - return fileSizeStr; -} - // Displays some text with a solid horizontal line on the next line. // // Parameters: @@ -4109,6 +4086,11 @@ function readConfigFile() if (typeof(settingsObj[prop]) === "boolean") gBlankNFilesListedStrIfLoadableModule = settingsObj[prop]; } + else if (propUpper == "DISPLAYUSERAVATARS") + { + if (typeof(settingsObj[prop]) === "boolean") + gDispayUserAvatars = settingsObj[prop]; + } else if (propUpper == "THEMEFILENAME") { if (typeof(settingsObj[prop]) === "string") @@ -4366,7 +4348,34 @@ function parseArgs(argv) // Default gScriptmode to MODE_LIST_DIR; for FLBehavior as FL_NONE, no special behavior gScriptMode = MODE_LIST_DIR; - + + /* + // Temporary + if (user.is_sysop) + { + console.print("\x01n\r\n" + argv.length + " args:\r\n"); + for (var i = 0; i < argv.length; ++i) + console.print(argv[i] + "\r\n"); + console.crlf(); + console.print("FLBehavior: " + FLBehavior + "; binary:\r\n"); + console.print(FLBehavior.toString(2) + "\r\n"); + console.print("Behavior flags:\r\n"); + if (Boolean(FLBehavior & FL_ULTIME)) + console.print("FL_ULTIME\r\n"); + if (Boolean(FLBehavior & FL_DLTIME)) + console.print("FL_DLTIME\r\n"); + if (Boolean(FLBehavior & FL_NO_HDR)) + console.print("FL_NO_HDR\r\n"); + if (Boolean(FLBehavior & FL_FINDDESC)) + console.print("FL_FINDDESC\r\n"); + if (Boolean(FLBehavior & FL_EXFIND)) + console.print("FL_EXFIND\r\n"); + if (Boolean(FLBehavior & FL_VIEW)) + console.print("FL_VIEW\r\n"); + console.pause(); + } + // End Temporary + */ // 2 args - Scanning/searching if (argv.length == 2) { @@ -4395,7 +4404,8 @@ function parseArgs(argv) if (Boolean(FLBehavior & FL_VIEW)) { // View ZIP/ARC/GIF etc. info - // TODO: Not sure what to do with this + // TODO: Not sure what to do with this; not sure if it's possible + // to get this here } } // 3 args - Internal code, mode, filespec/description keyword @@ -4432,7 +4442,8 @@ function parseArgs(argv) else if (Boolean(FLBehavior & FL_VIEW)) { // View ZIP/ARC/GIF etc. info - // TODO: Not sure what to do with this + gFilespec = argv[2]; + gScriptMode = MODE_VIEWING_FILES; // Not sure if this is really necessary, but for completeness } } @@ -5393,7 +5404,8 @@ function getFileInfoLineArrayForTraditionalUI(pFileList, pIdx, pFormatInfo) var filename = shortenFilename(pFileList[pIdx].name, pFormatInfo.filenameLen, true); // Note: substrWithAttrCodes() is defined in dd_lightbar_menu.js var fileInfoLines = []; - fileInfoLines.push(format(pFormatInfo.formatStr, pIdx+1, filename, getFileSizeStr(pFileList[pIdx].size, pFormatInfo.fileSizeLen), substrWithAttrCodes(descLines[0], 0, pFormatInfo.descLen))); + var fileSizeStr = file_size_str(pFileList[pIdx].size, null, FILE_SIZE_PRECISION); + fileInfoLines.push(format(pFormatInfo.formatStr, pIdx+1, filename, fileSizeStr.substr(0, pFormatInfo.fileSizeLen), substrWithAttrCodes(descLines[0], 0, pFormatInfo.descLen))); if (userExtDescEnabled) { for (var i = 1; i < descLines.length; ++i) @@ -5574,4 +5586,260 @@ function cursorRightCodeIdx(pStr) retObj.idx = -1; return retObj; +} + +// Performs the file viewing flow, for use as a loadable module +// +// Parameters: +// pDirCode: The internal code of the file directory to use +// pFilespec: The filename or spec of the files to look for +// +// Return value: 0 on success or non-zero on failure +function doFileView(pDirCode, pFilespec) +{ + // To do the stock file view behavior: + //return bbs.list_files(file_area.dir[pDirCode].number, pFilespec, FL_VIEW); + + + var retCode = 0; // 1 (or non-zero?) for canceling + + var libIdx = file_area.dir[pDirCode].lib_index; + console.line_counter = 0; // To avoid screen pauses + printf("\x01n\x01cSearching \x01h%s\x01n\x01c - \x01h%s\x01n", file_area.lib_list[libIdx].description, file_area.dir[pDirCode].description); + console.crlf(); + var filebase = new FileBase(pDirCode); + if (filebase.open()) + { + var fileList = filebase.get_list(pFilespec, FileBase.DETAIL.EXTENDED, 0, true, file_area.dir[pDirCode].sort); + filebase.close(); + var continueFileList = true; + for (var i = 0; i < fileList.length && continueFileList; ++i) + { + var fileMetadata = fileList[i]; + // Add the dir code to the metadata object (convenience for viewFile()) + fileMetadata.dirCode = pDirCode; + viewFile(fileMetadata); + + // Loop control variable for user interaction + var continueOn = true; + while (continueOn) + { + // Prompt the user for the next action. For instance: + // appjs119.zip: Batch download, Extended info, View file, Quit, Prev or [Next]: + var promptText = bbs.text(bbs.text.FileInfoPrompt).replace("%s", fileMetadata.name); + console.mnemonics(promptText); + //var userInput = console.inkey(K_UPPER|K_NOECHO|K_NOSPIN|K_NOCRLF, 5); + var allowedKeys = "QBEVPN" + CTRL_C; + var userInput = console.getkeys(allowedKeys, -1, K_UPPER|K_NOECHO|K_NOSPIN|K_NOCRLF); + if (userInput == "Q" || userInput == CTRL_C || console.aborted) + { + retCode = 1; // Non-zero exit code to cancel + continueOn = false; // This individual file + continueFileList = false; // All files + } + else if (userInput == "B") + { + // Add the file to batch downloaded queue (confirm first) + console.crlf(); + var addFileConfirmed = console.yesno(format("Add %s to your batch DL queue", fileMetadata.name)); + if (addFileConfirmed) + { + // If the file isn't in the user's batch DL queue already, then add it. + var fileAlreadyInQueue = false; + var batchDLQueueStats = getUserDLQueueStats(); + for (var fIdx = 0; fIdx < batchDLQueueStats.filenames.length && !fileAlreadyInQueue; ++fIdx) + fileAlreadyInQueue = (batchDLQueueStats.filenames[fIdx].filename == fileMetadata.name); + if (!fileAlreadyInQueue) + { + var addToQueueSuccessful = true; + var batchDLFilename = system.data_dir + "user/" + format("%04d", user.number) + ".dnload"; + var batchDLFile = new File(batchDLFilename); + if (batchDLFile.open(batchDLFile.exists ? "r+" : "w+")) + { + batchDLFile.writeln(""); + + // Add the required "dir" and "desc" properties to the user's batch download + // queue file. The section is the filename. Also, this script should add a + // dirCode property to each metadata object in the list. + addToQueueSuccessful = batchDLFile.iniSetValue(fileMetadata.name, "dir", pDirCode); + if (addToQueueSuccessful) + { + addToQueueSuccessful = batchDLFile.iniSetValue(fileMetadata.name, "desc", fileMetadata.desc); + // Update the batch DL queue stats object + ++(batchDLQueueStats.numFilesInQueue); + batchDLQueueStats.filenames.push({ filename: fileMetadata.name, desc: fileMetadata.desc }); + batchDLQueueStats.totalSize += fileMetadata.size; + batchDLQueueStats.totalCost += fileMetadata.cost; + } + + batchDLFile.close(); + } + + if (addToQueueSuccessful) + printf("\x01n\x01c\x01h%s\x01n\x01c was added to your batch DL queue.\x01n", fileMetadata.name); + else + printf("\x01n\x01y\x01hFailed to add \x01w%s \x01yto your batch DL queue\x01n", fileMetadata.name); + console.crlf(); + console.pause(); + } + else + { + // File is already in the user's DL queue + printf(bbs.text(bbs.text.FileAlreadyInQueue), fileMetadata.name); + console.pause(); + } + } + else + { + printf("\x01n\x01c\x01h%s\x01n\x01c was not added to your batch DL queue.\x01n", fileMetadata.name); + console.crlf(); + console.pause(); + } + } + else if (userInput == "E") + { + // View file extended info (add dirCode to the metadata for convenience) + fileMetadata.dirCode = pDirCode; + // If using ANSI, show the info in a scrolling interface; otherwise, + // use a non-scrolling interface. + if (gUseLightbarInterface && console.term_supports(USER_ANSI)) + showFileInfo_ANSI(fileMetadata); + else + { + showFileInfo_noANSI(fileMetadata); + console.pause(); + } + } + else if (userInput == "V") + { + // View file + viewFile(fileMetadata); + } + else if (userInput == "P") + { + // Previous file + if (i > 0) + { + i -= 2; // i will be incremented by 1 again by the for loop + continueOn = false; // The current file + } + } + else if (userInput == "N" || userInput == "") + { + // Next file - No action to take here (default action) + continueOn = false; // The current file + } + + // If the user aboreted, then stop + if (console.aborted) + { + retCode = -1; + continueOn = false; // The current file + continueFileList = false; // All files + } + } + } + } + else + { + log(LOG_ERR, format("Failed to open file dir %s (%s - %s)", pDirCode, file_area.lib_list[libIdx].description, file_area.dir[pDirCode].description)); + } + + return retCode; +} + +// Adds commas to a number in the expected places, and +// returns the result as a string. +function numberWithCommas(pNum) +{ + return pNum.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +// Calculates the time (in seconds) for the user to download a number of bytes +function calcDownloadTimeInSeconds(pNumBytes) +{ + // Synchronet JS documentation: + // https://nix.synchro.net/jsobjs.html + // It mentions both bbs.download_cps and user.stats.download_cps + // added in Synchronet 3.20, but bbs.download_cps seems to be + // unavailable at this time of writing + return pNumBytes / user.stats.download_cps; +} + +// Converts a number of seconds to a string representing HH:MM:SS +function secondsToTimeStr(pNumSeconds) +{ + const SECS_PER_HOUR = 3600.0; + const SECS_PER_MIN = 60.0; + var numSecondsRemaining = pNumSeconds; + var numHours = Math.floor(numSecondsRemaining / SECS_PER_HOUR); + numSecondsRemaining -= numHours * SECS_PER_HOUR; + var numMins = Math.floor(numSecondsRemaining / SECS_PER_MIN); + numSecondsRemaining -= numMins * SECS_PER_MIN; + return format("%02d:%02d:%02d", numHours, numMins, numSecondsRemaining); +} + +// Gets a date format string for use with strftime() based on +// the system's settings (European date, military time) +function getTimeFormatStr() +{ + // https://cplusplus.com/reference/ctime/strftime/ + // Specifier Replaced by Example + // %a Abbreviated weekday name Thu + // %b Abbreviated month name Aug + // %d Day of the month, zero-padded (01-31) 23 + // %Y Year 2001 + // %I Hour in 12h format (01-12) 02 + // %H Hour in 24h format (00-23) 14 + // %M Minute (00-59) 55 + // %S Second (00-61) 02 + // %p AM or PM designation PM + + var timeFormatStr = "%a "; // Abbreviated weekday name + // Date format + if (Boolean(system.settings & SYS_EURODATE)) + timeFormatStr += "%d %b %Y "; // Day, month, year + else + timeFormatStr += "%b %d %Y "; // Month, day, year + // Time (12-hour or 24-hour) + if (Boolean(system.settings & SYS_MILITARY)) + timeFormatStr += "%H:%M:%S"; // 24-hour time + else + timeFormatStr += "%I:%M:%S %p"; // 12-hour AM/PM time + return timeFormatStr; +} + +// Tries to get avatar data for a given username and returns it as an +// array of strings. If the avatar can't be found, this will return an +// empty array. +// +// Parameters: +// pUsername: The username to look up, to find the avatar +// +// Return value: An array of strings representing the user's avatar. If not found, this will be an empty array. +function getAvatarArray(pUsername) +{ + var avatarLineArray = []; + var userNum = system.matchuser(pUsername); + if (userNum > 0) + { + var avatar = gAvatar.read(userNum, pUsername, /*netaddr*/null, userNum); + if(gAvatar.is_enabled(avatar)) + { + load('graphic.js'); + var graphic = new Graphic(gAvatar.defs.width, gAvatar.defs.height); + try + { + graphic.BIN = base64_decode(avatar.data); + var avatarMsg = graphic.MSG.replace(/\r\n$/, ""); // Remove any trailing CRLF + var avatarLines = avatarMsg.split("\r\n"); + if (Array.isArray(avatarLines)) + avatarLineArray = avatarLines; + } + catch(e) + { + }; + } + } + return avatarLineArray; } \ No newline at end of file diff --git a/xtrn/ddfilelister/readme.txt b/xtrn/ddfilelister/readme.txt index 03c7c8ed9d8f47169446c53c3a7eed4a761b64ac..f8cde5d10dc9d38c3d4a7f25921581b786b014b8 100644 --- a/xtrn/ddfilelister/readme.txt +++ b/xtrn/ddfilelister/readme.txt @@ -1,6 +1,6 @@ Digital Distortion File Lister - Version 2.24a - Release date: 2024-10-29 + Version 2.25 + Release date: 2024-10-31 by @@ -198,8 +198,8 @@ following directories, in the following order: 2. sbbs/ctrl 3. The same directory as ddfilelister.js If you customize your configuration files, you can copy them to your sbbs/mods -or sbbs/ctrl directory so that they'll be more difficutl to accidentally -override if you update your xtrn/DDMsgReader from the Synchronet CVS +or sbbs/ctrl directory so that they'll be more difficult to accidentally +override if you update your xtrn/DDMsgReader from the Synchronet Git repository, where this reader's files are checked in. The configuration settings are described in the sections below: @@ -246,6 +246,10 @@ blankNFilesListedStrIfLoadableModule When used as a loadable module, whether or string (from text.dat) so that Synchronet won't display it after the lister exits +displayUserAvatars Whether or not to display uploader avatars + in extended information for files. Valid + values are true and false. + themeFilename The name of the configuration file to use for colors & string settings @@ -369,3 +373,5 @@ Synchronet's ctrl directory): - NScanHour (404) - NScanMinute (405) - NScanPmQ (406) +- FileInfoPrompt (232) +- FileAlreadyInQueue (303) diff --git a/xtrn/ddfilelister/revision_history.txt b/xtrn/ddfilelister/revision_history.txt index c6c6a59584037d8ed652ec01922321907d34db6d..6701e10258c0c6b24fddf2077083efa6cea1e263 100644 --- a/xtrn/ddfilelister/revision_history.txt +++ b/xtrn/ddfilelister/revision_history.txt @@ -5,6 +5,14 @@ Revision History (change log) ============================= Version Date Description ------- ---- ----------- +2.25 2024-10-31 Made 'view file' (FL_VIEW) work when used as a loadable + module. Also, when displaying extended informatoin for + a file, added more information to match Synchronet's stock + lister, and to display the user avatar for the uploader, + if available. + Added a new configuration option, displayUserAvatars, to + specify whether or not to display the user avatar of the + uploader in a file's extended information. 2.24a 2024-10-29 When doing a file search, don't pause for input between directories. This is a fix for issue 806 (reported by nelgin).