/* This is a login matrix script for Synchronet.
 * If the user's terminal supports ANSI, this will display an ANSI
 * graphic and a lightbar menu with logon options.  If the user's
 * terminal doesn't support ANSI, this will do a more traditional
 * login sequence.
 *
 * Author: Eric Oulashin (AKA Nightfox)
 * BBS: Digital Distortion
 * BBS address: digdist.bbsindex.com
 *
 * Date       Author            Description
 * 2009-06-12 Eric Oulashin     Started
 ...Removed some comments...
 * 2011-02-10 Eric Oulashin     Version 1.07
 *                              Removed the exit(); call at the
 *                              end of the script so that when
 *                              included in other JavaScript scripts,
 *                              the other script can continue executing.
 * 2011-02-11 Eric Oulashin     Version 1.08
 *                              Added support for playing a sound
 *                              when the user logs in successfully.
 * 2013-05-27 Eric Oulashin     Version 1.09
 *                              Fix to make random theme selection work in
 *                              Linux/UNIX (used * instead of *.*, which works
 *                              in both *nix and Windows).
 * 2016-12-09 Eric Oulashin     Version 1.10 Beta 1
 *                              Updated to make use of system.trashcan() to check
 *                              usernames that are blocked.
 * 2016-12-19 Eric Oulashin     Releasing version 1.10
 * 2017-08-11 Eric Oulashin     Version 1.11 Beta
 *                              For Synchronet 3.17 with the updated bbs.login()
 *                              function: Updated to position the cursor for the
 *                              system password and pass it to the updated
 *                              bbs.login() method
 * 2018-08-03 Eric Oulashin     Updated to delete instances of User objects that
 *                              are created, due to an optimization in Synchronet
 *                              3.17 that leaves user.dat open
 * 2019-01-11 Eric Oulashin     Releasing version 1.11
 * 2019-11-19 Eric Oulashin     Version 1.12
 *                              Updated to allow use with PETSCII
 * 2022-01-31 Eric Oulashin     Version 1.13
 *                              Sends a normal attribute before disconnecting the user
 * 2022-05-13 Eric Oulashin     Version 1.14
 *                              Removed the PasswordLength configuration option and
 *                              made use of the min_password_length and max_password_length
 *                              properties of the system object, which were added in
 *                              Synchronet 3.17c.
 * 2022-10-08 Eric Oulashin     Version 1.15
 *                              When retrieving a user's password for them, now the user
 *                              can enter an email address as well as their username.
 *                              Also, the password box now allows inptuting any length
 *                              of password and will only write mask characters to
 *                              the end of the password box.  Added a new optional
 *                              theme configuration option, PasswordBoxInnerWidth,
 *                              which can be used to specify the absolute inner width
 *                              of the password input box.
 *                              Also, the text of the USERNAME_NUM_PASS_PROMPT value in
 *                              the language file will now be truncated if it doesn't all
 *                              fit on the screen.
 * 2022-12-01 Eric Oulashin     Version 1.16
 *                              Users can now log in with their real name if they want.
 * 2022-12-02 Eric Oulashin     Version 1.17
 *                              Now checks the "Allow login by real name" setting (in
 *                              the node settigns) before trying to match the user by
 *                              real name
 * 2022-12-16 Eric Oulashin     Version 1.18
 *                              Theme configuration colors no longer require the Ctrl-A
 *                              character; just a list of attribute characters.
 * 2023-02-26 Eric Oulashin     Version 1.19
 *                              Now supports sixel images for the backgrounds to show
 *                              during login.
 * 2023-03-14 Eric Oulashin     Version 1.20
 *                              Enabled input timeouts for the matrix login where
 *                              possible (to help prevent bots taking up nodes).
 * 2023-04-02 Eric Oulashin     Version 1.20a
 *                              Now uses supports_sixel() from cterm_lib.js instead of
 *                              its own function for that.
 * 2023-09-10 Eric Oulashin     Version 1.21
 *                              Allows the user to select a message editor before emailing the sysop
 * 2024-02-27 Eric Oulashin     Version 1.22
 *                              Now treats the DEL key the same as backspace
 * 2024-03-23 Eric Oulashin     Version 1.23
 *                              New setting: RandomOnlySixelThemesForSixelTerminal - If enabled,
 *                              then when choosing a random theme, only use themes with sixels if
 *                              the user's terminal supports sixels.
 * 2024-03-23 Eric Oulashin     Version 1.24
 *                              Fix for "ghosting" effect of menu highlight color on the right
 *                              menu border of 1st item & left border of 2nd item; now uses
 *                              DDLightbarMenu rather than the old DDMatrixMenu implemented here
 * 2024-04-09 Eric Oulashin     Version 1.25 beta
 *                              Sixel support: Can now scale image files (png/jpg/gif) to sixel
 *                              for the user's terminal width.
 * 2024-07-07                   When emailing the sysop from the matrix menu, log the user in as
 *                              the guest & prompt for their return email address to be used
 *                              for replies.
 *                              Test theme support - New settings:
 *                              TestMatrixTheme: The name of a theme to use for testing
 *                              TestIPAddresses: A comma-separated of IP addresses (v4 or v6) for
 *                                               clients to see the test theme
 *                              TestHostnames: Comma-separated list of hostnames for clients to see the test theme
 * 2024-08-08 Eric Oulashin     Version 1.26
 *                              Removed unused LoggedOn variable from the loginMatrix() function.
 *                              That was generating an 'assignment to undeclared variable' error,
 *                              though it was not preventing login.
 * 2024-11-03 Eric Oulashin     Version 1.27
 *                              If a sixel version of a theme background doesn't exist, then check
 *                              whether an .ans or .asc version exists before calling bbs.menu(), to
 *                              avoid a warning displayed to the user if it doesn't exist. This
 *                              behavior is new in Synchronet 3.20.
 * 2025-04-08 Eric Oulashin     Version 1.28
 *                              For image-to-sixel conversion, support for using
 *                              "magick" instead of the assumed "convert". Also,
 *                              support for spaces in the fully-pathed sixel
 *                              converter executable, on Windows for now.
 */

"use strict";

// TODO: Support Synchronet's new 'fast logons'
/*
So... if you're writing a login matrix or custom login module where you want
to use a *different* method of detecting/enabling fast logon support, you'll
just need to set the SS_FASTLOGON flag in the JS bbs.sys_status property:
    bbs.sys_status |= SS_FASTLOGON;

var gFastLogonSupported = (typeof(SS_FASTLOGON) != "undefined");
*/

if (typeof(require) === "function")
{
	require("sbbsdefs.js", "K_NOCRLF");
	require("cterm_lib.js", "supports_sixel");
	require("text.js", "MsgSubj");
	require("key_defs.js", "CTRL_A");
	require("dd_lightbar_menu.js", "DDLightbarMenu");
}
else
{
	load("sbbsdefs.js");
	load("cterm_lib.js");
	load("text.js");
	load("key_defs.js");
	load("dd_lightbar_menu.js");
}

//var VERSION = "1.28";
//var VER_DATE = "2025-04-08";

// Program arguments:
// 0: Greeting file to display for the traditional login sequence,
//    relative to Synchronet's text/menu directory.  Defaults to
//    "../answer", which is the normal greeting file.
var gTraditionalGreetingFile = "../answer";
if (typeof(argv[0]) != "undefined")
	gTraditionalGreetingFile = argv[0];

// Box-drawing/border characters: Single-line
var UPPER_LEFT_SINGLE = "\xDA";
var HORIZONTAL_SINGLE = "\xC4";
var UPPER_RIGHT_SINGLE = "\xBF";
var VERTICAL_SINGLE = "\xB3";
var LOWER_LEFT_SINGLE = "\xC0";
var LOWER_RIGHT_SINGLE = "\xD9";
var T_SINGLE = "\xC2";
var LEFT_T_SINGLE = "\xC3";
var RIGHT_T_SINGLE = "\xB4";
var BOTTOM_T_SINGLE = "\xC1";
var CROSS_SINGLE = "\xC5";
// Box-drawing/border characters: Double-line
var UPPER_LEFT_DOUBLE = "\xC9";
var HORIZONTAL_DOUBLE = "\xCD";
var UPPER_RIGHT_DOUBLE = "\xBB";
var VERTICAL_DOUBLE = "\xBA";
var LOWER_LEFT_DOUBLE = "\xC8";
var LOWER_RIGHT_DOUBLE = "\xBC";
var T_DOUBLE = "\xCB";
var LEFT_T_DOUBLE = "\xCC";
var RIGHT_T_DOUBLE = "\xB9";
var BOTTOM_T_DOUBLE = "\xCA";
var CROSS_DOUBLE = "\xCE";
// Box-drawing/border characters: Vertical single-line with horizontal double-line
var UPPER_LEFT_VSINGLE_HDOUBLE = "\xD5";
var UPPER_RIGHT_VSINGLE_HDOUBLE = "\xB8";
var LOWER_LEFT_VSINGLE_HDOUBLE = "\xD4";
var LOWER_RIGHT_VSINGLE_HDOUBLE = "\xBE";
// Other special characters
var DOT_CHAR = "\xF9";
var THIN_RECTANGLE_LEFT = "\xDD";
var THIN_RECTANGLE_RIGHT = "\xDE";
var BLOCK1 = "\xB0"; // Dimmest block
var BLOCK2 = "\xB1";
var BLOCK3 = "\xB2";
var BLOCK4 = "\xDB"; // Brightest block
// Keyboard keys
var CR = CTRL_M;
var KEY_ENTER = CTRL_M;
const KEY_ESC = "\x1b";
var BACKSPACE = CTRL_H;

var gGuestUserNum = system.matchuser("guest");

// Image file formats supported (for converting background images to sixels to be displayed)
var gImgFilenameExts = ["png", "jpg", "jpeg", "gif"];
// Temporary directory for converting image files to sixel format (in the node directory
// so that we don't remove temporary sixel files for the same theme when another user is
// logging in)
var gSixelCvtTempDir = system.node_dir + "DDLoginMatrixTemp/";

// General filename for a theme configuration file
var gGeneralThemeCfgFilename = "DDMatrixTheme.cfg";

///////////////////////////
// Script execution code //
///////////////////////////

// Figure out the the script's execution 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.
// Note: gStartupPath will include the trailing slash.
var gStartupPath = '.';
try { throw dig.dist(dist); } catch(e) { gStartupPath = e.fileName; }
gStartupPath = backslash(gStartupPath.replace(/[\/\\][^\/\\]*$/,''));

// Get the main configuration options from DDLoginMatrix.cfg
var gMainCfgObj = ReadConfigFile(gStartupPath);

// Load the language strings from the specified language file.
var gMatrixLangStrings = new MatrixLangStrings();
var gStdLangStrings = new StdLangStrings();
var gGenLangStrings = new GeneralLangStrings();
// Load the strings from the lanuage file
loadLangStrings(gStartupPath + "DDLoginMatrixLangFiles/" + gMainCfgObj.Language + ".lng");

// The following 2 lines are only required for "Re-login" capability
bbs.logout();
system.node_list[bbs.node_num-1].status = NODE_LOGON;

// If the UseMatrix option is true and the user's terminal supports ANSI,
// then do the matrix-style login.  Otherwise, do the standard login.
if (gMainCfgObj.UseMatrix && (console.term_supports(USER_ANSI) || console.term_supports(USER_PETSCII)))
   loginMatrix();
else
   loginStandard();

// If the user logged in, then play the login sound if configured
// to do so.
if (user.number > 0)
{
	if (gMainCfgObj.PlaySound && (gMainCfgObj.SoundFile != ""))
		sound(gMainCfgObj.SoundFile);
}

// End of script execution

//////////////////////////////////////////////////////////////////////////////////
// Functions

