diff --git a/exec/load/dd_lightbar_menu.js b/exec/load/dd_lightbar_menu.js
index 1487fcb6b98ed70ba2d064417a0e7438d1f37936..9b1e5320ddef7c2ff7d4fdea527f49163344f842 100644
--- a/exec/load/dd_lightbar_menu.js
+++ b/exec/load/dd_lightbar_menu.js
@@ -285,6 +285,29 @@ The 'key down' behavior can be called explicitly, if needed, by calling the DoKe
 It takes 2 parameters: An object of selected item indexes (as passed to GetVal()) and, optionally,
 the pre-calculated number of items.
 lbMenu.DoKeyDown(pNumItems, pSelectedItemIndexes);
+
+
+For screen refreshing, DDLightbarMenu includes the function DrawPartial(), which can be used to
+redraw only a portion of the menu, specified by starting X & Y coordinates, width, and height.
+The starting X & Y coordinates are relative to the upper-left corner of the menu (not absolute
+screen coordinates) and start at (1, 1).  The function signature looks like this:
+ DrawPartial(pStartX, pStartY, pWidth, pHeight, pSelectedItemIndexes)
+The parameters:
+ pStartX: The column of the character in the menu to start at
+ pStartY: The row of the character in the menu to start at
+ pWidth: The width of the content to draw
+ pHeight: The height of the content to draw
+ pSelectedItemIndexes: Optional - An object containing indexes of selected items
+
+Another function, DrawPartialAbs(), provies the same functionality but with absolute screen coordinates
+(also starting at (1, 1) in the upper-left corner):
+ DrawPartialAbs(pStartX, pStartY, pWidth, pHeight, pSelectedItemIndexes)
+The parameters:
+ pStartX: The column of the character in the menu to start at
+  pStartY: The row of the character in the menu to start at
+ pWidth: The width of the content to draw
+ pHeight: The height of the content to draw
+ pSelectedItemIndexes: Optional - An object containing indexes of selected items
 */
 
 if (typeof(require) === "function")
@@ -451,6 +474,8 @@ function DDLightbarMenu(pX, pY, pWidth, pHeight)
 	this.DrawBorder = DDLightbarMenu_DrawBorder;
 	this.WriteItem = DDLightbarMenu_WriteItem;
 	this.WriteItemAtItsLocation = DDLightbarMenu_WriteItemAtItsLocation;
+	this.DrawPartial = DDLightbarMenu_DrawPartial;
+	this.DrawPartialAbs = DDLightbarMenu_DrawPartialAbs;
 	this.GetItemText = DDLightbarMenu_GetItemText;
 	this.Erase = DDLightbarMenu_Erase;
 	this.SetItemHotkey = DDLightbarMenu_SetItemHotkey;
@@ -730,6 +755,8 @@ function DDLightbarMenu_Draw(pSelectedItemIndexes, pDrawBorders, pDrawScrollbar)
 	// the rest of the height of the menu.
 	if (numItemsWritten < numPossibleItems)
 	{
+		var numberFormatStr = "%" + this.itemNumLen + "s ";
+		var itemFormatStr = "%-" + itemLen + "s";
 		for (; numItemsWritten < numPossibleItems; ++numItemsWritten)
 		{
 			writeTheItem = ((this.nextDrawOnlyItems.length == 0) || (this.nextDrawOnlyItems.indexOf(numItemsWritten) > -1));
@@ -738,8 +765,8 @@ function DDLightbarMenu_Draw(pSelectedItemIndexes, pDrawBorders, pDrawScrollbar)
 				console.gotoxy(curPos.x, curPos.y++);
 				console.print("\1n");
 				if (this.numberedMode)
-					printf("\1n%" + this.itemNumLen + "s ", "");
-				var itemText = addAttrsToString(format("%-" + itemLen + "s", ""), this.colors.itemColor);
+					printf(numberFormatStr, "");
+				var itemText = addAttrsToString(format(itemFormatStr, ""), this.colors.itemColor);
 				console.print(itemText);
 			}
 		}
@@ -876,6 +903,209 @@ function DDLightbarMenu_WriteItemAtItsLocation(pIdx, pHighlight, pSelected)
 	this.WriteItem(pIdx, null, pHighlight, pSelected);
 }
 
