diff --git a/xtrn/ddfilelister.js b/xtrn/ddfilelister.js
new file mode 100644
index 0000000000000000000000000000000000000000..7451fba24d5c83328374bf14c0a61d9b6aa70aa6
--- /dev/null
+++ b/xtrn/ddfilelister.js
@@ -0,0 +1,4212 @@
+/* This is a file lister door for Synchronet.
+ *
+ * Author: Eric Oulashin (AKA Nightfox)
+ * BBS: Digital Distortion
+ * BBS address: digitaldistortionbbs.com (or digdist.synchro.net)
+ *
+ * Date       Author            Description
+ * 2022-01-17 Eric Oulashin     Version 0.01
+ *                              Started work on this script
+ * 2022-02-06 Eric Oulashin     Version 2.00
+ *                              Functionality implemented (for lightbar/ANSI terminal).
+ *                              Seems to work as expected.  Releasing this version.
+ *                              I'm calling this version 2.00 because I had already
+ *                              released a file lister mod years ago (modding the stock
+ *                              Synchronet file list interface).
+ * 2022-02-07 Eric Oulashin     Version 2.01
+ *                              Fixed file description being undefined when viewing
+ *                              file info.  Fixed command bar refreshing when pressing
+ *                              the hotkeys.  Added an option to pause after viewing a
+ *                              file (defaults to true).
+ * 2022-02-13 Eric Oulashin     Version 2.02
+ *                              Things overall look good. Releasing this version.  Added
+ *                              the ability to do searching via filespec, description, and
+ *                              new file search (started working on this 2022-02-08).
+ * 2022-02-27 Eric Oulashin     Version 2.03
+ *                              For terminals over 25 rows tall, the file info window will
+ *                              now be up to 45 rows tall.  Also, fixed the display of the
+ *                              trailing blocks for the list header for wide terminals (over
+ *                              80 columns).
+ * 2022-03-09 Eric Oulashin     Version 2.04
+ *                              Bug fix: Now successfully formats filenames without extensions
+ *                              when listing files.
+ * 2022-03-12 Eric Oulashin     Version 2.05
+ *                              Now makes use of the user's extended file description setting:
+ *                              If the user's extended file description setting is enabled,
+ *                              the lister will now show extended file descriptions on the
+ *                              main screen in a split format, with the lightbar file list
+ *                              on the left and the extended file description for the
+ *                              highlighted file on the right.  Also, made the file info
+ *                              window taller for terminals within 25 lines high.
+ *                              I had started work on this on March 9, 2022.
+ * 2022-03-13 Eric Oulashin     Version 2.05a
+ *                              Fix for "fileDesc is not defined" error when displaying
+ *                              the file description on the main screen.  Also made a
+ *                              small refactor to the main screen refresh function.
+ * 2022-04-13 Eric Oulashin     Version 2.06
+ *                              When extended file descriptions are enabled, the file
+ *                              date is now shown with the file description on the last
+ *                              line.
+ * 2022-12-02 Eric Oulashin     Version 2.07
+ *                              In a file's extended description, added the number of times
+ *                              downloaded and date/time last downloaded.  Also, fixed a bug
+ *                              where some descriptions were blank in the Frame object because
+ *                              of a leading normal attribute (the fix may be a kludge though).
+ * 2023-01-18 Eric Oulashin     Version 2.08
+ *                              When doing a file search in multiple directories, the file
+ *                              library & directory is now shown in the header as the user
+ *                              scrolls through the file list/search results.  Also,
+ *                              used lfexpand() to ensure the extended description has
+ *                              CRLF endings, useful for splitting it into multiple lines properly.
+ * 2023-02-25 Eric Oulashin     Version 2.09
+ *                              Now supports being used as a loadable module for
+ *                              Scan Dirs and List Files
+ * 2023-02-27 Eric Oulashin     Version 2.10
+ *                              Now allows downloading a single selected file with the D key.
+ *                              Also, ddfilelister now checks whether the user has permission (based
+ *                              on ARS) to download before allowing adding files to their batch
+ *                              download queue (and downloading a single file as well).
+*/
+
+"use strict";
+
+if (typeof(require) === "function")
+{
+	require("sbbsdefs.js", "K_UPPER");
+	require('key_defs.js', 'KEY_UP');
+	require("text.js", "Email"); // Text string definitions (referencing text.dat)
+	require("dd_lightbar_menu.js", "DDLightbarMenu");
+	require("frame.js", "Frame");
+	require("scrollbar.js", "ScrollBar");
+	require("mouse_getkey.js", "mouse_getkey");
+	require("attr_conv.js", "convertAttrsToSyncPerSysCfg");
+}
+else
+{
+	load("sbbsdefs.js");
+	load('key_defs.js');
+	load("text.js"); // Text string definitions (referencing text.dat)
+	load("dd_lightbar_menu.js");
+	load("frame.js");
+	load("scrollbar.js");
+	load("mouse_getkey.js");
+	load("attr_conv.js");
+}
+
+
+/*
+Configured in SCFG->System->Loadable Modules:
+Scan Dirs:      User scans one or more directories for (e.g. new) files
+List Files:     User lists files within a file directory
+View File Info: User views detailed information on files in a directory
+
+This addresses/fixes feature request #521 for Nightfox
+
+Will need to document the mode argument bit values on the wiki, but
+it's the usual suspects: FL_* for scandirs and listfiles and FI_* for
+fileinfo. The scandirs_mod will be passed an extra bool (0/1) arg that
+indicates whether or not the user is scanning *all* directories.
+*/
+
+
+// This script requires Synchronet version 3.19 or newer.
+// If the Synchronet version is below the minimum, then exit.
+if (system.version_num < 31900)
+{
+	if (user.is_sysop)
+	{
+		var message = "\x01n\x01h\x01y\x01i* Warning:\x01n\x01h\x01w Digital Distortion File Lister "
+		            + "requires version \x01g3.19\x01w or\r\n"
+		            + "newer of Synchronet.  This BBS is using version \x01g" + system.version
+		            + "\x01w.\x01n";
+		console.crlf();
+		console.print(message);
+		console.crlf();
+		console.pause();
+	}
+	exit();
+}
+
+// Lister version information
+var LISTER_VERSION = "2.10";
+var LISTER_DATE = "2023-02-27";
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Global variables
+
+var KEY_BACKSPACE = CTRL_H;
+
+// Block characters
+var BLOCK1 = "\xB0"; // Dimmest block
+var BLOCK2 = "\xB1";
+var BLOCK3 = "\xB2";
+var BLOCK4 = "\xDB"; // Brightest block
+var THIN_RECTANGLE_LEFT = "\xDD";
+var THIN_RECTANGLE_RIGHT = "\xDE";
+var RIGHT_T_HDOUBLE_VSINGLE = "\xB5";
+var LEFT_T_HDOUBLE_VSINGLE = "\xcC6";
+
+// For file sizes
+//var BYTES_PER_TB = 1099511627776; // Seems to be too big for JS
+var BYTES_PER_GB = 1073741824;
+var BYTES_PER_MB = 1048576;
+var BYTES_PER_KB = 1024;
+
+// 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 = {
+	filenameStart: 0
+};
+// The end index of each column includes the trailing space so that
+// highlight colors will highlight the whole field
+gListIdxes.filenameEnd = gListIdxes.filenameStart + 13;
+gListIdxes.fileSizeStart = gListIdxes.filenameEnd;
+gListIdxes.fileSizeEnd = gListIdxes.fileSizeStart + 7;
+gListIdxes.descriptionStart = gListIdxes.fileSizeEnd;
+gListIdxes.descriptionEnd = console.screen_columns - 1; // Leave 1 character remaining on the screen
+// Colors
+var gColors = {
+	filename: "\x01n\x01b\x01h",
+	fileSize: "\x01n\x01m\x01h",
+	desc: "\x01n\x01w",
+	bkgHighlight: "\x01n\x01" + "4",
+	filenameHighlight: "\x01c\x01h",
+	fileSizeHighlight: "\x01c\x01h",
+	descHighlight: "\x01c\x01h",
+	fileTimestamp: "\x01g\x01h",
+	fileInfoWindowBorder: "\x01r",
+	fileInfoWindowTitle: "\x01g",
+	errorBoxBorder: "\x01g\x01h",
+	errorMessage: "\x01y\x01h",
+	successMessage: "\x01c",
+
+	batchDLInfoWindowBorder: "\x01r",
+	batchDLInfoWindowTitle: "\x01g",
+	confirmFileActionWindowBorder: "\x01r",
+	confirmFileActionWindowWindowTitle: "\x01g",
+
+	fileAreaMenuBorder: "\x01b",
+	fileNormalBkg: "\x01" + "4",
+	fileAreaNum: "\x01w",
+	fileAreaDesc: "\x01w",
+	fileAreaNumItems: "\x01w",
+
+	fileAreaMenuHighlightBkg: "\x01" + "7",
+	fileAreaNumHighlight: "\x01b",
+	fileAreaDescHighlight: "\x01b",
+	fileAreaNumItemsHighlight: "\x01b"
+};
+
+
+// Actions
+var FILE_VIEW_INFO = 1;
+var FILE_VIEW = 2;
+var FILE_ADD_TO_BATCH_DL = 3;
+var FILE_DOWNLOAD_SINGLE = 4;
+var HELP = 5;
+var QUIT = 6;
+var FILE_MOVE = 7;   // Sysop action
+var FILE_DELETE = 8; // Sysop action
+
+// Search/list modes
+var MODE_LIST_DIR = 1;
+var MODE_SEARCH_FILENAME = 2;
+var MODE_SEARCH_DESCRIPTION = 3;
+var MODE_NEW_FILE_SEARCH = 4;
+
+// Sort orders (not included in FileBase.SORT)
+var SORT_FL_ULTIME = 50; // Sort by upload time
+var SORT_FL_DLTIME = 51; // Sort by download time
+
+// The searc/list mode for the current run
+var gScriptMode = MODE_LIST_DIR; // Default
+var gListBehavior = FL_NONE; // From sbbsdefs.js
+
+// The directory internal code to list
+var gDirCode = bbs.curdir_code;
+
+
+
+// This will store the number of header lines that were displayed.  This will control
+// the starting row of the file list menu.
+var gNumHeaderLinesDisplayed = 0;
+
+// The number of milliseconds to wait after displaying an error message
+var gErrorMsgWaitMS = 1500;
+// The upper-left position, width, & size of the error message box
+var gErrorMsgBoxULX = 2;
+var gErrorMsgBoxULY = 4;
+var gErrorMsgBoxWidth = console.screen_columns - 2;
+var gErrorMsgBoxHeight = 3;
+
+// Whether or not to pause after viewing a file
+var gPauseAfterViewingFile = true;
+
+///////////////////////////////////////////////////////////////////////////////
+// Script execution code
+
+// The filename pattern to match
+var gFilespec = "*";
+
+// The sort order to use for the file list
+var gFileSortOrder = FileBase.SORT.NATURAL; // Natural sort order, same as DATE_A (import date ascending)
+
+var gSearchVerbose = false;
+
+// When called as a lodable module, one of the options is to scan all dirs
+var gScanAllDirs = false;
+
+// Read the configuration file and set the settings
+readConfigFile();
+
+// Parse command-line arguments (which sets program options)
+parseArgs(argv);
+
+// If the user's terminal doesn't support ANSI, then just call the standard Synchronet
+// file list function and exit now
+if (!console.term_supports(USER_ANSI))
+{
+	var exitCode = 0;
+	if (gScriptMode == MODE_SEARCH_FILENAME || gScriptMode == MODE_SEARCH_DESCRIPTION || gScriptMode == MODE_NEW_FILE_SEARCH)
+		bbs.scan_dirs(gListBehavior, gScanAllDirs);
+	else
+		exitCode = bbs.list_files(gDirCode, gFilespec, gListBehavior);
+	exit(exitCode);
+}
+
+// This array will contain file metadata objects
+var gFileList = [];
+
+// Populate the file list based on the script mode (list/search).
+// It's important that this is called before createFileListMenu(),
+// since this adjusts gListIdxes.filenameEnd based on the longest
+// filename length and terminal width.
+var listPopRetObj = populateFileList(gScriptMode);
+if (listPopRetObj.exitNow)
+	exit(listPopRetObj.exitCode);
+
+// If there are no files, then say so and exit.
+if (gFileList.length == 0)
+{
+	console.crlf();
+	console.print("\x01n\x01c");
+	if (gScriptMode == MODE_LIST_DIR)
+	{
+		if (gFilespec == "*" || gFilespec == "*.*")
+			console.print("There are no files in the directory.");
+		else
+			console.print("No files in the directory were found matching " + gFilespec);
+	}
+	else
+		console.print("No files were found.");
+	console.print("\x01n");
+	console.crlf();
+	console.pause();
+	exit(0);
+}
+
+
+// Clear the screen and display the header lines
+console.clear("\x01n");
+if ((gListBehavior & FL_NO_HDR) != FL_NO_HDR)
+	displayFileLibAndDirHeader();
+// Construct and display the menu/command bar at the bottom of the screen
+var fileMenuBar = new DDFileMenuBar({ x: 1, y: console.screen_rows });
+fileMenuBar.writePromptLine();
+// Create the file list menu
+var gFileListMenu = createFileListMenu(fileMenuBar.getAllActionKeysStr(true, true) + KEY_LEFT + KEY_RIGHT + KEY_DEL);
+// In a loop, show the file list menu, allowing the user to scroll the file list,
+// and respond to user input until the user decides to quit.
+gFileListMenu.Draw({});
+// If using extended descriptions, write the first file's description on the screen
+if (extendedDescEnabled())
+	displayFileExtDescOnMainScreen(0);
+var continueDoingFileList = true;
+var drawFileListMenu = false; // For screen refresh optimization
+while (continueDoingFileList)
+{
+	// Clear the menu's selected item indexes so it's 'fresh' for this round
+	for (var prop in gFileListMenu.selectedItemIndexes)
+		delete gFileListMenu.selectedItemIndexes[prop];
+	var actionRetObj = null;
+	var currentActionVal = null;
+	var userChoice = gFileListMenu.GetVal(drawFileListMenu, gFileListMenu.selectedItemIndexes);
+	drawFileListMenu = false; // For screen refresh optimization
+	var lastUserInputUpper = gFileListMenu.lastUserInput != null ? gFileListMenu.lastUserInput.toUpperCase() : null;
+	if (lastUserInputUpper == null || lastUserInputUpper == "Q")
+		continueDoingFileList = false;
+	else if (lastUserInputUpper == KEY_LEFT)
+		fileMenuBar.decrementMenuItemAndRefresh();
+	else if (lastUserInputUpper == KEY_RIGHT)
+		fileMenuBar.incrementMenuItemAndRefresh();
+	else if (lastUserInputUpper == KEY_ENTER)
+	{
+		currentActionVal = fileMenuBar.getCurrentSelectedAction();
+		fileMenuBar.setCurrentActionCode(currentActionVal);
+		actionRetObj = doAction_ANSI(currentActionVal, gFileList, gFileListMenu);
+	}
+	// Allow the delete key as a special key for sysops to delete the selected file(s). Also allow backspace
+	// due to some terminals returning backspace for delete.
+	else if (lastUserInputUpper == KEY_DEL || lastUserInputUpper == KEY_BACKSPACE)
+	{
+		if (user.is_sysop)
+		{
+			fileMenuBar.setCurrentActionCode(FILE_DELETE, true);
+			actionRetObj = doAction_ANSI(FILE_DELETE, gFileList, gFileListMenu);
+			currentActionVal = FILE_DELETE;
+		}
+	}
+	else
+	{
+		currentActionVal = fileMenuBar.getActionFromChar(lastUserInputUpper, false);
+		fileMenuBar.setCurrentActionCode(currentActionVal, true);
+		actionRetObj = doAction_ANSI(currentActionVal, gFileList, gFileListMenu);
+	}
+	// If an action was done (actionRetObj is not null), then look at actionRetObj and
+	// do what's needed.  Note that quit (for the Q key) is already handled.
+	if (actionRetObj != null)
+	{
+		if (actionRetObj.exitNow)
+			continueDoingFileList = false;
+		else
+		{
+			if ((gListBehavior & FL_NO_HDR) != FL_NO_HDR)
+			{
+				if (actionRetObj.reDrawHeaderTextOnly)
+				{
+					console.print("\x01n");
+					displayFileLibAndDirHeader(true); // Will move the cursor where it needs to be
+				}
+				else if (actionRetObj.reDrawListerHeader)
+				{
+					console.print("\x01n");
+					console.gotoxy(1, 1);
+					displayFileLibAndDirHeader();
+				}
+			}
+			if (actionRetObj.reDrawCmdBar) // Could call fileMenuBar.constructPromptText(); if needed
+				fileMenuBar.writePromptLine();
+			var redrewPartOfFileListMenu = false;
+			// If we are to re-draw the main screen content, then
+			// enable the flag to draw the file list menu on the next
+			// GetVal(); also, if extended descriptions are being shown,
+			// write the current file's extended description too.
+			if (actionRetObj.reDrawMainScreenContent)
+			{
+				drawFileListMenu = true;
+				if (extendedDescEnabled())
+					displayFileExtDescOnMainScreen(gFileListMenu.selectedItemIdx);
+			}
+			else
+			{
+				// If there is partial redraw information available, then use it
+				// to re-draw that part of the main screen
+				if (actionRetObj.fileListPartialRedrawInfo != null)
+				{
+					drawFileListMenu = false;
+					var startX = actionRetObj.fileListPartialRedrawInfo.absStartX;
+					var startY = actionRetObj.fileListPartialRedrawInfo.absStartY;
+					var width = actionRetObj.fileListPartialRedrawInfo.width;
+					var height = actionRetObj.fileListPartialRedrawInfo.height;
+					refreshScreenMainContent(startX, startY, width, height, true);
+					actionRetObj.refreshedSelectedFilesAlready = true;
+					redrewPartOfFileListMenu = true;
+				}
+				else
+				{
+					// Partial screen re-draw information was not returned.
+					continueDoingFileList = actionRetObj.continueFileLister;
+					drawFileListMenu = actionRetObj.reDrawMainScreenContent;
+					// If displaying extended descriptions and the user deleted some files, then
+					// refresh the file description area to erase the delete confirmation text
+					if (extendedDescEnabled()/* && currentActionVal == FILE_DELETE*/)
+					{
+						if (actionRetObj.hasOwnProperty("filesDeleted") && actionRetObj.filesDeleted)
+						{
+							var numFiles = gFileListMenu.NumItems();
+							if (numFiles > 0 && gFileListMenu.selectedItemIdx >= 0 && gFileListMenu.selectedItemIdx < numFiles)
+								displayFileExtDescOnMainScreen(gFileListMenu.selectedItemIdx);
+						}
+						else
+						{
+							var firstLine = startY + gFileListMenu.pos.y;
+							var lastLine = console.screen_rows - 1;
+							var width = console.screen_columns - gFileListMenu.size.width - 1;
+							displayFileExtDescOnMainScreen(gFileListMenu.selectedItemIdx, firstLine, lastLine, width);
+						}
+					}
+				}
+			}
+			// Remove checkmarks from any selected files in the file menu.
+			// For efficiency, we'd probably only do this if not re-drawing the wohle
+			// menu, but that's not working for now.
+			if (!actionRetObj.refreshedSelectedFilesAlready && /*!drawFileListMenu &&*/ gFileListMenu.numSelectedItemIndexes() > 0)
+			{
+				var bottomItemIdx = gFileListMenu.GetBottomItemIdx();
+				var redrawTopY = -1;
+				var redrawBottomY = -1;
+				if (actionRetObj.fileListPartialRedrawInfo != null)
+				{
+					redrawTopY = actionRetObj.fileListPartialRedrawInfo.absStartY;
+					redrawBottomY = actionRetObj.fileListPartialRedrawInfo.height + height - 1;
+				}
+				for (var idx in gFileListMenu.selectedItemIndexes)
+				{
+					var idxNum = +idx;
+					if (idxNum >= gFileListMenu.topItemIdx && idxNum <= bottomItemIdx)
+					{
+						var drawItem = true;
+						if (redrawTopY > -1 && redrawBottomY > redrawTopY)
+						{
+							var screenRowForItem = gFileListMenu.ScreenRowForItem(idxNum);
+							drawItem = (screenRowForItem < redrawTopY || screenRowForItem > redrawBottomY)
+						}
+						if (drawItem)
+						{
+							var isSelected = (idxNum == gFileListMenu.selectedItemIdx);
+							gFileListMenu.WriteItemAtItsLocation(idxNum, isSelected, false);
+						}
+						else
+							console.print("\x01n\r\nNot drawing idx " + idxNum + "\r\n\x01p");
+					}
+				}
+			}
+			// If part of the file list menu was re-drawn (partially, not completely), move the cursor
+			// to the lower-right corner of the screen so that it's out of the way
+			if (redrewPartOfFileListMenu)
+				console.gotoxy(console.screen_columns-1, console.screen_rows);
+		}
+	}
+}
+
+
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Functions: File actions
+
+// Performs a specified file action based on an action code. For the ANSI user interface.
+//
+// Parameters:
+//  pActionCode: A code specifying an action to do.  Must be one of the global
+//               action codes.
+//  pFileList: The list of file metadata objects, as retrieved from the filebase
+//  pFileListMenu: The file list menu
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function doAction_ANSI(pActionCode, pFileList, pFileListMenu)
+{
+	if (typeof(pActionCode) !== "number")
+		return getDefaultActionRetObj();
+
+	var fileMetadata = pFileList[pFileListMenu.selectedItemIdx];
+
+	var retObj = null;
+	switch (pActionCode)
+	{
+		case FILE_VIEW_INFO:
+			retObj = showFileInfo_ANSI(fileMetadata);
+			break;
+		case FILE_VIEW:
+			retObj = viewFile_ANSI(fileMetadata);
+			break;
+		case FILE_ADD_TO_BATCH_DL:
+			if (userCanDownloadFromFileArea_ShowErrorIfNot(fileMetadata.dirCode))
+				retObj = addSelectedFilesToBatchDLQueue_ANSI(fileMetadata, pFileList);
+			else
+			{
+				retObj = getDefaultActionRetObj();
+				retObj.reDrawListerHeader = true;
+				retObj.reDrawHeaderTextOnly = false;
+				retObj.reDrawMainScreenContent = true;
+				retObj.reDrawCmdBar = true;
+			}
+			break;
+		case FILE_DOWNLOAD_SINGLE:
+			if (userCanDownloadFromFileArea_ShowErrorIfNot(fileMetadata.dirCode) && pFileListMenu.selectedItemIdx >= 0 && pFileListMenu.selectedItemIdx < pFileListMenu.NumItems())
+				retObj = letUserDownloadSelectedFile_ANSI(fileMetadata);
+			else
+			{
+				retObj = getDefaultActionRetObj();
+				retObj = getDefaultActionRetObj();
+				retObj.reDrawListerHeader = true;
+				retObj.reDrawHeaderTextOnly = false;
+				retObj.reDrawMainScreenContent = true;
+				retObj.reDrawCmdBar = true;
+			}
+			break;
+		case HELP:
+			retObj = displayHelpScreen();
+			break;
+		case QUIT:
+			retObj = getDefaultActionRetObj();
+			retObj.continueFileLister = false;
+			break;
+		case FILE_MOVE: // Sysop action
+			if (user.is_sysop)
+				retObj = chooseFilebaseAndMoveFileToOtherFilebase_ANSI(pFileList, pFileListMenu);
+			break;
+		case FILE_DELETE: // Sysop action
+			if (user.is_sysop)
+				retObj = confirmAndRemoveFilesFromFilebase_ANSI(pFileList, pFileListMenu);
+			break;
+	}
+
+	return retObj;
+}
+
+// Returns an object for use for returning from performing a file action,
+// with default values.
+//
+// Return value: An object with the following properties:
+//               continueFileLister: Boolean - Whether or not the file lister should continue, or exit
+//               reDrawMainScreenContent: Boolean - Whether or not to re-draw the main screen content
+//                                        (file list, and extended description area if applicable)
+//               reDrawListerHeader: Boolean - Whether or not to re-draw the header at the top of the screen
+//               reDrawHeaderTextOnly: Boolean - Whether or not to re-draw the header text only.  This should
+//                                     take precedence over reDrawListerHeader.
+//               reDrawCmdBar: Boolean - Whether or not to re-draw the command bar at the bottom of the screen
+//               fileListPartialRedrawInfo: If part of the file list menu needs to be re-drawn,
+//                                          this will be an object that includes the following properties:
+//                                          startX: The starting X coordinate for where to re-draw
+//                                          startY: The starting Y coordinate for where to re-draw
+//                                          width: The width to re-draw
+//                                          height: The height to re-draw
+//               refreshedSelectedFilesAlready: Whether or not selected file checkmark items
+//                                              have already been refreshed (boolean)
+//               exitNow: Exit the file lister now (boolean)
+//               If no part of the file list menu needs to be re-drawn, this will be null.
+function getDefaultActionRetObj()
+{
+	return {
+		continueFileLister: true,
+		reDrawMainScreenContent: false,
+		reDrawListerHeader: false,
+		reDrawHeaderTextOnly: false,
+		reDrawCmdBar: false,
+		fileListPartialRedrawInfo: null,
+		refreshedSelectedFilesAlready: false,
+		exitNow: false
+	};
+}
+
+// Shows extended information about a file to the user.
+//
+// Parameters:
+//  pFileMetadata: The file metadata object for the file to view information about
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function showFileInfo_ANSI(pFileMetadata)
+{
+	var retObj = getDefaultActionRetObj();
+
+	// 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.
+	// The metadata object in pFileList should 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
+		fileMetadata = getFileInfoFromFilebase(dirCode, pFileMetadata.name, FileBase.DETAIL.EXTENDED);
+	// 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";
+
+	// 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 += "\x01c\x01hLib\x01g: \x01n\x01c" + libDesc.substr(0, frameInnerWidth-5) + "\x01n\x01w\r\n";
+	fileInfoStr += "\x01c\x01hDir\x01g: \x01n\x01c" + dirDesc.substr(0, frameInnerWidth-5) + "\x01n\x01w\r\n";
+	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;
+	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")
+	{
+		// 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);
+	}
+	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"))
+	{
+		fileDesc = convertAttrsToSyncPerSysCfg(fileDesc);
+		fileMetadata.attrsConverted = true;
+		if (fileMetadata.hasOwnProperty("extdesc"))
+			fileMetadata.extdesc = fileDesc;
+		else
+			fileMetadata.desc = 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";
+			}
+		}
+	}
+	fileInfoStr += "\x01n\x01w";
+
+	// Construct & draw a frame with the file information & do the input loop
+	// for the frame until the user closes the frame.
+	var frameUpperLeftX = 3;
+	var frameUpperLeftY = gNumHeaderLinesDisplayed + 3;
+	// Note: frameWidth is declared earlier
+	var frameHeight = console.screen_rows - 4 - frameUpperLeftY;
+	// If the user's console is more than 25 rows high, then make the info window
+	// taller so that its bottom row is 10 from the bottom, but only up to 45 rows tall.
+	if (console.screen_rows > 25)
+	{
+		var frameBottomRow = console.screen_rows - 4;
+		frameHeight = frameBottomRow - frameUpperLeftY + 1;
+		if (frameHeight > 45)
+			frameHeight = 45;
+	}
+	var frameTitle = "File Info";
+	displayBorderedFrameAndDoInputLoop(frameUpperLeftX, frameUpperLeftY, frameWidth, frameHeight,
+	                                   gColors.fileInfoWindowBorder, frameTitle,
+	                                   gColors.fileInfoWindowTitle, fileInfoStr);
+
+	// 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
+		absStartX: frameUpperLeftX,
+		absStartY: frameUpperLeftY,
+		width: frameWidth,
+		height: frameHeight
+	};
+
+	return retObj;
+}
+// Splits a string on a given string and then re-combines the string with \r\n (carriage return & newline)
+// at the end of each line
+//
+// Parameters:
+//  pStr: The string to split & recombine
+//  pSplitStr: The string to split the first string on
+//
+// Return value: The string split on pSplitStr and re-combined with \r\n at the end of each line
+function splitStrAndCombineWithRN(pStr, pSplitStr)
+{
+	if (typeof(pStr) !== "string")
+		return "";
+	if (typeof(pSplitStr) !== "string")
+		return pStr;
+
+	var newStr = "";
+	var strs = pStr.split(pSplitStr);
+	if (strs.length > 0)
+	{
+		for (var i = 0; i < strs.length; ++i)
+			newStr += strs[i] + "\r\n";
+		newStr = newStr.replace(/\r\n$/, "");
+	}
+	else
+		newStr = pStr;
+	return newStr;
+}
+
+// Lets the user view a file.
+//
+// Parameters:
+//  pFileMetadata: The file metadata object for the file to view
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function viewFile_ANSI(pFileMetadata)
+{
+	var retObj = getDefaultActionRetObj();
+
+	// Open the filebase & get the fully pathed filename
+	var fullyPathedFilename = "";
+	var filebase = new FileBase(pFileMetadata.dirCode);
+	if (filebase.open())
+	{
+		fullyPathedFilename = filebase.get_path(pFileMetadata);
+		filebase.close();
+	}
+	else
+	{
+		displayMsg("Failed to open the filebase!", true, true);
+		return retObj;
+	}
+
+	// View the file
+	console.gotoxy(1, console.screen_rows);
+	console.print("\x01n");
+	console.crlf();
+	var successfullyViewed = bbs.view_file(fullyPathedFilename);
+	console.print("\x01n");
+	if (gPauseAfterViewingFile || !successfullyViewed)
+		console.pause();
+
+	retObj.reDrawListerHeader = true;
+	retObj.reDrawMainScreenContent = true;
+	retObj.reDrawCmdBar = true;
+	return retObj;
+}
+
+// Allows the user to add their selected file to their batch downloaded queue
+//
+// Parameters:
+//  pFileMetadata: The file metadata object for the file
+//  pFileList: The list of file metadata objects from the file directory
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function addSelectedFilesToBatchDLQueue_ANSI(pFileMetadata, pFileList)
+{
+	var retObj = getDefaultActionRetObj();
+	if (!userCanDownloadFromFileArea_ShowErrorIfNot(pFileMetadata.dirCode))
+		return retObj;
+
+	// Confirm with the user to add the file(s) to their batch queue.  If they don't want to,
+	// then just return now.
+	var filenames = [];
+	var metadataObjects = [];
+	if (gFileListMenu.numSelectedItemIndexes() > 0)
+	{
+		for (var idx in gFileListMenu.selectedItemIndexes)
+		{
+			var idxNum = +idx;
+			filenames.push(pFileList[idxNum].name);
+			metadataObjects.push(pFileList[idxNum]);
+		}
+	}
+	else
+	{
+		filenames.push(pFileMetadata.name);
+		metadataObjects.push(pFileMetadata);
+	}
+	// Note that confirmFileActionWithUser() will re-draw the parts of the file
+	// list menu that are necessary.
+	var addFilesConfirmed = confirmFileActionWithUser(filenames, "Batch DL add", false);
+	retObj.refreshedSelectedFilesAlready = true;
+	if (addFilesConfirmed)
+	{
+		var batchDLQueueStats = getUserDLQueueStats();
+		var filenamesFailed = []; // To store filenames that failed to get added to the queue
+		var batchDLFilename = backslash(system.data_dir + "user") + format("%04d", user.number) + ".dnload";
+		var batchDLFile = new File(batchDLFilename);
+		if (batchDLFile.open(batchDLFile.exists ? "r+" : "w+"))
+		{
+			displayMsg("Adding file(s) to batch DL queue..", false, false);
+			for (var i = 0; i < metadataObjects.length; ++i)
+			{
+				// If the file isn't in the user's batch DL queue already, then add it.
+				var fileAlreadyInQueue = false;
+				for (var fIdx = 0; fIdx < batchDLQueueStats.filenames.length && !fileAlreadyInQueue; ++fIdx)
+					fileAlreadyInQueue = (batchDLQueueStats.filenames[fIdx].filename == metadataObjects[i].name);
+				if (!fileAlreadyInQueue)
+				{
+					var addToQueueSuccessful = true;
+					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(metadataObjects[i].name, "dir", metadataObjects[i].dirCode);
+					if (addToQueueSuccessful)
+					{
+						addToQueueSuccessful = batchDLFile.iniSetValue(metadataObjects[i].name, "desc", metadataObjects[i].desc);
+						// Update the batch DL queue stats object
+						++(batchDLQueueStats.numFilesInQueue);
+						batchDLQueueStats.filenames.push({ filename: metadataObjects[i].name, desc: metadataObjects[i].desc });
+						batchDLQueueStats.totalSize += +(metadataObjects[i].size);
+						batchDLQueueStats.totalCost += +(metadataObjects[i].cost);
+					}
+
+					if (!addToQueueSuccessful)
+						filenamesFailed.push(metadataObjects[i].name);
+				}
+			}
+
+			batchDLFile.close();
+		}
+
+		// Frame location & size for batch DL queue stats or filenames that failed
+		var frameUpperLeftX = gFileListMenu.pos.x + 2;
+		var frameUpperLeftY = gFileListMenu.pos.y + 2;
+		var frameWidth = console.screen_columns - 4; // Used to be gFileListMenu.size.width - 4;
+		var frameInnerWidth = frameWidth - 2; // Without borders
+		var frameHeight = 8;
+
+		// If there were no failures, then show a success message & prompt the user if they
+		// want to download their batch queue.  Otherwise, show the filenames that failed to
+		// get added.
+		if (filenamesFailed.length == 0)
+		{
+			displayMsg("Your batch DL queue was sucessfully updated", false, true);
+			// Prompt if the user wants to download their batch queue
+			if (bbs.batch_dnload_total > 0)
+			{
+				// Clear most of the screen area so the user has focus on the batch DL queue stats
+				var fullLineFormatStr = "%" + console.screen_columns + "s";
+				var leftFormatStr = "%" + frameUpperLeftX + "s";
+				var rightFormatStr = "%" + +(frameUpperLeftX+frameWidth-1) + "s";
+				var lastFrameRow = frameUpperLeftY + frameHeight - 1;
+				var lastRow = console.screen_rows - 1;
+				console.print("\x01n");
+				for (var screenRow = gNumHeaderLinesDisplayed+1; screenRow <= lastRow; ++screenRow)
+				{
+					console.gotoxy(1, screenRow);
+					if (screenRow < frameUpperLeftY || screenRow > lastFrameRow)
+						printf(fullLineFormatStr, "");
+					else
+					{
+						printf(leftFormatStr, "");
+						console.gotoxy(frameUpperLeftX+frameWidth, screenRow);
+						printf(rightFormatStr, "");
+					}
+				}
+
+				// Build a frame with batch DL queue stats and prompt the user if they want to
+				// download their batch DL queue
+				var frameTitle = "Download your batch queue (Y/N)?";
+				// \x01cFiles: \x01h1 \x01n\x01c(\x01h100 \x01n\x01cMax)  Credits: 0  Bytes: \x01h2,228,254 \x01n\x01c Time: 00:09:40
+				// Note: The maximum number of allowed files in the batch download queue doesn't seem to
+				// be available to JavaScript.
+				var totalQueueSize = batchDLQueueStats.totalSize + pFileMetadata.size;
+				var totalQueueCost = batchDLQueueStats.totalCost + pFileMetadata.cost;
+				var queueStats = "\x01n\x01cFiles: \x01h" + batchDLQueueStats.numFilesInQueue + "  \x01n\x01cCredits: \x01h"
+				               + totalQueueCost + "\x01n\x01c  Bytes: \x01h" + numWithCommas(totalQueueSize) + "\x01n\x01w\r\n";
+				for (var i = 0; i < batchDLQueueStats.filenames.length; ++i)
+				{
+					queueStats += shortenFilename(batchDLQueueStats.filenames[i].filename, frameInnerWidth, false) + "\r\n";
+					queueStats += batchDLQueueStats.filenames[i].desc.substr(0, frameInnerWidth) + "\r\n";
+					if (i < batchDLQueueStats.filenames.length-1)
+						queueStats += "\r\n";
+				}
+				var additionalQuitKeys = "yYnN";
+				var lastUserInput = displayBorderedFrameAndDoInputLoop(frameUpperLeftX, frameUpperLeftY, frameWidth,
+				                                                       frameHeight, gColors.batchDLInfoWindowBorder,
+				                                                       frameTitle, gColors.batchDLInfoWindowTitle,
+				                                                       queueStats, additionalQuitKeys);
+				// The main screen content (file list & extended description if applicable)
+				// will need to be redrawn after this.
+				retObj.reDrawMainScreenContent = true;
+				// If the user chose to download their file queue, then send it to the user.
+				// And the lister headers will need to be re-drawn as well.
+				if (lastUserInput.toUpperCase() == "Y")
+				{
+					retObj.reDrawListerHeader = true;
+					retObj.reDrawCmdBar = true;
+					console.print("\x01n");
+					console.gotoxy(1, console.screen_rows);
+					console.crlf();
+					bbs.batch_download();
+					// If the user is still online (chose not to hang up after transfer),
+					// then pause so that the user can see the batch download status
+					if (bbs.online > 0)
+						console.pause();
+				}
+			}
+		}
+		else
+		{
+			eraseMsgBoxScreenArea();
+			// Build a frame object to show the names of the files that failed to be added to the
+			// user's batch DL queue
+			var frameTitle = "Failed to add these files to batch DL queue";
+			var fileListStr = "\x01n\x01w";
+			for (var i = 0; i < filenamesFailed.length; ++i)
+				fileListStr += shortenFilename(filenamesFailed[i], frameInnerWidth, false) + "\r\n";
+			var lastUserInput = displayBorderedFrameAndDoInputLoop(frameUpperLeftX, frameUpperLeftY, frameWidth,
+																   frameHeight, gColors.batchDLInfoWindowBorder,
+																   frameTitle, gColors.batchDLInfoWindowTitle,
+																   fileListStr, "");
+			// Add the file list redraw info.  Note that the X and Y are relative
+			// to the file list menu, not absolute screen coordinates.
+			// To make the list refresh info to return to the main script loop
+			retObj.fileListPartialRedrawInfo = {
+				startX: 3,
+				startY: 3,
+				absStartX: gFileListMenu.pos.x + 3 - 1, // 1-based
+				absStartY: gFileListMenu.pos.y + 3 - 1, // 1-based
+				width: frameWidth + 1,
+				height: frameHeight
+			};
+		}
+	}
+
+	return retObj;
+}
+// Gets stats about the user's batch download queue.
+//
+// Return value: An object containing the following properties:
+//               numFilesInQueue: The number of files already in the queue
+//               totalSize: The total size of the files in the queue
+//               totalCost: The total cost of the files in the queue
+//               filenames: An array of objects, each containing the filename and
+//                          descriptions (desc) of the files in the download queue
+function getUserDLQueueStats()
+{
+	var retObj = {
+		numFilesInQueue: 0,
+		totalSize: 0,
+		totalCost: 0,
+		filenames: []
+	};
+
+	var batchDLFilename = backslash(system.data_dir + "user") + format("%04d", user.number) + ".dnload";
+	var batchDLFile = new File(batchDLFilename);
+	if (batchDLFile.open(batchDLFile.exists ? "r+" : "w+"))
+	{
+		// See if a section exists for the filename
+		//File.iniGetAllObjects([name_property] [,prefix=none] [,lowercase=false] [,blanks=false])
+		var allIniObjs = batchDLFile.iniGetAllObjects();
+		console.attributes = "N";
+		console.crlf();
+		for (var i = 0; i < allIniObjs.length; ++i)
+		{
+			if (typeof(allIniObjs[i]) === "object")
+			{
+				++(retObj.numFilesInQueue);
+				//allIniObjs[i].name
+				//allIniObjs[i].dir
+				//allIniObjs[i].desc
+				retObj.filenames.push({ filename: allIniObjs[i].name, desc: allIniObjs[i].desc });
+				// dir is the internal directory code
+				if (allIniObjs[i].dir.length > 0)
+				{
+					var filebase = new FileBase(allIniObjs[i].dir);
+					if (filebase.open())
+					{
+						var fileInfo = filebase.get(allIniObjs[i].name);
+						if (typeof(fileInfo) === "object")
+						{
+							retObj.totalSize += +(fileInfo.size);
+							retObj.totalCost += +(fileInfo.cost);
+						}
+						filebase.close();
+					}
+				}
+			}
+		}
+		batchDLFile.close();
+	}
+
+	return retObj;
+}
+
+// Lets the user download the currently selected file on the file list menu
+//
+// Parameters:
+//  pFileMetadata: The file metadata object for the file to download
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function letUserDownloadSelectedFile_ANSI(pFileMetadata)
+{
+	var retObj = getDefaultActionRetObj();
+	console.attributes = "N";
+	console.crlf();
+	console.crlf();
+	// If the user has the security level to download the file, let them do so
+	if (userCanDownloadFromFileArea_ShowErrorIfNot(pFileMetadata.dirCode))
+	{
+		console.print("\x01cDownloading \x01h" + pFileMetadata.name + "\x01n");
+		console.crlf();
+		var selectedFilanmeFullPath = backslash(file_area.dir[pFileMetadata.dirCode].path) + pFileMetadata.name;
+		bbs.send_file(selectedFilanmeFullPath);
+	}
+
+	retObj.reDrawListerHeader = true;
+	retObj.reDrawHeaderTextOnly = false;
+	retObj.reDrawMainScreenContent = true;
+	retObj.reDrawCmdBar = true;
+
+	return retObj;
+}
+
+// Displays the help screen.
+function displayHelpScreen()
+{
+	var retObj = getDefaultActionRetObj();
+
+	console.clear("\x01n");
+	// Display program information
+	displayTextWithLineBelow("Digital Distortion File Lister", true, "\x01n\x01c\x01h", "\x01k\x01h")
+	console.center("\x01n\x01cVersion \x01g" + LISTER_VERSION + " \x01w\x01h(\x01b" + LISTER_DATE + "\x01w)");
+	console.crlf();
+
+	// If listing files in a directory, display information about the current file directory.
+	if (gScriptMode == MODE_LIST_DIR)
+	{
+		var libIdx = file_area.dir[gDirCode].lib_index;
+		var dirIdx = file_area.dir[gDirCode].index;
+		console.print("\x01n\x01cCurrent file library: \x01g" + file_area.lib_list[libIdx].description);
+		console.crlf();
+		console.print("\x01cCurrent file directory: \x01g" + file_area.dir[gDirCode].description);
+		console.crlf();
+		console.print("\x01cThere are \x01g" + file_area.dir[gDirCode].files + " \x01cfiles in this directory.");
+	}
+	else if (gScriptMode == MODE_SEARCH_FILENAME)
+		console.print("\x01n\x01cCurrently performing a filename search");
+	else if (gScriptMode == MODE_SEARCH_DESCRIPTION)
+		console.print("\x01n\x01cCurrently performing a description search");
+	else if (gScriptMode == MODE_NEW_FILE_SEARCH)
+		console.print("\x01n\x01cCurrently performing a new file search");
+	console.crlf();
+	console.crlf();
+
+	// Display information about the lister
+	var helpStr = "This lists files in your current file directory with a lightbar interface (for an ANSI terminal).  ";
+	helpStr += "The file list can be navigated using the up & down arrow keys, PageUp, PageDown, Home, and End keys.  "
+	helpStr += "The currently highlighted file in the menu is used by default for the various actions.  For batch download "
+	helpStr += "selection, ";
+	if (user.is_sysop)
+		helpStr += "moving, and deleting, ";
+	helpStr += "you can select multiple files by using the spacebar.  ";
+	helpStr += "There is also a command bar accross the bottom of the screen - You can select an action on the ";
+	helpStr += "action bar by using the left & right arrow keys and pressing enter to choose an action.  Alternately, ";
+	helpStr += "you can press the first character of the action word to perform the action.";
+	helpStr += "  Also, the following actions are available:";
+	// Wrap the help string to the user's terminal width, and replace all instances of
+	// newlines with carriage return + newline, then display the help text.
+	helpStr = word_wrap(helpStr, console.screen_columns - 1).replace(/\n/g, "\r\n");
+	console.print(helpStr);
+	// Display the commands available
+	var commandStrWidth = 8;
+	var printfStr = "\x01n\x01c\x01h%-" + commandStrWidth + "s\x01g: \x01n\x01c%s\r\n";
+	printf(printfStr, "I", "Display extended file information");
+	printf(printfStr, "V", "View the file");
+	printf(printfStr, "B", "Flag the selected file(s) for batch download");
+	printf(printfStr, "D", "Download the highlighted (selected) file");
+	if (user.is_sysop)
+	{
+		printf(printfStr, "M", "Move the file(s) to another directory");
+		printf(printfStr, "DEL", "Delete the file(s)");
+	}
+	printf(printfStr, "?", "Show this help screen");
+	printf(printfStr, "Q", "Quit back to the BBS");
+	console.print("\x01n");
+	console.crlf();
+	//console.pause();
+
+	retObj.reDrawListerHeader = true;
+	retObj.reDrawMainScreenContent = true;
+	retObj.reDrawCmdBar = true;
+	return retObj;
+}
+
+// Allows the user to move the selected file to another filebase.  Only for sysops!
+//
+// Parameters:
+//  pFileList: The list of file metadata objects from the file directory
+//  pFileListMenu: The menu object for the file diretory
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function chooseFilebaseAndMoveFileToOtherFilebase_ANSI(pFileList, pFileListMenu)
+{
+	var retObj = getDefaultActionRetObj();
+
+	var fileMetadata = pFileList[pFileListMenu.selectedItemIdx];
+
+	// Confirm with the user to move the file(s).  If they don't want to,
+	// then just return now.
+	var filenames = [];
+	if (pFileListMenu.numSelectedItemIndexes() > 0)
+	{
+		for (var idx in pFileListMenu.selectedItemIndexes)
+			filenames.push(pFileList[+idx].name);
+	}
+	else
+		filenames.push(fileMetadata.name);
+	// Note that confirmFileActionWithUser() will re-draw the parts of the file
+	// list menu that are necessary.
+	var moveFilesConfirmed = confirmFileActionWithUser(filenames, "Move", false);
+	retObj.refreshedSelectedFilesAlready = true;
+	if (!moveFilesConfirmed)
+		return retObj;
+
+
+	// Create a file library menu for the user to choose a file library (and then directory)
+	var fileLibMenu = createFileLibMenu();
+	// For screen refresh purposes, construct the file list redraw info.  Note that the X and Y are relative
+	// to the file list menu, not absolute screen coordinates.
+	var topYForRefresh = fileLibMenu.pos.y - 1; // - 1 because of the label above the menu
+	var fileListPartialRedrawInfo = {
+		startX: fileLibMenu.pos.x - pFileListMenu.pos.x + 1,
+		startY: topYForRefresh - pFileListMenu.pos.y + 1,
+		absStartX: fileLibMenu.pos.x,
+		absStartY: topYForRefresh,
+		width: fileLibMenu.size.width + 1,
+		height: fileLibMenu.size.height + 1 // + 1 because of the label above the menu
+	};
+	console.gotoxy(fileLibMenu.pos.x, fileLibMenu.pos.y-1);
+	printf("\x01n\x01c\x01h|\x01n\x01c%-" + +(fileLibMenu.size.width-1) + "s\x01n", "Choose a destination area");
+	// Prompt the user which directory to move the file to
+	var chosenDirCode = null;
+	var continueOn = true;
+	while (continueOn)
+	{
+		var chosenLibIdx = fileLibMenu.GetVal();
+		if (typeof(chosenLibIdx) === "number")
+		{
+			// The file dir menu will be created at the same position & with the same size
+			// as the file library menu
+			var fileDirMenu = createFileDirMenu(chosenLibIdx);
+			chosenDirCode = fileDirMenu.GetVal();
+			if (typeof(chosenDirCode) === "string")
+			{
+				if (chosenDirCode != fileMetadata.dirCode)
+					continueOn = false;
+				else
+				{
+					chosenDirCode = "";
+					displayMsg("Can't move to the same directory", true);
+				}
+			}
+		}
+		else
+			continueOn = false;
+	}
+	// If the user chose a directory, then move the file there.
+	if (typeof(chosenDirCode) === "string" && chosenDirCode.length > 0)
+	{
+		// For logging
+		var libIdx = file_area.dir[chosenDirCode].lib_index;
+		var dirIdx = file_area.dir[chosenDirCode].index;
+		var libDesc = file_area.lib_list[libIdx].description;
+		var dirDesc =  file_area.dir[chosenDirCode].description;
+		var destLibAndDirDesc = libDesc + " - " + dirDesc;
+
+		// Build an array of file indexes and sort the array
+		var fileIndexes = [];
+		if (pFileListMenu.numSelectedItemIndexes() > 0)
+		{
+			for (var idx in pFileListMenu.selectedItemIndexes)
+				fileIndexes.push(+idx);
+		}
+		else
+			fileIndexes.push(+(pFileListMenu.selectedItemIdx));
+		// Ensure the file indexes are sorted in numerical order
+		fileIndexes.sort(function(a, b) { return a - b});
+
+		// Go through the list of files and move each of them
+		var moveAllSucceeded = true;
+		for (var i = 0; i < fileIndexes.length; ++i)
+		{
+			var fileIdx = fileIndexes[i];
+			// For logging
+			libIdx = file_area.dir[pFileList[fileIdx].dirCode].lib_index;
+			dirIdx = file_area.dir[pFileList[fileIdx].dirCode].index;
+			libDesc = file_area.lib_list[libIdx].description;
+			dirDesc =  file_area.dir[pFileList[fileIdx].dirCode].description;
+			var srcLibAndDirDesc = libDesc + " - " + dirDesc;
+
+			var moveRetObj = moveFileToOtherFilebase(pFileList[fileIdx], chosenDirCode);
+			var logMsg = "";
+			var logLevel = LOG_INFO;
+			if (moveRetObj.moveSucceeded)
+			{
+				logMsg = "Digital Distotion File Lister: Successfully moved " + pFileList[fileIdx].name
+				       + " from " + srcLibAndDirDesc + " to " + destLibAndDirDesc;
+
+				// If we're listing files in the user's current directory, then remove
+				// the file info object from the file list array.  Otherwise, update
+				// the metadata object in the list.
+				if (gScriptMode == MODE_LIST_DIR)
+				{
+					pFileList.splice(fileIdx, 1);
+					// Subtract 1 from the remaining indexes in the fileIndexes array
+					for (var j = i+1; j < fileIndexes.length; ++j)
+						fileIndexes[j] = fileIndexes[j] - 1;
+					// Have the file list menu set up its description width, colors, and format
+					// string again in case it no longer needs to use its scrollbar
+					pFileListMenu.SetItemWidthsColorsAndFormatStr();
+					retObj.reDrawMainScreenContent = true;
+				}
+				else
+				{
+					// Note: getFileInfoFromFilebase() will add dirCode to the metadata object
+					var fileDetail = (extendedDescEnabled() ? FileBase.DETAIL.EXTENDED : FileBase.DETAIL.NORM);
+					pFileList[fileIdx] = getFileInfoFromFilebase(chosenDirCode, pFileList[fileIdx].name, fileDetail);
+				}
+			}
+			else
+			{
+				moveAllSucceeded = false;
+				logLevel = LOG_ERR;
+				logMsg = "Digital Distotion File Lister: Failed to move " + pFileList[fileIdx].name
+				       + " from " + srcLibAndDirDesc + " to " + destLibAndDirDesc;
+			}
+			log(logLevel, logMsg);
+			bbs.log_str(logMsg);
+		}
+		// Adjust the selected item index in the file list menu if necesary
+		if (pFileListMenu.NumItems() == 0)
+			pFileListMenu.selectedItemIdx = 0;
+		else if (pFileListMenu.selectedItemIdx >= pFileListMenu.NumItems() - 1)
+			pFileListMenu.selectedItemIdx = pFileListMenu.NumItems() - 1;
+		// If doing a search (not listing files in the user's current directory), then
+		// if all files were in the same directory, then we'll need to update the header
+		// lines at the top of the file list.  If there's only one file in the list,
+		// the header lines will need to display the correct directory.  Otherwise,
+		// set allSameDir to false so the header lines will now say "various".
+		// However, if not all files were in the same directory, check to see if they
+		// are now, and if so, we'll need to re-draw the header lines.
+		if (gScriptMode != MODE_LIST_DIR && typeof(pFileList.allSameDir) == "boolean")
+		{
+			if (pFileList.allSameDir)
+			{
+				if (pFileList.length > 1)
+					pFileList.allSameDir = false;
+				retObj.reDrawHeaderTextOnly = true;
+			}
+			else
+			{
+				pFileList.allSameDir = true; // Until we find it's not true
+				for (var fileListIdx = 1; fileListIdx < pFileList.length && pFileList.allSameDir; ++fileListIdx)
+					pFileList.allSameDir = (pFileList[fileListIdx].dirCode == pFileList[0].dirCode);
+				retObj.reDrawHeaderTextOnly =  pFileList.allSameDir;
+			}
+		}
+		// Display a success/fail message
+		if (moveAllSucceeded)
+		{
+			var msg = "Successfully moved the file(s) to " + destLibAndDirDesc;
+			displayMsg(msg, false, true);
+		}
+		else
+		{
+			displayMsg("Failed to move the file(s)!", true, true);
+		}
+		// After moving the files, if there are no more files (in the directory or otherwise),
+		// say so and exit now.
+		if (gScriptMode == MODE_LIST_DIR && file_area.dir[gDirCode].files == 0)
+		{
+			displayMsg("There are no more files in the directory.", false);
+			retObj.exitNow = true;
+		}
+		else if (pFileList.length == 0)
+		{
+			displayMsg("There are no more files.", false);
+			retObj.exitNow = true;
+		}
+	}
+
+	// If not exiting now, we'll want to re-draw part of the file list to erase the
+	// area chooser menu.
+	if (!retObj.exitNow)
+		retObj.fileListPartialRedrawInfo = fileListPartialRedrawInfo;
+
+	return retObj;
+}
+
+// Allows the user to remove the selected file(s) from the filebase.  Only for sysops!
+//
+// Parameters:
+//  pFileList: The list of file metadata objects from the file directory
+//  pFileListMenu: The menu object for the file diretory
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.  For this function, the object
+//               returned will have the following additional properties:
+//               filesDeleted: Boolean - Whether or not files were actually deleted (after
+//                             confirmation)
+function confirmAndRemoveFilesFromFilebase_ANSI(pFileList, pFileListMenu)
+{
+	var retObj = getDefaultActionRetObj();
+	retObj.filesDeleted = false;
+
+	// Confirm the action with the user.  If the user confirms, then remove the file(s).
+	// If there are multiple selected files, then prompt to remove each of them.
+	// Otherwise, prompt for the one selected file.
+	var filenames = [];
+	if (pFileListMenu.numSelectedItemIndexes() > 0)
+	{
+		for (var idx in pFileListMenu.selectedItemIndexes)
+			filenames.push(pFileList[+idx].name);
+	}
+	else
+		filenames.push(pFileList[pFileListMenu.selectedItemIdx].name);
+	// Note that confirmFileActionWithUser() will re-draw the parts of the file list menu
+	// that are necessary.
+	var removeFilesConfirmed = confirmFileActionWithUser(filenames, "Remove", false);
+	retObj.refreshedSelectedFilesAlready = true;
+	if (removeFilesConfirmed)
+	{
+		retObj.filesDeleted = true; // Assume true even if some deletions may fail
+
+		var fileIndexes = [];
+		if (pFileListMenu.numSelectedItemIndexes() > 0)
+		{
+			for (var idx in pFileListMenu.selectedItemIndexes)
+				fileIndexes.push(+idx);
+		}
+		else
+			fileIndexes.push(+(pFileListMenu.selectedItemIdx));
+		// Ensure the file indexes are sorted in numerical order
+		fileIndexes.sort(function(a, b) { return a - b});
+
+		// Go through all the selected files and remove them.
+		// Note: Going through the list of indexes in reverse order so that
+		// removing each one from pFileList (gFileList) is simpler.
+		var removeAllSucceeded = true;
+		//for (var i = 0; i < fileIndexes.length; ++i)
+		for (var i = fileIndexes.length-1; i >= 0; --i)
+		{
+			var fileIdx = fileIndexes[i];
+			if (typeof(pFileList[fileIdx]) === "undefined")
+			{
+				removeAllSucceeded = false;
+				continue;
+			}
+			// For logging
+			var libIdx = file_area.dir[pFileList[fileIdx].dirCode].lib_index;
+			var dirIdx = file_area.dir[pFileList[fileIdx].dirCode].index;
+			var libDesc = file_area.lib_list[libIdx].description;
+			var dirDesc =  file_area.dir[pFileList[fileIdx].dirCode].description;
+			var libAndDirDesc = libDesc + " - " + dirDesc;
+
+			// Open the filebase and remove the file
+			var removeFileSucceeded = false;
+			var numFilesRemaining = 0;
+			var filebase = new FileBase(pFileList[fileIdx].dirCode);
+			if (filebase.open())
+			{
+				var filenameFullPath = filebase.get_path(pFileList[fileIdx].name); // For logging
+				try
+				{
+					removeFileSucceeded = filebase.remove(pFileList[fileIdx].name, true);
+				}
+				catch (error)
+				{
+					removeFileSucceeded = false;
+					// Make an entry in the BBS log that deleting the file failed
+					var logMsg = "ddfilelister: " + error;
+					log(LOG_ERR, logMsg);
+					bbs.log_str(logMsg);
+				}
+				// If the remove failed with deleting the file, then try without deleting the file
+				if (!removeFileSucceeded)
+				{
+					removeFileSucceeded = filebase.remove(pFileList[fileIdx].name, false);
+					if (removeFileSucceeded)
+					{
+						var logMsg = "ddfilelister: Removed " + filenameFullPath + " from the "
+						           + "filebase but couldn't actually delete the file";
+						log(LOG_INFO, logMsg);
+						bbs.log_str(logMsg);
+					}
+				}
+				if (removeFileSucceeded)
+				{
+					if (gScriptMode == MODE_LIST_DIR)
+						numFilesRemaining = filebase.files;
+				}
+				filebase.close();
+			}
+			else
+				removeAllSucceeded = false;
+
+			// Log a success/error message
+			var logMsg = "";
+			var logLevel = LOG_INFO;
+			if (removeFileSucceeded)
+			{
+				logMsg = "Digital Distortion File Lister: Successfully removed " + pFileList[fileIdx].name
+				       + " from " + libAndDirDesc + " (by " + user.alias + ")";
+				// Remove the file info object from the file list array
+				pFileList.splice(fileIdx, 1);
+				// If we were going through the list in forward order, we'd have to
+				// subtract 1 from the remaining indexes:
+				/*
+				// Subtract 1 from the remaining indexes in the fileIndexes array
+				for (var j = i+1; j < fileIndexes.length; ++j)
+					fileIndexes[j] = fileIndexes[j] - 1;
+				*/
+			}
+			else
+			{
+				removeAllSucceeded = false;
+				logMsg = "Digital Distortion File Lister: Failed to remove " + pFileList[fileIdx].name
+				       + " from " + libAndDirDesc + " (by " + user.alias + ")";
+				logLevel = LOG_ERR;
+			}
+			log(logLevel, logMsg);
+			bbs.log_str(logMsg);
+		}
+		// Display a success/failure message
+		if (removeAllSucceeded)
+			displayMsg("Successfully removed the file(s)", false, true);
+		else
+			displayMsg("Failed to remove 1 or more files", true, true);
+		// Adjust the selected item index in the file list menu if necesary
+		if (pFileListMenu.NumItems() == 0)
+			pFileListMenu.selectedItemIdx = 0;
+		else if (pFileListMenu.selectedItemIdx >= pFileListMenu.NumItems() - 1)
+			pFileListMenu.selectedItemIdx = pFileListMenu.NumItems() - 1;
+		// If the file list still has files in it, have the menu redraw
+		// itself to refresh with the missing entry.  Otherwise (no files left),
+		// say so and have the lister exit now.
+		numFilesRemaining = pFileList.length;
+		if (numFilesRemaining > 0)
+		{
+			// Have the file list menu set up its description width, colors, and format
+			// string again in case it no longer needs to use its scrollbar
+			pFileListMenu.SetItemWidthsColorsAndFormatStr();
+			retObj.reDrawMainScreenContent = true;
+			// If all files were not in the same directory, then check to see if all
+			// remaining files are now.  If so, we'll need to update the header lines
+			// at the top of the file list.
+			if (typeof(pFileList.allSameDir) == "boolean")
+			{
+				if (!pFileList.allSameDir)
+				{
+					pFileList.allSameDir = true; // Until we find it's not true
+					for (var i = 1; i < pFileList.length && pFileList.allSameDir; ++i)
+						pFileList.allSameDir = (pFileList[i].dirCode == pFileList[0].dirCode);
+					//retObj.reDrawListerHeader =  pFileList.allSameDir;
+					retObj.reDrawHeaderTextOnly =  pFileList.allSameDir;
+				}
+			}
+			// Also, if the file list menu can now show all its items on one
+			// page (not needing the scrollbar), set its top item index to 0.
+			if (pFileListMenu.CanShowAllItemsInWindow())
+				pFileListMenu.topItemIdx = 0;
+		}
+		else
+		{
+			if (gScriptMode == MODE_LIST_DIR)
+				displayMsg("The directory now has no files.", false, true);
+			else
+				displayMsg("There are no more files to show.", false, true);
+			retObj.exitNow = true;
+		}
+	}
+
+	return retObj;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// DDFileMenuBar stuff
+
+function DDFileMenuBar(pPos)
+{
+	// Member functions
+	this.constructPromptText = DDFileMenuBar_constructPromptText;
+	this.getItemTextFromIdx = DDFileMenuBar_getItemTextFromIdx;
+	this.writePromptLine = DDFileMenuBar_writePromptLine;
+	this.refreshWithNewAction = DDFileMenuBar_refreshWithNewAction;
+	this.getDDFileMenuBarItemText = DDFileMenuBar_getDDFileMenuBarItemText;
+	this.incrementMenuItemAndRefresh = DDFileMenuBar_incrementMenuItemAndRefresh;
+	this.decrementMenuItemAndRefresh = DDFileMenuBar_decrementMenuItemAndRefresh;
+	this.getCurrentSelectedAction = DDFileMenuBar_getCurrentSelectedAction;
+	this.getActionFromChar = DDFileMenuBar_getActionFromChar;
+	this.setCurrentActionCode = DDFileMenuBar_setCurrentActionCode;
+	this.getAllActionKeysStr = DDFileMenuBar_getAllActionKeysStr;
+
+	// Member variables
+	this.pos = {
+		x: 1,
+		y: 1
+	};
+	if (typeof(pPos) === "object" && pPos.hasOwnProperty("x") && pPos.hasOwnProperty("y") && typeof(pPos.x) === "number" && typeof(pPos.y) === "number")
+	{
+		if (pPos.x >= 1 && pPos.x <= console.screen_columns)
+			this.pos.x = pPos.x;
+		if (pPos.y >= 1 && pPos.y <= console.screen_rows)
+			this.pos.y = pPos.y;
+	}
+
+	this.currentCommandIdx = 0; // The index of the current command for the menu array
+	this.lastCommandIdx = 0;    // To keep track of the previous command index
+
+	// Construct this.cmdArray: An array of the options
+	this.cmdArray = [];
+	this.cmdArray.push(new DDFileMenuBarItem("Info", 0, FILE_VIEW_INFO));
+	this.cmdArray.push(new DDFileMenuBarItem("View", 0, FILE_VIEW));
+	this.cmdArray.push(new DDFileMenuBarItem("Batch", 0, FILE_ADD_TO_BATCH_DL));
+	this.cmdArray.push(new DDFileMenuBarItem("DL", 0, FILE_DOWNLOAD_SINGLE));
+	if (user.is_sysop)
+	{
+		this.cmdArray.push(new DDFileMenuBarItem("Move", 0, FILE_MOVE));
+		//this.cmdArray.push(new DDFileMenuBarItem("Del", 0, FILE_DELETE));
+		this.cmdArray.push(new DDFileMenuBarItem("DEL", 0, FILE_DELETE, KEY_DEL));
+	}
+	this.cmdArray.push(new DDFileMenuBarItem("?", 0, HELP));
+	this.cmdArray.push(new DDFileMenuBarItem("Quit", 0, QUIT));
+
+	// Construct the prompt text (this must happen after this.cmdArray is built)
+	this.promptText = "";
+	this.constructPromptText(); // this.promptText will be constructed here
+}
+// For the DDFileMenuBar class: Constructs the prompt text.  This must be called after
+// this.cmdArray is built.
+//
+// Return value: The number of additional solid blocks used to fill the whole screen row
+function DDFileMenuBar_constructPromptText()
+{
+	var totalItemTextLen = 0;
+	for (var i = 0; i < this.cmdArray.length; ++i)
+		totalItemTextLen += this.cmdArray[i].itemText.length;
+	// The number of inner characters (without the outer solid blocks) is the total text
+	// length of all the items + 2 characters for each item except the last one
+	var numInnerChars = totalItemTextLen + (2 * (this.cmdArray.length-1));
+	// The number of solid blocks: Subtracting 11 because there will be 5 block characters on each side,
+	// and subtract 1 extra so it doesn't fill the last character on the screen
+	var numSolidBlocks = console.screen_columns - numInnerChars - 11;
+	var numSolidBlocksPerSide = Math.floor(numSolidBlocks / 2);
+	// Build the prompt text: Start with the left blocks
+	this.promptText = "\x01n\x01w" + BLOCK1 + BLOCK2 + BLOCK3 + BLOCK4;
+	for (var i = 0; i < numSolidBlocksPerSide; ++i)
+		this.promptText += BLOCK4;
+	this.promptText += THIN_RECTANGLE_LEFT;
+	// Add the menu item text & block characters
+	var menuItemXPos = 6 + numSolidBlocksPerSide; // The X position of the start of item text for each item
+	for (var i = 0; i < this.cmdArray.length; ++i)
+	{
+		this.cmdArray[i].pos = menuItemXPos;
+		var numTrailingBlockChars = 0;
+		var selected = (i == this.currentCommandIdx);
+		var withTrailingBlock = false;
+		if (i < this.cmdArray.length-1)
+		{
+			withTrailingBlock = true;
+			numTrailingBlockChars = 2;
+		}
+		menuItemXPos += this.cmdArray[i].itemText.length + numTrailingBlockChars;
+		this.promptText += this.getDDFileMenuBarItemText(this.cmdArray[i].itemText, selected, withTrailingBlock);
+	}
+	// Add the right-side blocks
+	this.promptText += "\x01w" + THIN_RECTANGLE_RIGHT;
+	for (var i = 0; i < numSolidBlocksPerSide; ++i)
+		this.promptText += BLOCK4;
+	this.promptText += BLOCK3 + BLOCK2 + BLOCK1 + "\x01n";
+}
+// For the DDFileMenuBar class: Gets the text for a prompt item based on its index
+function DDFileMenuBar_getItemTextFromIdx(pIdx)
+{
+	if (typeof(pIdx) !== "number" || pIdx < 0 || pIdx >= this.cmdArray.length)
+		return "";
+	return this.cmdArray[pIdx].itemText;
+}
+// For the DDFileMenuBar class: Writes the prompt text at the defined location
+function DDFileMenuBar_writePromptLine()
+{
+	// Place the cursor at the defined location, then write the prompt text
+	console.gotoxy(this.pos.x, this.pos.y);
+	console.print(this.promptText);
+}
+// For the DDFileMenuBar class: Refreshes 2 items in the command bar text line
+//
+// Parameters:
+//  pCmdIdx: The index of the new/current command
+function DDFileMenuBar_refreshWithNewAction(pCmdIdx)
+{
+	if (typeof(pCmdIdx) !== "number")
+		return;
+	if (pCmdIdx == this.currentCommandIdx)
+		return;
+
+	// Refresh the prompt area for the previous index with regular colors
+	// Re-draw the last item text with regular colors
+	var itemText = this.getItemTextFromIdx(this.currentCommandIdx);
+	console.gotoxy(this.cmdArray[this.currentCommandIdx].pos, this.pos.y);
+	console.print("\x01n" + this.getDDFileMenuBarItemText(itemText, false, false));
+	// Draw the new item text with selected colors
+	itemText = this.getItemTextFromIdx(pCmdIdx);
+	console.gotoxy(this.cmdArray[pCmdIdx].pos, this.pos.y);
+	console.print("\x01n" + this.getDDFileMenuBarItemText(itemText, true, false));
+	console.gotoxy(this.pos.x+strip_ctrl(this.promptText).length-1, this.pos.y);
+
+	this.lastCommandIdx = this.currentCommandIdx;
+	this.currentCommandIdx = pCmdIdx;
+
+	// Re-construct the bar text to make sure it's up to date with the selected action
+	this.constructPromptText();
+}
+// For the DDFileMenuBar class: Returns a string containing a piece of text for the
+// menu bar text with its color attributes.
+//
+// Parameters:
+//  pText: The text for the item
+//  pSelected: Boolean - Whether or not the item is selected
+//  pWithTrailingBlock: Boolean - Whether or not to include the trailing block
+//
+// Return value: A string containing the item text for the action bar
+function DDFileMenuBar_getDDFileMenuBarItemText(pText, pSelected, pWithTrailingBlock)
+{
+	if (typeof(pText) !== "string" || pText.length == 0)
+		return "";
+
+	var selected = (typeof(pSelected) === "boolean" ? pSelected : false);
+	var withTrailingBlock = (typeof(pWithTrailingBlock) === "boolean" ? pWithTrailingBlock : false);
+
+	// Separate the first character from the rest of the text
+	var firstChar = pText.length > 0 ? pText.charAt(0) : "";
+	var restOfText = pText.length > 1 ? pText.substr(1, pText.length - 1) : "";
+	// Build the item text and return it
+	var itemText = "\x01n";
+	if (selected)
+		itemText += "\x01" + "1\x01r\x01h" + firstChar + "\x01n\x01" + "1\x01k" + restOfText;
+	else
+		itemText += "\x01" + "6\x01c\x01h" + firstChar + "\x01n\x01" + "6\x01k" + restOfText;
+	itemText += "\x01n";
+	if (withTrailingBlock)
+		itemText += "\x01w" + THIN_RECTANGLE_RIGHT + THIN_RECTANGLE_LEFT + "\x01n";
+	return itemText;
+}
+// For the DDFileMenuBar class: Increments to the next menu item and refreshes the
+// menu bar on the screen
+function DDFileMenuBar_incrementMenuItemAndRefresh()
+{
+	var newCmdIdx = this.currentCommandIdx + 1;
+	if (newCmdIdx >= this.cmdArray.length)
+		newCmdIdx = 0;
+	// Will set this.currentCommandIdx
+	this.refreshWithNewAction(newCmdIdx);
+}
+// For the DDFileMenuBar class: Decrements to the previous menu item and refreshes the
+// menu bar on the screen
+function DDFileMenuBar_decrementMenuItemAndRefresh()
+{
+	var newCmdIdx = this.currentCommandIdx - 1;
+	if (newCmdIdx < 0)
+		newCmdIdx = this.cmdArray.length - 1;
+	// Will set this.currentCommandIdx
+	this.refreshWithNewAction(newCmdIdx);
+}
+// For the DDFileMenuBar class: Gets the return code for the currently selected action
+function DDFileMenuBar_getCurrentSelectedAction()
+{
+	return this.cmdArray[this.currentCommandIdx].retCode;
+}
+// For the DDFileMenuBar class: Gets the return code matching a given character.
+// If there is no match, this will return -1.
+//
+// Parameters:
+//  pChar: The character to match
+//  pCaseSensitive: Optional - Boolean - Whether or not to do a case-sensitive match.
+//                  This defaults to false.
+function DDFileMenuBar_getActionFromChar(pChar, pCaseSensitive)
+{
+	if (typeof(pChar) !== "string" || pChar.length == 0)
+		return -1;
+
+	var caseSensitive = (typeof(pCaseSensitive) === "boolean" ? pCaseSensitive : false);
+
+	var retCode = -1;
+	if (caseSensitive)
+	{
+		for (var i = 0; i < this.cmdArray.length && retCode == -1; ++i)
+		{
+			if (this.cmdArray[i].hotkeyOverride != null && typeof(this.cmdArray[i].hotkeyOverride) !== "undefined")
+			{
+				if (pChar == this.cmdArray[i].hotkeyOverride)
+					retCode = this.cmdArray[i].retCode;
+			}
+			else if (this.cmdArray[i].itemText.length > 0 && this.cmdArray[i].itemText.charAt(0) == pChar)
+				retCode = this.cmdArray[i].retCode;
+		}
+	}
+	else
+	{
+		// Not case sensitive
+		var charUpper = pChar.toUpperCase();
+		for (var i = 0; i < this.cmdArray.length && retCode == -1; ++i)
+		{
+			if (this.cmdArray[i].hotkeyOverride != null && typeof(this.cmdArray[i].hotkeyOverride) !== "undefined")
+			{
+				if (pChar == this.cmdArray[i].hotkeyOverride)
+					retCode = this.cmdArray[i].retCode;
+			}
+			else if (this.cmdArray[i].itemText.length > 0 && this.cmdArray[i].itemText.charAt(0).toUpperCase() == charUpper)
+				retCode = this.cmdArray[i].retCode;
+		}
+	}
+	return retCode;
+}
+// For the DDFileMenuBar class: Sets the current command item in the menu bar based on its
+// action code
+//
+// Parameters:
+//  pActionCode: The code of the action
+//  pRefreshOnScreen: Optional boolean - Whether or not to refresh the command bar on the screen.
+//                    Defaults to false.
+function DDFileMenuBar_setCurrentActionCode(pActionCode, pRefreshOnScreen)
+{
+	if (typeof(pActionCode) !== "number")
+		return;
+
+	var refreshOnScreen = (typeof(pRefreshOnScreen) === "boolean" ? pRefreshOnScreen : false);
+
+	for (var i = 0; i < this.cmdArray.length; ++i)
+	{
+		if (this.cmdArray[i].retCode == pActionCode)
+		{
+			if (refreshOnScreen)
+				this.refreshWithNewAction(i);
+			else
+			{
+				this.currentCommandIdx = i;
+				this.lastCommandIdx = i;
+			}
+			break;
+		}
+	}
+}
+// For the DDFileMenuBar: Gets all the action hotkeys as a string
+//
+// Parameters:
+//  pLowercase: Boolean - Whether or not to include letters as lowercase
+//  pUppercase: Boolean - Whether or not to include letters as uppercase
+//
+// Return value: All the action hotkeys as a string
+function DDFileMenuBar_getAllActionKeysStr(pLowercase, pUppercase)
+{
+	var hotkeysStr = "";
+	for (var i = 0; i < this.cmdArray.length; ++i)
+	{
+		if (this.cmdArray[i].itemText.length > 0)
+			hotkeysStr += this.cmdArray[i].itemText.charAt(0);
+	}
+	var hotkeysToReturn = "";
+	if (pLowercase)
+		hotkeysToReturn += hotkeysStr.toLowerCase();
+	if (pUppercase)
+		hotkeysToReturn += hotkeysStr.toUpperCase();
+	return hotkeysToReturn;
+}
+
+// Consctructor for an DDFileMenuBarItem
+//
+// Parameters:
+//  pItemText: The text of the item
+//  pPos: Horizontal (or vertical) starting location in the bar
+//  pRetCode: The item's return code
+//  pHotkeyOverride: Optional: A key to use for the action instead of the first character in pItemText
+function DDFileMenuBarItem(pItemText, pPos, pRetCode, pHotkeyOverride)
+{
+	this.itemText = pItemText;
+	this.pos = pPos;
+	this.retCode = pRetCode;
+	this.hotkeyOverride = null;
+	if (pHotkeyOverride != null && typeof(pHotkeyOverride) !== "undefined")
+		this.hotkeyOverride = pHotkeyOverride;
+}
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Helper functions
+
+// Gets file metadata & its file time from a filebase.
+//
+// Parameters:
+//  pDirCode: The internal code of the directory the file is in
+//  pFilename: The name of the file (without the full path)
+//  pDetail: The detail level of the metadata object to get - See FileBase.DETAIL in JS docs
+//
+// Return value: An object containing the following properties:
+//               fileMetadataObj: An object with extended file metadata from the filebase.
+//                                This will have a dirCode property added.
+//               fileTime: The timestamp of the file
+function getFileInfoFromFilebase(pDirCode, pFilename, pDetail)
+{
+	if (typeof(pDirCode) !== "string" || pDirCode.length == 0 || typeof(pFilename) !== "string" || pFilename.length == 0)
+		return null;
+
+	var fileMetadataObj = null;
+	var filebase = new FileBase(pDirCode);
+	if (filebase.open())
+	{
+		// Just in case the file has the full path, get just the filename from it.
+		var filename = file_getname(pFilename);
+		var fileDetail = (typeof(pDetail) === "number" ? pDetail : FileBase.DETAIL.NORM);
+		if (typeof(pDetail) === "number")
+			fileDetail = pDetail;
+		else
+			fileDetail = (extendedDescEnabled() ? FileBase.DETAIL.EXTENDED : FileBase.DETAIL.NORM);
+		fileMetadataObj = filebase.get(filename, fileDetail);
+		fileMetadataObj.dirCode = pDirCode;
+		//fileMetadataObj.size = filebase.get_size(filename);
+		if (!fileMetadataObj.hasOwnProperty("time"))
+			fileMetadataObj.time = filebase.get_time(filename);
+		filebase.close();
+	}
+
+	return fileMetadataObj;
+}
+
+// Moves a file from one filebase to another
+//
+// Parameters:
+//  pSrcFileMetadata: Metadata for the source file.  This is assumed to contain 'normal' detail (not extended)
+//  pDestDirCode: The internal code of the destination filebase to move to the file to
+//
+// Return value: An object containing the following properties:
+//               moveSucceeded: Boolean - Whether or not the move succeeded
+//               failReason: If the move failed, this is a string that specifies why it failed
+function moveFileToOtherFilebase(pSrcFileMetadata, pDestDirCode)
+{
+	var retObj = {
+		moveSucceeded: false,
+		failReason: ""
+	};
+
+	// pSrcFileMetadata is assumed to be a basic file metadata object, without extended
+	// information.  Get a metadata object with maximum information so we have all
+	// metadata available.
+	var srcFilebase = new FileBase(pSrcFileMetadata.dirCode);
+	if (srcFilebase.open())
+	{
+		var extdFileInfo = srcFilebase.get(pSrcFileMetadata, FileBase.DETAIL.MAX);
+		// Move the file over, remove it from the original filebase, and add it to the new filebase
+		var srcFilenameFull = srcFilebase.get_path(pSrcFileMetadata);
+		var destFilenameFull = file_area.dir[pDestDirCode].path + pSrcFileMetadata.name;
+		if (file_rename(srcFilenameFull, destFilenameFull))
+		{
+			if (srcFilebase.remove(pSrcFileMetadata.name, false))
+			{
+				// Add the file to the other directory
+				var destFilebase = new FileBase(pDestDirCode);
+				if (destFilebase.open())
+				{
+					retObj.moveSucceeded = destFilebase.add(extdFileInfo);
+					destFilebase.close();
+				}
+				else
+				{
+					retObj.failReason = "Failed to open the destination filebase";
+					// Try to add the file back to the source filebase
+					var moveBackSucceeded = false;
+					if (file_rename(destFilenameFull, srcFilenameFull))
+						moveBackSucceeded = srcFilebase.add(extdFileInfo);
+					if (!moveBackSucceeded)
+						retObj.failReason += " & moving the file back failed";
+				}
+			}
+			else
+				retObj.failReason = "Failed to remove the file from the source directory";
+		}
+		else
+			retObj.failReason = "Failed to move the file to the new filebase directory";
+
+		srcFilebase.close();
+	}
+	else
+	{
+		var libIdx = file_area.dir[pSrcFileMetadata.dirCode].lib_index;
+		var dirIdx = file_area.dir[pSrcFileMetadata.dirCode].index;
+		var libDesc = file_area.lib_list[libIdx].description;
+		var dirDesc =  file_area.dir[pSrcFileMetadata.dirCode].description;
+		retObj.failreason = "Failed to open filebase: " + libDesc + " - " + dirDesc;
+	}
+
+	return retObj;
+}
+
+// Counts the number of occurrences of a substring within a string
+//
+// Parameters:
+//  pStr: The string to count occurences in
+//  pSubstr: The string to look for within pStr
+//
+// Return value: The number of occurrences of pSubstr found in pStr
+function countOccurrencesInStr(pStr, pSubstr)
+{
+	if (typeof(pStr) !== "string" || typeof(pSubstr) !== "string") return 0;
+	if (pStr.length == 0 || pSubstr.length == 0) return 0;
+
+	var count = 0;
+	var strIdx = pStr.indexOf(pSubstr);
+	while (strIdx > -1 && strIdx < pStr.length)
+	{
+		++count;
+		strIdx = pStr.indexOf(pSubstr, strIdx+1);
+	}
+	return count;
+}
+
+// Constructs & displays a frame with a border around it, and performs a user input loop
+// until the user quits out of the input loop.
+//
+// Parameters:
+//  pFrameX: The X coordinate of the upper-left corner of the frame (including border)
+//  pFrameY: The Y coordinate of the upper-left corner of the frame (including border)
+//  pFrameWidth: The width of the frame (including border)
+//  pFrameHeight: The height of the frame (including border)
+//  pBorderColor: The attribute codes for the border color
+//  pFrameTitle: The title (text) to use in the frame border
+//  pTitleColor: Optional string - The attribute codes for the color to use for the frame title
+//  pFrameContents: The contents to display in the frame
+//  pAdditionalQuitKeys: Optional - A string containing additional keys to quit the
+//                       input loop.  This is case-sensitive.
+//
+// Return value: The last keypress/input from the user
+function displayBorderedFrameAndDoInputLoop(pFrameX, pFrameY, pFrameWidth, pFrameHeight, pBorderColor, pFrameTitle, pTitleColor, pFrameContents, pAdditionalQuitKeys)
+{
+	if (typeof(pFrameX) !== "number" || typeof(pFrameY) !== "number" || typeof(pFrameWidth) !== "number" || typeof(pFrameHeight) !== "number")
+		return;
+
+	// Display the border for the frame
+	var keyHelpStr = "\x01n\x01c\x01hQ\x01b/\x01cEnter\x01b/\x01cESC\x01y: \x01gClose\x01b";
+	var scrollLoopNavHelp = "\x01c\x01hUp\x01b/\x01cDn\x01b/\x01cHome\x01b/\x01cEnd\x01b/\x01cPgup\x01b/\x01cPgDn\x01y: \x01gNav";
+	if (console.screen_columns >= 80)
+		keyHelpStr += ", " + scrollLoopNavHelp;
+	var borderColor = (typeof(pBorderColor) === "string" ? pBorderColor : "\x01r");
+	drawBorder(pFrameX, pFrameY, pFrameWidth, pFrameHeight, borderColor, "double", pFrameTitle, pTitleColor, keyHelpStr);
+
+	// Construct the frame window for the file info
+	// Create a Frame here with the full filename, extended description, etc.
+	var frameX = pFrameX + 1;
+	var frameY = pFrameY + 1;
+	var frameWidth = pFrameWidth - 2;
+	var frameHeight = pFrameHeight - 2;
+	var frameObj = new Frame(frameX, frameY, frameWidth, frameHeight, BG_BLACK);
+	frameObj.attr &=~ HIGH;
+	frameObj.v_scroll = true;
+	frameObj.h_scroll = false;
+	frameObj.scrollbars = true;
+	var scrollbarObj = new ScrollBar(frameObj, {bg: BG_BLACK, fg: LIGHTGRAY, orientation: "vertical", autohide: false});
+	// Put the file info string in the frame window, then start the
+	// user input loop for the frame
+	frameObj.putmsg(pFrameContents, "\x01n");
+	var lastUserInput = doFrameInputLoop(frameObj, scrollbarObj, pFrameContents, pAdditionalQuitKeys);
+	//infoFrame.bottom();
+
+	return lastUserInput;
+}
+
+// Displays a Frame object and handles the input loop for navigation until
+// the user presses Q, Enter, or ESC To quit the input loop
+//
+// Parameters:
+//  pFrame: The Frame object
+//  pScrollbar: The Scrollbar object for the Frame
+//  pFrameContentStr: The string content that was added to the Frame
+//  pAdditionalQuitKeys: Optional - A string containing additional keys to quit the
+//                       input loop.  This is case-sensitive.
+//
+// Return value: The last keypress/input from the user
+function doFrameInputLoop(pFrame, pScrollbar, pFrameContentStr, pAdditionalQuitKeys)
+{
+	var checkAdditionalQuitKeys = (typeof(pAdditionalQuitKeys) === "string" && pAdditionalQuitKeys.length > 0);
+
+	// Input loop for the frame to let the user scroll it
+	var frameContentTopYOffset = 0;
+	//var maxFrameYOffset = pFrameContentStr.split("\r\n").length - pFrame.height;
+	var maxFrameYOffset = countOccurrencesInStr(pFrameContentStr, "\r\n") - pFrame.height;
+	if (maxFrameYOffset < 0) maxFrameYOffset = 0;
+	var userInput = "";
+	var continueOn = true;
+	do
+	{
+		pFrame.scrollTo(0, frameContentTopYOffset);
+		pFrame.invalidate();
+		pScrollbar.cycle();
+		pFrame.cycle();
+		pFrame.draw();
+		// Note: getKeyWithESCChars() is defined in dd_lightbar_menu.js.
+		userInput = getKeyWithESCChars(K_NOECHO|K_NOSPIN|K_NOCRLF, 30000).toUpperCase();
+		if (userInput == KEY_UP)
+		{
+			if (frameContentTopYOffset > 0)
+				--frameContentTopYOffset;
+		}
+		else if (userInput == KEY_DOWN)
+		{
+			if (frameContentTopYOffset < maxFrameYOffset)
+				++frameContentTopYOffset;
+		}
+		else if (userInput == KEY_PAGEUP)
+		{
+			frameContentTopYOffset -= pFrame.height;
+			if (frameContentTopYOffset < 0)
+				frameContentTopYOffset = 0;
+		}
+		else if (userInput == KEY_PAGEDN)
+		{
+			frameContentTopYOffset += pFrame.height;
+			if (frameContentTopYOffset > maxFrameYOffset)
+				frameContentTopYOffset = maxFrameYOffset;
+		}
+		else if (userInput == KEY_HOME)
+			frameContentTopYOffset = 0;
+		else if (userInput == KEY_END)
+			frameContentTopYOffset = maxFrameYOffset;
+
+		// Check for whether to continue the input loop
+		continueOn = (userInput != "Q" && userInput != KEY_ENTER && userInput != KEY_ESC);
+		// If the additional quit keys does not contain the user's keypress, then continue
+		// the input loop.
+		// In other words, if the additional quit keys includes the user's keypress, then
+		// don't continue.
+		if (continueOn && checkAdditionalQuitKeys)
+			continueOn = (pAdditionalQuitKeys.indexOf(userInput) < 0);
+	} while (continueOn);
+
+	return userInput;
+}
+
+// Displays the header lines for showing above the file list
+//
+// Parameters:
+//  pTextOnly: Only draw the library & directory text (no decoration or other text).
+//             This is optional & defaults to false.
+//  pDirCodeOverride: Optional string: If this is valid, this will be used for the library & directory name
+function displayFileLibAndDirHeader(pTextOnly, pDirCodeOverride)
+{
+	// If the behavior flags include no header, then just return immediately
+	if ((gListBehavior & FL_NO_HDR) == FL_NO_HDR)
+		return;
+
+	var textOnly = (typeof(pTextOnly) === "boolean" ? pTextOnly : false);
+
+	// Determine if this is the first time this function has been called.  If so,
+	// we'll want to update gNumHeaderLinesDisplayed at the end.
+	var dispHdrFirstRun = false;
+	if (typeof(displayFileLibAndDirHeader.firstRun) == "undefined")
+	{
+		dispHdrFirstRun = true;
+		displayFileLibAndDirHeader.firstRun = true;
+	}
+
+	// For the library & directory text to display, if we're just listing the user's
+	// current directory, use that.  Otherwise, if all search results are in the same
+	// directory, then use a directory code from the file list.
+	var libIdx = 0;
+	var dirIdx = 0;
+	var libDesc = "";
+	var dirDesc =  "";
+	var dirCode = "";
+	if (gScriptMode == MODE_LIST_DIR)
+		dirCode = gDirCode;
+	else if (typeof(gFileList.allSameDir) === "boolean" && gFileList.allSameDir && gFileList.length > 0)
+		dirCode = gFileList[0].dirCode;
+	if (dirCode.length > 0)
+	{
+		libIdx = file_area.dir[dirCode].lib_index;
+		dirIdx = file_area.dir[dirCode].index;
+		libDesc = file_area.lib_list[libIdx].description;
+		dirDesc =  file_area.dir[dirCode].description;
+	}
+	else if (typeof(pDirCodeOverride) === "string" && file_area.dir.hasOwnProperty(pDirCodeOverride))
+	{
+		libIdx = file_area.dir[pDirCodeOverride].lib_index;
+		dirIdx = file_area.dir[pDirCodeOverride].index;
+		libDesc = file_area.lib_list[libIdx].description;
+		dirDesc =  file_area.dir[pDirCodeOverride].description;
+	}
+	else
+	{
+		libIdx = -1;
+		dirIdx = -1;
+		libDesc = "Various";
+		dirDesc = "Various";
+	}
+
+	var hdrTextWidth = console.screen_columns - 21;
+	var descWidth = hdrTextWidth - 11;
+	var libText = format("\x01cLib \x01w\x01h#\x01b%4d\x01c: \x01n\x01c%-" + descWidth + "s\x01n", +(libIdx+1), libDesc.substr(0, descWidth));
+	var dirText = format("\x01cDir \x01w\x01h#\x01b%4d\x01c: \x01n\x01c%-" + descWidth + "s\x01n", +(dirIdx+1), dirDesc.substr(0, descWidth));
+
+	// Library line
+	if (textOnly)
+	{
+		console.gotoxy(6, 1);
+		console.print("\x01n" + libText);
+		console.gotoxy(6, 2);
+		console.print("\x01n" + dirText);
+	}
+	else
+	{
+		console.print("\x01n\x01w" + BLOCK1 + BLOCK2 + BLOCK3 + BLOCK4 + THIN_RECTANGLE_LEFT);
+		console.print(libText);
+		console.print("\x01w" + THIN_RECTANGLE_RIGHT + "\x01k\x01h" + BLOCK4 + "\x01n\x01w" + THIN_RECTANGLE_LEFT +
+					  "\x01g\x01hDD File\x01n\x01w");
+		console.print(THIN_RECTANGLE_RIGHT + BLOCK4 + BLOCK3 + BLOCK2 + BLOCK1);
+		console.crlf();
+		// Directory line
+		console.print("\x01n\x01w" + BLOCK1 + BLOCK2 + BLOCK3 + BLOCK4 + THIN_RECTANGLE_LEFT);
+		console.print(dirText);
+		console.print("\x01w" + THIN_RECTANGLE_RIGHT + "\x01k\x01h" + BLOCK4 + "\x01n\x01w" + THIN_RECTANGLE_LEFT +
+					  "\x01g\x01hLister \x01n\x01w");
+		console.print(THIN_RECTANGLE_RIGHT + BLOCK4 + BLOCK3 + BLOCK2 + BLOCK1);
+		console.print("\x01n");
+
+		// List header
+		console.crlf();
+		displayListHdrLine(false);
+
+		if (dispHdrFirstRun)
+		{
+			gNumHeaderLinesDisplayed = 3;
+			gErrorMsgBoxULY = gNumHeaderLinesDisplayed; // Note: console.screen_rows is 1-based
+		}
+	}
+}
+// Displays the header line with the column headers for the file list
+//
+// Parameters:
+//  pMoveToLocationFirst: Boolean - Whether to move the cursor to the required location first.
+function displayListHdrLine(pMoveToLocationFirst)
+{
+	if (pMoveToLocationFirst && console.term_supports(USER_ANSI))
+		console.gotoxy(1, 3);
+	var filenameLen = gListIdxes.filenameEnd - gListIdxes.filenameStart;
+	var fileSizeLen = gListIdxes.fileSizeEnd - gListIdxes.fileSizeStart -1;
+	var descLen = gListIdxes.descriptionEnd - gListIdxes.descriptionStart + 1;
+	var formatStr = "\x01n\x01w\x01h%-" + filenameLen + "s %" + fileSizeLen + "s %-"
+	              + +(descLen-7) + "s\x01n\x01w%5s\x01n";
+	var listHdrEndText = THIN_RECTANGLE_RIGHT + BLOCK4 + BLOCK3 + BLOCK2 + BLOCK1;
+	printf(formatStr, "Filename", "Size", "Description", listHdrEndText);
+}
+
+// Creates the menu for displaying the file list
+//
+// Parameters:
+//  pQuitKeys: A string containing hotkeys to use as the menu's quit keys
+//
+// Return value: The DDLightbarMenu object for the file list in the file directory
+function createFileListMenu(pQuitKeys)
+{
+	//DDLightbarMenu(pX, pY, pWidth, pHeight)
+	// Create the menu object.  Place it below the header lines (which should have been written
+	// before this), and also leave 1 row at the bottom for the prompt line
+	var startRow = gNumHeaderLinesDisplayed > 0 ? gNumHeaderLinesDisplayed + 1 : 1;
+	// If we'll be displaying short (one-line) file descriptions, then use the whole width
+	// of the terminal (minus 1) for the menu width.  But if the user has extended (multi-line)
+	// file descriptions enabled, then set the menu width only up through the file size, since
+	// the extended file description will be displayed to the right of the menu.
+	var menuWidth = console.screen_columns - 1;
+	if (extendedDescEnabled())
+		menuWidth = gListIdxes.fileSizeEnd + 1;
+	var menuHeight = console.screen_rows - (startRow-1) - 1;
+	var fileListMenu = new DDLightbarMenu(1, startRow, menuWidth, menuHeight);
+	fileListMenu.scrollbarEnabled = true;
+	fileListMenu.borderEnabled = false;
+	fileListMenu.multiSelect = true;
+	fileListMenu.ampersandHotkeysInItems = false;
+	fileListMenu.wrapNavigation = false;
+
+	fileListMenu.extdDescEnabled = extendedDescEnabled();
+
+	// Add additional keypresses for quitting the menu's input loop so we can
+	// respond to these keys.
+	if (typeof(pQuitKeys) === "string")
+		fileListMenu.AddAdditionalQuitKeys(pQuitKeys);
+
+	// Define the menu's function to get the number of items.  This must be done here
+	// in order for the menu's CanShowAllItemsInWindow() to work propertly.
+	fileListMenu.NumItems = function() {
+		return gFileList.length;
+	};
+
+	// Define a function for setting the description width, colors, and format string
+	// based on whether the menu's scrollbar will be used.
+	//
+	// Return value: Boolean: Whether or not the width changed
+	fileListMenu.SetItemWidthsColorsAndFormatStr = function() {
+		var widthChanged = false;
+		var oldDescriptionEnd = gListIdxes.descriptionEnd;
+		gListIdxes.descriptionEnd = console.screen_columns - 1; // Leave 1 character remaining on the screen
+		// If the file list menu will use its scrollbar, then reduce the description width by 1
+		if (this.scrollbarEnabled && !this.CanShowAllItemsInWindow())
+			gListIdxes.descriptionEnd -= 1;
+		widthChanged = (gListIdxes.descriptionEnd != oldDescriptionEnd);
+		this.SetColors({
+			itemColor: [{start: gListIdxes.filenameStart, end: gListIdxes.filenameEnd, attrs: gColors.filename},
+			            {start: gListIdxes.fileSizeStart, end: gListIdxes.fileSizeEnd, attrs: gColors.fileSize},
+			            {start: gListIdxes.descriptionStart, end: gListIdxes.descriptionEnd, attrs: gColors.desc}],
+			selectedItemColor: [{start: gListIdxes.filenameStart, end: gListIdxes.filenameEnd, attrs: gColors.bkgHighlight + gColors.filenameHighlight},
+			                    {start: gListIdxes.fileSizeStart, end: gListIdxes.fileSizeEnd, attrs: gColors.bkgHighlight + gColors.fileSizeHighlight},
+			                    {start: gListIdxes.descriptionStart, end: gListIdxes.descriptionEnd, attrs: gColors.bkgHighlight + gColors.descHighlight}]
+		});
+
+		this.filenameLen = gListIdxes.filenameEnd - gListIdxes.filenameStart;
+		this.fileSizeLen = gListIdxes.fileSizeEnd - gListIdxes.fileSizeStart -1;
+		this.shortDescLen = gListIdxes.descriptionEnd - gListIdxes.descriptionStart + 1;
+		// If extended descriptions are enabled, then we won't be writing a description here
+		if (this.extdDescEnabled)
+			this.fileFormatStr = "%-" + this.filenameLen + "s %" + this.fileSizeLen + "s";
+		else
+		{
+			this.fileFormatStr = "%-" + this.filenameLen + "s %" + this.fileSizeLen
+			                   + "s %-" + this.shortDescLen + "s";
+		}
+		return widthChanged;
+	};
+	// Set up the menu's description width, colors, and format string
+	fileListMenu.SetItemWidthsColorsAndFormatStr();
+
+	fileListMenu.lastFileDirCode = "";
+	// Define the menu function for getting an item
+	fileListMenu.GetItem = function(pIdx) {
+		// In here, 'this' refers to the fileListMenu object
+		// If doing a file search, then update the header with the file library & directory
+		// name of the currently selected file (instead of displaying "Various"). This seems
+		// like a bit of a hack, but it works.
+		var allSameDir = (typeof(gFileList.allSameDir) === "boolean" ? gFileList.allSameDir : false);
+		if (isDoingFileSearch() && !allSameDir && gFileList[pIdx].dirCode != this.lastFileDirCode)
+		{
+			if ((gListBehavior & FL_NO_HDR) != FL_NO_HDR)
+			{
+				var originalCurPos = console.getxy();
+				displayFileLibAndDirHeader(true, gFileList[pIdx].dirCode);
+				console.gotoxy(originalCurPos);
+			}
+		}
+		this.lastFileDirCode = gFileList[pIdx].dirCode;
+
+		var menuItemObj = this.MakeItemWithRetval(pIdx);
+		var filename = shortenFilename(gFileList[pIdx].name, this.filenameLen, true);
+		// FileBase.format_name() could also be called to format the filename for display:
+		/*
+		var filebase = new FileBase(gFileList[pIdx].dirCode);
+		if (filebase.open())
+		{
+			filename = filebase.format_name(gFileList[pIdx].name, this.filenameLen, true);
+			filebase.close();
+		}
+		*/
+		var desc = (typeof(gFileList[pIdx].desc) === "string" ? gFileList[pIdx].desc : "");
+		menuItemObj.text = format(this.fileFormatStr,
+		                          filename,
+								  getFileSizeStr(gFileList[pIdx].size, this.fileSizeLen),
+		                          desc.substr(0, this.shortDescLen));
+		return menuItemObj;
+	}
+
+	fileListMenu.selectedItemIndexes = {};
+	fileListMenu.numSelectedItemIndexes = function() {
+		return Object.keys(this.selectedItemIndexes).length;
+	};
+
+	// If extended file descriptions are enabled, set the OnItemNav function for
+	// when the user navigates to a new item, to display the file description
+	// next to the file menu.
+	if (extendedDescEnabled())
+	{
+		fileListMenu.OnItemNav = function(pOldItemIdx, pNewItemIdx) {
+			displayFileExtDescOnMainScreen(pNewItemIdx);
+		};
+	}
+
+	return fileListMenu;
+}
+
+// Creates the menu for choosing a file library (for moving a message to another message area).
+// The return value of the chosen item is the file library index.
+//
+// Return value: The DDLightbarMenu object for choosing a file library
+function createFileLibMenu()
+{
+	// This probably shouldn't happen, but check to make sure there are file libraries
+	if (file_area.lib_list.length == 0)
+	{
+		console.crlf();
+		console.print("\x01n\x01y\x01hThere are no file libraries available\x01n");
+		console.crlf();
+		console.pause();
+		return;
+	}
+
+	//DDLightbarMenu(pX, pY, pWidth, pHeight)
+	// Create the menu object
+	var startRow = gNumHeaderLinesDisplayed + 4;
+	var fileLibMenu = new DDLightbarMenu(5, startRow, console.screen_columns - 10, console.screen_rows - startRow - 5);
+	fileLibMenu.scrollbarEnabled = true;
+	fileLibMenu.borderEnabled = true;
+	fileLibMenu.multiSelect = false;
+	fileLibMenu.ampersandHotkeysInItems = false;
+	fileLibMenu.wrapNavigation = false;
+
+	// Add additional keypresses for quitting the menu's input loop.
+	// Q: Quit
+	var additionalQuitKeys = "qQ";
+	fileLibMenu.AddAdditionalQuitKeys(additionalQuitKeys);
+
+	// Re-define the menu's function for getting the number of items.  This is necessary
+	// here in order for the menu's CanShowAllItemsInWindow() to work properly.
+	fileLibMenu.NumItems = function() {
+		return file_area.lib_list.length;
+	};
+
+	// Construct a format string for the file libraries
+	var largestNumDirs = getLargestNumDirsWithinFileLibs();
+	fileLibMenu.libNumLen = file_area.lib_list.length.toString().length;
+	fileLibMenu.numDirsLen = largestNumDirs.toString().length;
+	var menuInnerWidth = fileLibMenu.size.width - 2; // Menu width excluding borders
+	// If the scrollbar will be showing, reduce the inner width by 1
+	if (fileLibMenu.scrollbarEnabled && !fileLibMenu.CanShowAllItemsInWindow())
+		--menuInnerWidth;
+	// Allow 2 for spaces
+	fileLibMenu.libDescLen = menuInnerWidth - fileLibMenu.libNumLen - fileLibMenu.numDirsLen - 2;
+	fileLibMenu.libFormatStr = "%" + fileLibMenu.libNumLen + "d %-" + fileLibMenu.libDescLen + "s %" + fileLibMenu.numDirsLen + "d";
+
+	// Colors and their indexes
+	fileLibMenu.borderColor = gColors.fileAreaMenuBorder;
+	var libNumStart = 0;
+	var libNumEnd = fileLibMenu.libNumLen;
+	var descStart = libNumEnd;
+	var descEnd = descStart + fileLibMenu.libDescLen;
+	var numDirsStart = descEnd;
+	//var numDirsEnd = numDirsStart + fileLibMenu.numDirsLen;
+	fileLibMenu.SetColors({
+		itemColor: [{start: libNumStart, end: libNumEnd, attrs: "\x01n" + gColors.fileNormalBkg + gColors.fileAreaNum},
+		            {start: descStart, end:descEnd, attrs: "\x01n" + gColors.fileNormalBkg + gColors.fileAreaDesc},
+		            {start: numDirsStart, end: -1, attrs: "\x01n" + gColors.fileNormalBkg + gColors.fileAreaNumItems}],
+		selectedItemColor: [{start: libNumStart, end: libNumEnd, attrs: "\x01n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumHighlight},
+		                    {start: descStart, end:descEnd, attrs: "\x01n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaDescHighlight},
+		                    {start: numDirsStart, end: -1, attrs: "\x01n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumItemsHighlight}]
+	});
+
+	fileLibMenu.topBorderText = "\x01y\x01hFile Libraries";
+	// Define the menu function for getting an item
+	fileLibMenu.GetItem = function(pIdx) {
+		var menuItemObj = this.MakeItemWithRetval(pIdx);
+		menuItemObj.text = format(this.libFormatStr,
+		                          pIdx + 1,//file_area.lib_list[pIdx].number + 1,
+								  file_area.lib_list[pIdx].description.substr(0, this.libDescLen),
+								  file_area.lib_list[pIdx].dir_list.length);
+		return menuItemObj;
+	}
+
+	return fileLibMenu;
+}
+// Helper for createFileLibMenu(): Returns the largest number of directories within the file libraries
+function getLargestNumDirsWithinFileLibs()
+{
+	var largestNumDirs = 0;
+	for (var libIdx = 0; libIdx < file_area.lib_list.length; ++libIdx)
+	{
+		if (file_area.lib_list[libIdx].dir_list.length > largestNumDirs)
+			largestNumDirs = file_area.lib_list[libIdx].dir_list.length;
+	}
+	return largestNumDirs;
+}
+
+// Creates the menu for choosing a file directory within a file library (for moving
+// a message to another message area).
+// The return value of the chosen item is the internal code for the file directory.
+//
+// Parameters:
+//  pLibIdx: The file directory index
+//
+// Return value: The DDLightbarMenu object for choosing a file directory within the
+// file library at the given index
+function createFileDirMenu(pLibIdx)
+{
+	if (typeof(pLibIdx) !== "number")
+		return null;
+
+	var startRow = gNumHeaderLinesDisplayed + 4;
+	// Make sure there are directories in this library
+	if (file_area.lib_list[pLibIdx].dir_list.length == 0)
+	{
+		displayMsg("There are no directories in this file library", true, true);
+		return null;
+	}
+
+	//DDLightbarMenu(pX, pY, pWidth, pHeight)
+	// Create the menu object
+	var fileDirMenu = new DDLightbarMenu(5, startRow, console.screen_columns - 10, console.screen_rows - startRow - 5);
+	fileDirMenu.scrollbarEnabled = true;
+	fileDirMenu.borderEnabled = true;
+	fileDirMenu.multiSelect = false;
+	fileDirMenu.ampersandHotkeysInItems = false;
+	fileDirMenu.wrapNavigation = false;
+
+	// Add additional keypresses for quitting the menu's input loop.
+	// Q: Quit
+	var additionalQuitKeys = "qQ";
+	fileDirMenu.AddAdditionalQuitKeys(additionalQuitKeys);
+
+	// Re-define the menu's function for getting the number of items.  This is necessary
+	// here in order for the menu's CanShowAllItemsInWindow() to work properly.
+	fileDirMenu.NumItems = function() {
+		return file_area.lib_list[this.libIdx].dir_list.length;
+	};
+
+	fileDirMenu.libIdx = pLibIdx;
+	// Construct a format string for the file libraries
+	var largestNumFiles = getLargestNumFilesInLibDirs(pLibIdx);
+	fileDirMenu.dirNumLen = file_area.lib_list[pLibIdx].dir_list.length.toString().length;
+	fileDirMenu.numFilesLen = largestNumFiles.toString().length;
+	var menuInnerWidth = fileDirMenu.size.width - 2; // Menu width excluding borders
+	// If the scrollbar will be showing, reduce the inner width by 1
+	if (fileDirMenu.scrollbarEnabled && !fileDirMenu.CanShowAllItemsInWindow())
+		--menuInnerWidth;
+	// Allow 2 for spaces
+	fileDirMenu.dirDescLen = menuInnerWidth - fileDirMenu.dirNumLen - fileDirMenu.numFilesLen - 2;
+	fileDirMenu.dirFormatStr = "%" + fileDirMenu.dirNumLen + "d %-" + fileDirMenu.dirDescLen + "s %" + fileDirMenu.numFilesLen + "d";
+
+	// Colors and their indexes
+	fileDirMenu.borderColor = gColors.fileAreaMenuBorder;
+	var dirNumStart = 0;
+	var dirNumEnd = fileDirMenu.dirNumLen;
+	var descStart = dirNumEnd;
+	var descEnd = descStart + fileDirMenu.dirDescLen;
+	var numDirsStart = descEnd;
+	//var numDirsEnd = numDirsStart + fileDirMenu.numDirsLen;
+	fileDirMenu.SetColors({
+		itemColor: [{start: dirNumStart, end: dirNumEnd, attrs: "\x01n" + gColors.fileNormalBkg + gColors.fileAreaNum},
+		            {start: descStart, end:descEnd, attrs: "\x01n" + gColors.fileNormalBkg + gColors.fileAreaDesc},
+		            {start: numDirsStart, end: -1, attrs: "\x01n" + gColors.fileNormalBkg + gColors.fileAreaNumItems}],
+		selectedItemColor: [{start: dirNumStart, end: dirNumEnd, attrs: "\x01n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumHighlight},
+		                    {start: descStart, end:descEnd, attrs: "\x01n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaDescHighlight},
+		                    {start: numDirsStart, end: -1, attrs: "\x01n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumItemsHighlight}]
+	});
+
+	fileDirMenu.topBorderText = "\x01y\x01h" + ("File directories of " + file_area.lib_list[pLibIdx].description).substr(0, fileDirMenu.size.width-2);
+	// Define the menu function for ggetting an item
+	fileDirMenu.GetItem = function(pIdx) {
+		// Return the internal code for the directory for the item
+		var menuItemObj = this.MakeItemWithRetval(file_area.lib_list[this.libIdx].dir_list[pIdx].code);
+		menuItemObj.text = format(this.dirFormatStr,
+		                          pIdx + 1,//file_area.lib_list[this.libIdx].dir_list[pIdx].number + 1,
+								  file_area.lib_list[this.libIdx].dir_list[pIdx].description.substr(0, this.dirDescLen),
+								  file_area.lib_list[this.libIdx].dir_list[pIdx].files);
+		return menuItemObj;
+	}
+
+	return fileDirMenu;
+}
+
+// Returns the largest number of files in all directories in a file library
+//
+// Parameters:
+//  pLibIdx: The index of a file library
+//
+// Return value: The largest number of files of all the directories in the file library
+function getLargestNumFilesInLibDirs(pLibIdx)
+{
+	var largestNumFiles = 0;
+	for (var dirIdx = 0; dirIdx < file_area.lib_list[pLibIdx].dir_list.length; ++dirIdx)
+	{
+		var numFilesInDir = file_area.lib_list[pLibIdx].dir_list[dirIdx].files;
+		if (numFilesInDir > largestNumFiles)
+			largestNumFiles = numFilesInDir;
+	}
+	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:
+//  pText: The text to display
+//  pCenter: Whether or not to center the text.  Optional; defaults
+//           to false.
+//  pTextColor: The color to use for the text.  Optional; by default,
+//              normal white will be used.
+//  pLineColor: The color to use for the line underneath the text.
+//              Optional; by default, bright black will be used.
+function displayTextWithLineBelow(pText, pCenter, pTextColor, pLineColor)
+{
+	var centerText = (typeof(pCenter) == "boolean" ? pCenter : false);
+	var textColor = (typeof(pTextColor) == "string" ? pTextColor : "\x01n\x01w");
+	var lineColor = (typeof(pLineColor) == "string" ? pLineColor : "\x01n\x01k\x01h");
+
+	// Output the text and a solid line on the next line.
+	if (centerText)
+	{
+		console.center(textColor + pText);
+		var solidLine = "";
+		var textLength = console.strlen(pText);
+		for (var i = 0; i < textLength; ++i)
+			solidLine += HORIZONTAL_SINGLE;
+		console.center(lineColor + solidLine);
+	}
+	else
+	{
+		console.print(textColor + pText);
+		console.crlf();
+		console.print(lineColor);
+		var textLength = console.strlen(pText);
+		for (var i = 0; i < textLength; ++i)
+			console.print(HORIZONTAL_SINGLE);
+		console.crlf();
+	}
+}
+
+// Returns a string for a number with commas added every 3 places
+//
+// Parameters:
+//  pNum: The number to format
+//
+// Return value: A string with the number formatted with commas every 3 places
+function numWithCommas(pNum)
+{
+	var numStr = "";
+	if (typeof(pNum) === "number")
+		numStr = pNum.toString();
+	else if (typeof(pNum) === "string")
+		numStr = pNum;
+	else
+		return "";
+
+	// Check for a decimal point in the number
+	var afterDotSuffix = "";
+	var dotIdx = numStr.lastIndexOf(".");
+	if (dotIdx > -1)
+	{
+		afterDotSuffix = numStr.substr(dotIdx+1);
+		numStr = numStr.substr(0, dotIdx);
+	}
+	// First, build an array containing sections of the number containing
+	// 3 digits each (the last may contain less than 3)
+	var numParts = [];
+	var i = numStr.length - 1;
+	var continueOn = true;
+	while (continueOn/*i >= 0*/)
+	{
+		if (i >= 3)
+		{
+			numParts.push(numStr.substr(i-2, 3));
+			i -= 3;
+		}
+		else
+		{
+			numParts.push(numStr.substr(0, i+1));
+			i -= i;
+			continueOn = false;
+		}
+	}
+	// Reverse the array so the sections of digits are in forward order
+	numParts.reverse();
+	// Re-build the number string with commas
+	numStr = "";
+	for (var i = 0; i < numParts.length; ++i)
+		numStr += numParts[i] + ",";
+	if (/,$/.test(numStr))
+		numStr = numStr.substr(0, numStr.length-1);
+	// Append back the value after the decimal place if there was one
+	if (afterDotSuffix.length > 0)
+		numStr += "." + afterDotSuffix;
+	return numStr;
+}
+
+// Displays a set of messages at the status location on the screen, along with a border.  Then
+// refreshes the area of the screen to erase the message box.
+//
+// Parameters:
+//  pMsgArray: An array containing the messages to display
+//  pIsError: Boolean - Whether or not the messages are an error
+//  pWaitAndErase: Optional boolean - Whether or not to automatically wait a moment and then
+//                 erase the message box after drawing.  Defaults to true.
+function displayMsgs(pMsgArray, pIsError, pWaitAndErase)
+{
+	if (typeof(pMsgArray) !== "object" || pMsgArray.length == 0)
+		return;
+
+	var waitAndErase = (typeof(pWaitAndErase) === "boolean" ? pWaitAndErase : true);
+
+	// Draw the box border, then write the messages
+	var title = pIsError ? "Error" : "Message";
+	var titleColor = pIsError ? gColors.errorMessage : gColors.successMessage;
+	drawBorder(gErrorMsgBoxULX, gErrorMsgBoxULY, gErrorMsgBoxWidth, gErrorMsgBoxHeight,
+	           gColors.errorBoxBorder, "single", title, titleColor, "");
+	var msgColor = "\x01n" + (pIsError ? gColors.errorMessage : gColors.successMessage);
+	var innerWidth = gErrorMsgBoxWidth - 2;
+	var msgFormatStr = msgColor + "%-" + innerWidth + "s\x01n";
+	for (var i = 0; i < pMsgArray.length; ++i)
+	{
+		console.gotoxy(gErrorMsgBoxULX+1, gErrorMsgBoxULY+1);
+		printf(msgFormatStr, pMsgArray[i].substr(0, innerWidth));
+		if (waitAndErase)
+		{
+			// Wait for the error wait duration
+			mswait(gErrorMsgWaitMS);
+		}
+	}
+	if (waitAndErase)
+		eraseMsgBoxScreenArea();
+}
+function displayMsg(pMsg, pIsError, pWaitAndErase)
+{
+	if (typeof(pMsg) !== "string")
+		return;
+	displayMsgs([ pMsg ], pIsError, pWaitAndErase);
+}
+// Erases the message box screen area by re-drawing the necessary components
+function eraseMsgBoxScreenArea()
+{
+	// Refresh the list header line and have the file list menu refresh itself over
+	// the error message window
+	displayListHdrLine(true);
+	// This used to call gFileListMenu.DrawPartialAbs
+	refreshScreenMainContent(gErrorMsgBoxULX, gErrorMsgBoxULY+1, gErrorMsgBoxWidth, gErrorMsgBoxHeight-2);
+}
+
+// Draws a border
+//
+// Parameters:
+//  pX: The X location of the upper left corner
+//  pY: The Y location of the upper left corner
+//  pWidth: The width of the box
+//  pHeight: The height of the box
+//  pColor: A string containing color/attribute codes for the border characters
+//  pLineStyle: A string specifying the border character style, either "single" or "double"
+//  pTitle: Optional - A string specifying title text for the top border
+//  pTitleColor: Optional - Attribute codes for the color to use for the title text
+//  pBottomBorderText: Optional - A string specifying text to include in the bottom border
+function drawBorder(pX, pY, pWidth, pHeight, pColor, pLineStyle, pTitle, pTitleColor, pBottomBorderText)
+{
+	if (typeof(pX) !== "number" || typeof(pY) !== "number" || typeof(pWidth) !== "number" || typeof(pHeight) !== "number")
+		return;
+	if (typeof(pColor) !== "string")
+		return;
+
+	var borderChars = {
+		UL: UPPER_LEFT_SINGLE,
+		UR: UPPER_RIGHT_SINGLE,
+		LL: LOWER_LEFT_SINGLE,
+		LR: LOWER_RIGHT_SINGLE,
+		preText: RIGHT_T_SINGLE,
+		postText: LEFT_T_SINGLE,
+		horiz: HORIZONTAL_SINGLE,
+		vert: VERTICAL_SINGLE
+	};
+	if (typeof(pLineStyle) === "string" && pLineStyle.toUpperCase() == "DOUBLE")
+	{
+		borderChars.UL = UPPER_LEFT_DOUBLE;
+		borderChars.UR = UPPER_RIGHT_DOUBLE;
+		borderChars.LL = LOWER_LEFT_DOUBLE;
+		borderChars.LR = LOWER_RIGHT_DOUBLE;
+		borderChars.preText = RIGHT_T_DOUBLE;
+		borderChars.postText = LEFT_T_DOUBLE
+		borderChars.horiz = HORIZONTAL_DOUBLE;
+		borderChars.vert = VERTICAL_DOUBLE;
+	}
+
+	// Top border
+	console.gotoxy(pX, pY);
+	console.print("\x01n" + pColor);
+	console.print(borderChars.UL);
+	var innerWidth = pWidth - 2;
+	// Include the title text in the top border, if there is any specified
+	var titleTextWithoutAttrs = strip_ctrl(pTitle); // Possibly used twice, so only call strip_ctrl() once
+	if (typeof(pTitle) === "string" && titleTextWithoutAttrs.length > 0)
+	{
+		var titleLen = strip_ctrl(pTitle).length;
+		if (titleLen > pWidth - 4)
+			titleLen = pWidth - 4;
+		innerWidth -= titleLen;
+		innerWidth -= 2; // ?? Correctional
+		// Note: substrWithAttrCodes() is defined in dd_lightbar_menu.js
+		var titleText = pTitle;
+		if (typeof(pTitleColor) === "string")
+			titleText = "\x01n" + pTitleColor + titleTextWithoutAttrs;
+		console.print(borderChars.preText + "\x01n" + substrWithAttrCodes(titleText, 0, titleLen) +
+		              "\x01n" + pColor + borderChars.postText);
+		if (innerWidth > 0)
+			console.print(pColor);
+	}
+	for (var i = 0; i < innerWidth; ++i)
+		console.print(borderChars.horiz);
+	console.print(borderChars.UR);
+	// Side borders
+	var rightCol = pX + pWidth - 1;
+	var endScreenRow = pY + pHeight - 1;
+	for (var screenRow = pY + 1; screenRow < endScreenRow; ++screenRow)
+	{
+		console.gotoxy(pX, screenRow);
+		console.print(borderChars.vert);
+		console.gotoxy(rightCol, screenRow);
+		console.print(borderChars.vert);
+	}
+	// Bottom border
+	console.gotoxy(pX, endScreenRow);
+	console.print(borderChars.LL);
+	innerWidth = pWidth - 2;
+	// Include the bottom border text in the top border, if there is any specified
+	if (typeof(pBottomBorderText) === "string" && pBottomBorderText.length > 0)
+	{
+		var textLen = strip_ctrl(pBottomBorderText).length;
+		if (textLen > pWidth - 4)
+			textLen = pWidth - 4;
+		innerWidth -= textLen;
+		innerWidth -= 2; // ?? Correctional
+		// Note: substrWithAttrCodes() is defined in dd_lightbar_menu.js
+		console.print(borderChars.preText + "\x01n" + substrWithAttrCodes(pBottomBorderText, 0, textLen) +
+		              "\x01n" + pColor + borderChars.postText);
+		if (innerWidth > 0)
+			console.print(pColor);
+	}
+	for (var i = 0; i < innerWidth; ++i)
+		console.print(borderChars.horiz);
+	console.print(borderChars.LR);
+}
+
+// Draws a horizontal separator line on the screen, in high green
+//
+// Parameters:
+//  pX: The X coordinate to start at
+//  pY: The Y coordinate to start at
+//  pWidth: The width of the line to draw
+function drawSeparatorLine(pX, pY, pWidth)
+{
+	if (typeof(pX) !== "number" || typeof(pY) !== "number" || typeof(pWidth) !== "number")
+		return;
+	if (pX < 1 || pX > console.screen_columns)
+		return;
+	if (pY < 1 || pY > console.screen_rows)
+		return;
+	if (pWidth < 1)
+		return;
+
+	var width = pWidth;
+	var maxWidth = console.screen_columns - pX + 1;
+	if (width > maxWidth)
+		width = maxWidth;
+
+	console.gotoxy(pX, pY);
+	console.print("\x01n\x01g\x01h");
+	for (var i = 0; i < width; ++i)
+		console.print(HORIZONTAL_SINGLE);
+	console.print("\x01n");
+}
+
+// Confirms with the user to perform an action with a file or set of files
+//
+// Parameters:
+//  pFilenames: An array of filenames (as strings), or a string containing a filename
+//  pActionName: String - The name of the action to confirm
+//  pDefaultYes: Boolean - True if the default is to be yes, or false if no
+//
+// Return value: Boolean - True if the user confirmed, or false if not
+function confirmFileActionWithUser(pFilenames, pActionName, pDefaultYes)
+{
+	if (typeof(pFilenames) !== "object" && typeof(pFilenames) !== "string")
+		return false;
+	if (typeof(pActionName) !== "string")
+		return false;
+
+	var actionConfirmed = false;
+
+	var numFilenames = 1;
+	if (typeof(pFilenames) === "object")
+		numFilenames = pFilenames.length;
+	if (numFilenames < 1)
+		return false;
+	// If there is only 1 filename, then prompt the user near the bottom of the screen
+	else if (numFilenames == 1)
+	{
+		var filename = (typeof(pFilenames) === "string" ? pFilenames : pFilenames[0]);
+		drawSeparatorLine(1, console.screen_rows-2, console.screen_columns-1);
+		console.gotoxy(1, console.screen_rows-1);
+		console.cleartoeol("\x01n");
+		console.gotoxy(1, console.screen_rows-1);
+		var shortFilename = shortenFilename(filename, Math.floor(console.screen_columns/2), false);
+		if (pDefaultYes)
+			actionConfirmed = console.yesno(pActionName + " " + shortFilename);
+		else
+			actionConfirmed = !console.noyes(pActionName + " " + shortFilename);
+		// Refresh the main screen content, to erase the confirmation prompt
+		refreshScreenMainContent(1, console.screen_rows-2, console.screen_columns, 2, true);
+	}
+	else
+	{
+		// Construct & draw a frame with the file list & display the frame to confirm with the
+		// user to delete the files
+		var frameUpperLeftX = gFileListMenu.pos.x + 2;
+		var frameUpperLeftY = gFileListMenu.pos.y + 2;
+		//var frameWidth = gFileListMenu.size.width - 4;
+		var frameWidth = console.screen_columns - 4;
+		var frameHeight = 10;
+		var frameTitle = pActionName + " files? (Y/N)";
+		var additionalQuitKeys = "yYnN";
+		var frameInnerWidth = frameWidth - 2; // Without borders; for filename lengths
+		var fileListStr = "\x01n\x01w";
+		for (var i = 0; i < pFilenames.length; ++i)
+			fileListStr += shortenFilename(pFilenames[i], frameInnerWidth, false) + "\r\n";
+		var lastUserInput = displayBorderedFrameAndDoInputLoop(frameUpperLeftX, frameUpperLeftY, frameWidth,
+		                                                       frameHeight, gColors.confirmFileActionWindowBorder,
+		                                                       frameTitle, gColors.confirmFileActionWindowWindowTitle,
+		                                                       fileListStr, additionalQuitKeys);
+		actionConfirmed = (lastUserInput.toUpperCase() == "Y");
+		// Refresh the main screen content, to erase the confirmation window
+		refreshScreenMainContent(frameUpperLeftX, frameUpperLeftY, frameWidth, frameHeight, true);
+	}
+
+	return actionConfirmed;
+}
+
+
+// Reads the configuration file and sets the settings accordingly
+function readConfigFile()
+{
+	this.cfgFileSuccessfullyRead = false;
+
+	var themeFilename = ""; // In case a theme filename is specified
+
+	// Determine the script's startup directory.
+	// This code is a trick that was created by Deuce, suggested by Rob Swindell
+	// as a way to detect which directory the script was executed in.  I've
+	// shortened the code a little.
+	var startupPath = '.';
+	try { throw dig.dist(dist); } catch(e) { startupPath = e.fileName; }
+	startupPath = backslash(startupPath.replace(/[\/\\][^\/\\]*$/,''));
+
+	// Open the main configuration file.  First look for it in the sbbs/mods
+	// directory, then sbbs/ctrl, then in the same directory as this script.
+	var cfgFilename = "ddfilelister.cfg";
+	var cfgFilenameFullPath = file_cfgname(system.mods_dir, cfgFilename);
+	if (!file_exists(cfgFilenameFullPath))
+		cfgFilenameFullPath = file_cfgname(system.ctrl_dir, cfgFilename);
+	if (!file_exists(cfgFilenameFullPath))
+		cfgFilenameFullPath = file_cfgname(startupPath, cfgFilename);
+	var cfgFile = new File(cfgFilenameFullPath);
+	if (cfgFile.open("r"))
+	{
+		this.cfgFileSuccessfullyRead = true;
+
+		var fileLine = null;     // A line read from the file
+		var equalsPos = 0;       // Position of a = in the line
+		var commentPos = 0;      // Position of the start of a comment
+		var setting = null;      // A setting name (string)
+		var settingUpper = null; // Upper-case setting name
+		var value = null;        // To store a value for a setting (string)
+		while (!cfgFile.eof)
+		{
+			// Read the next line from the config file.
+			fileLine = cfgFile.readln(2048);
+
+			// fileLine should be a string, but I've seen some cases
+			// where it isn't, so check its type.
+			if (typeof(fileLine) != "string")
+				continue;
+
+			// If the line starts with with a semicolon (the comment
+			// character) or is blank, then skip it.
+			if ((fileLine.substr(0, 1) == ";") || (fileLine.length == 0))
+				continue;
+
+			// If the line has a semicolon anywhere in it, then remove
+			// everything from the semicolon onward.
+			commentPos = fileLine.indexOf(";");
+			if (commentPos > -1)
+				fileLine = fileLine.substr(0, commentPos);
+
+			// Look for an equals sign, and if found, separate the line
+			// into the setting name (before the =) and the value (after the
+			// equals sign).
+			equalsPos = fileLine.indexOf("=");
+			if (equalsPos > 0)
+			{
+				// Read the setting & value, and trim leading & trailing spaces.
+				setting = trimSpaces(fileLine.substr(0, equalsPos), true, false, true);
+				settingUpper = setting.toUpperCase();
+				value = trimSpaces(fileLine.substr(equalsPos+1), true, false, true);
+				var valueUpper = value.toUpperCase();
+
+				// Set the appropriate valueUpper in the settings object.
+				if (settingUpper == "SORTORDER")
+				{
+					// FileBase.SORT properties
+					// Name		Type	Description
+					// NATURAL	number	Natural sort order (same as DATE_A)
+					// NAME_AI	number	Filename ascending, case insensitive sort order
+					// NAME_DI	number	Filename descending, case insensitive sort order
+					// NAME_AS	number	Filename ascending, case sensitive sort order
+					// NAME_DS	number	Filename descending, case sensitive sort order
+					// DATE_A	number	Import date/time ascending sort order
+					// DATE_D	number	Import date/time descending sort order
+					if (valueUpper == "NATURAL")
+						gFileSortOrder = FileBase.SORT.NATURAL;
+					else if (valueUpper == "NAME_AI")
+						gFileSortOrder = FileBase.SORT.NAME_AI;
+					else if (valueUpper == "NAME_DI")
+						gFileSortOrder = FileBase.SORT.NAME_DI;
+					else if (valueUpper == "NAME_AS")
+						gFileSortOrder = FileBase.SORT.NAME_AS;
+					else if (valueUpper == "NAME_DS")
+						gFileSortOrder = FileBase.SORT.NAME_DS;
+					else if (valueUpper == "DATE_A")
+						gFileSortOrder = FileBase.SORT.DATE_A;
+					else if (valueUpper == "DATE_D")
+						gFileSortOrder = FileBase.SORT.DATE_D;
+					else if (valueUpper == "ULTIME")
+						gFileSortOrder = SORT_FL_ULTIME;
+					else if (valueUpper == "DLTIME")
+						gFileSortOrder = SORT_FL_DLTIME;
+					else // Default
+						gFileSortOrder = FileBase.SORT.NATURAL;
+				}
+				else if (settingUpper == "PAUSEAFTERVIEWINGFILE")
+					gPauseAfterViewingFile = (value.toUpperCase() == "TRUE");
+				else if (settingUpper == "THEMEFILENAME")
+				{
+					// First look for the theme config file in the sbbs/mods
+					// directory, then sbbs/ctrl, then the same directory as
+					// this script.
+					themeFilename = system.mods_dir + value;
+					if (!file_exists(themeFilename))
+						themeFilename = system.ctrl_dir + value;
+					if (!file_exists(themeFilename))
+						themeFilename = startupPath + value;
+				}
+			}
+		}
+
+		cfgFile.close();
+	}
+	else
+	{
+		// Was unable to read the configuration file.  Output a warning to the user
+		// that defaults will be used and to notify the sysop.
+		console.print("\x01n");
+		console.crlf();
+		console.print("\x01w\x01hUnable to open the configuration file: \x01y" + cfgFilename);
+		console.crlf();
+		console.print("\x01wDefault settings will be used.  Please notify the sysop.");
+		mswait(2000);
+	}
+	
+	// If a theme filename was specified, then read the colors & strings
+	// from it.
+	if (themeFilename.length > 0)
+	{
+		var themeFile = new File(themeFilename);
+		if (themeFile.open("r"))
+		{
+			var fileLine = null;     // A line read from the file
+			var equalsPos = 0;       // Position of a = in the line
+			var commentPos = 0;      // Position of the start of a comment
+			var setting = null;      // A setting name (string)
+			var value = null;        // To store a value for a setting (string)
+			while (!themeFile.eof)
+			{
+				// Read the next line from the config file.
+				fileLine = themeFile.readln(2048);
+
+				// fileLine should be a string, but I've seen some cases
+				// where it isn't, so check its type.
+				if (typeof(fileLine) != "string")
+					continue;
+
+				// If the line starts with with a semicolon (the comment
+				// character) or is blank, then skip it.
+				if ((fileLine.substr(0, 1) == ";") || (fileLine.length == 0))
+					continue;
+
+				// If the line has a semicolon anywhere in it, then remove
+				// everything from the semicolon onward.
+				commentPos = fileLine.indexOf(";");
+				if (commentPos > -1)
+					fileLine = fileLine.substr(0, commentPos);
+
+				// Look for an equals sign, and if found, separate the line
+				// into the setting name (before the =) and the value (after the
+				// equals sign).
+				equalsPos = fileLine.indexOf("=");
+				if (equalsPos > 0)
+				{
+					// Read the setting (without leading/trailing spaces) & value
+					setting = trimSpaces(fileLine.substr(0, equalsPos), true, false, true);
+					value = fileLine.substr(equalsPos+1);
+
+					if (gColors.hasOwnProperty(setting))
+					{
+						// Trim leading & trailing spaces from the value when
+						// setting a color.  Also, replace any instances of "\x01" or "\1"
+						// with the Synchronet attribute control character.
+						gColors[setting] = trimSpaces(value, true, false, true).replace(/\\[xX]01/g, "\x01").replace(/\\1/g, "\x01");
+					}
+				}
+			}
+
+			themeFile.close();
+		}
+		else
+		{
+			// Was unable to read the theme file.  Output a warning to the user
+			// that defaults will be used and to notify the sysop.
+			this.cfgFileSuccessfullyRead = false;
+			console.print("\x01n");
+			console.crlf();
+			console.print("\x01w\x01hUnable to open the theme file: \x01y" + themeFilename);
+			console.crlf();
+			console.print("\x01wDefault colors will be used.  Please notify the sysop.");
+			mswait(2000);
+		}
+	}
+}
+
+// Removes multiple, leading, and/or trailing spaces
+// The search & replace regular expressions used in this
+// function came from the following URL:
+//  http://qodo.co.uk/blog/javascript-trim-leading-and-trailing-spaces
+//
+// Parameters:
+//  pString: The string to trim
+//  pLeading: Whether or not to trim leading spaces (optional, defaults to true)
+//  pMultiple: Whether or not to trim multiple spaces (optional, defaults to true)
+//  pTrailing: Whether or not to trim trailing spaces (optional, defaults to true)
+//
+// Return value: The string with whitespace trimmed
+function trimSpaces(pString, pLeading, pMultiple, pTrailing)
+{
+	if (typeof(pString) !== "string")
+		return "";
+
+	var leading = (typeof(pLeading) === "boolean" ? pLeading : true);
+	var multiple = (typeof(pMultiple) === "boolean" ? pMultiple : true);
+	var trailing = (typeof(pTrailing) === "boolean" ? pTrailing : true);
+
+	// To remove both leading & trailing spaces:
+	//pString = pString.replace(/(^\s*)|(\s*$)/gi,"");
+
+	var string = pString;
+	if (leading)
+		string = string.replace(/(^\s*)/gi,"");
+	if (multiple)
+		string = string.replace(/[ ]{2,}/gi," ");
+	if (trailing)
+		string = string.replace(/(\s*$)/gi,"");
+
+	return string;
+}
+
+// Given a filename, this returns a filename shortened to a maximum length, preserving the
+// filename extension.
+//
+// Parameters:
+//  pFilename: The filename to shorten
+//  pMaxLen: The maximum length of the shortened filename
+//  pFillWidth: Boolean - Whether or not to fill the width specified by pMaxLen
+//
+// Return value: A string with the shortened filename
+function shortenFilename(pFilename, pMaxLen, pFillWidth)
+{
+	if (typeof(pFilename) !== "string")
+		return "";
+	if (typeof(pMaxLen) !== "number" || pMaxLen < 1)
+		return "";
+
+	var shortenedFilename = ""; // Will contain the shortened filename
+	// Get the filename extension.  And the way we shorten the filename
+	// will depend on whether the filename actually has an extension or not.
+	var filenameExt = file_getext(pFilename);
+	if (typeof(filenameExt) === "string")
+	{
+		var filenameWithoutExt = file_getname(pFilename);
+		var extIdx = filenameWithoutExt.indexOf(filenameExt);
+		if (extIdx >= 0)
+			filenameWithoutExt = filenameWithoutExt.substr(0, extIdx);
+
+		var maxWithoutExtLen = pMaxLen - filenameExt.length;
+		if (filenameWithoutExt.length > maxWithoutExtLen)
+			filenameWithoutExt = filenameWithoutExt.substr(0, maxWithoutExtLen);
+
+		var fillWidth = (typeof(pFillWidth) === "boolean" ? pFillWidth : false);
+		if (fillWidth)
+			shortenedFilename = format("%-" + maxWithoutExtLen + "s%s", filenameWithoutExt, filenameExt);
+		else
+			shortenedFilename = filenameWithoutExt + filenameExt;
+	}
+	else
+		shortenedFilename = pFilename.substr(0, pMaxLen);
+	return shortenedFilename;
+}
+
+
+// Parses command-line arguments, where each argument in the given array is in
+// the format -arg=val.  If the value is the string "true" or "false", then the
+// value will be a boolean.  Otherwise, the value will be a string.
+//
+// Parameters:
+//  argv: An array of strings containing values in the format -arg=val
+//
+// Return value: Boolean: Whether or not this script was run as a loadable
+//                        module (depending on the arguments used).
+function parseArgs(argv)
+{
+	// Default program options
+	gScriptMode = MODE_LIST_DIR;
+
+	// Sanity checking for argv - Make sure it's an array
+	if ((typeof(argv) != "object") || (typeof(argv.length) != "number"))
+		return false;
+
+	// This script accepts arguments in -arg=val format; See if there are any of those.
+	// If so, process arguments with that assumption; otherwise, we'll check for args
+	// passed when Synchronet runs this as a loadable module.
+	var scriptRanAsLoadableModule = false;
+	var anySettingEqualsVal = false;
+	for (var i = 0; i < argv.length && !anySettingEqualsVal; ++i)
+		anySettingEqualsVal = (typeof(argv[i]) === "string" && argv[i].length > 0 && argv[i].charAt(0) == "-" && argv[i].indexOf("=") > 1);
+	if (anySettingEqualsVal)
+	{
+		// Go through argv looking for strings in the format -arg=val and parse them
+		// into objects in the argVals array.
+		var equalsIdx = 0;
+		var argName = "";
+		var argVal = "";
+		var argValUpper = ""; // For case-insensitive matching
+		for (var i = 0; i < argv.length; ++i)
+		{
+			// We're looking for strings that start with "-", except strings that are
+			// only "-".
+			if (typeof(argv[i]) !== "string" || argv[i].length == 0 || argv[i].charAt(0) != "-" || argv[i] == "-")
+				continue;
+
+			// Look for an = and if found, split the string on the =
+			equalsIdx = argv[i].indexOf("=");
+			// If a = is found, then split on it and add the argument name & value
+			// to the array.  Otherwise (if the = is not found), then treat the
+			// argument as a boolean and set it to true (to enable an option).
+			if (equalsIdx > -1)
+			{
+				argName = argv[i].substring(1, equalsIdx).toUpperCase();
+				argVal = argv[i].substr(equalsIdx+1);
+				argValUpper = argVal.toUpperCase();
+				if (argName === "MODE")
+				{
+					if (argValUpper === "SEARCH_FILENAME")
+						gScriptMode = MODE_SEARCH_FILENAME;
+					else if (argValUpper === "SEARCH_DESCRIPTION")
+						gScriptMode = MODE_SEARCH_DESCRIPTION;
+					else if (argValUpper === "NEW_FILE_SEARCH")
+						gScriptMode = MODE_NEW_FILE_SEARCH;
+					else if (argValUpper === "LIST_CURDIR")
+						gScriptMode = MODE_LIST_DIR;
+				}
+			}
+			// Nothing to do if an = was not found
+		}
+	}
+	else
+	{
+		// Check for arguments as if this was run by Synchronet as a loadable module
+		// (for either Scan Dirs or List Files)
+
+		/*
+		// bbs.list_files() & bbs.scan_dirs()
+		//********************************************
+		var FL_NONE			=0;			// No special behavior
+		var FL_ULTIME		=(1<<0);	// List files by upload time
+		var FL_DLTIME		=(1<<1);	// List files by download time
+		var FL_NO_HDR		=(1<<2);	// Don't list directory header
+		var FL_FINDDESC		=(1<<3);	// Find text in description
+		var FL_EXFIND		=(1<<4);	// Find text in description - extended info
+		var FL_VIEW			=(1<<5);	// View ZIP/ARC/GIF etc. info
+		*/
+
+		// There must be either 2 or 3 arguments
+		if (argv.length < 2)
+			return false;
+		// The 2nd argument is the mode/behavior bits in either case
+		var FLBehavior = parseInt(argv[1]);
+		if (isNaN(FLBehavior))
+			return false;
+		else
+			gListBehavior = FLBehavior;
+		scriptRanAsLoadableModule = true;
+
+		// Default gScriptmode to MODE_LIST_DIR; for FLBehavior as FL_NONE, no special behavior
+		gScriptMode = MODE_LIST_DIR;
+
+		// 2 args - Scanning/searching
+		if (argv.length == 2)
+		{
+			// - 0: Bool (scanning all directories): 0/1
+			// - 1: FL_ mode value
+			gScanAllDirs = (argv[0] == "1");
+			if ((FLBehavior & FL_ULTIME) == FL_ULTIME)
+				gScriptMode = MODE_NEW_FILE_SEARCH;
+			else if ((FLBehavior & FL_FINDDESC) == FL_FINDDESC || (FLBehavior & FL_EXFIND) == FL_EXFIND)
+				gScriptMode = MODE_SEARCH_DESCRIPTION;
+			if ((FLBehavior & FL_VIEW) == FL_VIEW)
+			{
+				// View ZIP/ARC/GIF etc. info
+				// TODO: Not sure what to do with this
+			}
+		}
+		// 3 args - Listing
+		else if (argv.length >= 3) //==3
+		{
+			// - 0: Directory internal code
+			// - 1: FL_ mode value
+			// - 2: Filespec (i.e., *, *.zip, etc.)
+			if (!file_area.dir.hasOwnProperty(argv[0]))
+				return false;
+			gDirCode = argv[0];
+			gFilespec = argv[2];
+			if ((FLBehavior & FL_VIEW) == FL_VIEW)
+			{
+				// View ZIP/ARC/GIF etc. info
+				// TODO: Not sure what to do with this
+			}
+		}
+
+		// Options that apply to both searching and listing
+		if ((FLBehavior & FL_ULTIME) == FL_ULTIME)
+			gFileSortOrder = SORT_FL_ULTIME;
+		else if ((FLBehavior & FL_DLTIME) == FL_DLTIME)
+			gFileSortOrder = SORT_FL_DLTIME;
+
+	}
+
+	return scriptRanAsLoadableModule;
+}
+
+// Populates the file list (gFileList).
+//
+// Parameters:
+//  pSearchMode: The search mode
+//
+// Return value: An obmect with the following properties:
+//               exitNow: Boolean: Whether or not this script should exit after calling this function
+//               exitCode: The exit code to use if needing to exit after calling this function
+function populateFileList(pSearchMode)
+{
+	var retObj = {
+		exitNow: false,
+		exitCode: 0
+	};
+	
+	var dirErrors = [];
+	var allSameDir = true;
+
+	// Do the things for list or search, depending on the specified mode
+	if (pSearchMode == MODE_LIST_DIR) // This is the default
+	{
+		var filebase = new FileBase(gDirCode);
+		if (filebase.open())
+		{
+			// If there are no files in the filebase, then say so and exit now.
+			if (filebase.files == 0)
+			{
+				filebase.close();
+				var libIdx = file_area.dir[gDirCode].lib_index;
+				console.crlf();
+				console.print("\x01n\x01cThere are no files in \x01h" + file_area.lib_list[libIdx].description + "\x01n\x01c - \x01h" +
+							  file_area.dir[gDirCode].description + "\x01n");
+				console.crlf();
+				console.pause();
+				retObj.exitNow = true;
+				retObj.exitCode = 0;
+				return retObj;
+			}
+
+			// Get a list of file data
+			var fileDetail = (extendedDescEnabled() ? FileBase.DETAIL.EXTENDED : FileBase.DETAIL.NORM);
+			if (gFileSortOrder == SORT_FL_ULTIME || gFileSortOrder == SORT_FL_DLTIME)
+			{
+				gFileList = filebase.get_list(gFilespec, fileDetail, 0, true, FileBase.SORT.NATURAL);
+				if (gFileSortOrder == SORT_FL_ULTIME)
+					gFileList.sort(fileInfoSortULTime);
+				else if (gFileSortOrder == SORT_FL_DLTIME)
+					gFileList.sort(fileInfoSortDLTime);
+			}
+			else
+				gFileList = filebase.get_list(gFilespec, fileDetail, 0, true, gFileSortOrder);
+			filebase.close();
+			// Add a dirCode property to the file metadata objects (for consistency,
+			// as file search results may contain files from multiple directories).
+			// Also, if the metadata objects have an extdesc, remove any trailing CRLF
+			// from the end.
+			for (var i = 0; i < gFileList.length; ++i)
+			{
+				gFileList[i].dirCode = gDirCode;
+				if (gFileList[i].hasOwnProperty("extdesc") && /\r\n$/.test(gFileList[i].extdesc))
+				{
+					gFileList[i].extdesc = gFileList[i].extdesc.substr(0, gFileList[i].extdesc.length-2);
+					// Fix line endings if necessary
+					gFileList[i].extdesc = lfexpand(gFileList[i].extdesc);
+				}
+			}
+		}
+		else
+		{
+			console.crlf();
+			console.print("\x01n\x01h\x01yUnable to open \x01w" + file_area.dir[gDirCode].description + "\x01n");
+			console.crlf();
+			console.pause();
+			retObj.exitNow = true;
+			retObj.exitCode = 1;
+			return retObj;
+		}
+	}
+	else if (pSearchMode == MODE_SEARCH_FILENAME)
+	{
+		var lastDirCode = "";
+
+		// If not searching all already, prompt the user for directory, library, or all
+		var validInputOptions = "DLA";
+		var userInputDLA = "";
+		if (gScanAllDirs)
+			userInputDLA = "A";
+		else
+		{
+			console.attributes = "N";
+			console.crlf();
+			console.mnemonics(bbs.text(DirLibOrAll));
+			userInputDLA = console.getkeys(validInputOptions, -1, K_UPPER);
+		}
+		var userFilespec = "";
+		if (userInputDLA.length > 0 && validInputOptions.indexOf(userInputDLA) > -1)
+		{
+			// Prompt the user for a filespec to search for
+			console.mnemonics(bbs.text(FileSpecStarDotStar));
+			userFilespec = console.getstr();
+			if (userFilespec.length == 0)
+				userFilespec = "*"; // Should this be *.*?
+			console.print("Searching....");
+		}
+		var searchRetObj = searchDirGroupOrAll(userInputDLA, function(pDirCode) {
+			return searchDirWithFilespec(pDirCode, userFilespec);
+		});
+		allSameDir = searchRetObj.allSameDir;
+		for (var i = 0; i < searchRetObj.errors.length; ++i)
+			dirErrors.push(searchRetObj.errors[i]);
+	}
+	else if (pSearchMode == MODE_SEARCH_DESCRIPTION)
+	{
+		var lastDirCode = "";
+
+		// If not saerching all already, prompt the user for directory, library, or all
+		var validInputOptions = "DLA";
+		var userInputDLA = "";
+		if (gScanAllDirs)
+			userInputDLA = "A";
+		else
+		{
+			console.print("\x01n");
+			console.crlf();
+			//console.print("\r\n\x01c\x01hFind Text in File Descriptions (no wildcards)\x01n\r\n");
+			console.mnemonics(bbs.text(DirLibOrAll));
+			console.print("\x01n");
+			userInputDLA = console.getkeys(validInputOptions, -1, K_UPPER);
+		}
+		var searchDescription = "";
+		if (userInputDLA.length > 0 && validInputOptions.indexOf(userInputDLA) > -1)
+		{
+			// Prompt the user for a description to search for
+			console.mnemonics(bbs.text(SearchStringPrompt));
+			searchDescription = console.getstr(40, K_LINE|K_UPPER);
+			if (searchDescription.length > 0)
+				console.print("Searching....");
+			else
+			{
+				retObj.exitNow = true;
+				retObj.exitCode = 0;
+				return retObj;
+			}
+		}
+		var searchRetObj = searchDirGroupOrAll(userInputDLA, function(pDirCode) {
+			return searchDirWithDescUpper(pDirCode, searchDescription);
+		});
+		allSameDir = searchRetObj.allSameDir;
+		for (var i = 0; i < searchRetObj.errors.length; ++i)
+			dirErrors.push(searchRetObj.errors[i]);
+	}
+	else if (pSearchMode == MODE_NEW_FILE_SEARCH)
+	{
+		// New file search
+		var lastDirCode = "";
+
+		// 2022-02-011 - Digital Man said:
+		/*
+		Upon logon to the terminal server, bbs.new_file_time and bbs.last_new_file_time
+		are set to the current user.new_file_time value.
+
+		A new file scan displays files uploaded (added/imported) since the current
+		bbs.new_file_time value. The bbs.new_file_time value can be manipulated by the
+		user, e.g. they want to review files that have been uploaded over the past
+		month or whatever.
+
+		When the user executes a new file scan, bbs.last_new_file_time is updated to
+		the current time (this happens automatically when using the built-in file
+		listing logic). The bbs.last_new_file_time isn't actually used for anything
+		until the user logs-off and its value is then copied to the user.new_file_time
+		to be used for the next logon (this copy/sync of the last_new_file_time ->
+		user.new_file_time is built-in to the BBS's logout logci and nothing a script
+		would need to do).
+
+		This scheme insures that a user will never "miss" the display of new files on
+		the BBS and that they would not normally see the same/repeat "new" files
+		between successive sessions.
+		*/
+
+		// If not searching all dirs already, prompt the user for directory, library, or all
+		var userInputDLA = "";
+		if (gScanAllDirs)
+			userInputDLA = "A";
+		else
+		{
+			console.print("\x01n");
+			console.crlf();
+			console.mnemonics(bbs.text(DirLibOrAll));
+			var validInputOptions = "DLA";
+			userInputDLA = console.getkeys(validInputOptions, -1, K_UPPER);
+			console.print("\x01n");
+			console.crlf();
+		}
+		if (userInputDLA == "D" || userInputDLA == "L" || userInputDLA == "A")
+		{
+			console.print("\x01n\x01cSearching for files uploaded after \x01h" + system.timestr(bbs.new_file_time) + "\x01n");
+			console.crlf();
+		}
+		var searchRetObj = searchDirGroupOrAll(userInputDLA, function(pDirCode) {
+			return searchDirNewFiles(pDirCode, bbs.new_file_time);
+		});
+		// Now bbs.last_new_file_time needs to be updated with the current time
+		bbs.last_new_file_time = time();
+		// user.new_file_time should be updated with the value of bbs.last_new_file_time
+		// when the user logs off.
+		allSameDir = searchRetObj.allSameDir;
+		for (var i = 0; i < searchRetObj.errors.length; ++i)
+			dirErrors.push(searchRetObj.errors[i]);
+	}
+	else
+	{
+		retObj.exitNow = true;
+		retObj.exitCode = 1;
+		return retObj;
+	}
+
+	if (pSearchMode != MODE_LIST_DIR)
+		gFileList.allSameDir = allSameDir;
+
+	if (dirErrors.length > 0)
+	{
+		console.print("\x01n\x01y\x01h");
+		for (var i = 0; i < dirErrors.length; ++i)
+		{
+			console.print(dirErrors[i]);
+			console.crlf();
+		}
+		console.print("\x01n");
+		console.pause();
+		retObj.exitNow = true;
+		retObj.exitCode = 1;
+		return retObj;
+	}
+
+	// Figure out the longest filename in the list.
+	var longestFilenameLen = 0;
+	for (var i = 0; i < gFileList.length; ++i)
+	{
+		if (gFileList[i].name.length > longestFilenameLen)
+			longestFilenameLen = gFileList[i].name.length;
+	}
+	var displayFilenameLen = gListIdxes.filenameEnd - gListIdxes.filenameStart + 1;
+	// If the user has extended descriptions enabled, then allow 47 characters for the
+	// description and adjust the filename length accordingly
+	gListIdxes.descriptionEnd = console.screen_columns - 1; // Leave 1 character remaining on the screen
+	if (extendedDescEnabled())
+	{
+		gListIdxes.descriptionStart = gListIdxes.descriptionEnd - 47 + 1;
+		gListIdxes.fileSizeEnd = gListIdxes.descriptionStart;
+		gListIdxes.fileSizeStart = gListIdxes.fileSizeEnd - 7;
+		gListIdxes.filenameEnd = gListIdxes.fileSizeStart;
+	}
+	// If not displaying extended descriptions, then if the longest filename
+	// is longer than the current display filename length and the user's
+	// terminal is at least 100 columns wide, then increase the filename length
+	// for the list by 20;
+	else if (longestFilenameLen > displayFilenameLen && console.screen_columns >= 100)
+	{
+		gListIdxes.filenameEnd += 20;
+		gListIdxes.fileSizeStart = gListIdxes.filenameEnd;
+		gListIdxes.fileSizeEnd = gListIdxes.fileSizeStart + 7;
+		gListIdxes.descriptionStart = gListIdxes.fileSizeEnd;
+	}
+
+	return retObj;
+}
+
+// Searches files in a directory by filespec.
+//
+// Parameters:
+//  pDirCode: The internal code of a directory to search
+//  pFilespec: A filespec describing files to search for
+//
+// Return value: An object with the following properties:
+//               foundFiles: Boolean - Whether or not any files were found
+//               dirErrors: An array of strings that will be populated with any errors that occur
+function searchDirWithFilespec(pDirCode, pFilespec)
+{
+	var retObj = {
+		foundFiles: false,
+		dirErrors: []
+	};
+	if (typeof(pDirCode) !== "string" || pDirCode.length == 0 || typeof(pFilespec) !== "string")
+		return retObj;
+
+	var filebase = new FileBase(pDirCode);
+	if (filebase.open())
+	{
+		var fileDetail = (extendedDescEnabled() ? FileBase.DETAIL.EXTENDED : FileBase.DETAIL.NORM);
+		var fileList;
+		if (gFileSortOrder == SORT_FL_ULTIME || gFileSortOrder == SORT_FL_DLTIME)
+		{
+			fileList = filebase.get_list(pFilespec, fileDetail, 0, true, FileBase.SORT.NATURAL);
+			if (gFileSortOrder == SORT_FL_ULTIME)
+				fileList.sort(fileInfoSortULTime);
+			else if (gFileSortOrder == SORT_FL_DLTIME)
+				fileList.sort(fileInfoSortDLTime);
+		}
+		else
+			fileList = filebase.get_list(pFilespec, fileDetail, 0, true, gFileSortOrder);
+		retObj.foundFiles = (fileList.length > 0);
+		filebase.close();
+		for (var i = 0; i < fileList.length; ++i)
+		{
+			// Fix line endings in extdesc if necessary
+			if (fileList[i].hasOwnProperty("extdesc") && /\r\n$/.test(fileList[i].extdesc))
+				fileList[i].extdesc = lfexpand(fileList[i].extdesc);
+			fileList[i].dirCode = pDirCode;
+			gFileList.push(fileList[i]);
+		}
+	}
+	else
+	{
+		var libAndDirDesc = file_area.lib_list[libIdx].description + " - "
+						  + file_area.lib_list[libIdx].dir_list[dirIdx].description + " ("
+						  + file_area.lib_list[libIdx].dir_list[dirIdx].code + ")";
+		retObj.dirErrors.push("Failed to open " + libAndDirDesc);
+	}
+	return retObj;
+}
+
+// Searches files in a directory by description.
+//
+// Parameters:
+//  pDirCode: The internal code of a directory to search
+//  pDescUpper: The description to search for.  This is assumed to be uppercase.
+//
+// Return value: An object with the following properties:
+//               foundFiles: Boolean - Whether or not any files were found
+//               dirErrors: An array of strings that will be populated with any errors that occur
+function searchDirWithDescUpper(pDirCode, pDescUpper)
+{
+	var retObj = {
+		foundFiles: false,
+		dirErrors: []
+	};
+	if (typeof(pDirCode) !== "string" || pDirCode.length == 0 || typeof(pDescUpper) !== "string" || pDescUpper.length == 0)
+		return retObj;
+
+	var filebase = new FileBase(pDirCode);
+	if (filebase.open())
+	{
+		//var fileList = filebase.get_list(gFilespec, FileBase.DETAIL.EXTENDED, 0, true, gFileSortOrder);
+		var fileList;
+		if (gFileSortOrder == SORT_FL_ULTIME || gFileSortOrder == SORT_FL_DLTIME)
+		{
+			fileList = filebase.get_list(gFilespec, FileBase.DETAIL.EXTENDED, 0, true, FileBase.SORT.NATURAL);
+			if (gFileSortOrder == SORT_FL_ULTIME)
+				fileList.sort(fileInfoSortULTime);
+			else if (gFileSortOrder == SORT_FL_DLTIME)
+				fileList.sort(fileInfoSortDLTime);
+		}
+		else
+			fileList = filebase.get_list(gFilespec, FileBase.DETAIL.EXTENDED, 0, true, gFileSortOrder);
+		filebase.close();
+		for (var i = 0; i < fileList.length; ++i)
+		{
+			// Search both 'desc' and 'extdesc', if available in the file metadata object
+			var fileIsMatch = false;
+			if (fileList[i].hasOwnProperty("desc"))
+			{
+				var fileDesc = strip_ctrl(fileList[i].desc).toUpperCase();
+				fileIsMatch = (fileDesc.indexOf(pDescUpper) > -1);
+			}
+			if (!fileIsMatch && fileList[i].hasOwnProperty("extdesc"))
+			{
+				// Fix line endings if necessary
+				fileList[i].extdesc = lfexpand(fileList[i].extdesc);
+				var descLines = fileList[i].extdesc.split("\r\n");
+				var fileDesc = strip_ctrl(descLines.join(" ")).toUpperCase();
+				fileIsMatch = (fileDesc.indexOf(pDescUpper) > -1);
+			}
+			if (fileIsMatch)
+			{
+				retObj.foundFiles = true;
+				fileList[i].dirCode = pDirCode;
+				gFileList.push(fileList[i]);
+			}
+		}
+	}
+	else
+	{
+		var libAndDirDesc = file_area.lib_list[libIdx].description + " - "
+						  + file_area.lib_list[libIdx].dir_list[dirIdx].description + " ("
+						  + file_area.lib_list[libIdx].dir_list[dirIdx].code + ")";
+		retObj.dirErrors.push("Failed to open " + libAndDirDesc);
+	}
+	return retObj;
+}
+
+// Searches files in a directory that were added later than a given time.
+//
+// Parameters:
+//  pDirCode: The internal code of a directory to search
+//  pSinceTime: The time after which to look for newer files
+//
+// Return value: An object with the following properties:
+//               foundFiles: Boolean - Whether or not any files were found
+//               dirErrors: An array of strings that will be populated with any errors that occur
+function searchDirNewFiles(pDirCode, pSinceTime)
+{
+	var retObj = {
+		foundFiles: false,
+		dirErrors: []
+	};
+	if (typeof(pDirCode) !== "string" || pDirCode.length == 0 || typeof(pSinceTime) !== "number")
+		return retObj;
+
+	var filebase = new FileBase(pDirCode);
+	if (filebase.open())
+	{
+		var fileDetail = (extendedDescEnabled() ? FileBase.DETAIL.EXTENDED : FileBase.DETAIL.NORM);
+		var fileList;
+		if (gFileSortOrder == SORT_FL_ULTIME || gFileSortOrder == SORT_FL_DLTIME)
+		{
+			fileList = filebase.get_list(gFilespec, fileDetail, 0, true, FileBase.SORT.NATURAL);
+			if (gFileSortOrder == SORT_FL_ULTIME)
+				fileList.sort(fileInfoSortULTime);
+			else if (gFileSortOrder == SORT_FL_DLTIME)
+				fileList.sort(fileInfoSortDLTime);
+		}
+		else
+			fileList = filebase.get_list(gFilespec, fileDetail, 0, true, gFileSortOrder);
+		filebase.close();
+		for (var i = 0; i < fileList.length; ++i)
+		{
+			if (fileList[i].added >= pSinceTime)
+			{
+				retObj.foundFiles = true;
+				// Fix line endings in extdesc if necessary
+				if (fileList[i].hasOwnProperty("extdesc"))
+					fileList[i].extdesc = lfexpand(fileList[i].extdesc);
+				fileList[i].dirCode = pDirCode;
+				gFileList.push(fileList[i]);
+			}
+		}
+	}
+	else
+	{
+		var libAndDirDesc = file_area.lib_list[libIdx].description + " - "
+						  + file_area.lib_list[libIdx].dir_list[dirIdx].description + " ("
+						  + file_area.lib_list[libIdx].dir_list[dirIdx].code + ")";
+		retObj.dirErrors.push("Failed to open " + libAndDirDesc);
+	}
+	return retObj;
+}
+
+// Searches either the user's current directory, library, or all dirs in all
+// libraries, using a search function that populages gFileList.
+//
+// Parameters:
+//  pSearchOption: A string that specifies "D" for directory, "L" for library, or "A" for all
+//  pDirSearchFn: A search function that searches a file directory for criteria and populates gFileList.
+//                This function must return an object with the following properties:
+//                foundFiles: Boolean - Whether any files were found
+//                dirErrors: An array containing strings for any errors found
+//
+// Return value: An object with the following properties:
+//               allSameDir: Boolean - Whethre or not all files found are in the same directory
+//               errors: An array containing strings for any errors found
+function searchDirGroupOrAll(pSearchOption, pDirSearchFn)
+{
+	var retObj = {
+		allSameDir: true,
+		errors: []
+	};
+
+	if (typeof(pSearchOption) !== "string")
+	{
+		retObj.errors.push("Invalid search option");
+		return retObj;
+	}
+	if (typeof(pDirSearchFn) !== "function")
+	{
+		retObj.errors.push("No search function");
+		return retObj;
+	}
+
+	var searchOptionUpper = pSearchOption.toUpperCase();
+	if (searchOptionUpper == "D")
+	{
+		// Current directory
+		var searchRetObj = pDirSearchFn(gDirCode);
+		retObj.allSameDir = true;
+		for (var i = 0; i < searchRetObj.dirErrors.length; ++i)
+			retObj.errors.push(searchRetObj.dirErrors[i]);
+	}
+	else if (searchOptionUpper == "L")
+	{
+		// Current file library
+		var libIdx = file_area.dir[gDirCode].lib_index;
+		for (var dirIdx = 0; dirIdx < file_area.lib_list[libIdx].dir_list.length; ++dirIdx)
+		{
+			if (file_area.lib_list[libIdx].dir_list[dirIdx].can_access) // And can_download?
+			{
+				if (gSearchVerbose)
+				{
+					console.print(" " + file_area.lib_list[libIdx].dir_list[dirIdx].description + "..");
+					console.crlf();
+				}
+				var lastDirCode = (gFileList.length > 0 ? gFileList[gFileList.length-1].dirCode : "");
+				var dirCode = file_area.lib_list[libIdx].dir_list[dirIdx].code;
+				var searchRetObj = pDirSearchFn(dirCode);
+				if (retObj.allSameDir && searchRetObj.foundFiles && lastDirCode.length > 0)
+					retObj.allSameDir = (dirCode == lastDirCode);
+				for (var i = 0; i < searchRetObj.dirErrors.length; ++i)
+					retObj.errors.push(searchRetObj.dirErrors[i]);
+			}
+		}
+	}
+	else if (searchOptionUpper == "A")
+	{
+		// All file libraries & directories
+		for (var libIdx = 0; libIdx < file_area.lib_list.length; ++libIdx)
+		{
+			if (gSearchVerbose)
+			{
+				console.print("Searching " + file_area.lib_list[libIdx].description + "..");
+				console.crlf();
+			}
+			for (var dirIdx = 0; dirIdx < file_area.lib_list[libIdx].dir_list.length; ++dirIdx)
+			{
+				if (file_area.lib_list[libIdx].dir_list[dirIdx].can_access) // And can_download?
+				{
+					if (gSearchVerbose)
+					{
+						console.print(" " + file_area.lib_list[libIdx].dir_list[dirIdx].description + "..");
+						console.crlf();
+					}
+					var lastDirCode = (gFileList.length > 0 ? gFileList[gFileList.length-1].dirCode : "");
+					var dirCode = file_area.lib_list[libIdx].dir_list[dirIdx].code;
+					var searchRetObj = pDirSearchFn(dirCode);
+					if (retObj.allSameDir && searchRetObj.foundFiles && lastDirCode.length > 0)
+						retObj.allSameDir = (dirCode == lastDirCode);
+					for (var i = 0; i < searchRetObj.dirErrors.length; ++i)
+						retObj.errors.push(searchRetObj.dirErrors[i]);
+				}
+			}
+		}
+	}
+	else
+	{
+		//retObj.errors.push("Invalid search option" + (pSearchOption.length > 0 ? ": " + pSearchOption : ""));
+		retObj.errors.push("Aborted");
+	}
+
+	return retObj;
+}
+
+// Returns whether the user has their extended file description setting enabled
+// and if it can be supported in the user's terminal mode.  Extended descriptions
+// will be displayed in the main screen if the user has that option enabled and
+// the user's terminal is at least 80 columns wide.
+function extendedDescEnabled()
+{
+	var userExtDescEnabled = ((user.settings & USER_EXTDESC) == USER_EXTDESC);
+	return userExtDescEnabled && console.screen_columns >= 80;
+}
+
+// Displays a file's extended description on the main screen, next to the
+// file list menu.  This is to be used when the user's extended file description
+// option is enabled (where the menu would take up about the left half of
+// the screen).
+//
+// Parameters:
+//  pFileIdx: The index of the file metadata object in gFileList to use
+//  pStartScreenRow: Optional - The screen row number to start printing, for partial
+//                   screen refreshing (can be in the middle of the extended description)
+//  pEndScreenRow: Optional - The screen row number to stop printing, for partial
+//                   screen refreshing (can be in the middle of the extended description)
+//  pMaxWidth: Optional - The maximum width to use for printing the description lines
+function displayFileExtDescOnMainScreen(pFileIdx, pStartScreenRow, pEndScreenRow, pMaxWidth)
+{
+	if (typeof(pFileIdx) !== "number")
+		return;
+	if (pFileIdx < 0 || pFileIdx >= gFileList.length)
+		return;
+
+	// Get the file description from its metadata object
+	var fileMetadata = gFileList[pFileIdx];
+	var fileDesc = "";
+	if (fileMetadata.hasOwnProperty("extdesc") && fileMetadata.extdesc.length > 0)
+		fileDesc = fileMetadata.extdesc;
+	else
+		fileDesc = fileMetadata.desc;
+	if (typeof(fileDesc) != "string")
+		fileDesc = "";
+
+	// This might be overkill, but just in case, convert any non-Synchronet
+	// attribute codes to Synchronet attribute codes in the description.
+	// This will help simplify getting substrings for formatting.  Then for
+	// efficiency, put the converted description back into the metadata
+	// object in the array so that it doesn't have to be converted again.
+	if (!fileMetadata.hasOwnProperty("attrsConverted"))
+	{
+		fileDesc = convertAttrsToSyncPerSysCfg(fileDesc);
+		fileMetadata.attrsConverted = true;
+		if (fileMetadata.hasOwnProperty("extdesc"))
+			fileMetadata.extdesc = fileDesc;
+		else
+			fileMetadata.desc = fileDesc;
+	}
+
+	// Calculate where to write the description on the screen
+	var startX = gFileListMenu.size.width + 1; // Assuming the file menu starts at the leftmost column
+	var maxDescLen = console.screen_columns - startX;
+	if (typeof(pMaxWidth) === "number" && pMaxWidth >= 0 && pMaxWidth < maxDescLen)
+		maxDescLen = pMaxWidth;
+	// Go to the location on the screen and write the file description
+	var formatStr = "%-" + maxDescLen + "s";
+	// firstScreenRow is the first row on the screen where the extended description
+	// should start at.  lastScreenRow is the last row (inclusive) to use for
+	// printing the extended description
+	var firstScreenRow = gNumHeaderLinesDisplayed + 1;
+	var lastScreenRow = console.screen_rows - 1; // This is inclusive
+	// screenRowForPrinting will be used for the actual screen row we're at while
+	// printing the extended description lines
+	var screenRowForPrinting = firstScreenRow;
+	// If pStartScreenRow or pEndScreenRow are specified, then use
+	// them to specify the start & end screen rows to actually print
+	if (typeof(pStartScreenRow) === "number" && pStartScreenRow >= firstScreenRow && pStartScreenRow <= lastScreenRow)
+		screenRowForPrinting = pStartScreenRow;
+	if (typeof(pEndScreenRow) === "number" && pEndScreenRow > firstScreenRow && pStartScreenRow <= lastScreenRow)
+		lastScreenRow = pEndScreenRow;
+	var fileDescArray = fileDesc.split("\r\n");
+	console.print("\x01n");
+	// screenRowNum is to keep track of the row on the screen where the
+	// description line would be placed, in case the start row is after that
+	var screenRowNum = firstScreenRow;
+	for (var i = 0; i < fileDescArray.length; ++i)
+	{
+		if (screenRowForPrinting > screenRowNum++)
+			continue;
+		// Note: substrWithAttrCodes() is defined in dd_lightbar_menu.js
+		// Normally it would be handy to use printf() to print the text line:
+		//printf(formatStr, substrWithAttrCodes(fileDescArray[i], 0, maxDescLen));
+		// However, printf() doesn't account for attribute codes and thus may not
+		// fill the rest of the width.  So, we do that manually.
+		var descLine = substrWithAttrCodes(fileDescArray[i], 0, maxDescLen);
+		var lineTextLength = console.strlen(descLine);
+		if (lineTextLength > 0)
+		{
+			console.gotoxy(startX, screenRowForPrinting++);
+			console.print(descLine);
+			var remainingLen = maxDescLen - lineTextLength;
+			if (remainingLen > 0)
+				printf("%" + remainingLen + "s", "");
+		}
+		// Stop printing the description lines when we reach the last line on
+		// the screen where we want to print.
+		if (screenRowForPrinting > lastScreenRow)
+			break;
+	}
+	// If there is room, shoe the file date on the next line
+	if (screenRowForPrinting <= lastScreenRow && fileMetadata.hasOwnProperty("time"))
+	{
+		console.print("\x01n");
+		console.gotoxy(startX, screenRowForPrinting++);
+		var dateStr = "Date: " + strftime("%Y-%m-%d", fileMetadata.time);
+		printf("%-" + maxDescLen + "s", dateStr.substr(0, maxDescLen));
+	}
+	// Clear the rest of the lines to the bottom of the list area
+	console.print("\x01n");
+	while (screenRowForPrinting <= lastScreenRow)
+	{
+		console.gotoxy(startX, screenRowForPrinting++);
+			printf(formatStr, "");
+	}
+}
+
+// Refreshes (re-draws) the main content of the screen (file list menu,
+// and extended description area if enabled).  The coordinates are absolute
+// screen coordinates.
+//
+// Parameters:
+//  pUpperLeftX: The X coordinate of the upper-left corner of the area to re-draw
+//  pUpperLeftY: The Y coordinate of the upper-left corner of the area to re-draw
+//  pWidth: The width of the area to re-draw
+//  pHeight: The height of the area to re-draw
+//  pSelectedItemIdxes: Optional: An object with selected item indexes for the file menu.
+//                                If not passed, an empty object will be used.
+//                      This can also be a boolean, and if true, will refresh the
+//                      selected items on the file menu (with checkmarks) outside the
+//                      given top & bottom screen rows.
+function refreshScreenMainContent(pUpperLeftX, pUpperLeftY, pWidth, pHeight, pSelectedItemIdxes)
+{
+	// Have the file list menu partially re-draw itself if necessary
+	var startXWithinFileList = (pUpperLeftX >= gFileListMenu.pos.x && pUpperLeftX < gFileListMenu.pos.x + gFileListMenu.size.width);
+	var startYWithinFileList = (pUpperLeftY >= gFileListMenu.pos.y && pUpperLeftY < gFileListMenu.pos.y + gFileListMenu.size.height);
+	if (startXWithinFileList && startYWithinFileList)
+	{
+		var selectedItemIdxesIsValid = (typeof(pSelectedItemIdxes) === "object");
+		var selectedItemIdxes = (selectedItemIdxesIsValid ? pSelectedItemIdxes : {});
+		gFileListMenu.DrawPartialAbs(pUpperLeftX, pUpperLeftY, pWidth, pHeight, selectedItemIdxes);
+	}
+	// If pSelectedItemIdxes is a bool instead of an object and is true,
+	// refresh the selected items (with checkmarks) outside the top & bottom
+	// lines on the file menu
+	if (!selectedItemIdxesIsValid && typeof(pSelectedItemIdxes) === "boolean" && pSelectedItemIdxes && gFileListMenu.numSelectedItemIndexes() > 0)
+	{
+		var bottomScreenRow = pUpperLeftY + pHeight - 1;
+		for (var idx in gFileListMenu.selectedItemIndexes)
+		{
+			var idxNum = +idx;
+			var itemScreenRow = gFileListMenu.ScreenRowForItem(idxNum);
+			if (itemScreenRow == -1)
+				continue;
+			if (itemScreenRow < pUpperLeftY || itemScreenRow > bottomScreenRow)
+			{
+				var isSelected = (idxNum == gFileListMenu.selectedItemIdx);
+				gFileListMenu.WriteItemAtItsLocation(idxNum, isSelected, false);
+			}
+		}
+	}
+	// If the user has extended descriptions enabled, then the file menu
+	// is only taking up about half the screen on the left, and we'll also
+	// have to refresh the description area.
+	if (extendedDescEnabled())
+	{
+		var fileMenuRightX = gFileListMenu.pos.x + gFileListMenu.size.width - 1;
+		var width = pWidth - (fileMenuRightX - pUpperLeftX + 1);
+		if (width > 0)
+		{
+			var firstRow = pUpperLeftY;
+			// The last row is inclusive.  It seems like there might be an off-by-1
+			// problem here?  I thought 1 would need to be subtracted from lastRow
+			var lastRow = pUpperLeftY + pHeight;
+			// We don't want to overwrite the last row on the screen, since that's
+			// used for the command bar
+			if (lastRow == console.screen_rows)
+				--lastRow;
+			displayFileExtDescOnMainScreen(gFileListMenu.selectedItemIdx, firstRow, lastRow, width);
+		}
+	}
+}
+
+// Returns whether or not the lister is doing a file search
+function isDoingFileSearch()
+{
+	return (gScriptMode == MODE_SEARCH_FILENAME || gScriptMode == MODE_SEARCH_DESCRIPTION || gScriptMode == MODE_NEW_FILE_SEARCH);
+}
+
+// Custom file info sort function for upload time
+function fileInfoSortULTime(pA, pB)
+{
+	if (pA.hasOwnProperty("added") && pB.hasOwnProperty("added"))
+	{
+		if (pA.added < pB.added)
+			return -1;
+		else if (pA.added > pB.added)
+			return 1;
+		else
+			return 0;
+	}
+	else
+	{
+		if (pA.time < pB.time)
+			return -1;
+		else if (pA.time > pB.time)
+			return 1;
+		else
+			return 0;
+	}
+}
+
+// Custom file info sort function for download time
+function fileInfoSortDLTime(pA, pB)
+{
+	if (pA.hasOwnProperty("last_downloaded") && pB.hasOwnProperty("last_downloaded"))
+	{
+		if (pA.last_downloaded < pB.last_downloaded)
+			return -1;
+		else if (pA.last_downloaded > pB.last_downloaded)
+			return 1;
+		else
+			return 0;
+	}
+	else
+		return 0;
+}
+
+// Returns whether or not a user can download from a file directory, and writes an error if not.
+//
+// Parameters:
+//  pDirCode: The internal directory code of a file directory
+//
+// Return value: Boolean - Whether or not the user can download from the file directory given
+function userCanDownloadFromFileArea_ShowErrorIfNot(pDirCode)
+{
+	var userCanDownload = bbs.compare_ars(file_area.dir[pDirCode].download_ars);
+	if (!userCanDownload)
+	{
+		// The user doesn't have permission to download from this directory
+		//file_area.dir[pDirCode].name
+		var areaFullDesc = file_area.dir[pDirCode].lib_name + ": "
+						 + file_area.dir[pDirCode].description;
+		areaFullDesc = word_wrap(areaFullDesc, console.screen_columns-1, areaFullDesc.length).replace(/\r|\n/g, "\r\n");
+		while (areaFullDesc.lastIndexOf("\r\n") == areaFullDesc.length-2)
+			areaFullDesc = areaFullDesc.substr(0, areaFullDesc.length-2);
+		console.crlf();
+		console.print(areaFullDesc);
+		console.crlf();
+		console.mnemonics(bbs.text(CantDownloadFromDir));
+		console.crlf();
+		console.pause();
+	}
+	return userCanDownload;
+}
\ No newline at end of file
diff --git a/xtrn/ddfilelister/ddfilelister.js b/xtrn/ddfilelister/ddfilelister.js
index 465e2610a61611b45b6e07c667f7b51e41341b89..28eda16bda163ce08280132b232a9d6250658e4a 100644
--- a/xtrn/ddfilelister/ddfilelister.js
+++ b/xtrn/ddfilelister/ddfilelister.js
@@ -61,6 +61,11 @@
  * 2023-02-25 Eric Oulashin     Version 2.09
  *                              Now supports being used as a loadable module for
  *                              Scan Dirs and List Files
+ * 2023-02-27 Eric Oulashin     Version 2.10
+ *                              Now allows downloading a single selected file with the D key.
+ *                              Also, ddfilelister now checks whether the user has permission to
+ *                              download before allowing adding files to their batch download queue
+ *                              (and downloading a single file as well).
 */
 
 "use strict";
@@ -68,6 +73,7 @@
 if (typeof(require) === "function")
 {
 	require("sbbsdefs.js", "K_UPPER");
+	require('key_defs.js', 'KEY_UP');
 	require("text.js", "Email"); // Text string definitions (referencing text.dat)
 	require("dd_lightbar_menu.js", "DDLightbarMenu");
 	require("frame.js", "Frame");
@@ -78,6 +84,7 @@ if (typeof(require) === "function")
 else
 {
 	load("sbbsdefs.js");
+	load('key_defs.js');
 	load("text.js"); // Text string definitions (referencing text.dat)
 	load("dd_lightbar_menu.js");
 	load("frame.js");
@@ -121,13 +128,15 @@ if (system.version_num < 31900)
 }
 
 // Lister version information
-var LISTER_VERSION = "2.09";
-var LISTER_DATE = "2023-02-25";
+var LISTER_VERSION = "2.10";
+var LISTER_DATE = "2023-02-27";
 
 
 ///////////////////////////////////////////////////////////////////////////////
 // Global variables
 
+var KEY_BACKSPACE = CTRL_H;
+
 // Block characters
 var BLOCK1 = "\xB0"; // Dimmest block
 var BLOCK2 = "\xB1";
@@ -194,10 +203,11 @@ var gColors = {
 var FILE_VIEW_INFO = 1;
 var FILE_VIEW = 2;
 var FILE_ADD_TO_BATCH_DL = 3;
-var HELP = 4;
-var QUIT = 5;
-var FILE_MOVE = 6;   // Sysop action
-var FILE_DELETE = 7; // Sysop action
+var FILE_DOWNLOAD_SINGLE = 4;
+var HELP = 5;
+var QUIT = 6;
+var FILE_MOVE = 7;   // Sysop action
+var FILE_DELETE = 8; // Sysop action
 
 // Search/list modes
 var MODE_LIST_DIR = 1;
@@ -259,10 +269,7 @@ if (!console.term_supports(USER_ANSI))
 {
 	var exitCode = 0;
 	if (gScriptMode == MODE_SEARCH_FILENAME || gScriptMode == MODE_SEARCH_DESCRIPTION || gScriptMode == MODE_NEW_FILE_SEARCH)
-	{
-		//if (user.is_sysop) console.print("\x01n\r\nScan dirs\r\n"); // Temporary
 		bbs.scan_dirs(gListBehavior, gScanAllDirs);
-	}
 	else
 		exitCode = bbs.list_files(gDirCode, gFilespec, gListBehavior);
 	exit(exitCode);
@@ -337,15 +344,16 @@ while (continueDoingFileList)
 	{
 		currentActionVal = fileMenuBar.getCurrentSelectedAction();
 		fileMenuBar.setCurrentActionCode(currentActionVal);
-		actionRetObj = doAction(currentActionVal, gFileList, gFileListMenu);
+		actionRetObj = doAction_ANSI(currentActionVal, gFileList, gFileListMenu);
 	}
-	// Allow the delete key as a special key for sysops to delete the selected file(s)
-	else if (lastUserInputUpper == KEY_DEL)
+	// Allow the delete key as a special key for sysops to delete the selected file(s). Also allow backspace
+	// due to some terminals returning backspace for delete.
+	else if (lastUserInputUpper == KEY_DEL || lastUserInputUpper == KEY_BACKSPACE)
 	{
 		if (user.is_sysop)
 		{
 			fileMenuBar.setCurrentActionCode(FILE_DELETE, true);
-			actionRetObj = doAction(FILE_DELETE, gFileList, gFileListMenu);
+			actionRetObj = doAction_ANSI(FILE_DELETE, gFileList, gFileListMenu);
 			currentActionVal = FILE_DELETE;
 		}
 	}
@@ -353,7 +361,7 @@ while (continueDoingFileList)
 	{
 		currentActionVal = fileMenuBar.getActionFromChar(lastUserInputUpper, false);
 		fileMenuBar.setCurrentActionCode(currentActionVal, true);
-		actionRetObj = doAction(currentActionVal, gFileList, gFileListMenu);
+		actionRetObj = doAction_ANSI(currentActionVal, gFileList, gFileListMenu);
 	}
 	// If an action was done (actionRetObj is not null), then look at actionRetObj and
 	// do what's needed.  Note that quit (for the Q key) is already handled.
@@ -478,7 +486,7 @@ while (continueDoingFileList)
 ///////////////////////////////////////////////////////////////////////////////
 // Functions: File actions
 
-// Performs a specified file action based on an action code.
+// Performs a specified file action based on an action code. For the ANSI user interface.
 //
 // Parameters:
 //  pActionCode: A code specifying an action to do.  Must be one of the global
@@ -488,22 +496,45 @@ while (continueDoingFileList)
 //
 // Return value: An object with values to indicate status & screen refresh actions; see
 //               getDefaultActionRetObj() for details.
-function doAction(pActionCode, pFileList, pFileListMenu)
+function doAction_ANSI(pActionCode, pFileList, pFileListMenu)
 {
 	if (typeof(pActionCode) !== "number")
 		return getDefaultActionRetObj();
 
+	var fileMetadata = pFileList[pFileListMenu.selectedItemIdx];
+
 	var retObj = null;
 	switch (pActionCode)
 	{
 		case FILE_VIEW_INFO:
-			retObj = showFileInfo(pFileList, pFileListMenu);
+			retObj = showFileInfo_ANSI(fileMetadata);
 			break;
 		case FILE_VIEW:
-			retObj = viewFile(pFileList, pFileListMenu);
+			retObj = viewFile_ANSI(fileMetadata);
 			break;
 		case FILE_ADD_TO_BATCH_DL:
-			retObj = addSelectedFilesToBatchDLQueue(pFileList, pFileListMenu);
+			if (userCanDownloadFromFileArea_ShowErrorIfNot(fileMetadata.dirCode))
+				retObj = addSelectedFilesToBatchDLQueue_ANSI(fileMetadata, pFileList);
+			else
+			{
+				retObj = getDefaultActionRetObj();
+				retObj.reDrawListerHeader = true;
+				retObj.reDrawHeaderTextOnly = false;
+				retObj.reDrawMainScreenContent = true;
+				retObj.reDrawCmdBar = true;
+			}
+			break;
+		case FILE_DOWNLOAD_SINGLE:
+			if (userCanDownloadFromFileArea_ShowErrorIfNot(fileMetadata.dirCode) && pFileListMenu.selectedItemIdx >= 0 && pFileListMenu.selectedItemIdx < pFileListMenu.NumItems())
+				retObj = letUserDownloadSelectedFile_ANSI(fileMetadata);
+			else
+			{
+				retObj = getDefaultActionRetObj();
+				retObj.reDrawListerHeader = true;
+				retObj.reDrawHeaderTextOnly = false;
+				retObj.reDrawMainScreenContent = true;
+				retObj.reDrawCmdBar = true;
+			}
 			break;
 		case HELP:
 			retObj = displayHelpScreen();
@@ -514,11 +545,11 @@ function doAction(pActionCode, pFileList, pFileListMenu)
 			break;
 		case FILE_MOVE: // Sysop action
 			if (user.is_sysop)
-				retObj = chooseFilebaseAndMoveFileToOtherFilebase(pFileList, pFileListMenu);
+				retObj = chooseFilebaseAndMoveFileToOtherFilebase_ANSI(pFileList, pFileListMenu);
 			break;
 		case FILE_DELETE: // Sysop action
 			if (user.is_sysop)
-				retObj = confirmAndRemoveFilesFromFilebase(pFileList, pFileListMenu);
+				retObj = confirmAndRemoveFilesFromFilebase_ANSI(pFileList, pFileListMenu);
 			break;
 	}
 
@@ -563,12 +594,11 @@ function getDefaultActionRetObj()
 // Shows extended information about a file to the user.
 //
 // Parameters:
-//  pFileList: The list of file metadata objects, as retrieved from the filebase
-//  pFileListMenu: The file list menu
+//  pFileMetadata: The file metadata object for the file to view information about
 //
 // Return value: An object with values to indicate status & screen refresh actions; see
 //               getDefaultActionRetObj() for details.
-function showFileInfo(pFileList, pFileListMenu)
+function showFileInfo_ANSI(pFileMetadata)
 {
 	var retObj = getDefaultActionRetObj();
 
@@ -582,13 +612,13 @@ function showFileInfo(pFileList, pFileListMenu)
 	// can display the extended description.
 	// The metadata object in pFileList should have a dirCode added by this script.
 	var dirCode = gDirCode;
-	if (pFileList[pFileListMenu.selectedItemIdx].hasOwnProperty("dirCode"))
-		dirCode = pFileList[pFileListMenu.selectedItemIdx].dirCode;
+	if (pFileMetadata.hasOwnProperty("dirCode"))
+		dirCode = pFileMetadata.dirCode;
 	var fileMetadata = null;
 	if (extendedDescEnabled())
-		fileMetadata = pFileList[pFileListMenu.selectedItemIdx];
+		fileMetadata = pFileMetadata;
 	else
-		fileMetadata = getFileInfoFromFilebase(dirCode, pFileList[pFileListMenu.selectedItemIdx].name, FileBase.DETAIL.EXTENDED);
+		fileMetadata = getFileInfoFromFilebase(dirCode, pFileMetadata.name, FileBase.DETAIL.EXTENDED);
 	// Build a string with the file information
 	// Make sure the displayed filename isn't too crazy long
 	var frameInnerWidth = frameWidth - 2; // Without borders
@@ -746,21 +776,20 @@ function splitStrAndCombineWithRN(pStr, pSplitStr)
 // Lets the user view a file.
 //
 // Parameters:
-//  pFileList: The list of file metadata objects, as retrieved from the filebase
-//  pFileListMenu: The file list menu
+//  pFileMetadata: The file metadata object for the file to view
 //
 // Return value: An object with values to indicate status & screen refresh actions; see
 //               getDefaultActionRetObj() for details.
-function viewFile(pFileList, pFileListMenu)
+function viewFile_ANSI(pFileMetadata)
 {
 	var retObj = getDefaultActionRetObj();
 
 	// Open the filebase & get the fully pathed filename
 	var fullyPathedFilename = "";
-	var filebase = new FileBase(pFileList[pFileListMenu.selectedItemIdx].dirCode);
+	var filebase = new FileBase(pFileMetadata.dirCode);
 	if (filebase.open())
 	{
-		fullyPathedFilename = filebase.get_path(pFileList[pFileListMenu.selectedItemIdx]);
+		fullyPathedFilename = filebase.get_path(pFileMetadata);
 		filebase.close();
 	}
 	else
@@ -787,14 +816,16 @@ function viewFile(pFileList, pFileListMenu)
 // Allows the user to add their selected file to their batch downloaded queue
 //
 // Parameters:
+//  pFileMetadata: The file metadata object for the file
 //  pFileList: The list of file metadata objects from the file directory
-//  pFileListMenu: The menu object for the file diretory
 //
 // Return value: An object with values to indicate status & screen refresh actions; see
 //               getDefaultActionRetObj() for details.
-function addSelectedFilesToBatchDLQueue(pFileList, pFileListMenu)
+function addSelectedFilesToBatchDLQueue_ANSI(pFileMetadata, pFileList)
 {
 	var retObj = getDefaultActionRetObj();
+	if (!userCanDownloadFromFileArea_ShowErrorIfNot(pFileMetadata.dirCode))
+		return retObj;
 
 	// Confirm with the user to add the file(s) to their batch queue.  If they don't want to,
 	// then just return now.
@@ -811,8 +842,8 @@ function addSelectedFilesToBatchDLQueue(pFileList, pFileListMenu)
 	}
 	else
 	{
-		filenames.push(pFileList[pFileListMenu.selectedItemIdx].name);
-		metadataObjects.push(pFileList[pFileListMenu.selectedItemIdx]);
+		filenames.push(pFileMetadata.name);
+		metadataObjects.push(pFileMetadata);
 	}
 	// Note that confirmFileActionWithUser() will re-draw the parts of the file
 	// list menu that are necessary.
@@ -832,7 +863,7 @@ function addSelectedFilesToBatchDLQueue(pFileList, pFileListMenu)
 				// If the file isn't in the user's batch DL queue already, then add it.
 				var fileAlreadyInQueue = false;
 				for (var fIdx = 0; fIdx < batchDLQueueStats.filenames.length && !fileAlreadyInQueue; ++fIdx)
-					exists = (batchDLQueueStats.filenames[fIdx].filename == metadataObjects[i].name);
+					fileAlreadyInQueue = (batchDLQueueStats.filenames[fIdx].filename == metadataObjects[i].name);
 				if (!fileAlreadyInQueue)
 				{
 					var addToQueueSuccessful = true;
@@ -902,8 +933,8 @@ function addSelectedFilesToBatchDLQueue(pFileList, pFileListMenu)
 				// \x01cFiles: \x01h1 \x01n\x01c(\x01h100 \x01n\x01cMax)  Credits: 0  Bytes: \x01h2,228,254 \x01n\x01c Time: 00:09:40
 				// Note: The maximum number of allowed files in the batch download queue doesn't seem to
 				// be available to JavaScript.
-				var totalQueueSize = batchDLQueueStats.totalSize + pFileList[pFileListMenu.selectedItemIdx].size;
-				var totalQueueCost = batchDLQueueStats.totalCost + pFileList[pFileListMenu.selectedItemIdx].cost;
+				var totalQueueSize = batchDLQueueStats.totalSize + pFileMetadata.size;
+				var totalQueueCost = batchDLQueueStats.totalCost + pFileMetadata.cost;
 				var queueStats = "\x01n\x01cFiles: \x01h" + batchDLQueueStats.numFilesInQueue + "  \x01n\x01cCredits: \x01h"
 				               + totalQueueCost + "\x01n\x01c  Bytes: \x01h" + numWithCommas(totalQueueSize) + "\x01n\x01w\r\n";
 				for (var i = 0; i < batchDLQueueStats.filenames.length; ++i)
@@ -991,7 +1022,8 @@ function getUserDLQueueStats()
 		// See if a section exists for the filename
 		//File.iniGetAllObjects([name_property] [,prefix=none] [,lowercase=false] [,blanks=false])
 		var allIniObjs = batchDLFile.iniGetAllObjects();
-		console.print("\x01n\r\n");
+		console.attributes = "N";
+		console.crlf();
 		for (var i = 0; i < allIniObjs.length; ++i)
 		{
 			if (typeof(allIniObjs[i]) === "object")
@@ -1024,6 +1056,36 @@ function getUserDLQueueStats()
 	return retObj;
 }
 
+// Lets the user download the currently selected file on the file list menu
+//
+// Parameters:
+//  pFileMetadata: The file metadata object for the file to download
+//
+// Return value: An object with values to indicate status & screen refresh actions; see
+//               getDefaultActionRetObj() for details.
+function letUserDownloadSelectedFile_ANSI(pFileMetadata)
+{
+	var retObj = getDefaultActionRetObj();
+	console.attributes = "N";
+	console.crlf();
+	console.crlf();
+	// If the user has the security level to download the file, let them do so
+	if (userCanDownloadFromFileArea_ShowErrorIfNot(pFileMetadata.dirCode))
+	{
+		console.print("\x01cDownloading \x01h" + pFileMetadata.name + "\x01n");
+		console.crlf();
+		var selectedFilanmeFullPath = backslash(file_area.dir[pFileMetadata.dirCode].path) + pFileMetadata.name;
+		bbs.send_file(selectedFilanmeFullPath);
+	}
+
+	retObj.reDrawListerHeader = true;
+	retObj.reDrawHeaderTextOnly = false;
+	retObj.reDrawMainScreenContent = true;
+	retObj.reDrawCmdBar = true;
+
+	return retObj;
+}
+
 // Displays the help screen.
 function displayHelpScreen()
 {
@@ -1076,11 +1138,12 @@ function displayHelpScreen()
 	var printfStr = "\x01n\x01c\x01h%-" + commandStrWidth + "s\x01g: \x01n\x01c%s\r\n";
 	printf(printfStr, "I", "Display extended file information");
 	printf(printfStr, "V", "View the file");
-	printf(printfStr, "B", "Flag the file(s) for batch download");
+	printf(printfStr, "B", "Flag the selected file(s) for batch download");
+	printf(printfStr, "D", "Download the highlighted (selected) file");
 	if (user.is_sysop)
 	{
 		printf(printfStr, "M", "Move the file(s) to another directory");
-		printf(printfStr, "D", "Delete the file(s)");
+		printf(printfStr, "DEL", "Delete the file(s)");
 	}
 	printf(printfStr, "?", "Show this help screen");
 	printf(printfStr, "Q", "Quit back to the BBS");
@@ -1102,10 +1165,12 @@ function displayHelpScreen()
 //
 // Return value: An object with values to indicate status & screen refresh actions; see
 //               getDefaultActionRetObj() for details.
-function chooseFilebaseAndMoveFileToOtherFilebase(pFileList, pFileListMenu)
+function chooseFilebaseAndMoveFileToOtherFilebase_ANSI(pFileList, pFileListMenu)
 {
 	var retObj = getDefaultActionRetObj();
 
+	var fileMetadata = pFileList[pFileListMenu.selectedItemIdx];
+
 	// Confirm with the user to move the file(s).  If they don't want to,
 	// then just return now.
 	var filenames = [];
@@ -1115,7 +1180,7 @@ function chooseFilebaseAndMoveFileToOtherFilebase(pFileList, pFileListMenu)
 			filenames.push(pFileList[+idx].name);
 	}
 	else
-		filenames.push(pFileList[pFileListMenu.selectedItemIdx].name);
+		filenames.push(fileMetadata.name);
 	// Note that confirmFileActionWithUser() will re-draw the parts of the file
 	// list menu that are necessary.
 	var moveFilesConfirmed = confirmFileActionWithUser(filenames, "Move", false);
@@ -1153,7 +1218,7 @@ function chooseFilebaseAndMoveFileToOtherFilebase(pFileList, pFileListMenu)
 			chosenDirCode = fileDirMenu.GetVal();
 			if (typeof(chosenDirCode) === "string")
 			{
-				if (chosenDirCode != pFileList[pFileListMenu.selectedItemIdx].dirCode)
+				if (chosenDirCode != fileMetadata.dirCode)
 					continueOn = false;
 				else
 				{
@@ -1309,7 +1374,7 @@ function chooseFilebaseAndMoveFileToOtherFilebase(pFileList, pFileListMenu)
 //               returned will have the following additional properties:
 //               filesDeleted: Boolean - Whether or not files were actually deleted (after
 //                             confirmation)
-function confirmAndRemoveFilesFromFilebase(pFileList, pFileListMenu)
+function confirmAndRemoveFilesFromFilebase_ANSI(pFileList, pFileListMenu)
 {
 	var retObj = getDefaultActionRetObj();
 	retObj.filesDeleted = false;
@@ -1523,10 +1588,12 @@ function DDFileMenuBar(pPos)
 	this.cmdArray.push(new DDFileMenuBarItem("Info", 0, FILE_VIEW_INFO));
 	this.cmdArray.push(new DDFileMenuBarItem("View", 0, FILE_VIEW));
 	this.cmdArray.push(new DDFileMenuBarItem("Batch", 0, FILE_ADD_TO_BATCH_DL));
+	this.cmdArray.push(new DDFileMenuBarItem("DL", 0, FILE_DOWNLOAD_SINGLE));
 	if (user.is_sysop)
 	{
 		this.cmdArray.push(new DDFileMenuBarItem("Move", 0, FILE_MOVE));
-		this.cmdArray.push(new DDFileMenuBarItem("Del", 0, FILE_DELETE));
+		//this.cmdArray.push(new DDFileMenuBarItem("Del", 0, FILE_DELETE));
+		this.cmdArray.push(new DDFileMenuBarItem("DEL", 0, FILE_DELETE, KEY_DEL));
 	}
 	this.cmdArray.push(new DDFileMenuBarItem("?", 0, HELP));
 	this.cmdArray.push(new DDFileMenuBarItem("Quit", 0, QUIT));
@@ -1695,7 +1762,12 @@ function DDFileMenuBar_getActionFromChar(pChar, pCaseSensitive)
 	{
 		for (var i = 0; i < this.cmdArray.length && retCode == -1; ++i)
 		{
-			if (this.cmdArray[i].itemText.length > 0 && this.cmdArray[i].itemText.charAt(0) == pChar)
+			if (this.cmdArray[i].hotkeyOverride != null && typeof(this.cmdArray[i].hotkeyOverride) !== "undefined")
+			{
+				if (pChar == this.cmdArray[i].hotkeyOverride)
+					retCode = this.cmdArray[i].retCode;
+			}
+			else if (this.cmdArray[i].itemText.length > 0 && this.cmdArray[i].itemText.charAt(0) == pChar)
 				retCode = this.cmdArray[i].retCode;
 		}
 	}
@@ -1705,7 +1777,12 @@ function DDFileMenuBar_getActionFromChar(pChar, pCaseSensitive)
 		var charUpper = pChar.toUpperCase();
 		for (var i = 0; i < this.cmdArray.length && retCode == -1; ++i)
 		{
-			if (this.cmdArray[i].itemText.length > 0 && this.cmdArray[i].itemText.charAt(0).toUpperCase() == charUpper)
+			if (this.cmdArray[i].hotkeyOverride != null && typeof(this.cmdArray[i].hotkeyOverride) !== "undefined")
+			{
+				if (pChar == this.cmdArray[i].hotkeyOverride)
+					retCode = this.cmdArray[i].retCode;
+			}
+			else if (this.cmdArray[i].itemText.length > 0 && this.cmdArray[i].itemText.charAt(0).toUpperCase() == charUpper)
 				retCode = this.cmdArray[i].retCode;
 		}
 	}
@@ -1769,11 +1846,15 @@ function DDFileMenuBar_getAllActionKeysStr(pLowercase, pUppercase)
 //  pItemText: The text of the item
 //  pPos: Horizontal (or vertical) starting location in the bar
 //  pRetCode: The item's return code
-function DDFileMenuBarItem(pItemText, pPos, pRetCode)
+//  pHotkeyOverride: Optional: A key to use for the action instead of the first character in pItemText
+function DDFileMenuBarItem(pItemText, pPos, pRetCode, pHotkeyOverride)
 {
 	this.itemText = pItemText;
 	this.pos = pPos;
 	this.retCode = pRetCode;
+	this.hotkeyOverride = null;
+	if (pHotkeyOverride != null && typeof(pHotkeyOverride) !== "undefined")
+		this.hotkeyOverride = pHotkeyOverride;
 }
 
 
@@ -3409,7 +3490,7 @@ function populateFileList(pSearchMode)
 			userInputDLA = "A";
 		else
 		{
-			console.print("\x01n");
+			console.attributes = "N";
 			console.crlf();
 			console.mnemonics(bbs.text(DirLibOrAll));
 			userInputDLA = console.getkeys(validInputOptions, -1, K_UPPER);
@@ -4100,3 +4181,30 @@ function fileInfoSortDLTime(pA, pB)
 	else
 		return 0;
 }
+
+// Returns whether or not a user can download from a file directory, and writes an error if not.
+//
+// Parameters:
+//  pDirCode: The internal directory code of a file directory
+//
+// Return value: Boolean - Whether or not the user can download from the file directory given
+function userCanDownloadFromFileArea_ShowErrorIfNot(pDirCode)
+{
+	var userCanDownload = file_area.dir[pDirCode].can_download;
+	if (!userCanDownload)
+	{
+		// The user doesn't have permission to download from this directory
+		//file_area.dir[pDirCode].name
+		var areaFullDesc = file_area.dir[pDirCode].lib_name + ": " + file_area.dir[pDirCode].description;
+		areaFullDesc = word_wrap(areaFullDesc, console.screen_columns-1, areaFullDesc.length).replace(/\r|\n/g, "\r\n");
+		while (areaFullDesc.lastIndexOf("\r\n") == areaFullDesc.length-2)
+			areaFullDesc = areaFullDesc.substr(0, areaFullDesc.length-2);
+		console.crlf();
+		console.print(areaFullDesc);
+		console.crlf();
+		console.mnemonics(bbs.text(CantDownloadFromDir));
+		console.crlf();
+		console.pause();
+	}
+	return userCanDownload;
+}
\ No newline at end of file
diff --git a/xtrn/ddfilelister/readme.txt b/xtrn/ddfilelister/readme.txt
index 5e5aea7785eb8de45510bf053270432d8182b77b..3ec5c200482015a72cc348c87ab4f04f7403612c 100644
--- a/xtrn/ddfilelister/readme.txt
+++ b/xtrn/ddfilelister/readme.txt
@@ -1,6 +1,6 @@
                         Digital Distortion File Lister
-                                 Version 2.09
-                           Release date: 2023-02-25
+                                 Version 2.10
+                           Release date: 2023-02-27
 
                                      by
 
diff --git a/xtrn/ddfilelister/revision_history.txt b/xtrn/ddfilelister/revision_history.txt
index 688287ba6f8e99ef5aecd8cd1113894c5e2dcf14..fd099bb894deacb46169b90101e959800c205f52 100644
--- a/xtrn/ddfilelister/revision_history.txt
+++ b/xtrn/ddfilelister/revision_history.txt
@@ -5,6 +5,7 @@ Revision History (change log)
 =============================
 Version  Date         Description
 -------  ----         -----------
+2.10     2023-02-27   Now allows downloading a single selected file with the D key
 2.09     2023-02-25   Now supports being used as a loadable module for Scan Dirs
                       and List Files (applicable for Synchronet 3.20+)
 2.08     2023-01-18   When doing a file search in multiple directories, the
@@ -43,4 +44,4 @@ Version  Date         Description
                       info.  Fixed command bar refreshing when pressing the
                       hotkeys.  Added an option to pause after viewing a file
                       (defaults to true).
-2.00     2022-02-06   Initial version.
\ No newline at end of file
+2.00     2022-02-06   Initial version.