function loginMatrix()
{
	// Get a theme configuration and use it to configure the background, menu, etc.
	var themeCfgObj = getThemeCfg(gStartupPath, gMainCfgObj.MatrixTheme, gMainCfgObj.RandomOnlySixelThemesForSixelTerminal);
	// Convert the theme images to sixels, if needed
	var cvtImgsToSixelRetObj = convertThemeImgsToSixel(themeCfgObj);

	// Construct the matrix menu
	const LOGIN = 0;
	const NEWUSER = 1;
	const GUEST = 2;
	const RETRIEVE_PASSWORD = 3;
	const EMAIL_SYSOP = 4;
	const DISCONNECT = 5;
	const PAGE_SYSOP = 6;
	const REMOTE_SYSTEM = 7;

	// Create the menu and add the menu items
	var matrixMenu = createLightbarMatrixMenu(themeCfgObj);
	matrixMenu.timeoutMS = gMainCfgObj.MenuTimeoutMS;
	matrixMenu.Add(gMatrixLangStrings.LOGIN, LOGIN);
	if (gMainCfgObj.MenuDisplayNewUser && !Boolean(system.settings & SYS_CLOSED))
		matrixMenu.Add(gMatrixLangStrings.NEWUSER, NEWUSER);
	if (gGuestUserNum > 0 && gMainCfgObj.MenuDisplayGuestAccountIfExists)
		matrixMenu.Add(gMatrixLangStrings.GUEST, GUEST);
	if (gMainCfgObj.MenuDisplayRetrievePassword)
		matrixMenu.Add(gMatrixLangStrings.RETRIEVE_PASSWORD, RETRIEVE_PASSWORD);
	if (gMainCfgObj.MenuDisplayEmailSysop)
		matrixMenu.Add(gMatrixLangStrings.EMAIL_SYSOP, EMAIL_SYSOP);
	if (gMainCfgObj.MenuDisplayPageSysop)
		matrixMenu.Add(gMatrixLangStrings.PAGE_SYSOP, PAGE_SYSOP);
	matrixMenu.Add(gMatrixLangStrings.DISCONNECT, DISCONNECT);

	/*
	// Remote systems to connect to
	const remoteConnectionsStartIdx = gMainCfgObj.remoteConnections.length > 0 ? matrixMenu.NumItems() : -1;
	for (var i = 0; i < gMainCfgObj.remoteConnections.length; ++i)
		matrixMenu.Add(gMainCfgObj.remoteConnections[i].display_name, REMOTE_SYSTEM);
	*/


	// Figure out what the menu's width & height should be, based on the number of items and the item text length
	var numMenuItems = matrixMenu.NumItems();
	matrixMenu.size.height = numMenuItems + 2; // + 2 for the borders
	var maxItemTextLength = 0;
	for (var i = 0; i < numMenuItems; ++i)
	{
		var item = matrixMenu.GetItem(i);
		if (item == null)
			continue;
		var itemScreenLen = console.strlen(item.text);
		if (item.text.indexOf("&") > -1)
			--itemScreenLen;
		if (itemScreenLen > maxItemTextLength)
			maxItemTextLength = itemScreenLen;
	}
	matrixMenu.size.width = maxItemTextLength + 2; // + 2 for borders

	// Logon loop
	var continueOn = true;
	for (var loopVar = 0; loopVar < 10 && continueOn; ++loopVar)
	{
		// The "node sync" is required for sysop interruption/chat/etc.
		bbs.nodesync();

		// Clear the screen and display the initial background file
		console.print("\x01q\x01n");
		console.clear();
		// If the background file is a sixel image, use that; othwerise,
		// assume it's a standard text/ANSI file.  The sixel file could
		// exist in the theme directory or in the temporary sixel conversion
		// directory (if it was originally a PNG, JPG, or other image file).
		var sixelFileInThemeDirExists = file_exists(themeCfgObj.InitialBackgroundFilename + ".sixel");
		var tmpSixelFilename = gSixelCvtTempDir + file_getname(themeCfgObj.InitialBackgroundFilename) + ".sixel"
		var tmpSixelFileExists = file_exists(tmpSixelFilename);
		if ((sixelFileInThemeDirExists || tmpSixelFileExists) && supports_sixel() && themeCfgObj.userTermIsWideEnough())
		{
			if (tmpSixelFileExists)
				showSixel(tmpSixelFilename);
			else if (sixelFileInThemeDirExists)
				showSixel(themeCfgObj.InitialBackgroundFilename + ".sixel");
			// Also, if InitialBackgroundInfoLines is populated, display the text lines specified by them
			for (var i in themeCfgObj.InitialBackgroundInfoLines)
			{
				if (themeCfgObj.InitialBackgroundInfoLines[i].str.length == 0)
					continue;
				console.gotoxy(themeCfgObj.InitialBackgroundInfoLines[i].X, themeCfgObj.InitialBackgroundInfoLines[i].Y);
				// putmsg() resets attributes back to normal, so we have to include the actual attribute
				// codes as part of the string we pass to putmsg()
				//console.attributes = "N" + themeCfgObj.InitialBackgroundInfoLines[i].attrChars;
				//console.putmsg(themeCfgObj.InitialBackgroundInfoLines[i].str);
				console.attributes = "N";
				console.putmsg(attrCodeStr(themeCfgObj.InitialBackgroundInfoLines[i].attrChars) + themeCfgObj.InitialBackgroundInfoLines[i].str);
			}
			console.attributes = "N";
		}
		else if (ascOrAnsFileExists(themeCfgObj.InitialBackgroundFilename))
			bbs.menu(themeCfgObj.InitialBackgroundFilename);

		// Show the matrix menu and respond to the user's choice.
		var doNewUser = false;
		matrixMenu.selectedItemIndex = 0;
		// If the theme is configured to clear the space around the menu, then do so
		if (themeCfgObj.ClearSpaceAroundMenu)
			writeSpacesAroundRectangle(matrixMenu.pos.x, matrixMenu.pos.y, matrixMenu.size.width, matrixMenu.size.height);
		// If the theme is configured to display the menu title, then do so above the menu
		if (themeCfgObj.DisplayMenuTitle && matrixMenu.pos.y > 1)
		{
			console.attributes = "N";
			var titleText = randomDimBrightString(themeCfgObj.MenuTitle, "\x01w");
			console.gotoxy(matrixMenu.pos.x, matrixMenu.pos.y-1);
			console.print(themeCfgObj.MenuColor_ClearAroundMenu + titleText);
			console.attributes = "N";
			// Clear the rest of the area in the title line
			//printf("%*s", matrixMenu.size.width - console.strlen(titleText), "");
		}
		// Display the menu and get the user's selection
		var userChoice = matrixMenu.GetVal(true);
		if (userChoice == null)
		{
			console.clear("\x01n");
			console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
			continueOn = false; // For the outer loop
			bbs.hangup();
		}
		else if (userChoice == LOGIN)
		{
			var loginRetObj = doMatrixLogin(themeCfgObj);
			continueOn = loginRetObj.continueLoginMatrixLoop;
			doNewUser = loginRetObj.doNewUser;
		}
		else if (userChoice == NEWUSER)
		{
			// Only allow new users if the system's "closed" setting is false.
			doNewUser = !Boolean(system.settings & SYS_CLOSED);
		}
		else if (userChoice == GUEST)
		{
			if (bbs.login("guest", themeCfgObj.PasswordPromptColor +
			              gMatrixLangStrings.PASSWORD_PROMPT + " " +
			              themeCfgObj.PasswordTextColor))
			{
				continueOn = false; // For the outer loop
				console.clear("\x01n");
				bbs.logon();
				break;
			}
			else
			{
				console.gotoxy(themeCfgObj.MenuX, themeCfgObj.MenuY);
				console.print(gMatrixLangStrings.GUEST_ACCT_FAIL);
				mswait(1500);
			}
		}
		else if (userChoice == RETRIEVE_PASSWORD)
		{
			console.clear("\x01n");
			// Prompt the user for their username or email address
			console.print(gStdLangStrings.USERNAME_OR_EMAIL_ADDR_PROMPT + "\x01n:");
			console.crlf();
			var userNameOrEmailAddr = console.getstr(console.screen_columns-1, K_TAB); // K_UPRLWR
			// Look up the user's account & email their password to them if found
			emailAcctInfo(userNameOrEmailAddr, false, true);
		}
		else if (userChoice == EMAIL_SYSOP)
		{
			console.clear("\x01n");
			continueOn = false; // For the outer loop
			// Log the user on as the guest; during this process, it will prompt for an email address.
			// Then let the user send an email.
			if (bbs.login("guest", themeCfgObj.PasswordPromptColor +
			              gMatrixLangStrings.PASSWORD_PROMPT + " " +
			              themeCfgObj.PasswordTextColor))
			{
				// Ask the user to enter an email address (for any replies). And in order to save
				// the email address, it seems we need to load the guest user account and save it.
				if (gGuestUserNum > 0)
				{
					var guestUser = new User(gGuestUserNum);
					console.print("\x01cPlease enter an email address which can be used for replies\x01g\x01h:\x01n\r\n");
					guestUser.netmail = console.getstr(console.screen_columns-1, K_LINE|K_NOSPIN);
					guestUser.close();
				}
				// Allow the user to select a message editor, and then email the sysop
				bbs.select_editor();
				bbs.email(1);
				// Log the user off
				console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
				bbs.hangup();
				break;
			}
			else
			{
				// Still let the user send an email to the sysop
				// Allow the user to select a message editor, and then email the sysop
				bbs.select_editor();
				bbs.email(1);
				if (console.yesno(gMatrixLangStrings.LOGOFF_CONFIRM_TEXT))
				{
					console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
					bbs.hangup();
					break;
				}
			}
		}
		else if (userChoice == PAGE_SYSOP)
		{
			console.clear("\x01n\x01c");
			var sysopPaged = bbs.page_sysop();
			console.crlf();
			if (sysopPaged)
				console.print(gMatrixLangStrings.SYSOP_HAS_BEEN_PAGED + "\r\n");
			else
				console.print(gMatrixLangStrings.UNABLE_TO_PAGE_SYSOP + "\r\n");
			console.pause();
		}
		else if (userChoice == DISCONNECT)
		{
			console.clear("\x01n");
			console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
			continueOn = false; // For the outer loop
			bbs.hangup();
		}
		else if (userChoice == REMOTE_SYSTEM)
		{
			var chosenSystemIdx = matrixMenu.selectedItemIdx - remoteConnectionsStartIdx;

			// gMainCfgObj.remoteConnections
			// display_name
			// address
			// port (number)
			// protocol
			//console.print("\x01n\r\nYou chose: " + gMainCfgObj.remoteConnections[chosenSystemIdx].display_name + "\r\n\x01p"); // Temporary
			var sysAddrAndPort = gMainCfgObj.remoteConnections[chosenSystemIdx].address + ":" + gMainCfgObj.remoteConnections[chosenSystemIdx].port;
			var protLower = gMainCfgObj.remoteConnections[chosenSystemIdx].protocol.toLowerCase();
			var telgateFlags = TG_NONE;
			if (protLower == "telnet")
			{
			}
			else if (protLower == "rlogin")
				telgateFlags = TG_RLOGIN;

			//bbs.exec("?telgate " + sysAddrAndPort);
			console.print("\x01n\r\n\x01h\x01hPress \x01yCtrl-]\x01w for a control menu anytime.\r\n\r\n");
			console.pause();
			writeln("\x01h\x01yConnecting to: \x01w" + sysAddrAndPort + "\x01n");
			bbs.telnet_gate(sysAddrAndPort, telgateFlags);
			console.clear();
		}
		else
		{
			// If the user's input is blank, that probably means the
			// input timeout was hit, so disconnect the user.
			if (matrixMenu.lastUserInput == null || (typeof(matrixMenu.lastUserInput) === "string" && matrixMenu.lastUserInput.length == 0))
			{
				// We probably hit tine input timeout, so disconnect the user.
				console.clear("\x01n");
				console.print("\x01n\x01h\x01w" + system.name + ": " +
							  gMatrixLangStrings.INPUT_TIMEOUT_REACHED + "\x01n");
				continueOn = false; // For the outer loop
				// Remove the temporary sixel conversion directory
				deleteSixelTempDir();
				bbs.hangup();
			}
		}

		// If the user wants to create a new user account, then do it.
		if (doNewUser)
		{
			console.clear("\x01n");
			if (bbs.newuser())
			{
				bbs.logon();
				continueOn = false; // For the outer loop
			}
			else
				continue;
		}
	}

	// Remove the temporary sixel conversion directory
	deleteSixelTempDir();
}