+// Draws part of the menu, starting at a certain location within the menu and
+// with a given width & height (for screen refreshing).  The start X and Y location
+// are relative to the menu (not the screen), and they start at (1, 1) in the upper-left
+//
+// Parameters:
+//  pStartX: The column of the character in the menu to start at
+//  pStartY: The row of the character in the menu to start at
+//  pWidth: The width of the content to draw
+//  pHeight: The height of the content to draw
+//  pSelectedItemIndexes: Optional - An object containing indexes of selected items
+function DDLightbarMenu_DrawPartial(pStartX, pStartY, pWidth, pHeight, pSelectedItemIndexes)
+{
+	// Sanity check the parameters
+	if (typeof(pStartX) !== "number" || typeof(pStartY) !== "number" || typeof(pWidth) !== "number" || typeof(pHeight) !== "number")
+		return;
+	if (pStartX < 1 || pStartX > this.size.width)
+		return;
+	if (pStartY < 1 || pStartY > this.size.height)
+		return;
+
+	// Fix the width & height if needed
+	var width = pWidth;
+	if (width > (this.size.width - pStartX + 1))
+		width = (this.size.width - pStartX + 1);
+	var height = pHeight;
+	if (height > (this.size.height - pStartY + 1))
+		height = (this.size.height - pStartY + 1);
+
+	var selectedItemIndexes = { }; // For multi-select mode
+	if (typeof(pSelectedItemIndexes) == "object")
+		selectedItemIndexes = pSelectedItemIndexes;
+
+	// If borders are enabled, draw any border characters in the region first
+	// The X & Y locations are 1-based
+	var lastLineNum = (pStartY  + this.pos.y + height) - 1; // Last line # on the screen
+	if (lastLineNum > this.pos.y + this.size.height - 1)
+		lastLineNum = this.pos.y + this.size.height - 1;
+	if (this.borderEnabled)
+	{
+		var lastX = pStartX + width - 1;
+		for (var lineNum = pStartY + this.pos.y - 1; lineNum <= lastLineNum; ++lineNum)
+		{
+			// Top line
+			if (lineNum == this.pos.y)
+			{
+				console.print("\1n" + this.colors.borderColor);
+				for (var posX = pStartX; posX <= lastX; ++posX)
+				{
+					console.gotoxy(posX, lineNum);
+					if (posX == this.pos.x)
+						console.print(this.borderChars.upperLeft);
+					else if (posX == this.pos.x + this.size.width - 1)
+						console.print(this.borderChars.upperRight);
+					else
+						console.print(this.borderChars.top);
+				}
+			}
+			// Bottom line
+			else if (lineNum == this.pos.y + this.size.height - 1)
+			{
+				console.print("\1n" + this.colors.borderColor);
+				for (var posX = pStartX; posX <= lastX; ++posX)
+				{
+					console.gotoxy(posX, lineNum);
+					if (posX == this.pos.x)
+						console.print(this.borderChars.lowerLeft);
+					else if (posX == this.pos.x + this.size.width - 1)
+						console.print(this.borderChars.lowerRight);
+					else
+						console.print(this.borderChars.bottom);
+				}
+			}
+			// Somewhere between the top & bottom line
+			else
+			{
+				var printedBorderColor = false;
+				for (var posX = pStartX; posX <= lastX; ++posX)
+				{
+					console.gotoxy(posX, lineNum);
+					if (posX == this.pos.x)
+					{
+						if (!printedBorderColor)
+						{
+							console.print("\1n" + this.colors.borderColor);
+							printedBorderColor = true;
+						}
+						console.print(this.borderChars.left);
+					}
+					else if (posX == this.pos.x + this.size.width - 1)
+					{
+						if (!printedBorderColor)
+						{
+							console.print("\1n" + this.colors.borderColor);
+							printedBorderColor = true;
+						}
+						console.print(this.borderChars.right);
+					}
+				}
+			}
+		}
+	}
+	// Calculate the width and starting index of the menu items
+	// Note that pStartX is relative to the menu, not the screen
+	var itemLen = width;
+	var writeMenuItems = true; // Might not if the draw area only includes the scrollbar or border
+	var itemTxtStartIdx = pStartX - 1;
+	if (this.borderEnabled)
+	{
+		if (itemTxtStartIdx > 0)
+			--itemTxtStartIdx; // pStartX - 2
+		if (pStartX == 1)
+			--itemLen;
+		// Starts on 2 & width is 5: 2, 3, 4, 5, 6
+		var lastCol = this.pos.x + pStartX + width - 1;
+		if (this.pos.x + pStartX + width - 1 >= lastCol) // The last column drawn will contain the right border char
+			--itemLen;
+		if ((pStartX == 1 && width == 1) || pStartX == this.size.width)
+			writeMenuItems  = false;
+		else if (this.scrollbarEnabled && !this.CanShowAllItemsInWindow() && pStartX == this.size.width-1)
+			writeMenuItems = false;
+	}
+	if (this.scrollbarEnabled && !this.CanShowAllItemsInWindow())
+	{
+		var scrollbarCol = this.borderEnabled ? this.pos.x + this.size.width - 2 : this.pos.x + this.size.width - 1;
+		if (this.pos.x + pStartX + width - 1 >= scrollbarCol) // The last column drawn includes the scrollbar
+			--itemLen;
+		if (!this.borderEnabled && pStartX == this.size.width)
+			writeMenuItems = false;
+		// Just draw the whole srollbar to ensure it's updated
+		this.DisplayInitialScrollbar(this.scrollbarInfo.solidBlockLastStartRow, this.scrollbarInfo.numSolidScrollBlocks);
+	}
+	if (itemTxtStartIdx < 0)
+		itemTxtStartIdx = 0;
+	// Write the menu items
+	if (writeMenuItems)
+	{
+		var blankItemTextFormatStr = "\1n%" + itemLen + "s";
+		for (var lineNum = pStartY + this.pos.y - 1; lineNum <= lastLineNum; ++lineNum)
+		{
+			var startX = pStartX;
+			// If borders are enabled, skip the top & bottom lines since borders were already drawn
+			if (this.borderEnabled)
+			{
+				if (lineNum == this.pos.y || lineNum == lastLineNum)
+					continue;
+				else
+				{
+					if (pStartX + this.pos.x - 1 == this.pos.x)
+						++startX;
+				}
+			}
+			// Write the menu item text
+			var itemIdx = this.topItemIdx + (lineNum - this.pos.y);
+			if (this.borderEnabled) --itemIdx;
+			var highlightItem = itemIdx == this.selectedItemIdx;
+			var itemText = this.GetItemText(itemIdx, null, highlightItem, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
+			var shortenedText = substrWithAttrCodes(itemText, itemTxtStartIdx, itemLen);
+			// If shortenedText is empty (perhaps there's no menu item for this line),
+			// then make shortenedText consist of all spaces at the proper length
+			if (shortenedText.length == 0)
+				shortenedText = format(blankItemTextFormatStr, "");
+			console.gotoxy(startX, lineNum);
+			console.print(shortenedText + "\1n");
+		}
+	}
+}
+// Draws part of the menu, starting at a certain location within the menu and
+// with a given width & height (for screen refreshing).  For this version, the start X
+// and Y location are absolute on the screen.  They start at (1, 1) in the upper-left.
+//
+// Parameters:
+//  pStartX: The column of the character in the menu to start at
+//  pStartY: The row of the character in the menu to start at
+//  pWidth: The width of the content to draw
+//  pHeight: The height of the content to draw
+//  pSelectedItemIndexes: Optional - An object containing indexes of selected items
+function DDLightbarMenu_DrawPartialAbs(pStartX, pStartY, pWidth, pHeight, pSelectedItemIndexes)
+{
+	if (typeof(pStartX) !== "number" || typeof(pStartY) !== "number" || typeof(pWidth) !== "number" || typeof(pHeight) !== "number")
+		return;
+
+	// Calculate the start X & Y coordinates relative to the menu (1-based), and adjust height &
+	// width if necessary.  Then draw partial.
+	var height = pHeight;
+	var width = pWidth;
+	var startX = pStartX - this.pos.x + 1;
+	var startY = pStartY - this.pos.y + 1;
+	if (startX < 1)
+	{
+		var XDiff = 1 - startX;
+		startX += XDiff;
+		width -= XDiff;
+	}
+	if (startY < 1)
+	{
+		var YDiff = 1 - startY;
+		startY += YDiff;
+		height -= YDiff;
+	}
+	this.DrawPartial(startX, startY, width, height, pSelectedItemIndexes);
+}
+
+
 // Gets the text of a menu item with colors applied
 //
 // Parameters:
@@ -1258,10 +1488,6 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 			if (mk !== null && mk.mouse !== null)
 			{
 				goAheadAndExit = !mouseNoAction; // Only really needed with an input timer?
-				// Temporary
-				console.print("\1n\r\nHere! - mouseNoAction: " + goAheadAndExit + ", goAheadAndExit: " + goAheadAndExit + "\r\n");
-				console.pause();
-				// End Temporary
 			}
 			if (goAheadAndExit)
 			{
@@ -1513,7 +1739,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 				if (this.multiSelect)
 				{
 					if (Object.keys(selectedItemIndexes).length == 0)
-						selectedItemIndexes[this.selectedItemIdx] = true;
+						selectedItemIndexes[+(this.selectedItemIdx)] = true;
 				}
 				else
 					retVal = this.GetItem(this.selectedItemIdx).retval;
@@ -1539,8 +1765,8 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 				if (allowSelectItem)
 				{
 					var added = false; // Will be true if added or false if deleted
-					if (selectedItemIndexes.hasOwnProperty(this.selectedItemIdx))
-						delete selectedItemIndexes[this.selectedItemIdx];
+					if (selectedItemIndexes.hasOwnProperty(+(this.selectedItemIdx)))
+						delete selectedItemIndexes[+(this.selectedItemIdx)];
 					else
 					{
 						var addIt = true;
@@ -1548,7 +1774,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 							addIt = (Object.keys(selectedItemIndexes).length < this.maxNumSelections);
 						if (addIt)
 						{
-							selectedItemIndexes[this.selectedItemIdx] = true;
+							selectedItemIndexes[+(this.selectedItemIdx)] = true;
 							added = true;
 						}
 					}
@@ -1620,15 +1846,15 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 				this.selectedItemIdx = userEnteredItemNum-1;
 				if (this.multiSelect)
 				{
-					if (selectedItemIndexes.hasOwnProperty(this.selectedItemIdx))
-						delete selectedItemIndexes[this.selectedItemIdx];
+					if (selectedItemIndexes.hasOwnProperty(+(this.selectedItemIdx)))
+						delete selectedItemIndexes[+(this.selectedItemIdx)];
 					else
 					{
 						var addIt = true;
 						if (this.maxNumSelections > 0)
 							addIt = (Object.keys(selectedItemIndexes).length < this.maxNumSelections);
 						if (addIt)
-							selectedItemIndexes[this.selectedItemIdx] = true;
+							selectedItemIndexes[+(this.selectedItemIdx)] = true;
 					}
 					// TODO: Put a check-mark next to the selected item
 					// TODO: Screen refresh?
@@ -1715,7 +1941,7 @@ function DDLightbarMenu_DoKeyDown(pSelectedItemIndexes, pNumItems)
 	if (this.selectedItemIdx < numItems-1)
 	{
 		// Draw the current item in regular colors
-		this.WriteItemAtItsLocation(this.selectedItemIdx, false, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
+		this.WriteItemAtItsLocation(this.selectedItemIdx, false, selectedItemIndexes.hasOwnProperty(+(this.selectedItemIdx)));
 		++this.selectedItemIdx;
 		// Draw the new current item in selected colors
 		// If the selected item is below the bottom of the menu, then we'll need to
@@ -1730,7 +1956,7 @@ function DDLightbarMenu_DoKeyDown(pSelectedItemIndexes, pNumItems)
 		{
 			// The selected item is not below the bottom of the menu, so we can
 			// just draw the selected item highlighted.
-			this.WriteItemAtItsLocation(this.selectedItemIdx, true, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
+			this.WriteItemAtItsLocation(this.selectedItemIdx, true, selectedItemIndexes.hasOwnProperty(+(this.selectedItemIdx)));
 		}
 	}
 	else
@@ -1740,7 +1966,7 @@ function DDLightbarMenu_DoKeyDown(pSelectedItemIndexes, pNumItems)
 		if (this.wrapNavigation)
 		{
 			// Draw the current item in regular colors
-			this.WriteItemAtItsLocation(this.selectedItemIdx, false, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
+			this.WriteItemAtItsLocation(this.selectedItemIdx, false, selectedItemIndexes.hasOwnProperty(+(this.selectedItemIdx)));
 			// Go to the first item and scroll to the top if necessary
 			this.selectedItemIdx = 0;
 			var oldTopItemIdx = this.topItemIdx;
@@ -1750,7 +1976,7 @@ function DDLightbarMenu_DoKeyDown(pSelectedItemIndexes, pNumItems)
 			else
 			{
 				// Draw the new current item in selected colors
-				this.WriteItemAtItsLocation(this.selectedItemIdx, true, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
+				this.WriteItemAtItsLocation(this.selectedItemIdx, true, selectedItemIndexes.hasOwnProperty(+(this.selectedItemIdx)));
 			}
 		}
 	}
diff --git a/xtrn/ddfilelister/ddfilelister.cfg b/xtrn/ddfilelister/ddfilelister.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..a6c515ddbdd04a2dc951b44022c054c039db8b74
--- /dev/null
+++ b/xtrn/ddfilelister/ddfilelister.cfg
@@ -0,0 +1,12 @@
+; The sort order for the file list
+; NATURAL: Natural sort order (same as DATE_A)
+; NAME_AI: Filename ascending, case insensitive sort order
+; NAME_DI: Filename descending, case insensitive sort order
+; NAME_AS: Filename ascending, case sensitive sort order
+; NAME_DS: Filename descending, case sensitive sort order
+; DATE_A: Import date/time ascending sort order
+; DATE_D: Import date/time descending sort order
+sortOrder=NATURAL
+
+; The name of the color theme configuration file
+themeFilename=defaultTheme.cfg
diff --git a/xtrn/ddfilelister/ddfilelister.js b/xtrn/ddfilelister/ddfilelister.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f2b4e4280b36591acb1d608005e5402f933b9e2
--- /dev/null
+++ b/xtrn/ddfilelister/ddfilelister.js
@@ -0,0 +1,2525 @@
+/* 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).
+*/
+
+if (typeof(require) === "function")
+{
+	require("sbbsdefs.js", "K_UPPER");
+	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");
+}
+else
+{
+	load("sbbsdefs.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");
+}
+
+
+// If the user's terminal doesn't support ANSI, then just call the standard Synchronet
+// file list function and exit now
+// TODO: Create a traditional user interface?
+if (!console.term_supports(USER_ANSI))
+{
+	bbs.list_files();
+	exit();
+}
+
+
+// Store whether the user is a sysop
+var gUserIsSysop = user.compare_ars("SYSOP");
+
+
+// This script requires Synchronet version 3.19 or higher.
+// If the Synchronet version is below the minimum, then just call the standard
+// Synchronet file list and exit.
+if (system.version_num < 31900)
+{
+	if (gUserIsSysop)
+	{
+		var message = "\1n\1h\1y\1i* Warning:\1n\1h\1w Digital Distortion File Lister "
+		            + "requires version \1g3.19\1w or\r\n"
+		            + "higher of Synchronet.  This BBS is using version \1g" + system.version
+		            + "\1w.\1n";
+		console.crlf();
+		console.print(message);
+		console.crlf();
+		console.pause();
+	}
+	bbs.list_files();
+	exit();
+}
+
+// Lister version information
+var LISTER_VERSION = "2.00";
+var LISTER_DATE = "2022-02-06";
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Global variables
+
+// 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;
+// For terminals that are at least 100 characters wide, allow 10 more characters
+// for the filename.  This will also give more space for the description.
+if (console.screen_columns >= 100)
+	gListIdxes.filenameEnd += 10;
+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: "\1n\1b\1h",
+	fileSize: "\1n\1m\1h",
+	desc: "\1n\1w",
+	bkgHighlight: "\1n\1" + "4",
+	filenameHighlight: "\1c\1h",
+	fileSizeHighlight: "\1c\1h",
+	descHighlight: "\1c\1h",
+	fileTimestamp: "\1g\1h",
+	fileInfoWindowBorder: "\1r",
+	fileInfoWindowTitle: "\1g",
+	errorBoxBorder: "\1g\1h",
+	errorMessage: "\1y\1h",
+	successMessage: "\1c",
+
+	batchDLInfoWindowBorder: "\1r",
+	batchDLInfoWindowTitle: "\1g",
+	confirmFileActionWindowBorder: "\1r",
+	confirmFileActionWindowWindowTitle: "\1g",
+
+	fileAreaMenuBorder: "\1b",
+	fileNormalBkg: "\1" + "4",
+	fileAreaNum: "\1w",
+	fileAreaDesc: "\1w",
+	fileAreaNumItems: "\1w",
+
+	fileAreaMenuHighlightBkg: "\1" + "7",
+	fileAreaNumHighlight: "\1b",
+	fileAreaDescHighlight: "\1b",
+	fileAreaNumItemsHighlight: "\1b"
+};
+
+
+// Actions
+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
+
+
+
+// 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;
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Script execution code
+
+var gFilebase = new FileBase(bbs.curdir_code);
+if (!gFilebase.open())
+{
+	console.crlf();
+	console.print("\1n\1h\1yUnable to open \1w" + file_area.dir[bbs.curdir_code].description + "\1n");
+	console.crlf();
+	console.pause();
+	exit(1);
+}
+
+// If we got here, the gFilebase successfully opened.
+// If there are no files in the filebase, then say so and exit now.
+if (gFilebase.files == 0)
+{
+	var libIdx = file_area.dir[bbs.curdir_code].lib_index;
+	console.crlf();
+	console.print("\1n\1cThere are no files in \1h" + file_area.lib_list[libIdx].description + "\1n\1c - \1h" +
+	              file_area.dir[bbs.curdir_code].description + "\1n");
+	console.crlf();
+	console.pause();
+	exit();
+}
+
+// The sort order to use for the file list
+var gFileSortOrder = FileBase.SORT.NATURAL; // Natural sort order, same as DATE_A (import date ascending)
+
+// Read the configuration file and set the settings
+readConfigFile();
+
+// To check a user's file basic/extended detail information setting:
+// if ((user.settings & USER_EXTDESC) == USER_EXTDESC)
+
+// Get a list of file data with normal detail (without extended info).  When the user
+// selects a file to view extended info, we'll get metadata about the file with extended detail.
+//var gFileList = gFilebase.get_list("*", FileBase.DETAIL.NORM); // FileBase.DETAIL.EXTENDED
+var gFileList = gFilebase.get_list("*", FileBase.DETAIL.NORM, 0, true, gFileSortOrder); // FileBase.DETAIL.EXTENDED
+
+// Clear the screen and display the header lines
+console.clear("\1n");
+displayFileLibAndDirHeader(bbs.curdir_code);
+// 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);
+// 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({});
+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 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)
+	{
+		var currentActionVal = fileMenuBar.getCurrentSelectedAction();
+		fileMenuBar.setCurrentActionCode(currentActionVal);
+		actionRetObj = doAction(currentActionVal, bbs.curdir_code, gFilebase, gFileList, gFileListMenu);
+	}
+	else
+	{
+		var currentActionVal = fileMenuBar.getActionFromChar(lastUserInputUpper, false);
+		fileMenuBar.setCurrentActionCode(currentActionVal);
+		actionRetObj = doAction(currentActionVal, bbs.curdir_code, gFilebase, 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 (actionRetObj.reDrawListerHeader)
+			{
+				console.print("\1n");
+				console.gotoxy(1, 1);
+				displayFileLibAndDirHeader(bbs.curdir_code);
+			}
+			if (actionRetObj.reDrawCmdBar)
+			{
+				//fileMenuBar.constructPromptText();
+				fileMenuBar.writePromptLine();
+			}
+			var redrewPartOfFileListMenu = false;
+			if (actionRetObj.fileListPartialRedrawInfo != null)
+			{
+				drawFileListMenu = false;
+				var startX = actionRetObj.fileListPartialRedrawInfo.startX;
+				var startY = actionRetObj.fileListPartialRedrawInfo.startY;
+				var width = actionRetObj.fileListPartialRedrawInfo.width;
+				var height = actionRetObj.fileListPartialRedrawInfo.height;
+				gFileListMenu.DrawPartial(startX, startY, width, height, {});
+				redrewPartOfFileListMenu = true;
+			}
+			else
+			{
+				continueDoingFileList = actionRetObj.continueFileLister;
+				drawFileListMenu = actionRetObj.reDrawFileListMenu;
+			}
+			// If we're not redrawing the whole file list menu, then remove
+			// checkmarks from any selected files
+			if (!drawFileListMenu && gFileListMenu.numSelectedItemIndexes() > 0)
+			{
+				var lastItemIdxOnScreen = gFileListMenu.topItemIdx + gFileListMenu.size.height - 1;
+				var listItemStartRow = gFileListMenu.pos.y;
+				var redrawStartX = gFileListMenu.pos.x + gFileListMenu.size.width - 1;
+				var redrawWidth = 1;
+				if (gFileListMenu.borderEnabled) // Shouldn't have this enabled
+				{
+					--lastItemIdxOnScreen;
+					++listItemStartRow;
+					--redrawStartX;
+				}
+				if (gFileListMenu.scrollbarEnabled && !gFileListMenu.CanShowAllItemsInWindow())
+				{
+					--redrawStartX;
+					++redrawWidth;
+				}
+				for (var idx in gFileListMenu.selectedItemIndexes)
+				{
+					var idxNum = +idx;
+					if (idxNum >= gFileListMenu.topItemIdx && idxNum <= lastItemIdxOnScreen)
+					{
+						gFileListMenu.DrawPartialAbs(redrawStartX, listItemStartRow+idxNum, redrawWidth, 1, {});
+						redrewPartOfFileListMenu = true;
+					}
+				}
+			}
+			// 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);
+		}
+	}
+}
+
+gFilebase.close();
+
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Functions: File actions
+
+// Performs a specified file action based on an action code.
+//
+// Parameters:
+//  pActionCode: A code specifying an action to do.  Must be one of the global
+//               action codes.
+//  pDirCode: The internal code of the file directory
+//  pFilebase: A Filebase object representing the downloadable file directory.  This
+//             is assumed to be open.
+//  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(pActionCode, pDirCode, pFilebase, pFileList, pFileListMenu)
+{
+	if (typeof(pActionCode) !== "number")
+		return getDefaultActionRetObj();
+	if (pFilebase == null || typeof(pFilebase) !== "object")
+		return getDefaultActionRetObj();
+
+	var retObj = null;
+	switch (pActionCode)
+	{
+		case FILE_VIEW_INFO:
+			retObj = showFileInfo(pFilebase, pFileList, pFileListMenu);
+			break;
+		case FILE_VIEW:
+			retObj = viewFile(pFilebase, pFileList, pFileListMenu);
+			break;
+		case FILE_ADD_TO_BATCH_DL:
+			retObj = addSelectedFilesToBatchDLQueue(pDirCode, pFilebase, pFileList, pFileListMenu);
+			break;
+		case HELP:
+			retObj = displayHelpScreen(pDirCode, pFilebase);
+			break;
+		case QUIT:
+			retObj = getDefaultActionRetObj();
+			retObj.continueFileLister = false;
+			break;
+		case FILE_MOVE: // Sysop action
+			if (gUserIsSysop)
+				retObj = chooseFilebaseAndMoveFileToOtherFilebase_Lightbar(pDirCode, pFilebase, pFileList, pFileListMenu);
+			break;
+		case FILE_DELETE: // Sysop action
+			if (gUserIsSysop)
+				retObj = removeFileFromFilebase(pDirCode, pFilebase, 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
+//               reDrawFileListMenu: Boolean - Whether or not to re-draw the whole file list
+//               reDrawListerHeader: Boolean - Whether or not to re-draw the header at the top of the screen
+//               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
+//               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,
+		reDrawFileListMenu: false,
+		reDrawListerHeader: false,
+		reDrawCmdBar: false,
+		fileListPartialRedrawInfo: null,
+		exitNow: false
+	};
+}
+
+// Shows extended information about a file to the user.
+//
+// Parameters:
+//  pFilebase: A Filebase object representing the downloadable file directory.  This
+//             is assumed to be open.
+//  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 showFileInfo(pFilebase, pFileList, pFileListMenu)
+{
+	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;
+
+	// 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.
+	var extdFileInfo = pFilebase.get(pFileList[pFileListMenu.selectedItemIdx], FileBase.DETAIL.EXTENDED);
+	// Build a string with the file information
+	var fileTime = pFilebase.get_time(extdFileInfo.name);
+	// Make sure the displayed filename isn't too crazy long
+	var adjustedFilename = shortenFilename(extdFileInfo.name, frameWidth-2, false);
+	var fileInfoStr = "\1n\1wFilename";
+	if (adjustedFilename.length < extdFileInfo.name.length)
+		fileInfoStr += " (shortened)";
+	fileInfoStr += ":\r\n";
+	fileInfoStr += gColors.filename + adjustedFilename +  "\1n\1w\r\n";
+	// Note: File size can also be retrieved by calling pFilebase.get_size(extdFileInfo.name)
+	// TODO: Shouldn't need the max length here
+	fileInfoStr += "Size: " + gColors.fileSize + getFileSizeStr(extdFileInfo.size, 99999) + "\1n\1w\r\n";
+	fileInfoStr += "Timestamp: " + gColors.fileTimestamp + strftime("%Y-%m-%d %H:%M:%S", fileTime) + "\1n\1w\r\n"
+	fileInfoStr += "\r\n";
+	fileInfoStr += gColors.desc;
+	// extdFileInfo should have extdDesc, but check just in case
+	var fileDesc = "";
+	if (extdFileInfo.hasOwnProperty("extdesc") && extdFileInfo.extdesc.length > 0)
+		fileDesc = extdFileInfo.extdesc;
+	else
+		fileDesc = extdFileInfo.desc;
+	if (fileDesc.length > 0)
+		fileInfoStr += "Description:\r\n" + fileDesc;
+	else
+		fileInfoStr += "No description available";
+	fileInfoStr += "\1n\1w";
+
+	// Construct & draw a frame with the file information & do the input loop
+	// for the frame until the user closes the frame.
+	var frameUpperLeftX = pFileListMenu.pos.x + 2;
+	var frameUpperLeftY = pFileListMenu.pos.y + 2;
+	// Note: frameWidth is declared earlier
+	var frameHeight = 10;
+	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: 2,
+		startY: 2,
+		width: frameWidth+1,
+		height: frameHeight
+	};
+
+	return retObj;
+}
+
+// Lets the user view a file.
+//
+// Parameters:
+//  pFilebase: A Filebase object representing the downloadable file directory.  This
+//             is assumed to be open.
+//  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 viewFile(pFilebase, pFileList, pFileListMenu)
+{
+	var retObj = getDefaultActionRetObj();
+
+	var fullyPathedFilename = pFilebase.get_path(pFileList[pFileListMenu.selectedItemIdx]);
+	console.gotoxy(1, console.screen_rows);
+	console.print("\1n");
+	console.crlf();
+	var successfullyViewed = bbs.view_file(fullyPathedFilename);
+	if (!successfullyViewed)
+		console.pause();
+
+	retObj.reDrawListerHeader = true;
+	retObj.reDrawFileListMenu = true;
+	retObj.reDrawCmdBar = true;
+	return retObj;
+}
+
+// Allows the user to add their selected file to their batch downloaded queue
+//
+// Parameters:
+//  pDirCode: The internal code of the file directory
+//  pFilebase: The FileBase object representing the file directory (assumed open)
+//  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(pDirCode, pFilebase, pFileList, pFileListMenu)
+{
+	var retObj = getDefaultActionRetObj();
+
+	// 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(pFileList[pFileListMenu.selectedItemIdx].name);
+		metadataObjects.push(pFileList[pFileListMenu.selectedItemIdx]);
+	}
+	// Note that confirmFileActionWithUser() will re-draw the parts of the file
+	// list menu that are necessary.
+	var addFilesConfirmed = confirmFileActionWithUser(filenames, "Batch DL add", false);
+	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)
+					exists = (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.
+					addToQueueSuccessful = batchDLFile.iniSetValue(metadataObjects[i].name, "dir", pDirCode);
+					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 = gFileListMenu.size.width - 4;
+		var frameInnerWidth = frameWidth - 2; // Without borders
+		var frameHeight = 8;
+		// To make the list refresh info to return to the main script loop
+		function makeBatchRefreshInfoObj(pFrameWidth, pFrameHeight)
+		{
+			return {
+				startX: 3,
+				startY: 3,
+				width: pFrameWidth+1,
+				height: pFrameHeight
+			};
+		}
+
+		// 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("\1n");
+				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)?";
+				// \1cFiles: \1h1 \1n\1c(\1h100 \1n\1cMax)  Credits: 0  Bytes: \1h2,228,254 \1n\1c Time: 00:09:40
+				//var fileSize = gFilebase.get_size(gFileList[pIdx].name);
+				// 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 queueStats = "\1n\1cFiles: \1h" + batchDLQueueStats.numFilesInQueue + "  \1n\1cCredits: \1h"
+				               + totalQueueCost + "\1n\1c  Bytes: \1h" + numWithCommas(totalQueueSize) + "\1n\1w\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);
+				if (lastUserInput.toUpperCase() == "Y")
+				{
+					retObj.reDrawFileListMenu = true;
+					retObj.reDrawListerHeader = true;
+					retObj.reDrawCmdBar = true;
+					console.print("\1n");
+					console.gotoxy(1, console.screen_rows);
+					console.crlf();
+					bbs.batch_download();
+				}
+				else
+				{
+					retObj.reDrawFileListMenu = true;
+					// 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 = makeBatchRefreshInfoObj(frameWidth, frameHeight);
+				}
+			}
+		}
+		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 = "\1n\1w";
+			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, "");
+			// 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 = makeBatchRefreshInfoObj(frameWidth, 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.print("\1n\r\n");
+		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();
+					}
+				}
+			}
+		}
+
+		/*
+		var sections = batchDLFile.iniGetSections();
+		retObj.numFilesInQueue = sections.length;
+		for (var i = 0; i < sections.length; ++i)
+		{
+			//var desc = 
+			//retObj.filenames.push({ filename: sections[i], desc:  });
+			// Get the dir code from the section, then get the size and cost for
+			// the file from the filebase and add them to the totals in retObj
+			var dirCode = batchDLFile.iniGetValue(sections[i], "dir", "");
+			if (dirCode.length > 0)
+			{
+				var filebase = new FileBase(dirCode);
+				if (filebase.open())
+				{
+					var fileInfo = filebase.get(sections[i]);
+					if (typeof(fileInfo) === "object")
+					{
+						retObj.totalSize += +(fileInfo.size);
+						retObj.totalCost += +(fileInfo.cost);
+					}
+					filebase.close();
+				}
+			}
+		}
+		*/
+		batchDLFile.close();
+	}
+
+	return retObj;
+}
+
+// Displays the help screen.
+//
+// Parameters:
+//  pDirCode: The internal code of the file directory being used
+//  pFilebase: A Filebase object representing the downloadable file directory.  This
+//             is assumed to be open.
+function displayHelpScreen(pDirCode, pFilebase)
+{
+	var retObj = getDefaultActionRetObj();
+
+	console.clear("\1n");
+	// Display program information
+	displayTextWithLineBelow("Digital Distortion File Lister", true, "\1n\1c\1h", "\1k\1h")
+	console.center("\1n\1cVersion \1g" + LISTER_VERSION + " \1w\1h(\1b" + LISTER_DATE + "\1w)");
+	console.crlf();
+
+	// Display information about the current file directory
+	var libIdx = file_area.dir[pDirCode].lib_index;
+	var dirIdx = file_area.dir[pDirCode].index;
+	console.print("\1n\1cCurrent file library: \1g" + file_area.lib_list[libIdx].description);
+	console.crlf();
+	console.print("\1cCurrent file directory: \1g" + file_area.dir[pDirCode].description);
+	console.crlf();
+	console.print("\1cThere are \1g" + pFilebase.files + " \1cfiles in this directory.");
+	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 (gUserIsSysop)
+		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 = "\1n\1c\1h%-" + commandStrWidth + "s\1g: \1n\1c%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");
+	if (gUserIsSysop)
+	{
+		printf(printfStr, "M", "Move the file(s) to another directory");
+		printf(printfStr, "D", "Delete the file(s)");
+	}
+	printf(printfStr, "?", "Show this help screen");
+	printf(printfStr, "Q", "Quit back to the BBS");
+	console.print("\1n");
+	console.crlf();
+	//console.pause();
+
+	retObj.reDrawListerHeader = true;
+	retObj.reDrawFileListMenu = true;
+	retObj.reDrawCmdBar = true;
+	return retObj;
+}
+
+// Allows the user to move the selected file to another filebase.  Only for sysops!
+//
+// Parameters:
+//  pDirCode: The internal code of the original file directory
+//  pFilebase: The FileBase object representing the file directory (assumed open)
+//  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_Lightbar(pDirCode, pFilebase, pFileList, pFileListMenu)
+{
+	var retObj = getDefaultActionRetObj();
+
+	// Confirm with the user to move the file(s).  If they don't want to,
+	// then just return now.
+	var filenames = [];
+	if (gFileListMenu.numSelectedItemIndexes() > 0)
+	{
+		for (var idx in gFileListMenu.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 moveFilesConfirmed = confirmFileActionWithUser(filenames, "Move", false);
+	if (!moveFilesConfirmed)
+		return retObj;
+
+
+	retObj.reDrawFileListMenu = true;
+	// Prompt the user which directory to move the file to
+	var chosenDirCode = null;
+	var fileLibMenu = createFileLibMenu();
+	console.gotoxy(fileLibMenu.pos.x, fileLibMenu.pos.y-1);
+	printf("\1n\1c\1h|\1n\1c%-" + +(fileLibMenu.size.width-1) + "s\1n", "Choose a destination area");
+	var continueOn = true;
+	while (continueOn)
+	{
+		var chosenLibIdx = fileLibMenu.GetVal();
+		if (typeof(chosenLibIdx) === "number")
+		{
+			var fileDirMenu = createFileDirMenu(chosenLibIdx);
+			chosenDirCode = fileDirMenu.GetVal();
+			if (typeof(chosenDirCode) === "string")
+			{
+				if (chosenDirCode != pDirCode)
+					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)
+	{
+		// Build an array of file indexes and sort the array
+		var fileIndexes = [];
+		if (gFileListMenu.numSelectedItemIndexes() > 0)
+		{
+			for (var idx in gFileListMenu.selectedItemIndexes)
+				fileIndexes.push(+idx);
+		}
+		else
+			fileIndexes.push(+(pFileListMenu.selectedItemIdx));
+		fileIndexes.sort();
+
+		// 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];
+			var moveRetObj = moveFileToOtherFilebase(pFilebase, pFileList[fileIdx], chosenDirCode);
+			if (moveRetObj.moveSucceeded)
+			{
+				// Remove the file info object from the file list array
+				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;
+			}
+			else
+			{
+				moveAllSucceeded = false;
+				displayMsg(pFileList[fileIdx].name, true);
+				displayMsg(moveRetObj.failReason, true);
+			}
+		}
+		if (moveAllSucceeded)
+		{
+			var libIdx = file_area.dir[chosenDirCode].lib_index;
+			var msg = "Successfully moved the file(s) to "
+			        + file_area.lib_list[libIdx].description + " - "
+			        + file_area.dir[chosenDirCode].description
+			displayMsg(msg, false);
+		}
+		// After moving the files, if the file directory is empty, say so
+		if (pFilebase.files == 0)
+		{
+			displayMsg("The directory now has no files.", false);
+			retObj.exitNow = true;
+		}
+	}
+
+	return retObj;
+}
+
+// Allows the user to remove the selected file from the filebase.  Only for sysops!
+//
+// Parameters:
+//  pDirCode: The internal code of the original file directory
+//  pFilebase: The FileBase object representing the file directory (assumed open)
+//  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 removeFileFromFilebase(pDirCode, pFilebase, pFileList, pFileListMenu)
+{
+	var retObj = getDefaultActionRetObj();
+
+	// 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 (gFileListMenu.numSelectedItemIndexes() > 0)
+	{
+		for (var idx in gFileListMenu.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);
+	if (removeFilesConfirmed)
+	{
+		// FileBase.remove(filename [,delete=false])
+		var succeeded = pFilebase.remove(pFileList[pFileListMenu.selectedItemIdx].name, true);
+		if (succeeded)
+		{
+			var messages = [ "Successfully removed the file(s)." ];
+			// Remove the file info object from the file list array
+			pFileList.splice(pFileListMenu.selectedItemIdx, 1);
+			// Adjust the file list menu's current selected index
+			--pFileListMenu.selectedItemIdx;
+			if (pFileListMenu.selectedItemIdx < 0)
+				pFileListMenu.selectedItemIdx = 0;
+			if (pFileListMenu.topItemIdx > pFileListMenu.selectedItemIdx)
+				pFileListMenu.topItemIdx = pFileListMenu.selectedItemIdx;
+			// If the file directory 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.
+			if (pFilebase.files > 0)
+				retObj.reDrawFileListMenu = true;
+			else
+			{
+				messages.push("The directory now has no files.");
+				retObj.exitNow = true;
+			}
+			displayMsgs(messages, false);
+		}
+		else
+			displayMsg("Failed to remove the file!", true); // console.print("\1y\1hFailed to remove the file!\1n");
+	}
+
+	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));
+	if (gUserIsSysop)
+	{
+		this.cmdArray.push(new DDFileMenuBarItem("Move", 0, FILE_MOVE));
+		this.cmdArray.push(new DDFileMenuBarItem("Del", 0, FILE_DELETE));
+	}
+	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 = "\1n\1w" + 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 += "\1w" + THIN_RECTANGLE_RIGHT;
+	for (var i = 0; i < numSolidBlocksPerSide; ++i)
+		this.promptText += BLOCK4;
+	this.promptText += BLOCK3 + BLOCK2 + BLOCK1 + "\1n";
+}
+// 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.lastCommandIdx)
+		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.lastCommandIdx);
+	console.gotoxy(this.cmdArray[this.lastCommandIdx].pos, this.pos.y);
+	console.print("\1n" + 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("\1n" + 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;
+}
+// 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 = "\1n";
+	if (selected)
+		itemText += "\1" + "1\1r\1h" + firstChar + "\1n\1" + "1\1k" + restOfText;
+	else
+		itemText += "\1" + "6\1c\1h" + firstChar + "\1n\1" + "6\1k" + restOfText;
+	itemText += "\1n";
+	if (withTrailingBlock)
+		itemText += "\1w" + THIN_RECTANGLE_RIGHT + THIN_RECTANGLE_LEFT + "\1n";
+	return itemText;
+}
+// For the DDFileMenuBar class: Increments to the next menu item and refreshes the
+// menu bar on the screen
+function DDFileMenuBar_incrementMenuItemAndRefresh()
+{
+	++this.currentCommandIdx;
+	if (this.currentCommandIdx >= this.cmdArray.length)
+		this.currentCommandIdx = 0;
+	this.refreshWithNewAction(this.currentCommandIdx);
+}
+// For the DDFileMenuBar class: Decrements to the previous menu item and refreshes the
+// menu bar on the screen
+function DDFileMenuBar_decrementMenuItemAndRefresh()
+{
+	--this.currentCommandIdx;
+	if (this.currentCommandIdx < 0)
+		this.currentCommandIdx = this.cmdArray.length - 1;
+	this.refreshWithNewAction(this.currentCommandIdx);
+}
+// 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].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].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
+function DDFileMenuBar_setCurrentActionCode(pActionCode)
+{
+	if (typeof(pActionCode) !== "number")
+		return;
+
+	for (var i = 0; i < this.cmdArray.length; ++i)
+	{
+		if (this.cmdArray[i].retCode == pActionCode)
+		{
+			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
+function DDFileMenuBarItem(pItemText, pPos, pRetCode)
+{
+	this.itemText = pItemText;
+	this.pos = pPos;
+	this.retCode = pRetCode;
+}
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Helper functions
+
+// Moves a file from one filebase to another
+//
+// Parameters:
+//  pSrcFilebase: A FileBase object representing the source filebase.  This is assumed to be open.
+//  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(pSrcFilebase, 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 extdFileInfo = pSrcFilebase.get(pSrcFileMetadata, FileBase.DETAIL.MAX);
+	// Move the file over, remove it from the original filebase, and add it to the new filebase
+	var srcFilenameFull = pSrcFilebase.get_path(pSrcFileMetadata);
+	var destFilenameFull = file_area.dir[pDestDirCode].path + pSrcFileMetadata.name;
+	if (file_rename(srcFilenameFull, destFilenameFull))
+	{
+		if (pSrcFilebase.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 = pSrcFilebase.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";
+
+	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 = "\1n\1c\1hQ\1b/\1cEnter\1b/\1cESC\1y: \1gClose\1b";
+	var scrollLoopNavHelp = "\1c\1hUp\1b/\1cDn\1b/\1cHome\1b/\1cEnd\1b/\1cPgup\1b/\1cPgDn\1y: \1gNav";
+	if (console.screen_columns >= 80)
+		keyHelpStr += ", " + scrollLoopNavHelp;
+	var borderColor = (typeof(pBorderColor) === "string" ? pBorderColor : "\1r");
+	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, "\1n");
+	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:
+//  pDirCode: The internal code of the file directory to use
+function displayFileLibAndDirHeader(pDirCode)
+{
+	if (typeof(pDirCode) !== "string")
+		return;
+	if (typeof(file_area.dir[pDirCode]) === "undefined")
+		return;
+
+	var libIdx = file_area.dir[pDirCode].lib_index;
+	var dirIdx = file_area.dir[pDirCode].index;
+	var libDesc = file_area.lib_list[libIdx].description;
+	var dirDesc =  file_area.dir[pDirCode].description;
+
+	var hdrTextWidth = console.screen_columns - 21;
+	var descWidth = hdrTextWidth - 11;
+
+	// Library line
+	console.print("\1n\1w" + BLOCK1 + BLOCK2 + BLOCK3 + BLOCK4 + THIN_RECTANGLE_LEFT);
+	printf("\1cLib \1w\1h#\1b%4d\1c: \1n\1c%-" + descWidth + "s\1n", +(libIdx+1), libDesc.substr(0, descWidth));
+	console.print("\1w" + THIN_RECTANGLE_RIGHT + "\1k\1h" + BLOCK4 + "\1n\1w" + THIN_RECTANGLE_LEFT +
+	              "\1g\1hDD File\1n\1w");
+	console.print(THIN_RECTANGLE_RIGHT + BLOCK4 + BLOCK3 + BLOCK2 + BLOCK1);
+	console.crlf();
+	// Directory line
+	console.print("\1n\1w" + BLOCK1 + BLOCK2 + BLOCK3 + BLOCK4 + THIN_RECTANGLE_LEFT);
+	printf("\1cDir \1w\1h#\1b%4d\1c: \1n\1c%-" + descWidth + "s\1n", +(dirIdx+1), dirDesc.substr(0, descWidth));
+	console.print("\1w" + THIN_RECTANGLE_RIGHT + "\1k\1h" + BLOCK4 + "\1n\1w" + THIN_RECTANGLE_LEFT +
+	              "\1g\1hLister \1n\1w");
+	console.print(THIN_RECTANGLE_RIGHT + BLOCK4 + BLOCK3 + BLOCK2 + BLOCK1);
+	console.print("\1n");
+	gNumHeaderLinesDisplayed = 2;
+
+	// List header
+	console.crlf();
+	displayListHdrLine(false);
+	++gNumHeaderLinesDisplayed;
+
+	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 shortDescLen = gListIdxes.descriptionEnd - gListIdxes.descriptionStart + 1;
+	var formatStr = "\1n\1w\1h%-" + filenameLen + "s %" + fileSizeLen + "s %-"
+	              + +(shortDescLen-7) + "s\1n\1w%5s\1n";
+	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;
+	var fileListMenu = new DDLightbarMenu(1, startRow, console.screen_columns - 1, console.screen_rows - (startRow-1) - 1);
+	fileListMenu.scrollbarEnabled = true;
+	fileListMenu.borderEnabled = false;
+	fileListMenu.multiSelect = true;
+	fileListMenu.ampersandHotkeysInItems = false;
+	fileListMenu.wrapNavigation = false;
+
+	// Add additional keypresses for quitting the menu's input loop so we can
+	// respond to these keys.
+	if (typeof(pQuitKeys) === "string")
+		fileListMenu.AddAdditionalQuitKeys(pQuitKeys);
+
+	fileListMenu.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}]
+	});
+
+	fileListMenu.filenameLen = gListIdxes.filenameEnd - gListIdxes.filenameStart;
+	fileListMenu.fileSizeLen = gListIdxes.fileSizeEnd - gListIdxes.fileSizeStart -1;
+	fileListMenu.shortDescLen = gListIdxes.descriptionEnd - gListIdxes.descriptionStart + 1;
+	fileListMenu.fileFormatStr = "%-" + fileListMenu.filenameLen
+	                           + "s %" + fileListMenu.fileSizeLen
+	                           + "s %-" + fileListMenu.shortDescLen + "s";
+
+	// Define the menu functions for getting the number of items and getting an item
+	fileListMenu.NumItems = function() {
+		// could also return gFilebase.files
+		return gFileList.length;
+	};
+	fileListMenu.GetItem = function(pIdx) {
+		var menuItemObj = this.MakeItemWithRetval(pIdx);
+		var filename = shortenFilename(gFileList[pIdx].name, this.filenameLen, true);
+		// Note: The file size is in bytes
+		var fileSize = gFilebase.get_size(gFileList[pIdx].name);
+		var desc = (typeof(gFileList[pIdx].desc) === "string" ? gFileList[pIdx].desc : "");
+		menuItemObj.text = format(this.fileFormatStr,
+		                          filename,//gFileList[pIdx].name.substr(0, this.filenameLen),
+								  getFileSizeStr(fileSize, this.fileSizeLen),
+		                          desc.substr(0, this.shortDescLen));
+		return menuItemObj;
+	}
+
+	fileListMenu.selectedItemIndexes = {};
+	fileListMenu.numSelectedItemIndexes = function() {
+		return Object.keys(this.selectedItemIndexes).length;
+	};
+	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("\1n\1y\1hThere are no file libraries available\1n");
+		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);
+
+	// 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
+	// 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: "\1n" + gColors.fileNormalBkg + gColors.fileAreaNum},
+		            {start: descStart, end:descEnd, attrs: "\1n" + gColors.fileNormalBkg + gColors.fileAreaDesc},
+		            {start: numDirsStart, end: -1, attrs: "\1n" + gColors.fileNormalBkg + gColors.fileAreaNumItems}],
+		selectedItemColor: [{start: libNumStart, end: libNumEnd, attrs: "\1n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumHighlight},
+		                    {start: descStart, end:descEnd, attrs: "\1n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaDescHighlight},
+		                    {start: numDirsStart, end: -1, attrs: "\1n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumItemsHighlight}]
+	});
+
+	fileLibMenu.topBorderText = "\1y\1hFile Libraries";
+	// Define the menu functions for getting the number of items and getting an item
+	fileLibMenu.NumItems = function() {
+		return file_area.lib_list.length;
+	};
+	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)
+	{
+		// TODO: Better error display
+		console.gotoxy(5, startRow);
+		console.print("\1n\1y\1hThere are no directories in this file library  \1n");
+		console.crlf();
+		console.pause();
+		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);
+
+	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
+	// 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: "\1n" + gColors.fileNormalBkg + gColors.fileAreaNum},
+		            {start: descStart, end:descEnd, attrs: "\1n" + gColors.fileNormalBkg + gColors.fileAreaDesc},
+		            {start: numDirsStart, end: -1, attrs: "\1n" + gColors.fileNormalBkg + gColors.fileAreaNumItems}],
+		selectedItemColor: [{start: dirNumStart, end: dirNumEnd, attrs: "\1n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumHighlight},
+		                    {start: descStart, end:descEnd, attrs: "\1n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaDescHighlight},
+		                    {start: numDirsStart, end: -1, attrs: "\1n" + gColors.fileAreaMenuHighlightBkg + gColors.fileAreaNumItemsHighlight}]
+	});
+
+	fileDirMenu.topBorderText = "\1y\1h" + ("File directories of " + file_area.lib_list[pLibIdx].description).substr(0, fileDirMenu.size.width-2);
+	// Define the menu functions for getting the number of items and getting an item
+	fileDirMenu.NumItems = function() {
+		return file_area.lib_list[this.libIdx].dir_list.length;
+	};
+	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),
+								  getNumFilesInDir(this.libIdx, pIdx));
+		return menuItemObj;
+	}
+
+	return fileDirMenu;
+}
+// Returns the number of files in a file directory
+//
+// Parameters:
+//  pLibIdx: The library index
+//  pDirIdx: The directory index within the file library
+//
+// Return value: The number of files in the file directory
+function getNumFilesInDir(pLibIdx, pDirIdx)
+{
+	if (typeof(pLibIdx) !== "number" || typeof(pDirIdx) !== "number")
+		return 0;
+	if (pLibIdx < 0 || pLibIdx >= file_area.lib_list.length)
+		return 0;
+	if (pDirIdx < 0 || pDirIdx >= file_area.lib_list[pLibIdx].dir_list.length)
+		return 0;
+
+	var numFiles = 0;
+	var filebase = new FileBase(file_area.lib_list[pLibIdx].dir_list[pDirIdx].code);
+	if (filebase.open())
+	{
+		numFiles = filebase.files;
+		filebase.close();
+	}
+	return numFiles;
+}
+// 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 = getNumFilesInDir(pLibIdx, dirIdx);
+		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 : "\1n\1w");
+	var lineColor = (typeof(pLineColor) == "string" ? pLineColor : "\1n\1k\1h");
+
+	// 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 = "\1n" + (pIsError ? gColors.errorMessage : gColors.successMessage);
+	var innerWidth = gErrorMsgBoxWidth - 2;
+	var msgFormatStr = msgColor + "%-" + innerWidth + "s\1n";
+	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);
+	gFileListMenu.DrawPartialAbs(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("\1n" + 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 = "\1n" + pTitleColor + titleTextWithoutAttrs;
+		console.print(borderChars.preText + "\1n" + substrWithAttrCodes(titleText, 0, titleLen) +
+		              "\1n" + 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 + "\1n" + substrWithAttrCodes(pBottomBorderText, 0, textLen) +
+		              "\1n" + 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("\1n\1g\1h");
+	for (var i = 0; i < width; ++i)
+		console.print(HORIZONTAL_SINGLE);
+	console.print("\1n");
+}
+
+// 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("\1n");
+		console.gotoxy(1, console.screen_rows-1);
+		var shortFilename = shortenFilename(filename, console.screen_columns-28, false);
+		if (pDefaultYes)
+			actionConfirmed = console.yesno(pActionName + " " + shortFilename);
+		else
+			actionConfirmed = !console.noyes(pActionName + " " + shortFilename);
+		gFileListMenu.DrawPartialAbs(1, console.screen_rows-2, console.screen_columns, 2, {});
+	}
+	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 frameHeight = 10;
+		var frameTitle = pActionName + " files? (Y/N)";
+		var additionalQuitKeys = "yYnN";
+		var frameInnerWidth = frameWidth - 2; // Without borders; for filename lengths
+		var fileListStr = "\1n\1w";
+		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");
+		gFileListMenu.DrawPartialAbs(frameUpperLeftX, frameUpperLeftY, frameWidth, frameHeight, {});
+	}
+
+	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)
+		var valueUpper = null;   // Upper-cased 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);
+				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 // Default
+						gFileSortOrder = FileBase.SORT.NATURAL;
+				}
+				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("\1n");
+		console.crlf();
+		console.print("\1w\1hUnable to open the configuration file: \1y" + cfgFilename);
+		console.crlf();
+		console.print("\1wDefault 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 "\1"
+						// with the Synchronet attribute control character.
+						gColors[setting] = trimSpaces(value, true, false, true).replace(/\\1/g, "\1");
+					}
+				}
+			}
+
+			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("\1n");
+			console.crlf();
+			console.print("\1w\1hUnable to open the theme file: \1y" + themeFilename);
+			console.crlf();
+			console.print("\1wDefault 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 filenameExt = file_getext(pFilename);
+	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);
+	var adjustedFilename = "";
+	if (fillWidth)
+		adjustedFilename = format("%-" + maxWithoutExtLen + "s%s", filenameWithoutExt, filenameExt);
+	else
+		adjustedFilename = filenameWithoutExt + filenameExt;
+
+	return adjustedFilename;
+}
diff --git a/xtrn/ddfilelister/defaultTheme.cfg b/xtrn/ddfilelister/defaultTheme.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..257282e71d96e64313dca0b4a0c0a6cfe067ef9b
--- /dev/null
+++ b/xtrn/ddfilelister/defaultTheme.cfg
@@ -0,0 +1,57 @@
+; Default color theme
+
+; Filename in the file list
+filename=\1n\1b\1h
+; File size in the file list
+fileSize=\1n\1m\1h
+; Description in the file list
+desc=\1n\1w
+; Background color for the highlighted (selected) file menu itme
+bkgHighlight=\1n4
+; Highlight filename color for the file list
+filenameHighlight=\1c\1h
+; Highlight file size color for the file list
+fileSizeHighlight=\1c\1h
+; Highlight description color for the file list
+descHighlight=\1c\1h
+; File timestamp color for showing an extended file description
+fileTimestamp=\1g\1h
+; For the extended file information box border
+fileInfoWindowBorder=\1r
+; For the title of the extended file information box
+fileInfoWindowTitle=\1g
+; Error box border
+errorBoxBorder=\1g\1h
+; Error message color
+errorMessage=\1y\1h
+; Success message color
+successMessage=\1c
+; Batch download confirm/info window border color
+batchDLInfoWindowBorder=\1r
+; Batch download info window title color
+batchDLInfoWindowTitle=\1g
+; Multi-file action confirm window border color
+confirmFileActionWindowBorder=\1r
+; Multi-file action confirm window title color
+confirmFileActionWindowWindowTitle=\1g
+
+; Colors related to moving a file
+; The color of the file area menu border (for moving a file)
+fileAreaMenuBorder=\1b
+; The file area entry background color for 'normal' colors (for moving a file)
+fileNormalBkg=\1n4
+; The file library/directory number for 'normal' colors (for moving a file)
+fileAreaNum=\1w
+; The file library/directory description for 'normal' colors (for moving a file)
+fileAreaDesc=\1w
+; The number of directories/files for 'normal' colors (for moving a file)
+fileAreaNumItems=\1w
+
+; The file area entry background color for 'highlight' colors (for moving a file)
+fileAreaMenuHighlightBkg=\1n7
+; The file library/directory number for 'highlight' colors (for moving a file)
+fileAreaNumHighlight=\1b
+; The file library/directory description for 'highlight' colors (for moving a file)
+fileAreaDescHighlight=\1b
+; The number of directories/files for 'highlight' colors (for moving a file)
+fileAreaNumItemsHighlight=\1b
\ No newline at end of file
diff --git a/xtrn/ddfilelister/readme.txt b/xtrn/ddfilelister/readme.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ed433c7ee6c38f37408dcfc1d4cfab389911291e
--- /dev/null
+++ b/xtrn/ddfilelister/readme.txt
@@ -0,0 +1,247 @@
+                        Digital Distortion File Lister
+                                 Version 2.00
+                           Release date: 2022-02-06
+
+                                     by
+
+                                Eric Oulashin
+                          Sysop of Digital Distortion
+                  BBS internet address: digitaldistortionbbs.com
+                     Alternate address: digdist.bbsindex.com
+                        Email: eric.oulashin@gmail.com
+
+
+
+This file describes the Digital Distortion File Lister.
+
+Contents
+========
+1. Disclaimer
+2. Introduction
+3. Installation & Setup
+   - Command shell setup
+   - Background: Running JavaScript scripts in Synchronet
+4. Configuration file & color/text theme configuration file
+
+
+1. Disclaimer
+=============
+I cannot guarantee that this script is 100% free of bugs.  However, I have
+tested it, and I used it often during development, so I have some confidence
+that there are no serious issues with it (at least, none that I have seen).
+
+
+2. Introduction
+===============
+This release is version 2.00 because I had previously released a message lister
+mod for Synchronet which was just a list header and a command bar to display
+under the list, and it still used Synchronet's stock file list.  Now that
+Synchronet provides a JavaScript interface to its filebases (as of version
+3.19), more customization is possible with JavaScript.
+
+Digital Distortion File Lister is a script for Synchronet that provides an
+enhanced user interface (for ANSI terminals) for listing files in the user's
+current file directory.  This file lister uses a lightbar interface to list the
+files, a 'command bar' at the bottom of the screen allowing the user to use the
+left & right arrow keys or the first character of the action to select an
+action.  The file lister also uses message boxes to display information.
+
+If the user's terminal does not support ANSI, the file lister will run the
+stock Synchronet file lister interface instead.
+
+When adding files to the user's batch download queue or (for the sysop)
+selecting files to move or delete, multi-select mode can be used, allowing
+the user to select multiple files using the spacebar.  If the spacebar is not
+used, the file at the current highlighted lightbar location will be used.
+
+For viewing file information or viewining file contents, only the current file
+where the lightbar location will be used.
+
+For the lightbar file list, basic descriptions and short filenames are used in
+order for the information for each file to fit on a single line in the list.
+The user can view extended information for each file, in which case a window
+will be displayed with the filename, size, timestamp, and extended description.
+The file lister also provides the ability to view files (according to
+Synchronet's viewable files configuration), and adding files to the user's
+batch download queue.  Additionally, sysops can delete files and move files to
+another file directory.
+
+
+3. Installation & Setup
+=======================
+Aside from readme.txt revision_history.txt, Digital Distortion File Lister is
+comprised of the following files:
+
+1. ddfilelister.js        The Digital Distortion File Lister script
+
+2. ddfilelister.cfg       The file lister configuration file
+
+3. defaultTheme.cfg       The default theme file containing colors used in the
+                          file lister
+
+The configuration files are plain text files, so they can be edited using any
+editor.
+
+The .js script and .cfg files can be placed together in any directory.  When
+the lister reads the configuration file & theme file, the lister will first
+look in your sbbs/mods directory, then sbbs/ctrl, then in the same directory
+where the .js script is located.  So, if you desire, you could place
+ddfilelister.js in sbbs/exec and the .cfg file sin your sbbs/ctrl directory,
+for example.
+
+Command shell setup
+-------------------
+Running the file lister involves simply having the BBS run a command to run
+ddfilelister.js.  No command-line parameters are required.  The command to run
+ddfilelister.js can be placed in a command shell for a command key, or in
+SCFG > External Programs > Online Programs (Doors) in one of your program
+groups, and then you could have your command shell run the lister via the
+internal program code you configured in SCFG.
+
+Installing into a command shell is described in the subsection "Installing
+into a command shell".
+
+Background: Running JavaScript scripts in Synchronet
+----------------------------------------------------
+The command line to run this script would be as follows (if the script is in
+your sbbs/xtrn/ddfilelister directory):
+?../xtrn/ddfilelister/ddfilelister.js
+
+In a Baja script, you can use the 'exec' command to run a JavaScript script, as
+in the following example:
+exec "?../xtrn/ddfilelister/ddfilelister.js"
+
+In a JavaScript script, you can use the bbs.exec() function to run a JavaScript
+script, as in the following example:
+bbs.exec("?../xtrn/ddfilelister/ddfilelister.js");
+
+To install the file lister as an external program (in SCFG in External
+Programs > Online Programs (Doors)), see the following document for more
+information:
+http://wiki.synchro.net/howto:door:index?s[]=doors
+
+
+4. Configuration file & color/text theme configuration file
+===========================================================
+Digital Distortion File Lister allows changing some settings and colors via
+configuration files.  The configuration files are plain text and can be edited
+with any text editor.  These are the configuration files used by Digital
+Distortion File Lister:
+- ddfilelister.cfg: The main configuration file
+- defaultTheme.cfg: The default theme configuration file which defines colors
+  for items displayed in the file lister.  The name of this file can be
+  specified in ddfilelister.cfg, so that alternate "theme" configuration files
+  can be used if desired.
+
+Each setting in the configuration files has the format setting=value, where
+"setting" is the name of the setting or color, and "value" is the corresponding
+value to use.  The colors specified in the theme configuration file are
+Synchronet color/attribute codes.  Comments are allowed in the configuration
+files - Commented lines begin with a semicolon (;).
+
+Digital Distortion File Lister will look for the configuration files in the
+following directories, in the following order:
+1. sbbs/mods
+2. sbbs/ctrl
+3. The same directory as ddfilelister.js
+If you customize your configuration files, you can copy them to your sbbs/mods
+or sbbs/ctrl directory so that they'll be more difficutl to accidentally
+override if you update your xtrn/DDMsgReader from the Synchronet CVS
+repository, where this reader's files are checked in.
+
+The configuration settings are described in the sections below:
+
+Main configuration file (DDMsgReader.cfg)
+-----------------------------------------
+Setting                               Description
+-------                               -----------
+sortOrder                             String: The file sort order to use.
+                                      Valid values are:
+                                      NATURAL: Natural sort order (same as DATE_A)
+                                      NAME_AI: Filename ascending, case insensitive sort order
+                                      NAME_DI: Filename descending, case insensitive sort order
+                                      NAME_AS: Filename ascending, case sensitive sort order
+                                      NAME_DS: Filename descending, case sensitive sort order
+                                      DATE_A: Import date/time ascending sort order
+                                      DATE_D: Import date/time descending sort order
+
+themeFilename                         The name of the configuration file to
+                                      use for colors & string settings
+
+Theme configuration file
+------------------------
+The convention for the setting names in the theme configuration file is that
+setting names ending in 'Text' are for whole text strings, and the setting
+names that don't end in 'Text' are for colors.
+
+Setting                              Element in the file lister
+-------                              --------------------------
+filename                             Filename in the file list
+
+fileSize                             File size in the file list
+
+desc                                 Description in the file list
+
+bkgHighlight                         Background color for the highlighted
+                                     (selected) file menu itme
+
+filenameHighlight                    Highlight filename color for the file list
+
+fileSizeHighlight                    Highlight file size color for the file
+                                     list
+
+descHighlight                        Highlight description color for the file
+                                     list
+
+fileTimestamp                        File timestamp color for showing an
+                                     extended file description
+
+fileInfoWindowBorder                 For the extended file information box
+                                     border
+
+fileInfoWindowTitle                  For the title of the extended file
+                                     information box
+
+errorBoxBorder                       Error box border
+
+errorMessage                         Error message color
+
+successMessage                       Success message color
+
+batchDLInfoWindowBorder              Batch download confirm/info window border
+                                     color
+
+batchDLInfoWindowTitle               Batch download info window title color
+
+confirmFileActionWindowBorder        Multi-file action confirm window border
+                                     color
+
+confirmFileActionWindowWindowTitle   Multi-file action confirm window title
+                                     color
+
+fileAreaMenuBorder                   The color of the file area menu border (for
+                                     moving a file)
+
+fileNormalBkg                        The file area entry background color for
+                                     'normal' colors (for moving a file)
+
+fileAreaNum                          The file library/directory number for
+                                     'normal' colors (for moving a file)
+
+fileAreaDesc                         The file library/directory description for
+                                     'normal' colors (for moving a file)
+
+fileAreaNumItems                     The number of directories/files for
+                                     'normal' colors (for moving a file)
+
+fileAreaMenuHighlightBkg              The file area entry background color for
+                                      'highlight' colors (for moving a file)
+
+fileAreaNumHighlight                  The file library/directory number for
+                                      'highlight' colors (for moving a file)
+
+fileAreaDescHighlight                 The file library/directory description for
+                                      'highlight' colors (for moving a file)
+
+fileAreaNumItemsHighlight            The number of directories/files for
+                                     'highlight' colors (for moving a file)
\ No newline at end of file
diff --git a/xtrn/ddfilelister/revision_history.txt b/xtrn/ddfilelister/revision_history.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ab09abb2f7f84579526ab214ef9029b2614c67a9
--- /dev/null
+++ b/xtrn/ddfilelister/revision_history.txt
@@ -0,0 +1,8 @@
+This file lists all of the changes made for each release of the Digital
+Distortion File Lister.
+
+Revision History (change log)
+=============================
+Version  Date         Description
+-------  ----         -----------
+2.00     2022-02-06   Initial version.
\ No newline at end of file