// Helper for loginMatrix(): Performs the matrix login procedure (prompting for
// username & password, etc.)
//
// Parameters:
//  pThemeCfgObj: An object with theme configuration settings
//
// Return value: An object with the following properties:
//               continueLoginMatrixLoop: Whether or not to continue the matrix loop in the calling code
//               doNewUser: Whether or not to start the new user process
function doMatrixLogin(pThemeCfgObj)
{
	var retObj = {
		continueLoginMatrixLoop: true,
		doNewUser: false
	};

	// Clear the screen and display the login background file, if
	// one is specified.
	console.clear("\x01n");
	if (pThemeCfgObj.LoginBackgroundFilename.length > 0)
	{
		// If the background file is a sixel image, use that; othwerise,
		// assume it's a standard text/ANSI file.  The sixel file could
		// be in the theme directory or in the temporary sixel conversion
		// directory.
		var sixelFileInThemeDirExists = file_exists(pThemeCfgObj.LoginBackgroundFilename + ".sixel");
		var tmpSixelFilename = gSixelCvtTempDir + file_getname(pThemeCfgObj.LoginBackgroundFilename) + ".sixel"
		var tmpSixelFileExists = file_exists(tmpSixelFilename);
		if ((sixelFileInThemeDirExists || tmpSixelFileExists) && supports_sixel() && pThemeCfgObj.userTermIsWideEnough())
		{
			if (tmpSixelFileExists)
				showSixel(tmpSixelFilename);
			else if (sixelFileInThemeDirExists)
				showSixel(pThemeCfgObj.LoginBackgroundFilename + ".sixel");
		}
		else if (ascOrAnsFileExists(pThemeCfgObj.LoginBackgroundFilename))
			bbs.menu(pThemeCfgObj.LoginBackgroundFilename);
	}

	// Draw boxes for the username, password, and status.
	// First, calculate the box sizes.
	var usernamePrompt = gMatrixLangStrings.USERNAME_PROMPT;
	if (gMainCfgObj.AllowUserNumber)
		usernamePrompt = gMatrixLangStrings.USERNAME_OR_NUM_PROMPT;
	var usernamePromptLen = console.strlen(usernamePrompt);
	var passwordPrompt = gMatrixLangStrings.PASSWORD_PROMPT;
	var passwordPromptLen = console.strlen(passwordPrompt);
	var usernameBoxInnerWidth = pThemeCfgObj.UsernameLength + passwordPromptLen + 1;
	var statusBoxWidth = pThemeCfgObj.StatusBoxInnerWidth + 2;

	var passwordLength = 16; // Default password length
	// If the theme configuration has a PasswordBoxInnerWidth, then
	// use it for the absolute inner width of the password box.  Otherwise,
	// adjust the size of the password box to match the system-configured
	// maximum password length.
	if (pThemeCfgObj.hasOwnProperty("PasswordBoxInnerWidth"))
		passwordLength = pThemeCfgObj.PasswordBoxInnerWidth - passwordPromptLen - 1; // - 1
	else
	{
		// If system.min_password_length or system.max_password_length are defined
		// (minimum & maximum password length; added in Synchronet v3.18), then
		// make sure the configured password length fits these settings.
		if (typeof(system.min_password_length) === "number")
		{
			if (passwordLength < system.min_password_length)
			{
				// Fix the password box inner width
				var widthDiff = system.min_password_length - passwordLength;
				passwordLength += widthDiff;
				// Make sure the password box location will let it fit on the screen
				// with the new width, up to the width of the user's terminal
				// - 2 for the borders and also subtract the prompt length
				var maxPassWidth = console.screen_columns - 2 - passwordPromptLen;
				if (passwordLength > maxPassWidth)
					passwordLength = maxPassWidth;
			}
		}
		if (typeof(system.max_password_length) === "number")
		{
			if (passwordLength > system.max_password_length)
			{
				passwordLength = system.max_password_length;
				// Make sure the password box location will let it fit on the screen
				// with the new width, up to the width of the user's terminal
				// - 2 for the borders and also subtract the prompt length
				var maxPassWidth = console.screen_columns - 2 - passwordPromptLen;
				if (passwordLength > maxPassWidth)
					passwordLength = maxPassWidth;
			}
			else if (passwordLength < system.max_password_length)
			{
				// Fix the password box inner width
				var widthDiff = system.max_password_length - passwordLength;
				passwordLength += widthDiff;
				// Make sure the password box location will let it fit on the screen
				// with the new width, up to the width of the user's terminal
				// - 2 for the borders and also subtract the prompt length
				var maxPassWidth = console.screen_columns - 2 - passwordPromptLen;
				if (passwordLength > maxPassWidth)
					passwordLength = maxPassWidth;
			}
		}
	}

	var passwordBoxInnerWidth = passwordLength + passwordPromptLen + 1;
	var usernameBoxWidth = usernameBoxInnerWidth + 2;
	var passwordBoxWidth = passwordBoxInnerWidth + 2;

	// In case the password box inner width was changed (due to minimum
	// & maximum password length in the Synchronet configuration), make sure
	// the password box horizontal location is valid.
	//passwordPrompt is the password prompt text
	var currentEndX = pThemeCfgObj.PasswordX + passwordBoxWidth - 1;
	if (currentEndX > console.screen_columns)
	{
		// If the password box would now be too far to the right, then
		// move it left.
		var diff = currentEndX - console.screen_columns;
		pThemeCfgObj.PasswordX -= diff;
		// If it's too far left, then we'll have to have the password box
		// start on the left edge and make it the width of the console.
		// Unfortunately, this will mean that the BBS's configured maximum
		// password length won't be supported because it's too long.
		if (pThemeCfgObj.PasswordX < 1)
		{
			pThemeCfgObj.PasswordX = 1;
			passwordBoxInnerWidth = console.screen_columns - 2;
			passwordBoxWidth = console.screen_columns;
		}
	}

	// Draw the username box
	drawOneLineInputBox(pThemeCfgObj.UsernameX, pThemeCfgObj.UsernameY,
	                    usernameBoxWidth, "double", pThemeCfgObj.UsernameBoxBorderColor,
	                    pThemeCfgObj.UsernameBoxBkgColor,
	                    pThemeCfgObj.UsernamePromptColor + usernamePrompt);

	// Draw the password box
	drawOneLineInputBox(pThemeCfgObj.PasswordX, pThemeCfgObj.PasswordY,
	                    passwordBoxWidth, "double",
	                    pThemeCfgObj.PasswordBoxBorderColor,
	                    pThemeCfgObj.PasswordBoxBkgColor,
	                    pThemeCfgObj.PasswordPromptColor + passwordPrompt);

	// Status box
	drawOneLineInputBox(pThemeCfgObj.StatusX, pThemeCfgObj.StatusY,
	                    statusBoxWidth, "double",
	                    pThemeCfgObj.StatusBoxBorderColor,
	                    pThemeCfgObj.StatusBoxBkgColor);

	// Figure out the screen positions for the username & password inputs,
	// and the status text line
	var usernameX = pThemeCfgObj.UsernameX + passwordPromptLen + 2;
	var usernameY = pThemeCfgObj.UsernameY + 1;
	var passwordX = pThemeCfgObj.PasswordX + passwordPromptLen + 2;
	var passwordY = pThemeCfgObj.PasswordY + 1;
	var statusX = pThemeCfgObj.StatusX + 1;
	var statusY = pThemeCfgObj.StatusY + 1;

	// Set the text for prompting the user to enter their username/number.
	var enterUsernameAndPasswordStr = "";
	if (gMainCfgObj.AllowUserNumber && !Boolean(bbs.node_settings & NM_NO_NUM))
		enterUsernameAndPasswordStr = randomDimBrightString(format("%-" + usernameBoxWidth + "s", gMatrixLangStrings.USERNAME_NUM_PASS_PROMPT), "\x01w");
	else
		enterUsernameAndPasswordStr = randomDimBrightString(format("%-" + usernameBoxWidth + "s", gMatrixLangStrings.USERNAME_PASS_PROMPT), "\x01w");

	// If the username/password text is too long for where it should be located, then truncate it
	var promptUsernamePasswordPromptTextX = usernameX-11;
	var maxUsernamePasswordPromptLen = console.screen_columns - promptUsernamePasswordPromptTextX + 1;
	if (console.strlen(enterUsernameAndPasswordStr) > maxUsernamePasswordPromptLen)
		enterUsernameAndPasswordStr = substrWithAttrCodes(enterUsernameAndPasswordStr, 0, maxUsernamePasswordPromptLen);

	// Prompt for username & password.  Give the user 10 chances.
	var loggedOn = false;    // Whether or not the user successfully logged on
	var returnToLogonMenu = false; // Whether or not to display the logon menu again
	//var loginAttempts = 0;
	for (var loginAttempts = 0; loginAttempts < gMainCfgObj.MaxLoginAttempts; ++loginAttempts)
	{
		// Prepare to prompt for the username
		console.gotoxy(promptUsernamePasswordPromptTextX, usernameY-2);
		console.print(enterUsernameAndPasswordStr);
		console.gotoxy(usernameX, usernameY);
		// If this is the not the first time through the loop,
		// then clear the username input.
		if (loginAttempts > 0)
		{
			flashMessage(usernameX, usernameY, "", 0, usernameBoxInnerWidth-10,
						 true, pThemeCfgObj.UsernameBoxBkgColor);
						 console.gotoxy(usernameX, usernameY);
		}
		console.print(pThemeCfgObj.UsernameTextColor);
		// Prompt for the username
		//var username = console.getstr(pThemeCfgObj.UsernameLength, K_UPRLWR | K_TAB);
		//var username = console.getstr("", 128, K_UPRLWR|K_TAB|K_NOCRLF|K_NOSPIN);
		var username = getStrWithTimeout(128, K_UPRLWR|K_TAB|K_NOCRLF|K_NOSPIN, null, gMainCfgObj.MenuTimeoutMS);
		// If the username is blank, then we want to return to the logon menu
		// (set returnToLogonMenu to true and break out of this
		// username/password loop).
		if (username.length == 0)
		{
			returnToLogonMenu = true;
			break;
		}

		truncsp(username);

		// If the user entered a username that's blocked, then output an
		// error and hang up.
		if (system.trashcan("name", username))
		{
			console.clear("\x01n");
			alert(log(LOG_NOTICE, "!Failed login with blocked user name: " + username));
			console.print("\x01n");
			bbs.hangup();
		}

		// If the user typed "new", then let them create a new
		// user account.  Set doNewUser to true and break out
		// of the username/password loop.
		if (username.toUpperCase() == gGenLangStrings.NEW.toUpperCase())
		{
			retObj.doNewUser = true;
			break;
		}

		// If we get here, then the user didn't enter "new".
		// If user numbers are allowed and the username contains
		// all digits, then set userNum to what the user entered.
		// Otherwise, look for the user number that matches the
		// username.
		var userNum = 0;
		if (gMainCfgObj.AllowUserNumber && !Boolean(bbs.node_settings & NM_NO_NUM) && username.match(/^[0-9]+$/))
			userNum = +username;
		else
		{
			// Try matching the user via username.  If that fails, and logins by real name
			// is allowed (in node settings), try matching against the user's real name.
			// It's possible there may be multiple users with the same name (though probably
			// not likely); this will match the first user with that name.
			userNum = system.matchuser(username);
			if (userNum < 1 && Boolean(bbs.node_settings & NM_LOGON_R)) // NM_LOGON_R: Allow logins using real name
				userNum = system.matchuserdata(U_NAME, username);
		}

		// If the user number is valid, then we can continue and
		// prompt for a password.
		if ((userNum > 0) && (system.username(userNum).length > 0))
		{
			// If the user didn't enter "guest", go to the password
			// prompt location and get ready to prompt for the password.
			if (username != "guest")
			{
				console.gotoxy(passwordX, passwordY);
				// If this is the not the first time through the loop,
				// then clear the password.
				if (loginAttempts > 0)
				{
					flashMessage(passwordX, passwordY, "", 0, passwordBoxInnerWidth-10,
								 true, pThemeCfgObj.PasswordTextColor);
					console.gotoxy(passwordX, passwordY);
				}
			}

			// Temporarily blank the "Unknown user" and "Invalid Logon" text strings
			bbs.replace_text(UnknownUser, "");
			bbs.replace_text(InvalidLogon, "");

			// Prompt for the password
			// BBS.login() update for 2017-08-10:
			// Allow more JavaScript control over password prompting:
			// bbs.login() now accepts 2 additional optional arguments: user_pw and sys_pw
			// if these passwords are supplied, they won't be prompted for by the underlying C
			// functions. If the password_prompt argument (2nd arg) is not supplied, no prompt
			// will be displayed, but a password must still be entered.
			// The default behavior is the same as before.
			// Original: bbs.login(user_name, password_prompt)
			var loginSuccess = false;
			if (system.version_num >= 31700)
			{
				// Maximum password length: Inner box width - prompt length
				// - 1 for the space after the prompt
				var maxPassLen = passwordBoxInnerWidth - passwordPromptLen;// - 1;
				if (typeof(system.max_password_length) === "number")
				{
					if (maxPassLen > system.max_password_length)
						maxPassLen = system.max_password_length;
				}
				if (pThemeCfgObj.hasOwnProperty("PasswordBoxInnerWidth"))
					--maxPassLen;
				// Note: getStrWithTimeout() allows inputting more characters than are available
				// with the password box width, to allow inputting any length of password.
				var userPassword = getStrWithTimeout(maxPassLen, K_UPPER|K_NOSPIN|K_NOCRLF, "*", gMainCfgObj.MenuTimeoutMS);
				// If the inptuted password is blank, then we want to return to the logon menu
				// (set returnToLogonMenu to true and break out of this
				// username/password loop).
				if (userPassword.length == 0)
				{
					returnToLogonMenu = true;
					break;
				}
				// If the user is the sysop
				var theUser = new User(userNum);
				if (theUser.compare_ars("SYSOP"))
				{
					console.gotoxy(passwordX-passwordPromptLen-1, passwordY);
					// Blank out the password area
					//printf("\x01n%" + +(maxPassLen) + "s", "");
					printf("\x01n%" + +(passwordBoxInnerWidth) + "s", "");
					// Get the system password from the user
					console.gotoxy(passwordX-passwordPromptLen-1, passwordY);
					console.print("\x01c\x01hSY: \x01n");
					var systemPassword = getStrWithTimeout(0, K_UPPER|K_NOSPIN|K_NOCRLF|K_NOECHO, null, gMainCfgObj.MenuTimeoutMS);
					loginSuccess = bbs.login(username, pThemeCfgObj.PasswordTextColor, userPassword, systemPassword);
				}
				else
					loginSuccess = bbs.login(username, pThemeCfgObj.PasswordTextColor, userPassword);
				//delete theUser;
				theUser = undefined; // Destructs the object now, rather than with 'delete'
			}
			else
				loginSuccess = bbs.login(username, pThemeCfgObj.PasswordTextColor);
			if (loginSuccess)
			{
				console.clear();
				bbs.logon();
				loggedOn = true;
				retObj.continueLoginMatrixLoop = false; // For login matrix loop
				break;
			}
			else
			{
				// Go to the status box and tell the user the login
				// was invalid.
				flashMessage(statusX, statusY, pThemeCfgObj.StatusTextColor +
							 gMatrixLangStrings.INVALID_LOGIN, 1500,
							 pThemeCfgObj.StatusBoxInnerWidth,
							 (loginAttempts > 0));
				// Clear the password from the password box
				flashMessage(passwordX, passwordY, "", 0, passwordBoxInnerWidth-10,
							 true, pThemeCfgObj.PasswordBoxBkgColor);
				// Clear the status from the status box
				flashMessage(statusX, statusY, "", 0, pThemeCfgObj.StatusBoxInnerWidth,
							 true, pThemeCfgObj.StatusBoxBkgColor);
				// If the Synchronet version is at least 3.17 and the user is a
				// sysop, then re-write the password prompt text
				if (system.version_num >= 31700)
				{
					var theUser = new User(userNum);
					if (theUser.compare_ars("SYSOP"))
					{
						console.gotoxy(passwordX-passwordPromptLen-1, passwordY);
						console.print("\x01n" + pThemeCfgObj.PasswordPromptColor + passwordPrompt);
					}
					//delete theUser;
					theUser = undefined; // Destructs the object now, rather than with 'delete'
				}
			}

			// Revert the "Unknown user" and "Invalid Logon" text strings
			// back to their defaults.
			bbs.revert_text(390);
			bbs.revert_text(391);
		}
		else
		{
			var errorMsg = "";
			if (gMainCfgObj.AllowUserNumber)
				errorMsg = gMatrixLangStrings.UNKNOWN_USERNAME_OR_NUM;
			else
				errorMsg = gMatrixLangStrings.UNKNOWN_USERNAME;

			// Go to the status box and tell the user that the
			// username/number is unknown.
			flashMessage(statusX, statusY, pThemeCfgObj.StatusTextColor +
						 errorMsg, 1500, pThemeCfgObj.StatusBoxInnerWidth,
						 (loginAttempts > 0));
			// Clear the status text from the status box
			flashMessage(statusX, statusY, "", 0, pThemeCfgObj.StatusBoxInnerWidth,
						 true, pThemeCfgObj.StatusBoxBkgColor);
		}
	}

	// If we shouldn't return to the menu or do the new user login,
	// then quit the main menu loop.
	if (!returnToLogonMenu && !retObj.doNewUser)
	{
		retObj.continueLoginMatrixLoop = false; // For the login matrix loop
		// If the user didn't log on, then hang up.
		if (!loggedOn)
		{
			console.clear("\x01n");
			console.gotoxy(1, 1);
			console.print(gMatrixLangStrings.LOGIN_ATTEMPTS_FAIL_MSG.replace("#", gMainCfgObj.MaxLoginAttempts));
			console.print("\x01n");
			bbs.hangup();
		}
	}

	return retObj;
}

/////////////////////////////////////////////////
// Other functions

// Performs the loop for the standard (non-matrix) login.
function loginStandard()
{
	var returnVal = true;

	if (gTraditionalGreetingFile.length > 0)
		bbs.menu(gTraditionalGreetingFile);
	for (var loopVar = 0; loopVar < 10; ++loopVar)
	{
		// The "node sync" is required for sysop interruption/chat/etc.
		bbs.nodesync();

		returnVal = doLogin(loopVar == 0);
		if (returnVal)
			break;

		// Password failure counts as 2 attempts
		++loopVar;
	}

	return returnVal;
}

// Helper for loginStandard() - Performs the username & password input.
function doLogin(pFirstTime)
{
	// Display login prompt
	console.print("\r\n" + gStdLangStrings.USERNAME_PROMPT);
	if (!Boolean(bbs.node_settings & NM_NO_NUM) && gMainCfgObj.AllowUserNumber)
		console.print(gStdLangStrings.OR_NUMER_PROMPT);
	if (!Boolean(system.settings & SYS_CLOSED))
		console.print("\r\n" + gStdLangStrings.NEW_USER_INFO);
	if (gGuestUserNum > 0 && gMainCfgObj.MenuDisplayGuestAccountIfExists)
		console.print("\r\n" + gStdLangStrings.GUEST_INFO);
	console.print("\r\n" + gStdLangStrings.LOGIN_PROMPT);

	// Get login string
	var str = console.getstr(25, // maximum user name length
	                         K_UPRLWR | K_TAB); // getkey/str mode flags
	truncsp(str);
	if (str.length == 0) // blank
		return false;

	// Set the color to high white on black background, and output
	// a couple blank lines for spacing after the login ANSI.
	console.print("\x01n\x01h\x01w\r\n\r\n");

	// New user application?
	if (str.toUpperCase() == gGenLangStrings.NEW.toUpperCase())
	{
		if (bbs.newuser())
		{
			bbs.logon();
			exit();
		}
		return true;
	}

	// Continue normal login (prompting for password)
	var retval = true;
	if (bbs.login(str, gStdLangStrings.PASSWORD_PROMPT))
		bbs.logon();
	else
	{
		if (gMainCfgObj.MenuDisplayRetrievePassword)
			retval = emailAcctInfo(str, true, false);
	}

	return retval;
}

// This function handles emailing the user's account information to the user.
//
// Parameters:
//  pUsernameOrEmailAddr: The user's username or email address
//  pAskIfForgotPass: Boolean - Whether or not to prompt the user whether or
//                    not they forgot their password before doing the lookup.
//  pPauseAfterMessages: Boolean - Whether or not to pause after displaying
//                       messages.  Optional.
function emailAcctInfo(pUsernameOrEmailAddr, pAskIfForgotPass, pPauseAfterMessages)
{
	if (pUsernameOrEmailAddr.length == 0)
		return false;

	var pauseAfterMsgs = false;
	if (pPauseAfterMessages != null)
		pauseAfterMsgs = pPauseAfterMessages;

	var retval = true;

	var usernum = 0;
	// If pUsernameOrEmailAddr has an @ in it, look up the user number based on email address. Otherwise,
	// look up the user number based on email (netmail) address.
	if (pUsernameOrEmailAddr.indexOf("@") > -1)
		usernum = system.matchuserdata(U_NETMAIL, pUsernameOrEmailAddr, 1); // Skip the sysop when searching
	else // Assume username
		usernum = system.matchuser(pUsernameOrEmailAddr);
	if (usernum > 0)
	{
		var theUser = new User(usernum);
		// Make sure the user is a valid and active user, is not a sysop,
		// and  has an internet email address.
		var continueOn = (!(theUser.settings&(USER_DELETED|USER_INACTIVE))
		                  && theUser.security.level < 90
		                  && netaddr_type(theUser.netmail) == NET_INTERNET);
		// If the user can retrieve their password and if pAskIfForgotPass is
		// true, then ask the user if they forgot their password.
		if (continueOn && pAskIfForgotPass)
			continueOn = !console.noyes(gGenLangStrings.DID_YOU_FORGET_PASSWORD_CONFIRM);
		// If we can send the user their account info, then go ahead and do it.
		if (continueOn)
		{
			console.print(gGenLangStrings.EMAIL_ADDR_CONFIRM_PROMPT);
			var email_addr = console.getstr(50);
			if (email_addr.toLowerCase() == theUser.netmail.toLowerCase())
			{
				var msgbase = new MsgBase("mail");
				if (msgbase.open() == false)
				{
					console.print("\r\n" + msgbase.last_error + "\r\n");
					if (pauseAfterMsgs)
						console.pause();
					alert(log(LOG_ERR,"!ERROR " + msgbase.last_error));
				}
				else
				{
					var hdr =
					{
						to: theUser.alias,
						to_net_addr: theUser.netmail, 
						to_net_type: NET_INTERNET,
						from: system.name,
						from_net_addr: "noreply@" + system.inet_addr,
						from_ext: "1", 
						subject: system.name + " user account information"
					};

					var msgtxt = gGenLangStrings.ACCT_INFO_REQUESTED_ON_TIME + " "
					           + system.timestr() + "\r\n";
					msgtxt += gGenLangStrings.BY + " " + client.host_name + " [" +
					client.ip_address +"] " + gGenLangStrings.VIA + " " +
					client.protocol + " (TCP " + gGenLangStrings.PORT + " " +
					client.port + "):\r\n\r\n";
					msgtxt += gGenLangStrings.INFO_ACCT_NUM + " " + theUser.number + "\r\n";
					msgtxt += gGenLangStrings.INFO_CREATED + " " + system.timestr(theUser.stats.firston_date) + "\r\n";
					msgtxt += gGenLangStrings.INFO_LAST_ON + " " + system.timestr(theUser.stats.laston_date) + "\r\n";
					msgtxt += gGenLangStrings.INFO_CONNECT + " " + theUser.host_name + " [" + theUser.ip_address + "] " +
					          gGenLangStrings.VIA + " " + theUser.connection + "\r\n";
					msgtxt += gGenLangStrings.INFO_PASSWORD + " " + theUser.security.password + "\r\n";

					if (msgbase.save_msg(hdr, msgtxt))
					{
						console.crlf();
						console.print(gGenLangStrings.ACCT_INFO_EMAILED_TO + theUser.netmail);
						console.crlf();
						if (pauseAfterMsgs)
							console.pause();
					}
					else
					{
						console.crlf();
						console.print(gGenLangStrings.ERROR_SAVING_BULKMAIL_MESSAGE + " " + msgbase.last_error);
						console.crlf();
						if (pauseAfterMsgs)
							console.pause();
						alert(log(LOG_ERR,"!ERROR " + msgbase.last_error));
					}

					msgbase.close();
				}
				retval = true;
			}
			else
			{
				alert(log(LOG_WARNING, gStdLangStrings.INFO_INCORRECT_EMAIL_ADDR + " " + email_addr));
				console.crlf();
				console.print(gGenLangStrings.INFO_INCORRECT_EMAIL_ADDR + " " + email_addr);
				console.crlf();
				if (pauseAfterMsgs)
					console.pause();
				retval = false;
			}
		}
		else
		{
			console.crlf();
			console.print(gGenLangStrings.UNABLE_TO_RETRIEVE_ACCT_INFO);
			console.crlf();
			if (pauseAfterMsgs)
				console.pause();
			retval = false;
		}
		//delete theUser;
		theUser = undefined; // Destructs the object now, rather than with 'delete'
	}
	else
	{
		console.crlf();
		console.print(gMatrixLangStrings.UNKNOWN_USERNAME_OR_NUM);
		console.crlf();
		if (pauseAfterMsgs)
			console.pause();
	}

	return retval;
}

// Returns the name of a random theme directory within the DDLoginMatrixThemes
// directory.
//
// Parameters:
//  pScriptDir: The script execution directory (with trailing slash)
//  pOnlySixelThemesForSixelTerminal: Boolean - Whether or not to only use themes
//                                    with sixels for sixel-capable terminals.
//                                    Defaults to false.
//
// Return value: The name of a random theme directory within the
//               DDLoginMatrixThemes directory.
function randomMatrixThemeDir(pScriptDir, pOnlySixelThemesForSixelTerminal)
{
	var onlySixelThemesForSixelTerm = (typeof(pOnlySixelThemesForSixelTerminal) === "boolean" ? pOnlySixelThemesForSixelTerminal : false);

	// Build an array of the directories in the DDLoginMatrixThemes
	// directory.
	var dirs = []; // An array of the directory names
	var files = directory(pScriptDir + "DDLoginMatrixThemes/*");
	var pos = 0;  // For finding text positions in the filenames
	var filename = null;
	var seen = {}; // For storing directory names we've already seen
	var userTermSupportsSixel = supports_sixel();
	for (var i in files)
	{
		if (file_isdir(files[i]))
		{
			// Look for "digdist" in the path and copy only the path
			// from that point.
			pos = files[i].indexOf("digdist");
			if (pos > 0)
				filename = files[i].substr(pos);
			else
				filename = files[i];

			// If we haven't seen the filename, then add it to the
			// dirs array.
			if (typeof(seen[filename]) == "undefined")
			{
				var includeThisTheme = true;

				// Check to see if the theme has sixel images.
				// The filename path must be fixed to be a relative path.
				// Look for ".." in the path and remove everything before that
				var themeDirName = filename;
				var dotsIdx = themeDirName.indexOf("..");
				if (dotsIdx > -1)
					themeDirName = themeDirName.substr(dotsIdx);
				// Check for graphic files (we can display sixels & convert some graphic files to sixels)
				var imageFilenames = [];
				for (var imgExtI = 0; imgExtI < gImgFilenameExts.length; ++imgExtI)
				{
					var filenames = directory(backslash(themeDirName) + "*." + gImgFilenameExts[imgExtI]);
					imageFilenames = imageFilenames.concat(filenames);
				}
				imageFilenames = imageFilenames.concat(directory(backslash(themeDirName) + "*.sixel"));
				if (imageFilenames.length > 0)
				{
					// If the user's terminal doesn't support sixels, then if there are only
					// .sixel or other image files for the background images (no .asc/.ans
					// versions), then don't include this theme.
					if (!userTermSupportsSixel)
					{
						includeThisTheme = false; // In case there are only sixel backgrounds
						for (var filenameI in imageFilenames)
						{
							// Remove the filename extension and see if there is a .asc or
							// .ans version of the file. If so, then include this theme
							var filenameWithoutExt = imageFilenames[filenameI];
							var extIdx = filenameWithoutExt.lastIndexOf(".");
							if (extIdx > -1)
								filenameWithoutExt = filenameWithoutExt.substr(0, extIdx);
							includeThisTheme = (directory(filenameWithoutExt + ".ans").length > 0 || directory(filenameWithoutExt + ".asc").length > 0);
							if (includeThisTheme)
								break;
						}
					}
				}
				else
				{
					// No image/sixel files.  If the user's terminal supports sixels and we
					// only want to include sixel themes, then don't include this theme.
					if (userTermSupportsSixel && onlySixelThemesForSixelTerm)
						includeThisTheme = false;
				}

				// If we should include this theme, then do so
				if (includeThisTheme)
				{
					dirs.push(filename);
					seen[filename] = true;
				}
			}
		}
	}

	// Return one of the directory names at random
	//return(dirs.length > 0 ? filenames[random(filenames.length)] : "");
	var dirName = "";
	// If the filenames array has some filenames in it, then get
	// one at random.  Also, fix the filename to have the correct
	// full path.
	if (dirs.length > 0)
	{
		dirName = dirs[random(dirs.length)];
		var pos = dirName.indexOf("DDLoginMatrixThemes");
		if (pos > -1)
			dirName = pScriptDir + dirName.substr(pos);
	}

	return dirName;
}

// Reads DDMatrixTheme.cfg in a random theme configuration
// directory and returns the configuration settings in an
// object.
//
// Parameters:
//  pScriptDir: The full path where the script is located (with trailing slash)
//  pWhichTheme: String - Specifies which theme configuration to get, or "Random"
//               to choose a random theme.
//  pOnlySixelThemesForSixelTerminal: Boolean - When choosing a random theme, whether
//                                    or not to only use themes with sixels for
//                                    sixel-capable terminals.
//
// Return value: An object containing the theme settings.
function getThemeCfg(pScriptDir, pWhichTheme, pRandomOnlySixelThemesForSixelTerminal)
{
	var themeDir = "";
	if (pWhichTheme.toLowerCase() == "random")
		themeDir = randomMatrixThemeDir(pScriptDir, pRandomOnlySixelThemesForSixelTerminal);
	else
		themeDir = backslash(pScriptDir) + "DDLoginMatrixThemes/" + pWhichTheme + "/";

	// Create cfgObject, the configuration object that we will
	// be returning.
	var cfgObj = {
		ThemeDir: themeDir,
		MinTerminalWidth: 0, // No minimum width
		InitialBackgroundFilename: themeDir + "InitialBackground",
		LoginBackgroundFilename: themeDir + "LoginBackground",
		ScaleInitialBkgImgWithHeight: false,
		ScaleLoginImgWithHeight: false,
		MenuX: 43,
		MenuY: 8,
		MenuBorders: "double",
		ClearSpaceAroundMenu: true,
		MenuTitle: "Login menu",
		DisplayMenuTitle: true,
		MenuColor_Border: "\x01h\x01b",
		MenuColor_Unselected: "\x01n\x01h\x01w",
		MenuColor_Selected: "\x01n\x01" + "4\x01h\x01c",
		MenuColor_Hotkey: "\x01h\x01y",
		MenuColor_ClearAroundMenu: "\x01n",
		UsernameX: 22,
		UsernameY: 7,
		UsernameLength: 25,
		UsernameBoxBorderColor: "\x01n\x01h\x01g",
		UsernameBoxBkgColor: "\x01n",
		UsernamePromptColor: "\x01n\x01c",
		UsernameTextColor: "\x01h\x01c",
		PasswordX: 22,
		PasswordY: 11,
		PasswordBoxBorderColor: "\x01n\x01h\x01g",
		PasswordBoxBkgColor: "\x01n",
		PasswordPromptColor: "\x01n\x01c",
		PasswordTextColor: "\x01h\x01c",
		StatusX: 22,
		StatusY: 15,
		StatusBoxInnerWidth: 35,
		StatusBoxBorderColor: "\x01n\x01h\x01b",
		StatusBoxBkgColor: "\x01n",
		StatusTextColor: "\x01n\x01h\x01y",
		InitialBackgroundInfoLines: [],
		userTermIsWideEnough: function()
		{
			return (this.MinTerminalWidth > 0 ? console.screen_columns >= this.MinTerminalWidth : true);
		}
	};

	// If themeDir is a valid directory, then open DDMatrixTheme.cfg
	// in that directory.
	if (file_isdir(themeDir))
	{
		var cfgFile = new File(themeDir + gGeneralThemeCfgFilename);
		if (cfgFile.open("r"))
		{
			// Read each line from the config file and set the
			// various options in cfgObj.
			while (!cfgFile.eof)
			{
				// Read the line from the config file, look for a =, and
				// if found, read the option & value and set them
				// in cfgObj.
				var fileLine = cfgFile.readln(512);
				// 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 is blank or starts with with a semicolon
				// (the comment character), then skip it.
				if ((fileLine.length == 0) || (fileLine.substr(0, 1) == ";"))
					continue;

				// Look for an = in the line, and if found, split into
				// option & value.  If a = is not found, then skip this line.
				var equalsIdx = fileLine.indexOf("=");
				if (equalsIdx < 0)
					continue;

				// Extract the option & value, trimming leading & trailing spaces.
				var optionName = trimSpaces(fileLine.substr(0, equalsIdx), true, false, true);
				var optionValue = trimSpaces(fileLine.substr(equalsIdx+1), true, false, true);

				// Initial or logon background filename
				if (optionName == "InitialBackgroundFilename" || optionName == "LoginBackgroundFilename")
				{
					// If the value is non-blank, then set it in cfgObj.
					// Otherwise, set the value in cfgObj to a blank string.
					if (optionValue.length > 0)
					{
						cfgObj[optionName] = themeDir + optionValue;
						// The filename path must be fixed to be a relative path.
						// Look for ".." in the path and remove everything before
						// that, but add another "../" before it.
						//var dotsIdx = cfgObj[optionName].indexOf("..");
						//if (dotsIdx > -1)
						//	cfgObj[optionName] = "../" + cfgObj[optionName].substr(dotsIdx);
					}
					else
						cfgObj[optionName] = "";
				}
				// Numeric options
				else if (optionName == "MenuX" || optionName == "UsernameX" || optionName == "PasswordX" || optionName == "StatusX")
					cfgObj[optionName] = strToIntOrPercentOfValue(optionValue, console.screen_columns, 1);
				else if (optionName == "MenuY" || optionName == "UsernameY" || optionName == "PasswordY" || optionName == "StatusY")
					cfgObj[optionName] = strToIntOrPercentOfValue(optionValue, console.screen_rows, 1);
				else if (optionName == "UsernameLength" || optionName == "StatusBoxInnerWidth" ||
						 optionName == "PasswordBoxInnerWidth")
				{
					// Ensure the value is an integer
					var valueInt = parseInt(optionValue);
					if (!isNaN(valueInt))
						cfgObj[optionName] = valueInt;
				}
				else if (optionName == "MenuBorders")
					cfgObj.MenuBorders = optionValue.toLowerCase(); // Ensure lowercase
				// Boolean options
				else if (optionName == "ClearSpaceAroundMenu" || optionName == "DisplayMenuTitle" ||
				         optionName == "ScaleInitialBkgImgWithHeight" || optionName == "ScaleLoginImgWithHeight")
				{
					// Boolean
					var optionValLower = optionValue.toLowerCase();
					cfgObj[optionName] = (optionValLower == "yes" || optionValLower == "true");
				}
				// Menu title
				else if (optionName == "MenuTitle")
					cfgObj[optionName] = optionValue; // Set it as-is
				// Minimum terminal width
				else if (optionName == "MinTerminalWidth")
				{
					var valNum = parseInt(optionValue);
					if (!isNaN(valNum) && valNum > 0)
						cfgObj.MinTerminalWidth = valNum;
				}
				// Initial background info line
				else if (optionName.indexOf("InitialBackgroundInfoLine") == 0)
				{
					// A text string to display on the initial background after the initial background has been displayed
					// The value needs to be in the format colors,X,Y,string
					// For example: nch,1,1,@BBS@
					var valArray = optionValue.split(",");
					if (valArray.length == 4)
					{
						var screenX = strToIntOrPercentOfValue(valArray[1], console.screen_columns, 1);
						var screenY = strToIntOrPercentOfValue(valArray[2], console.screen_rows, 1);
						if (screenX >= 1 && screenY >= 1)
						{
							cfgObj.InitialBackgroundInfoLines.push({
								X: screenX,
								Y: screenY,
								attrChars: valArray[0].toUpperCase(),
								str: valArray[3]
							});
						}
					}
				}
				// Colors
				else if (optionName == "MenuColor_Border" || optionName == "MenuColor_Unselected" ||
				         optionName == "MenuColor_Selected" || optionName == "MenuColor_Hotkey" ||
				         optionName == "MenuColor_ClearAroundMenu" || optionName == "UsernameBoxBorderColor" ||
				         optionName == "UsernameBoxBkgColor" || optionName == "PasswordBoxBorderColor" ||
				         optionName == "PasswordBoxBkgColor" || optionName == "StatusBoxBorderColor" ||
				         optionName == "StatusBoxBkgColor" || optionName == "UsernamePromptColor" ||
				         optionName == "UsernameTextColor" || optionName == "PasswordPromptColor" ||
				         optionName == "PasswordTextColor" || optionName == "StatusTextColor")
				{
					cfgObj[optionName] = attrCodeStr(optionValue);
				}
			}

			cfgFile.close();
		}
	}

	return cfgObj;
}

// Reads the main configuration file, DDLoginMatrix.cfg, and returns
// an object containing the script configuration options.
//
// Parameters:
//  pScriptDir: The full path where the script is located (with trailing slash)
//
// Return object properties:
//  UseMatrix: Whether or not to use the matrix-style login
//  MenuTimeoutMS: The menu input timeout, in ms.
//  MenuDisplayNewUser: Boolean - Whether or not to display the new user menu option
//  MenuDisplayGuestAccountIfExists: Boolean - Whether or not to display the guest
//                                   account menu option, if the guest account exists
//  MenuDisplayRetrievePassword: Boolean - Whether or not to display the menu option
//                               for retrieving a user's password.
//  MenuDisplayEmailSysop: Boolean - Whether or not to display the "email sysop" menu option
//  MenuDisplayPageSysop: Boolean - Whether or not to display the "page sysop" menu option
//  MaxLoginAttempts: The maximum number of user login attempts
//  AllowUserNumber: Boolean - Whether or not to allow logging in via user number
//  MatrixTheme: A string specifying the theme to use.  "Random" specifies to choose a
//               random theme.
//  Language: The name of the language to use.  This will be the name of the file in the
//            DDLoginMatrixLangFiles directory without the extension.
function ReadConfigFile(pScriptDir)
{
	// Set up a config object with default values
	var cfgObj = {
		UseMatrix: true,
		MenuTimeoutMS: 60000,
		MenuDisplayNewUser: true,
		MenuDisplayGuestAccountIfExists: true,
		MenuDisplayRetrievePassword: true,
		MenuDisplayEmailSysop: true,
		MenuDisplayPageSysop: true,
		MaxLoginAttempts: 3,
		AllowUserNumber: true,
		MatrixTheme: "Random",
		RandomOnlySixelThemesForSixelTerminal: false,
		Language: "English",
		SoundFile: "",
		PlaySound: true,
		PathToImgToSixelConv: "",
		remoteConnections: []
	};

	var cfgFile = new File(pScriptDir + "DDLoginMatrix.cfg");
	if (cfgFile.open("r"))
	{
		var settingsObj = cfgFile.iniGetObject();
		cfgFile.close();
		for (var settingName in cfgObj)
		{
			if (settingsObj.hasOwnProperty(settingName))
			{
				var loadedSettingType = typeof(settingsObj[settingName]);
				var cfgObjSettingType = typeof(cfgObj[settingName]);
				if (loadedSettingType == cfgObjSettingType)
					cfgObj[settingName] = settingsObj[settingName];
				else
				{
					// For backward compatibility, for the boolean settings, allow "yes"/"no" in addition to true/false
					if (cfgObjSettingType == "boolean" && loadedSettingType == "string")
					{
						var settingLower = settingsObj[settingName].toLowerCase();
						cfgObj[settingName] = (settingLower == "yes" || settingLower == "true");
					}
				}
			}
		}

		// If TestIPAddresses or TestHostnames is specified and there is a TestMatrixTheme specified,
		// then if the user's IP address is included in the list if test IP addresses, then use the
		// test theme
		var testIPAddrsValid = typeof(settingsObj.TestIPAddresses) === "string" && settingsObj.TestIPAddresses != "";
		var testHostnamesValid = typeof(settingsObj.TestHostnames) === "string" && settingsObj.TestHostnames != "";
		var testMatrixThemeValid = typeof(settingsObj.TestMatrixTheme) === "string" && settingsObj.TestMatrixTheme != "";
		if (testMatrixThemeValid && (testIPAddrsValid || testHostnamesValid))
		{
			var clientMatch = false;
			if (testIPAddrsValid)
			{
				var testIPAddrs = settingsObj.TestIPAddresses.split(",");
				for (var i = 0; i < testIPAddrs.length && !clientMatch; ++i)
					clientMatch = clientMatch || (skipsp(truncsp(testIPAddrs[i])) == client.ip_address);
			}
			if (!clientMatch && testHostnamesValid)
			{
				var testHostnames = settingsObj.TestHostnames.split(",");
				for (var i = 0; i < testHostnames.length && !clientMatch; ++i)
					clientMatch = clientMatch || (skipsp(truncsp(testHostnames[i])) == client.host_name);
			}
			if (clientMatch)
				cfgObj.MatrixTheme = settingsObj.TestMatrixTheme;
		}

		// Do some bounds checking on the numeric settings.
		if (cfgObj.MenuTimeoutMS < 1)
			cfgObj.MenuTimeoutMS = 60000;
		if (cfgObj.MaxLoginAttempts < 1)
			cfgObj.MaxLoginAttempts = 3;

		// Verify that the file specified by PathToImgToSixelConv exists; if not, then blank it
		if (!file_exists(cfgObj.PathToImgToSixelConv))
			cfgObj.PathToImgToSixelConv = "";
	}

	var remoteConnectionsFile = new File(pScriptDir + "remoteConnections.ini");
	if (remoteConnectionsFile.open("r"))
	{
		var iniObjects = remoteConnectionsFile.iniGetAllObjects();
		remoteConnectionsFile.close();
		for (var i = 0; i < iniObjects.length; ++i)
		{
			// name (section name)
			// display_name
			// address
			// port (number)
			// protocol
			cfgObj.remoteConnections.push({
				display_name: iniObjects[i].display_name,
				address: iniObjects[i].address,
				port: iniObjects[i].port,
				protocol: iniObjects[i].protocol
			});
		}
	}

	return cfgObj;
}

// Loads the language strings in gMatrixLangStrings, gStdLangStrings,
// and gGenLangStrings from a specified file.
//
// Paramaters:
//  pLangFile: The full path & filename of the file from which to load
//             the language strings
function loadLangStrings(pLangFile)
{
	// Try to find the correct filename case.  If unable to find it,
	// then just return.
	var langFilename = file_getcase(pLangFile);
	if (langFilename == undefined)
		return;

	// Open the language file ad start reading it.
	var langFile = new File(langFilename);
	if (langFile.open("r"))
	{
		// String categories
		const CAT_MATRIX = 0;  // Strings for the login matrix
		const CAT_STD = 1;     // Strings for standard login
		const CAT_GEN = 2;     // General strings

		var strCategory = -1; // Will store the current string category

		// Read each line from the config file and set the
		// strings in the proper language string object.
		while (!langFile.eof)
		{
			// Read the line from the config file, look for a =, and
			// if found, read the option & value and set them
			// in cfgObj.
			var fileLine = langFile.readln(512);
			// 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 is blank or starts with with a semicolon
			// (the comment character), then skip it.
			if ((fileLine.length == 0) || (fileLine.substr(0, 1) == ";"))
				continue;

			// Check for and set the string category
			if (fileLine.toUpperCase() == "[MATRIX]")
			{
				strCategory = CAT_MATRIX;
				continue;
			}
			else if (fileLine.toUpperCase() == "[STANDARD]")
			{
				strCategory = CAT_STD;
				continue;
			}
			else if (fileLine.toUpperCase() == "[GENERAL]")
			{
				strCategory = CAT_GEN;
				continue;
			}

			// Replace any instances of "\x01" or "\1" with the Synchronet attribute control character.
			fileLine = fileLine.replace(/\\[xX]01/g, "\x01").replace(/\\1/g, "\x01");

			// Look for an = in the line, and if found, split into
			// option & value.
			var equalsPos = fileLine.indexOf("=");
			if (equalsPos > -1)
			{
				// Extract the option & value, trimming leading & trailing spaces.
				var option = trimSpaces(fileLine.substr(0, equalsPos), true, false, true).toUpperCase();
				var optionValue = trimSpaces(fileLine.substr(equalsPos+1), true, false, true);
				// Set the option in the proper language object.
				switch (strCategory)
				{
					case CAT_MATRIX:
						gMatrixLangStrings[option] = optionValue;
						break;
					case CAT_STD:
						gStdLangStrings[option] = optionValue;
						break;
					case CAT_GEN:
						gGenLangStrings[option] = optionValue;
						break;
				}
			}
		}
		langFile.close();
	}
}

// Draws a one-line text box, with (optionally) some text inside it.
//
// Parameters:
//  pX: The upper-left horizontal coordinate of the box
//  pY: The upper-left vertical coordinate of the box
//  pStyle: "single" for single-line border, or "double" for double-line border
//  pBorderColor: A Synchronet color code to use for the border
//  pInnerColor: A Synchronet color code to use when blanking out the inside of the box
//  pInnerText: Optional - Text to be displayed inside the box (color codes allowed)
function drawOneLineInputBox(pX, pY, pWidth, pStyle, pBorderColor, pInnerColor, pInnerText)
{
	// Determine which border characters to use, based on pStyle
	const UPPER_LEFT = (pStyle == "double" ? UPPER_LEFT_DOUBLE : UPPER_LEFT_SINGLE);
	const HORIZONTAL = (pStyle == "double" ? HORIZONTAL_DOUBLE : HORIZONTAL_SINGLE);
	const UPPER_RIGHT = (pStyle == "double" ? UPPER_RIGHT_DOUBLE : UPPER_RIGHT_SINGLE);
	const LOWER_LEFT = (pStyle == "double" ? LOWER_LEFT_DOUBLE : LOWER_LEFT_SINGLE);
	const VERTICAL = (pStyle == "double" ? VERTICAL_DOUBLE : VERTICAL_SINGLE);
	const LOWER_RIGHT = (pStyle == "double" ? LOWER_RIGHT_DOUBLE : LOWER_RIGHT_SINGLE);

	var innerWidth = pWidth - 2;

	// Top border
	console.gotoxy(pX, pY);
	console.print(pBorderColor + UPPER_LEFT);
	for (var i = 0; i < innerWidth; ++i)
		console.print(HORIZONTAL);
	console.print(UPPER_RIGHT);
	// Middle row
	console.gotoxy(pX, pY+1);
	console.print(VERTICAL);
	if (pInnerText != null)
		console.print(pInnerText);
	console.print(pInnerColor);
	for (var i = (pInnerText != null ? strip_ctrl(pInnerText).length : 0); i < innerWidth; ++i)
		console.print(" ");
	console.print(pBorderColor + VERTICAL);
	// Bottom border
	console.gotoxy(pX, pY+2);
	console.print(LOWER_LEFT);
	for (var i = 0; i < innerWidth; ++i)
		console.print(HORIZONTAL);
	console.print(LOWER_RIGHT);
}

// Flashes a message on the screen at a given location for a given amount of time.
//
// Parameters:
//  pX: The horizontal location on the screen of where to write the message
//  pY: The vertical location on the screen of where to write the message
//  pMessage: The message to write on the screen
//  pPauseMS: The amount of time (in milliseconds) to pause before erasing the message
//  pFieldWidth: The width of the line on the screen where the text is to be drawn.
//               This is used for clearing the field.  If 0 or null, this will not be used.
//  pClearFieldFirst: Boolean - Whether or not to clear the field first
//  pClearAttr: If not null, this specifies the attribute to use to clear the field.
//              If this is left off (or is null), this function will use Synchronet's
//              normal attribute.
function flashMessage(pX, pY, pMessage, pPauseMS, pFieldWidth, pClearFieldFirst, pClearAttr)
{
	console.gotoxy(pX, pY);
	if (pClearFieldFirst && (pFieldWidth != null) && (pFieldWidth > 0))
	{
		// Clear the box
		if ((typeof(pClearAttr) != "undefined") && (pClearAttr != null))
			console.print(pClearAttr);
		else
			console.print("\x01n");

		for (var x = 0; x < pFieldWidth; ++x)
			console.print(" ");

		console.gotoxy(pX, pY);
	}
	console.print(pMessage);
	// Pause, and then clear the box
	if ((pPauseMS != null) && (pPauseMS > 0))
		mswait(pPauseMS);
	console.gotoxy(pX, pY);
	console.print("\x01n");
	var messageLen = strip_ctrl(pMessage).length;
	for (var x = 0; x < messageLen; ++x)
		console.print(" ");
}

// This function takes a string and returns a copy of the string
// with randomly-alternating dim & bright versions of a color.
//
// Parameters:
//  pString: The string to convert
//  pColor: The name of the color to use
function randomDimBrightString(pString, pColor)
{
	// Return if an invalid string is passed in.
	if (typeof(pString) == "undefined")
		return "";
	if (pString == null)
		return "";

	// Set the color.  Default to green.
	var color = "\x01g";
	if ((typeof(pColor) != "undefined") && (pColor != null))
      color = pColor;

	// Create a copy of the string without any control characters,
	// and then add our coloring to it.
	pString = strip_ctrl(pString);
	var returnString = "\x01n" + color;
	var bright = false;     // Whether or not to use the bright version of the color
	var oldBright = bright; // The value of bright from the last pass
	for (var i = 0; i < pString.length; ++i)
	{
		// Determine if this character should be bright
		bright = (Math.floor(Math.random()*2) == 1);
		if (bright != oldBright)
		{
			if (bright)
				returnString += "\x01h";
			else
				returnString += "\x01n" + color;
		}

		// Append the character from pString.
		returnString += pString.charAt(i);

		oldBright = bright;
	}

	return returnString;
}

// 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)
function trimSpaces(pString, pLeading, pMultiple, pTrailing)
{
	var leading = true;
	var multiple = true;
	var trailing = true;
	if (typeof(pLeading) != "undefined")
		leading = pLeading;
	if (typeof(pMultiple) != "undefined")
		multiple = pMultiple;
	if (typeof(pTrailing) != "undefined")
		trailing = pTrailing;
		
	// To remove both leading & trailing spaces:
	//pString = pString.replace(/(^\s*)|(\s*$)/gi,"");

	if (leading)
		pString = pString.replace(/(^\s*)/gi,"");
	if (multiple)
		pString = pString.replace(/[ ]{2,}/gi," ");
	if (trailing)
		pString = pString.replace(/(\s*$)/gi,"");

	return pString;
}

//////////////////////////////////////////////////////////////////////////////////////////
// Language object functions

// Constructor for the MatrixLangStrings object, which contains strings
// for the matrix-style login.  Defaults to English strings.
function MatrixLangStrings()
{
   this.LOGIN = "&Log in";
   this.NEWUSER = "&New user";
   this.GUEST = "&Guest account";
   this.RETRIEVE_PASSWORD = "&Retrieve password";
   this.EMAIL_SYSOP = "&Email the sysop";
   this.PAGE_SYSOP = "&Page the sysop";
   this.DISCONNECT = "&Disconnect";
   this.USERNAME_NUM_PASS_PROMPT = "Enter your username/# and password";
   this.USERNAME_PASS_PROMPT = "Enter your username and password";
   this.USERNAME_OR_NUM_PROMPT = "Name / #:";
   this.USERNAME_PROMPT = "Username:";
   this.PASSWORD_PROMPT = "Password:";
   this.UNKNOWN_USERNAME_OR_NUM = "Unknown username/number";
   this.UNKNOWN_USERNAME = "Unknown username";
   this.LOGIN_ATTEMPTS_FAIL_MSG = "\x01w\x01hUnable to log in after \x01y#\x01wattempts.";
   this.GUEST_ACCT_FAIL = "\x01n\x01h\x01yError: \x01wUnable to log into the guest account.";
   this.SYSOP_HAS_BEEN_PAGED = "\x01h\x01cThe sysop has been paged.";
   this.UNABLE_TO_PAGE_SYSOP = "\x01h\x01yUnable to page the sysop at this time.";
   this.LOGOFF_CONFIRM_TEXT = "Logoff";
   this.DISCONNECT_MESSAGE = "\x01n\x01h\x01wHave a nice day!";
   this.INPUT_TIMEOUT_REACHED = "Input timeout reached.";
   this.INVALID_LOGIN = "Invalid login";
   this.FAILED_LOGIN_BLOCKED_USERNAME = "!Failed login with blocked user name: ";
}

// Constructor for the StdLangStrings object, which contains strings for
// the standard-style login.  Defaults to English strings.
function StdLangStrings()
{
   this.USERNAME_PROMPT = "\r\n\x01n\x01h\x01gEnter \x01yUser Name";
   this.OR_NUMER_PROMPT = "\x01g or \x01yNumber\x01g.";
   this.NEW_USER_INFO = "\r\n\x01gIf you are a new user, enter \x01w'\x01yNew\x01w'\x01g.";
   this.GUEST_INFO = "\r\n\x01gFor the guest account, enter \x01w'\x01yGuest\x01w'\x01g.";
   this.LOGIN_PROMPT = "\r\nNN:\b\b\bLogin: \x01w";
   this.PASSWORD_PROMPT = "\x01n\x01c\x01hPW:\b\b\bPassword: \x01w";
   this.USERNAME_OR_EMAIL_ADDR_PROMPT = "\x01n\x01h\x01gEnter your \x01yUser Name \x01gor \x01yEmail Address";
}

// Constructor for the GeneralLangStrings object, which contains strings
// for other things.  Defaults to English strings.
function GeneralLangStrings()
{
   this.EMAIL_ADDR_CONFIRM_PROMPT = "\x01n\x01c\x01hPlease confirm your Internet e-mail address: \x01y";
   this.DID_YOU_FORGET_PASSWORD_CONFIRM = "Did you forget your password";
   this.ACCT_INFO_REQUESTED_ON_TIME = "Your user account information was requested on";
   this.BY = "by";
   this.VIA = "via";
   this.PORT = "port";
   this.NEW = "new";
   this.INFO_ACCT_NUM = "Account Number:";
   this.INFO_CREATED = "Created:";
   this.INFO_LAST_ON = "Last on:";
   this.INFO_CONNECT = "Connect:";
   this.INFO_PASSWORD = "Password:";
   this.INFO_INCORRECT_EMAIL_ADDR = "Incorrect e-mail address:";
   this.ACCT_INFO_EMAILED_TO = "\x01n\x01h\x01yAccount information e-mailed to: \x01w";
   this.ERROR_SAVING_BULKMAIL_MESSAGE = "Error saving bulkmail message:";
   this.UNKNOWN_USERNAME = "Unknown username";
   this.UNABLE_TO_RETRIEVE_ACCT_INFO = "\x01n\x01h\x01yUnable to send you your password (no email address/invalid user/sysop).";
}

////////////////////////////////////////////////
// Misc. functions

// Inputs a string from the user. Optionally hides their input with a mask character. Uses an input
// timeout.
//
// Parameters:
//  pMaxNumChars: The maximum number of characters to display; this is independent of text input
//                length.  Defaults to 0 (no maximum).
//  pMode: The mode bits to use.  See K_* in sbbsdefs.js.
//  pMaskChar: A mask character to use when printing the inputted characters.  Can be null for no mask.
//  pTimeoutMS: The input timeout (in milliseconds)
//
// Return value: The user's inputted string. This may be blank on timeout, or null, depending
//               on whether K_NUL was specified in the mode bits
function getStrWithTimeout(pMaxNumChars, pMode, pMaskChar, pTimeoutMS)
{
	var maxNumChars = 0;
	if ((typeof(pMaxNumChars) === "number") && (pMaxNumChars > 0))
		maxNumChars = pMaxNumChars;
	var modeBits = typeof(pMode) === "number" ? pMode : K_NONE;
	var timeoutMS = typeof(pTimeoutMS) === "number" && pTimeoutMS > 0 ? pTimeoutMS : 30000;

	var userInput = "";
	var continueOn = true;
	var numCharsPrinted = 0; // For backspace tracking (userInput could still be longer than the # printed)
	while (continueOn)
	{
		//var userChar = console.getkey(K_NOECHO|modeBits);
		var userChar = console.inkey(K_NOECHO|modeBits, timeoutMS);
		if (isPrintableChar(userChar))
		{
			if (Boolean(modeBits & K_UPRLWR))
			{
				if (userInput.length == 0 || userInput.charAt(userInput.length-1) == " ")
					userChar = userChar.toUpperCase();
			}
			userInput += userChar;
			if (!Boolean(modeBits & K_NOECHO) && numCharsPrinted < maxNumChars)
			{
				if (typeof(pMaskChar) === "string" && pMaskChar.length == 1)
					console.print(pMaskChar);
				else
					console.print(userChar);
				++numCharsPrinted;
			}
			// If limiting the length of the text inputted by the user:
			/*
			if (maxNumChars == 0 || userInput.length < maxNumChars)
			{
				if (Boolean(modeBits & K_UPRLWR))
				{
					if (userInput.length == 0 || userInput.charAt(userInput.length-1) == " ")
						userChar = userChar.toUpperCase();
				}
				userInput += userChar;
				if (!Boolean(modeBits & K_NOECHO))
				{
					if (typeof(pMaskChar) === "string" && pMaskChar.length == 1)
						console.print(pMaskChar);
					else
						console.print(userChar);
					++numCharsPrinted;
				}
			}
			*/
		}
		else if (userChar == BACKSPACE || userChar == KEY_DEL)
		{
			if (userInput.length > 0)
			{
				if (!Boolean(modeBits & K_NOECHO) && numCharsPrinted > 0)
				{
					console.print(BACKSPACE);
					console.print(" ");
					console.print(BACKSPACE);
					--numCharsPrinted;
				}
				userInput = userInput.substr(0, userInput.length-1);
			}
		}
		else if (userChar == KEY_ENTER)
		{
			continueOn = false;
			if (!Boolean(pMode & K_NOCRLF))
				console.crlf();
		}
		else if (userChar == null || userChar.length == 0)
			continueOn = false;
	}
	return userInput;
}

// Returns whether or not a character is printable.
//
// Parameters:
//  pChar: A character to test
//
// Return value: Boolean - Whether or not the character is printable
function isPrintableChar(pChar)
{
	// Make sure pChar is valid and is a string.
	if (typeof(pChar) != "string")
		return false;
	if (pChar.length == 0)
		return false;

	// Make sure the character is a printable ASCII character in the range of 32 to 254,
	// except for 127 (delete).
	var charCode = pChar.charCodeAt(0);
	return ((charCode > 31) && (charCode < 255) && (charCode != 127));
}

// Returns a substring of a string, accounting for Synchronet attribute
// codes (not including the attribute codes in the start index or length)
// This (and printedToRealIdxInStr()) is implemented in dd_lightbar_menu.js,
// but this login matrix script doesn't (yet) use that.
//
// Parameters:
//  pStr: The string to perform the substring on
//  pLen: The length of the substring
//
// Return value: A substring of the string according to the parameters
function substrWithAttrCodes(pStr, pStartIdx, pLen)
{
	if (typeof(pStr) != "string")
		return "";
	if (typeof(pStartIdx) != "number")
		return "";
	if (typeof(pLen) != "number")
		return "";
	if ((pStartIdx <= 0) && (pLen >= console.strlen(pStr)))
		return pStr;

	// Find the real start index.  If there are Synchronet attribute 
	var startIdx = printedToRealIdxInStr(pStr, pStartIdx);
	if (startIdx < 0)
		return "";
	// Find the actual length of the string to get
	var len = pLen;
	var printableCharCount = 0;
	var syncAttrCount = 0;
	var syncAttrRegexWholeWord = /^\x01[krgybmcw01234567hinpq,;\.dtl<>\[\]asz]$/i;
	var i = startIdx;
	while ((printableCharCount < pLen) && (i < pStr.length))
	{
		if (syncAttrRegexWholeWord.test(pStr.substr(i, 2)))
		{
			++syncAttrCount;
			i += 2;
		}
		else
		{
			++printableCharCount;
			++i;
		}
	}
	len += (syncAttrCount * 2);
	var shortenedStr = pStr.substr(startIdx, len);
	// Include any attribute codes that might appear before the start index
	// in the string
	var attrIdx = pStr.lastIndexOf("\x01", startIdx);
	if (attrIdx >= 0)
	{
		var attrStartIdx = -1;
		// Generate a string of all Synchronet attributes at the found location
		for (var i = attrIdx; i >= 0; i -= 2)
		{
			if (syncAttrRegexWholeWord.test(pStr.substr(i, 2)))
				attrStartIdx = i;
			else
				break;
		}
		if (attrStartIdx > -1)
		{
			var attrStr = pStr.substring(attrStartIdx, attrIdx+2);
			shortenedStr = attrStr + shortenedStr;
		}
	}
	return shortenedStr;
}

// Converts a 'printed' index in a string to its real index in the string
//
// Parameters:
//  pStr: The string to search in
//  pIdx: The printed index in the string
//
// Return value: The actual index in the string object, or -1 on error
function printedToRealIdxInStr(pStr, pIdx)
{
	if (typeof(pStr) != "string")
		return -1;
	if ((pIdx < 0) || (pIdx >= pStr.length))
		return -1;

	// Store the character at the given index if the string didn't have attribute codes.
	// Also, to help ensure this returns the correct index, get a substring with several
	// characters starting at the given index to match a word within the string
	var strWithoutAttrCodes = strip_ctrl(pStr);
	var substr_len = 5;
	var substrWithoutAttrCodes = strWithoutAttrCodes.substr(pIdx, substr_len);
	var printableCharAtIdx = strWithoutAttrCodes.charAt(pIdx);
	// Iterate through pStr until we find that character and return that index.
	var realIdx = 0;
	for (var i = 0; i < pStr.length; ++i)
	{
		// tempStr is the string to compare with substrWithoutAttrCodes
		var tempStr = strip_ctrl(pStr.substr(i)).substr(0, substr_len);
		if ((pStr.charAt(i) == printableCharAtIdx) && (tempStr == substrWithoutAttrCodes))
		{
			realIdx = i;
			break;
		}
	}
	return realIdx;
}

// Given a string of attribute characters, this function inserts the control code
// in front of each attribute character and returns the new string.
//
// Parameters:
//  pAttrCodeCharStr: A string of attribute characters (i.e., "YH" for yellow high)
//
// Return value: A string with the control character inserted in front of the attribute characters
function attrCodeStr(pAttrCodeCharStr)
{
	if (typeof(pAttrCodeCharStr) !== "string")
		return "";

	var str = "";
	var attrCodeStr = strip_ctrl(pAttrCodeCharStr); // Just in case
	// See this page for Synchronet color attribute codes:
	// http://wiki.synchro.net/custom:ctrl-a_codes
	for (var i = 0; i < attrCodeStr.length; ++i)
	{
		var currentChar = attrCodeStr.charAt(i);
		if (/[krgybmcwKRGYBMCWHhIiEeFfNn01234567]/.test(currentChar))
			str += "\x01" + currentChar;
	}
	return str;
}

function createLightbarMatrixMenu(pThemeCfgObj)
{
	var menu = new DDLightbarMenu(pThemeCfgObj.MenuX, pThemeCfgObj.MenuY, 19, 3);

	menu.borderEnabled = true;
	menu.scrollbarEnabled = false;
	menu.inputTimeoutMS = 60000;

	menu.colors.borderColor = pThemeCfgObj.MenuColor_Border;
	menu.colors.itemColor = pThemeCfgObj.MenuColor_Unselected;
	menu.colors.selectedItemColor = pThemeCfgObj.MenuColor_Selected;
	//hotkey = pThemeCfgObj.MenuColor_Hotkey;
	var borderStyleLower = pThemeCfgObj.MenuBorders.toLowerCase();
	if (borderStyleLower == "single")
	{
		menu.borderChars.upperLeft = UPPER_LEFT_SINGLE;
		menu.borderChars.upperRight = UPPER_RIGHT_SINGLE;
		menu.borderChars.lowerLeft = LOWER_LEFT_SINGLE;
		menu.borderChars.lowerRight = LOWER_RIGHT_SINGLE;
		menu.borderChars.top = HORIZONTAL_SINGLE;
		menu.borderChars.bottom = HORIZONTAL_SINGLE;
		menu.borderChars.left = VERTICAL_SINGLE;
		menu.borderChars.right = VERTICAL_SINGLE;
	}
	else if (borderStyleLower == "double")
	{
		menu.borderChars.upperLeft = UPPER_LEFT_DOUBLE;
		menu.borderChars.upperRight = UPPER_RIGHT_DOUBLE;
		menu.borderChars.lowerLeft = LOWER_LEFT_DOUBLE;
		menu.borderChars.lowerRight = LOWER_RIGHT_DOUBLE;
		menu.borderChars.top = HORIZONTAL_DOUBLE;
		menu.borderChars.bottom = HORIZONTAL_DOUBLE;
		menu.borderChars.left = VERTICAL_DOUBLE;
		menu.borderChars.right = VERTICAL_DOUBLE;
	}


	return menu;
}

// Writes spaces around a recgangular area
function writeSpacesAroundRectangle(pStartX, pStartY, pWidth, pHeight)
{
	console.attributes = "N";
	// Top border
	if (pStartX > 1 && pStartY > 1)
	{
		console.gotoxy(pStartX-1, pStartY-1);
		printf("%*s", pWidth+2, "");
	}
	// Side chars
	var leftX = pStartX - 1;
	var rightX = pStartX + pWidth;
	var lastRow = pStartY + pHeight;
	for (var row = pStartY; row < lastRow; ++row)
	{
		if (leftX >= 1)
		{
			console.gotoxy(leftX, row);
			console.print(" ");
		}
		if (rightX <= console.screen_columns)
		{
			console.gotoxy(rightX, row);
			console.print(" ");
		}
	}
	// Bottom border
	if (pStartX > 1)
	{
		console.gotoxy(pStartX-1, lastRow);
		printf("%*s", pWidth+2, "");
	}
}

// Converts an image file to sixel format.  From sixelgallery by Codefenix.
function convertAndShowImage(imgPath, scale)
{
	log(LOG_INFO, "converting: '" + imgPath + "' to sixel");
	if (file_exists(imgPath))
	{
		//console.clear(false);
		print("Preparing \x01b\x01h" + file_getname(imgPath) + " \x01w\x01hplease wait.\x01n.\x01k\x01h..\x01n.\x01k\x01h.\x01w\x01h!");
		//if (!scale)
		//	print("\r\n\x01n\x01k(scaling is disabled, so this may take a while)");
		var tmpSixel = backslash(temp_sixel_path) + "temp" + bbs.node_num + "-%05d.sixel";
		var cmd = path_to_im_conv + " -coalesce " + imgPath + " " + (scale ? ("-resize " + scale_max_width + "x" + scale_max_height + " ") : "") + tmpSixel;
		var rslt = system.exec(cmd);
		//console.clear(false);
		var frames = directory(backslash(temp_sixel_path) + "temp" + bbs.node_num + "*.sixel");
		if (frames.length > 0)
		{
			for (var f = 0; f < frames.length; f++)
			{
				console.home();
				showSixel(frames[f]);
				file_remove(frames[f]);
			}
		}
		else
		{
			//print("failed.");
			log(LOG_WARNING, "No output found for '" + imgPath + "'.");
		}
		if (rslt !== 0)
			log(LOG_WARNING, "Convert for '" + imgPath + "' rslt: " + rslt);
	}
}

// Shows a sixel image to the user.  Checks whether the user's terminal is capable of
// displaying a sixel image, and if not, this function does nothing.
//
// Paramaters:
//  pFilename: The filename (on the BBS machine) of the sixel file to load and show
function showSixel(pFilename)
{
	if (!supports_sixel())
		return false;

	var showSuccess = false;
	var imgFile = new File(pFilename);
	if (imgFile.exists)
	{
		if (imgFile.open("rb", true))
		{
			var readlen = console.output_buffer_level + console.output_buffer_space;
			readlen /= 2;
			console.clear();
			while (!imgFile.eof)
			{
				var imagedata = imgFile.read(readlen);
				while (console.output_buffer_space < imagedata.length)
					mswait(1);
				console.write(imagedata);
			}
			imgFile.close();
			showSuccess = true;
		}
	}
	return showSuccess;
}

// Converts the theme images to sixels, if they aren't already
//
// Return value: An object with the following properties:
//               InitialBkgAlreadySixel: Whether or not the initial background image file is already a sixel
//               LoginBkgAlreadySixel: Whether or not the logon background image file is already a sixel
function convertThemeImgsToSixel(pThemeCfgObj)
{
	console.print("\x01nOne moment please...");

	var retObj = {};

	retObj.InitialBkgAlreadySixel = file_exists(pThemeCfgObj.InitialBackgroundFilename + ".sixel");
	retObj.LoginBkgAlreadySixel = file_exists(pThemeCfgObj.LoginBackgroundFilename + ".sixel");
	
	if (!retObj.InitialBkgAlreadySixel)
	{
		if (!file_exists(gSixelCvtTempDir))
		{
			if (!mkdir(gSixelCvtTempDir))
				log(LOG_ERR, "!DDLoginmatrix: Failed to create temp sixel directory: " + gSixelCvtTempDir);
		}

		for (var i = 0; i < gImgFilenameExts.length; ++i)
		{
			var srcFilename = pThemeCfgObj.InitialBackgroundFilename + "." + gImgFilenameExts[i];
			if (file_exists(srcFilename))
			{
				var destFilename = gSixelCvtTempDir + file_getname(pThemeCfgObj.InitialBackgroundFilename) + ".sixel";
				convertImgToSixel(srcFilename, destFilename, true, pThemeCfgObj.ScaleInitialBkgImgWithHeight);
				break;
			}
		}
	}

	if (!retObj.LoginBkgAlreadySixel)
	{
		var destFilename = gSixelCvtTempDir + file_getname(pThemeCfgObj.LoginBackgroundFilename) + ".sixel";
		for (var i = 0; i < gImgFilenameExts.length; ++i)
		{
			var srcFilename = pThemeCfgObj.LoginBackgroundFilename + "." + gImgFilenameExts[i];
			if (file_exists(srcFilename) && !file_exists(destFilename))
			{
				convertImgToSixel(srcFilename, destFilename, true, pThemeCfgObj.ScaleLoginImgWithHeight);
				break;
			}
		}
	}

	return retObj;
}

// Converts an image to sixel format
//
// Parameters:
//  pSrcImgFilename: The source image filename
//  pDestImgFilename: The destination sixel filename
//  pScale: Whether or not to scale the image to the user's terminal width
//  pScaleWithHeight: Whether or not to take the user's terminal height into account when scaling the image
//
// Return value: Boolean - Whether or not the system command succeeded and the destination file exists
function convertImgToSixel(pSrcImgFilename, pDestImgFilename, pScale, pScaleWithHeight)
{
	var maxWidth = 640 * (console.screen_columns / 80);
	var maxHeight = 999999; // Large number; don't take terminal height into account
	if (pScaleWithHeight)
		maxHeight = 370 * (console.screen_rows / 24);
	var cmd = "";
	// By default, this assumes use of the 'convert' program from ImageMagick.
	// See if the conversion command uses magick (or magick.exe). If so,
	// don't use the -coalesce option.
	if (gMainCfgObj.PathToImgToSixelConv.toLowerCase().indexOf("magick") > -1)
		cmd = "\"" + gMainCfgObj.PathToImgToSixelConv + "\" \"" + pSrcImgFilename + "\" " + (pScale ? ("-resize " + maxWidth + "x" + maxHeight + " ") : "") + "\"" + pDestImgFilename + "\"";
	else
		cmd = "\"" + gMainCfgObj.PathToImgToSixelConv + "\" -coalesce \"" + pSrcImgFilename + "\" " + (pScale ? ("-resize " + maxWidth + "x" + maxHeight + " ") : "") + "\"" + pDestImgFilename + "\"";
	log(LOG_INFO, format("Sixel convert command: %s", cmd));
	var runningInWindows = /^WIN/.test(system.platform.toUpperCase());
	// A bit of a kludge:
	// If running in Windows and the command has a space in it, write a batch
	// file in the temp directory and run that.
	// For a *nix OS, we'd have to write a shell script and we'd have to know
	// the location of bash or another shell if bash doesn't exist, which would
	// be more complicated, so for now, this just handles the Windows case.
	var filesToDelete = []; // In case the batch file is used, we'll want to delete it to clean up
	if (runningInWindows && gMainCfgObj.PathToImgToSixelConv.indexOf(" ") > -1)
	{
		var batchFilename = gSixelCvtTempDir + "cvt.bat";
		var outFile = new File(batchFilename);
		if (outFile.open("w"))
		{
			outFile.writeln("@echo off");
			outFile.write(cmd);
			outFile.close();
			cmd = batchFilename;
			filesToDelete.push(batchFilename);
		}
	}
	var rslt = system.exec(cmd);
	// Cleanup
	for (var i = 0; i < filesToDelete.length; ++i)
		file_remove(filesToDelete[i]);
	var sixelFileExists = file_exists(pDestImgFilename);
	if (!sixelFileExists)
	{
		//log(LOG_ERR, "!DDLoginmatrix: Failed to run image to sixel conversion program (" + gMainCfgObj.PathToImgToSixelConv + "). Either the path is wrong or ImageMagick is not installed.");
		log(LOG_ERR, format("!DDLoginmatrix: Failed to run image to sixel conversion program (%s). Exit code: %d. Either the path is wrong or ImageMagick is not installed.",
		                    gMainCfgObj.PathToImgToSixelConv, rslt));
	}
	return (rslt == 0 && sixelFileExists);
}

// Removes the temporary sixel conversion directory and all files in it
function deleteSixelTempDir()
{
	if (!file_isdir(gSixelCvtTempDir))
		return;

	log(LOG_INFO, "DDLoginmatrix: Removing temporary sixel directory: " + gSixelCvtTempDir);
	// Delete all files in gSixelCvtTempDir, then remove the directory
	// rmfiles() was added in Synchronet 3.20
	if (typeof(rmfiles) === "function")
	{
		var tempFilenames = directory(gSixelCvtTempDir + "*.*");
		if (tempFilenames.length > 0)
		{
			if (!rmfiles(gSixelCvtTempDir))
				log(LOG_ERR, "!DDLoginmatrix: Failed to remove files in temporary sixel directory: " + gSixelCvtTempDir);
		}
	}
	else
	{
		var filenames = directory(gSixelCvtTempDir + "*");
		for (var i = 0; i < filenames.length; ++i)
		{
			if (!file_remove(filenames[i]))
				log(LOG_ERR, "!DDLoginmatrix: Failed to remove file in temporary sixel directory: " + filenames[i]);
		}
	}
	if (!rmdir(gSixelCvtTempDir))
		log(LOG_ERR, "!DDLoginmatrix: Failed to remove temporary sixel directory: " + gSixelCvtTempDir);
}

// Returns whether a file exists as an .asc or .ans file
//
// Parameters:
//  pBaseFilename: The base filename (without extension)
//
// Return value: Boolean - Whether or not the file exists as an .asc or .ans file
function ascOrAnsFileExists(pBaseFilename)
{
	var fileExists = false;
	if (typeof(bbs.menu_exists) === "function")
		fileExists = bbs.menu_exists(pBaseFilename);
	else
	{
		fileExists = bbs.file_exists(pBaseFilename + ".ans");
		if (!fileExists)
			fileExists = bbs.file_exists(pBaseFilename + ".asc");
	}
	return fileExists;
}

function strToIntOrPercentOfValue(pIntStr, pValueForPercentOf, pInitialDefaultVal)
{
	var value = (typeof(pInitialDefaultVal) === "number" ? Math.floor(pInitialDefaultVal) : 0);
	if (pIntStr.length > 0)
	{
		// If the value ends with a %, then set the value as a percentage
		// of screen width. If the value starts with a ., parse it as a
		// floating-point value and multiply that with the value.
		// Otherwise, parse it as an integer.
		if (pIntStr[pIntStr.length-1] == "%")
		{
			// parseInt() could be used, but parseFloat() here allows a fractional percentage
			var valueFloat = parseFloat(pIntStr.substr(0, pIntStr.length-1));
			if (!isNaN(valueFloat))
				value = Math.floor(pValueForPercentOf * (valueFloat / 100.0));
		}
		else if (pIntStr[0] == "." || pIntStr.indexOf("0.") == 0)
		{
			var valueFloat = parseFloat(pIntStr);
			if (!isNaN(valueFloat))
				value = Math.floor(pValueForPercentOf * valueFloat);
		}
		else
		{
			// If the string ends in "%+<number>" or "%-<number>", then treat it
			// as that percentage + or - a number. For instance, 23%+3 or 45%-4
			var matches = pIntStr.match(/^([0-9]+[.]?[0-9]*)%([+-])([0-9]+)$/);
			if (Array.isArray(matches) && matches.length == 4)
			{
				var percentage = parseFloat(matches[1]);
				var adjustValueFloat = parseFloat(matches[3]);
				if (!isNaN(percentage) && !isNaN(adjustValueFloat))
				{
					var percentageOfValue = Math.floor(pValueForPercentOf * (percentage / 100.0));
					if (matches[2] == "+")
						value = percentageOfValue + adjustValueFloat;
					else // The sign must be -
						value = percentageOfValue - adjustValueFloat;
				}
			}
			else
			{
				var valueInt = parseInt(pIntStr);
				if (!isNaN(valueInt))
					value = valueInt;
			}
		}
	}
	return value;
}
