Skip to content
Snippets Groups Projects
Select Git revision
  • dailybuild_linux-x64
  • dailybuild_win32
  • master default protected
  • sqlite
  • rip_abstraction
  • dailybuild_macos-armv8
  • dd_file_lister_filanem_in_desc_color
  • mode7
  • dd_msg_reader_are_you_there_warning_improvement
  • c23-playing
  • syncterm-1.3
  • syncterm-1.2
  • test-build
  • hide_remote_connection_with_telgate
  • 638-can-t-control-c-during-a-file-search
  • add_body_to_pager_email
  • mingw32-build
  • cryptlib-3.4.7
  • ree/mastermind
  • new_user_dat
  • sbbs320d
  • syncterm-1.6
  • syncterm-1.5
  • syncterm-1.4
  • sbbs320b
  • syncterm-1.3
  • syncterm-1.2
  • syncterm-1.2rc6
  • syncterm-1.2rc5
  • push
  • syncterm-1.2rc4
  • syncterm-1.2rc2
  • syncterm-1.2rc1
  • sbbs319b
  • sbbs318b
  • goodbuild_linux-x64_Sep-01-2020
  • goodbuild_win32_Sep-01-2020
  • goodbuild_linux-x64_Aug-31-2020
  • goodbuild_win32_Aug-31-2020
  • goodbuild_win32_Aug-30-2020
40 results

SlyEdit_Misc.js

Blame
  • SlyEdit_Misc.js 161.54 KiB
    // $Id$
    
    /* This file declares some general helper functions and variables
     * that are used by SlyEdit.
     *
     * Author: Eric Oulashin (AKA Nightfox)
     * BBS: Digital Distortion
     * BBS address: digdist.bbsindex.com
     *
     * Date       User              Description
     * 2009-06-06 Eric Oulashin     Started development
     * 2009-06-11 Eric Oulashin     Taking a break from development
     * 2009-08-09 Eric Oulashin     Started more development & testing
     * 2009-08-22 Eric Oulashin     Version 1.00
     *                              Initial public release
     * ....Removed some comments...
     * 2017-12-16 Eric Oulashin     Updated ReadSlyEditConfigFile() to include the
     *                              allowEditQuoteLines option.
     * 2017-12-18 Eric Oulashin     Update the KEY_PAGE_UP and KEY_PAGE_DOWN keys to
     *                              ensure they mat what's in sbbsdef.js
     * 2017-12-24 Eric Oulashin     Updated firstNonQuoteTxtIndex() to better handle
     *                              lines with 3 non-space characters before a >, to
     *                              not consider those sequences a quote when using
     *                              author initials.  When using author initials,
     *                              SlyEdit considers a quote sequence to only have 2
     *                              non-space characters (such as "EO>").
     * 2017-12-25 Eric Oulashin     Updated wrapTextLines() - Added an optional
     *                              parameter for the lineInfo object array so it
     *                              can be updated when lines are split (for quoting
     *                              with author initials).  That should fix an
     *                              issue where some wrapped/split quote lines
     *                              were missing the quote line prefix.
     * 2017-12-26 Eric Oulashin     Updated wrapTextLines() to (hopefully) better
     *                              handle situations when it wraps text into the
     *                              next line when that next line is blank - Ensuring
     *                              it adds a blank line below that.
     */
     
     load("text.js");
     
    // Note: These variables are declared with "var" instead of "const" to avoid
    // multiple declaration errors when this file is loaded more than once.
    
    // Values for attribute types (for text attribute substitution)
    var FORE_ATTR = 1; // Foreground color attribute
    var BKG_ATTR = 2;  // Background color attribute
    var SPECIAL_ATTR = 3; // Special attribute
    
    // Box-drawing/border characters: Single-line
    var UPPER_LEFT_SINGLE = "";
    var HORIZONTAL_SINGLE = "";
    var UPPER_RIGHT_SINGLE = "";
    var VERTICAL_SINGLE = "";
    var LOWER_LEFT_SINGLE = "";
    var LOWER_RIGHT_SINGLE = "";
    var T_SINGLE = "";
    var LEFT_T_SINGLE = "";
    var RIGHT_T_SINGLE = "";
    var BOTTOM_T_SINGLE = "";
    var CROSS_SINGLE = "";
    // Box-drawing/border characters: Double-line
    var UPPER_LEFT_DOUBLE = "";
    var HORIZONTAL_DOUBLE = "";
    var UPPER_RIGHT_DOUBLE = "";
    var VERTICAL_DOUBLE = "";
    var LOWER_LEFT_DOUBLE = "";
    var LOWER_RIGHT_DOUBLE = "";
    var T_DOUBLE = "";
    var LEFT_T_DOUBLE = "";
    var RIGHT_T_DOUBLE = "";
    var BOTTOM_T_DOUBLE = "";
    var CROSS_DOUBLE = "";
    // Box-drawing/border characters: Vertical single-line with horizontal double-line
    var UPPER_LEFT_VSINGLE_HDOUBLE = "";
    var UPPER_RIGHT_VSINGLE_HDOUBLE = "";
    var LOWER_LEFT_VSINGLE_HDOUBLE = "";
    var LOWER_RIGHT_VSINGLE_HDOUBLE = "";
    // Other special characters
    var DOT_CHAR = "";
    var CHECK_CHAR = "";
    var THIN_RECTANGLE_LEFT = "";
    var THIN_RECTANGLE_RIGHT = "";
    var BLOCK1 = ""; // Dimmest block
    var BLOCK2 = "";
    var BLOCK3 = "";
    var BLOCK4 = ""; // Brightest block
    
    // Navigational keys
    var UP_ARROW = "";
    var DOWN_ARROW = "";
    // CTRL keys
    var CTRL_A = "\x01";
    var CTRL_B = "\x02";
    //var KEY_HOME = CTRL_B;
    var CTRL_C = "\x03";
    var CTRL_D = "\x04";
    var CTRL_E = "\x05";
    //var KEY_END = CTRL_E;
    var CTRL_F = "\x06";
    //var KEY_RIGHT = CTRL_F;
    var CTRL_G = "\x07";
    var BEEP = CTRL_G;
    var CTRL_H = "\x08";
    var BACKSPACE = CTRL_H;
    var CTRL_I = "\x09";
    var TAB = CTRL_I;
    var CTRL_J = "\x0a";
    //var KEY_DOWN = CTRL_J;
    var CTRL_K = "\x0b";
    var CTRL_L = "\x0c";
    var INSERT_LINE = CTRL_L;
    var CTRL_M = "\x0d";
    var CR = CTRL_M;
    var KEY_ENTER = CTRL_M;
    var CTRL_N = "\x0e";
    var CTRL_O = "\x0f";
    var CTRL_P = "\x10";
    var CTRL_Q = "\x11";
    var XOFF = CTRL_Q;
    var CTRL_R = "\x12";
    var CTRL_S = "\x13";
    var XON = CTRL_S;
    var CTRL_T = "\x14";
    var CTRL_U = "\x15";
    var CTRL_V = "\x16";
    var KEY_INSERT = CTRL_V;
    var CTRL_W = "\x17";
    var CTRL_X = "\x18";
    var CTRL_Y = "\x19";
    var CTRL_Z = "\x1a";
    var KEY_ESC = "\x1b";
    var KEY_F1 = "\1F1";
    var KEY_F2 = "\1F2";
    var KEY_F3 = "\1F3";
    var KEY_F4 = "\1F4";
    var KEY_F5 = "\1F5";
    // PageUp & PageDown keys - Synchronet 3.17 as of about December 18, 2017
    // use CTRL-P and CTRL-N for PageUp and PageDown, respectively.  sbbsdefs.js
    // defines them as KEY_PAGEUP and KEY_PAGEDN; I've used slightly different names
    // in this script so that this script will work with Synchronet systems before
    // and after the update containing those key definitions.
    var KEY_PAGE_UP = CTRL_P;
    var KEY_PAGE_DOWN = CTRL_N;
    // Ensure KEY_PAGE_UP and KEY_PAGE_DOWN are set to what's defined in sbbs.js
    // for KEY_PAGEUP and KEY_PAGEDN in case they change.  Note that this relies
    // on sbbsdefs.js being loaded; SlyEdit.js loads sbbsdefs.js before this file,
    // so this should work.
    if (typeof(KEY_PAGEUP) === "string")
    	KEY_PAGE_UP = KEY_PAGEUP;
    if (typeof(KEY_PAGEDN) === "string")
    	KEY_PAGE_DOWN = KEY_PAGEDN;
    
    // Store the full path & filename of the Digital Distortion Message
    // Lister, since it will be used more than once.
    var gDDML_DROP_FILE_NAME = system.node_dir + "DDML_SyncSMBInfo.txt";
    
    var gUserSettingsFilename = backslash(system.data_dir + "user") + format("%04d", user.number) + ".SlyEdit_Settings";
    
    ///////////////////////////////////////////////////////////////////////////////////
    // Object/class stuff
    
    //////
    // TextLine stuff
    
    // TextLine object constructor: This is used to keep track of a text line,
    // and whether it has a hard newline at the end (i.e., if the user pressed
    // enter to break the line).
    //
    // Parameters (all optional):
    //  pText: The text for the line
    //  pHardNewlineEnd: Whether or not the line has a "hard newline" - What
    //                   this means is that text below it won't be wrapped up
    //                   to this line when re-adjusting the text lines.
    //  pIsQuoteLine: Whether or not the line is a quote line.
    function TextLine(pText, pHardNewlineEnd, pIsQuoteLine)
    {
    	this.text = "";               // The line text
    	this.hardNewlineEnd = false; // Whether or not the line has a hard newline at the end
    	this.isQuoteLine = false;    // Whether or not this is a quote line
    	// Copy the parameters if they are valid.
    	if ((pText != null) && (typeof(pText) == "string"))
    		this.text = pText;
    	if ((pHardNewlineEnd != null) && (typeof(pHardNewlineEnd) == "boolean"))
    		this.hardNewlineEnd = pHardNewlineEnd;
    	if ((pIsQuoteLine != null) && (typeof(pIsQuoteLine) == "boolean"))
    		this.isQuoteLine = pIsQuoteLine;
    
    	// NEW & EXPERIMENTAL:
    	// For color support
    	this.attrs = new Array(); // An array of attributes for the line
    	// Functions
    	this.length = TextLine_Length;
    	this.print = TextLine_Print;
    	this.doMacroTxtReplacement = TextLine_doMacroTxtReplacement;
    	this.getWord = TextLine_getWord;
    }
    // For the TextLine class: Returns the length of the text.
    function TextLine_Length()
    {
    	return this.text.length;
    }
    // For  the TextLine class: Prints the text line, using its text attributes.
    //
    // Parameters:
    //  pClearToEOL: Boolean - Whether or not to clear to the end of the line
    function TextLine_Print(pClearToEOL)
    {
    	console.print(this.text);
    
    	if (pClearToEOL)
    		console.cleartoeol();
    }
    // Performs text replacement (AKA macro replacement) in the text line.
    //
    // Parameters:
    //  pTxtReplacements: An associative array of text to be replaced (i.e.,
    //                    gTxtReplacements)
    //  pCharIndex: The current character index in the text line
    //  pUseRegex: Whether or not to treat the text replacement search string as a
    //             regular expression.
    //
    // Return value: An object containing the following properties:
    //               textLineIndex: The updated text line index (integer)
    //               wordLenDiff: The change in length of the word that
    //                            was replaced (integer)
    //               wordStartIdx: The index of the first character in the word.
    //                             Only valid if a word was found.  Otherwise, this
    //                             will be 0.
    //               newTextEndIdx: The index of the last character in the new
    //                              text.  Only valid if a word was replaced.
    //                              Otherwise, this will be 0.
    //               newTextLen: The length of the new text in the string.  Will be
    //                           the length of the existing word if the word wasn't
    //                           replaced or 0 if no word was found.
    //               madeTxtReplacement: Whether or not a text replacement was made
    //                                   (boolean)
    function TextLine_doMacroTxtReplacement(pTxtReplacements, pCharIndex, pUseRegex)
    {
    	var retObj = new Object();
    	retObj.textLineIndex = pCharIndex;
    	retObj.wordLenDiff = 0;
    	retObj.wordStartIdx = 0;
    	retObj.newTextEndIdx = 0;
    	retObj.newTextLen = 0;
    	retObj.madeTxtReplacement = false;
    
    	var wordObj = this.getWord(retObj.textLineIndex);
    	if (wordObj.foundWord)
    	{
    		retObj.wordStartIdx = wordObj.startIdx;
    		retObj.newTextLen = wordObj.word.length;
    
    		// See if the word starts with a capital letter; if so, we'll capitalize
    		// the replacement word.
    		var firstCharUpper = false;
    		var txtReplacement = "";
    		if (pUseRegex)
    		{
    			// Since a regular expression might have more characters in addition
    			// to the actual word, we need to go through all the replacement strings
    			// in pTxtReplacements and use the first one that changes the text.
    			for (var prop in pTxtReplacements)
    			{
    				if (pTxtReplacements.hasOwnProperty(prop))
    				{
    					var regex = new RegExp(prop);
    					txtReplacement = wordObj.word.replace(regex, pTxtReplacements[prop]);
    					retObj.madeTxtReplacement = (txtReplacement != wordObj.word);
    					// If a text replacement was made, then check and see if the first
    					// letter in the original text was uppercase, and if so, make the
    					// first letter in the new text (txtReplacement) uppercase.
    					if (retObj.madeTxtReplacement)
    					{
    						if (firstLetterIsUppercase(wordObj.word))
    						{
    							var letterInfo = getFirstLetterFromStr(txtReplacement);
    							if (letterInfo.idx > -1)
    							{
    								txtReplacement = txtReplacement.substr(0, letterInfo.idx)
    								               + letterInfo.letter.toUpperCase()
    								               + txtReplacement.substr(letterInfo.idx+1);
    							}
    						}
    						// Now that we've made a text replacement, stop going through
    						// pTxtReplacements looking for a matching regex.
    						break;
    					}
    				}
    			}
    		}
    		else
    		{
    			// Not using a regular expression.
    			firstCharUpper = (wordObj.word.charAt(0) == wordObj.word.charAt(0).toUpperCase());
    			// Convert the word to all uppercase to do the case-insensitive lookup
    			// in pTxtReplacements.
    			wordObj.word = wordObj.word.toUpperCase();
    			if (pTxtReplacements.hasOwnProperty(wordObj.word))
    			{
    				txtReplacement = pTxtReplacements[wordObj.word];
    				retObj.madeTxtReplacement = true;
    			}
    		}
    		if (retObj.madeTxtReplacement)
    		{
    			if (firstCharUpper)
    			txtReplacement = txtReplacement.charAt(0).toUpperCase() + txtReplacement.substr(1);
    			this.text = this.text.substr(0, wordObj.startIdx) + txtReplacement + this.text.substr(wordObj.endIndex+1);
    			// Based on the difference in word length, update the data that
    			// matters (retObj.textLineIndex, which keeps track of the index of the current line).
    			// Note: The horizontal cursor position variable should be replaced after calling this
    			// function.
    			retObj.wordLenDiff = txtReplacement.length - wordObj.word.length;
    			retObj.textLineIndex += retObj.wordLenDiff;
    			retObj.newTextEndIdx = wordObj.endIndex + retObj.wordLenDiff;
    			retObj.newTextLen = txtReplacement.length;
    		}
    	}
    
    	return retObj;
    }
    // Returns the word in a text line at a given index.  If the index
    // is at a space, then this function will return the word before
    // (to the left of) the space.
    //
    // Parameters:
    //  pEditLinesIndex: The index of the line to look at (0-based)
    //  pCharIndex: The character index in the text line (0-based)
    //
    // Return value: An object containing the following properties:
    //               foundWord: Whether or not a word was found (boolean)
    //               word: The word in the edit line at the given indexes (text).
    //                     This might include control/color codes, etc..
    //               plainWord: The word in the edit line without any control
    //                          or color codes, etc.  This may or may not be
    //                          the same as word.
    //               startIdx: The index of the first character of the word (integer)
    //               endIndex: The index of the last character of the word (integer)
    //                         This includes any control/color codes, etc.
    function TextLine_getWord(pCharIndex)
    {
    	var retObj = {
    		foundWord: false,
    		word: "",
    		plainWord: "",
    		startIdx: 0,
    		endIndex: 0
    	};
    
    	// Parameter checking
    	if ((pCharIndex < 0) || (pCharIndex >= this.text.length))
    		return retObj;
    
    	// If pCharIndex specifies the index of a space, then look for a non-space
    	// character before it.
    	var charIndex = pCharIndex;
    	while (this.text.charAt(charIndex) == " ")
    		--charIndex;
    	// Look for the start & end of the word based on the indexes of a space
    	// before and at/after the given character index.
    	var wordStartIdx = charIndex;
    	var wordEndIdx = charIndex;
    	while ((this.text.charAt(wordStartIdx) != " ") && (wordStartIdx >= 0))
    		--wordStartIdx;
    	++wordStartIdx;
    	while ((this.text.charAt(wordEndIdx) != " ") && (wordEndIdx < this.text.length))
    		++wordEndIdx;
    	--wordEndIdx;
    
    	retObj.foundWord = true;
    	retObj.startIdx = wordStartIdx;
    	retObj.endIndex = wordEndIdx;
    	retObj.word = this.text.substring(wordStartIdx, wordEndIdx+1);
    	retObj.plainWord = strip_ctrl(retObj.word);
    	return retObj;
    }
    
    
    // AbortConfirmFuncParams constructor: This object contains parameters used by
    // the abort confirmation function (actually, there are separate ones for
    // IceEdit and DCT Edit styles).
    function AbortConfirmFuncParams()
    {
    	this.editTop = gEditTop;
    	this.editBottom = gEditBottom;
    	this.editWidth = gEditWidth;
    	this.editHeight = gEditHeight;
    	this.editLinesIndex = gEditLinesIndex;
    	this.displayMessageRectangle = displayMessageRectangle;
    }
    
    //////
    // ChoiceScrollbox stuff
    
    // Returns the minimum width for a ChoiceScrollbox
    function ChoiceScrollbox_MinWidth()
    {
    	return 73; // To leave room for the navigation text in the bottom border
    }
    
    // ChoiceScrollbox constructor
    //
    // Parameters:
    //  pLeftX: The horizontal component (column) of the upper-left coordinate
    //  pTopY: The vertical component (row) of the upper-left coordinate
    //  pWidth: The width of the box (including the borders)
    //  pHeight: The height of the box (including the borders)
    //  pTopBorderText: The text to include in the top border
    //  pSlyEdCfgObj: The SlyEdit configuration object (color settings are used)
    //  pAddTCharsAroundTopText: Optional, boolean - Whether or not to use left & right T characters
    //                           around the top border text.  Defaults to true.
    // pReplaceTopTextSpacesWithBorderChars: Optional, boolean - Whether or not to replace
    //                           spaces in the top border text with border characters.
    //                           Defaults to false.
    function ChoiceScrollbox(pLeftX, pTopY, pWidth, pHeight, pTopBorderText, pSlyEdCfgObj,
                             pAddTCharsAroundTopText, pReplaceTopTextSpacesWithBorderChars)
    {
       // The default is to add left & right T characters around the top border
       // text.  But also use pAddTCharsAroundTopText if it's a boolean.
       var addTopTCharsAroundText = true;
       if (typeof(pAddTCharsAroundTopText) == "boolean")
          addTopTCharsAroundText = pAddTCharsAroundTopText;
       // If pReplaceTopTextSpacesWithBorderChars is true, then replace the spaces
       // in pTopBorderText with border characters.
       if (pReplaceTopTextSpacesWithBorderChars)
       {
          var startIdx = 0;
          var firstSpcIdx = pTopBorderText.indexOf(" ", 0);
          // Look for the first non-space after firstSpaceIdx
          var nonSpcIdx = -1;
          for (var i = firstSpcIdx; (i < pTopBorderText.length) && (nonSpcIdx == -1); ++i)
          {
             if (pTopBorderText.charAt(i) != " ")
                nonSpcIdx = i;
          }
          var firstStrPart = "";
          var lastStrPart = "";
          numSpaces = 0;
          while ((firstSpcIdx > -1) && (nonSpcIdx > -1))
          {
             firstStrPart = pTopBorderText.substr(startIdx, (firstSpcIdx-startIdx));
             lastStrPart = pTopBorderText.substr(nonSpcIdx);
             numSpaces = nonSpcIdx - firstSpcIdx;
             if (numSpaces > 0)
             {
                pTopBorderText = firstStrPart + "n" + pSlyEdCfgObj.genColors.listBoxBorder;
                for (var i = 0; i < numSpaces; ++i)
                   pTopBorderText += HORIZONTAL_SINGLE;
                pTopBorderText += "n" + pSlyEdCfgObj.genColors.listBoxBorderText + lastStrPart;
             }
    
             // Look for the next space and non-space character after that.
             firstSpcIdx = pTopBorderText.indexOf(" ", nonSpcIdx);
             // Look for the first non-space after firstSpaceIdx
             nonSpcIdx = -1;
             for (var i = firstSpcIdx; (i < pTopBorderText.length) && (nonSpcIdx == -1); ++i)
             {
                if (pTopBorderText.charAt(i) != " ")
                   nonSpcIdx = i;
             }
          }
       }
    
       this.SlyEdCfgObj = pSlyEdCfgObj;
    
       var minWidth = ChoiceScrollbox_MinWidth();
    
       this.dimensions = new Object();
       this.dimensions.topLeftX = pLeftX;
       this.dimensions.topLeftY = pTopY;
       // Make sure the width is the minimum width
       if ((pWidth < 0) || (pWidth < minWidth))
          this.dimensions.width = minWidth;
       else
          this.dimensions.width = pWidth;
       this.dimensions.height = pHeight;
       this.dimensions.bottomRightX = this.dimensions.topLeftX + this.dimensions.width - 1;
       this.dimensions.bottomRightY = this.dimensions.topLeftY + this.dimensions.height - 1;
    
       // The text item array and member variables relating to it and the items
       // displayed on the screen during the input loop
       this.txtItemList = new Array();
       this.chosenTextItemIndex = -1;
       this.topItemIndex = 0;
       this.bottomItemIndex = 0;
    
       // Top border string
       var innerBorderWidth = this.dimensions.width - 2;
       // Calculate the maximum top border text length to account for the left/right
       // T chars and "Page #### of ####" text
       var maxTopBorderTextLen = innerBorderWidth - (pAddTCharsAroundTopText ? 21 : 19);
       if (strip_ctrl(pTopBorderText).length > maxTopBorderTextLen)
          pTopBorderText = pTopBorderText.substr(0, maxTopBorderTextLen);
       this.topBorder = "n" + pSlyEdCfgObj.genColors.listBoxBorder + UPPER_LEFT_SINGLE;
       if (addTopTCharsAroundText)
          this.topBorder += RIGHT_T_SINGLE;
       this.topBorder += "n" + pSlyEdCfgObj.genColors.listBoxBorderText
         + pTopBorderText + "n" + pSlyEdCfgObj.genColors.listBoxBorder;
       if (addTopTCharsAroundText)
          this.topBorder += LEFT_T_SINGLE;
       const topBorderTextLen = strip_ctrl(pTopBorderText).length;
       var numHorizBorderChars = innerBorderWidth - topBorderTextLen - 20;
       if (addTopTCharsAroundText)
          numHorizBorderChars -= 2;
       for (var i = 0; i <= numHorizBorderChars; ++i)
          this.topBorder += HORIZONTAL_SINGLE;
       this.topBorder += RIGHT_T_SINGLE + "n" + pSlyEdCfgObj.genColors.listBoxBorderText
         + "Page    1 of    1" + "n" + pSlyEdCfgObj.genColors.listBoxBorder + LEFT_T_SINGLE
         + UPPER_RIGHT_SINGLE;
    
       // Bottom border string
       this.btmBorderNavText = "nhcb, cb, cNy)bext, cPy)brev, "
         + "cFy)birst, cLy)bast, cHOMEb, cENDb, cEntery=bSelect, "
         + "cESCnc/hcQy=bEnd";
       this.bottomBorder = "n" + pSlyEdCfgObj.genColors.listBoxBorder + LOWER_LEFT_SINGLE
         + RIGHT_T_SINGLE + this.btmBorderNavText + "n" + pSlyEdCfgObj.genColors.listBoxBorder
         + LEFT_T_SINGLE;
       var numCharsRemaining = this.dimensions.width - strip_ctrl(this.btmBorderNavText).length - 6;
       for (var i = 0; i < numCharsRemaining; ++i)
          this.bottomBorder += HORIZONTAL_SINGLE;
       this.bottomBorder += LOWER_RIGHT_SINGLE;
    
       // Item format strings
       this.listIemFormatStr = "n" + pSlyEdCfgObj.genColors.listBoxItemText + "%-"
                              + +(this.dimensions.width-2) + "s";
       this.listIemHighlightFormatStr = "n" + pSlyEdCfgObj.genColors.listBoxItemHighlight + "%-"
                              + +(this.dimensions.width-2) + "s";
    
       // Key functionality override function pointers
       this.enterKeyOverrideFn = null;
    
       // inputLoopeExitKeys is an object containing additional keypresses that will
       // exit the input loop.
       this.inputLoopExitKeys = new Object();
    
       // "Class" functions
       this.addTextItem = ChoiceScrollbox_AddTextItem; // Returns the index of the item
       this.getTextItem = ChoiceScrollbox_GetTextIem;
       this.replaceTextItem = ChoiceScrollbox_ReplaceTextItem;
       this.delTextItem = ChoiceScrollbox_DelTextItem;
       this.chgCharInTextItem = ChoiceScrollbox_ChgCharInTextItem;
       this.getChosenTextItemIndex = ChoiceScrollbox_GetChosenTextItemIndex;
       this.setItemArray = ChoiceScrollbox_SetItemArray; // Sets the item array; returns whether or not it was set.
       this.clearItems = ChoiceScrollbox_ClearItems; // Empties the array of items
       this.setEnterKeyOverrideFn = ChoiceScrollbox_SetEnterKeyOverrideFn;
       this.clearEnterKeyOverrideFn = ChoiceScrollbox_ClearEnterKeyOverrideFn;
       this.addInputLoopExitKey = ChoiceScrollbox_AddInputLoopExitKey;
       this.setBottomBorderText = ChoiceScrollbox_SetBottomBorderText;
       this.drawBorder = ChoiceScrollbox_DrawBorder;
       this.refreshItemCharOnScreen = ChoiceScrollbox_RefreshItemCharOnScreen;
       // Does the input loop.  Returns an object with the following properties:
       //  itemWasSelected: Boolean - Whether or not an item was selected
       //  selectedIndex: The index of the selected item
       //  selectedItem: The text of the selected item
       //  lastKeypress: The last key pressed by the user
       this.doInputLoop = ChoiceScrollbox_DoInputLoop;
    }
    function ChoiceScrollbox_AddTextItem(pTextLine, pStripCtrl)
    {
       var stripCtrl = true;
       if (typeof(pStripCtrl) == "boolean")
          stripCtrl = pStripCtrl;
    
       if (stripCtrl)
          this.txtItemList.push(strip_ctrl(pTextLine));
       else
          this.txtItemList.push(pTextLine);
       // Return the index of the added item
       return this.txtItemList.length-1;
    }
    function ChoiceScrollbox_GetTextIem(pItemIndex)
    {
       if (typeof(pItemIndex) != "number")
          return "";
       if ((pItemIndex < 0) || (pItemIndex >= this.txtItemList.length))
          return "";
    
       return this.txtItemList[pItemIndex];
    }
    function ChoiceScrollbox_ReplaceTextItem(pItemIndexOrStr, pNewItem)
    {
       if (typeof(pNewItem) != "string")
          return false;
    
       // Find the item index
       var itemIndex = -1;
       if (typeof(pItemIndexOrStr) == "number")
       {
          if ((pItemIndexOrStr < 0) || (pItemIndexOrStr >= this.txtItemList.length))
             return false;
          else
             itemIndex = pItemIndexOrStr;
       }
       else if (typeof(pItemIndexOrStr) == "string")
       {
          itemIndex = -1;
          for (var i = 0; (i < this.txtItemList.length) && (itemIndex == -1); ++i)
          {
             if (this.txtItemList[i] == pItemIndexOrStr)
                itemIndex = i;
          }
       }
       else
          return false;
    
       // Replace the item
       var replacedIt = false;
       if ((itemIndex > -1) && (itemIndex < this.txtItemList.length))
       {
          this.txtItemList[itemIndex] = pNewItem;
          replacedIt = true;
       }
       return replacedIt;
    }
    function ChoiceScrollbox_DelTextItem(pItemIndexOrStr)
    {
       // Find the item index
       var itemIndex = -1;
       if (typeof(pItemIndexOrStr) == "number")
       {
          if ((pItemIndexOrStr < 0) || (pItemIndexOrStr >= this.txtItemList.length))
             return false;
          else
             itemIndex = pItemIndexOrStr;
       }
       else if (typeof(pItemIndexOrStr) == "string")
       {
          itemIndex = -1;
          for (var i = 0; (i < this.txtItemList.length) && (itemIndex == -1); ++i)
          {
             if (this.txtItemList[i] == pItemIndexOrStr)
                itemIndex = i;
          }
       }
       else
          return false;
    
       // Remove the item
       var removedIt = false;
       if ((itemIndex > -1) && (itemIndex < this.txtItemList.length))
       {
          this.txtItemList = this.txtItemList.splice(itemIndex, 1);
          removedIt = true;
       }
       return removedIt;
    }
    function ChoiceScrollbox_ChgCharInTextItem(pItemIndexOrStr, pStrIndex, pNewText)
    {
       // Find the item index
       var itemIndex = -1;
       if (typeof(pItemIndexOrStr) == "number")
       {
          if ((pItemIndexOrStr < 0) || (pItemIndexOrStr >= this.txtItemList.length))
             return false;
          else
             itemIndex = pItemIndexOrStr;
       }
       else if (typeof(pItemIndexOrStr) == "string")
       {
          itemIndex = -1;
          for (var i = 0; (i < this.txtItemList.length) && (itemIndex == -1); ++i)
          {
             if (this.txtItemList[i] == pItemIndexOrStr)
                itemIndex = i;
          }
       }
       else
          return false;
    
       // Change the character in the item
       var changedIt = false;
       if ((itemIndex > -1) && (itemIndex < this.txtItemList.length))
       {
          this.txtItemList[itemIndex] = chgCharInStr(this.txtItemList[itemIndex], pStrIndex, pNewText);
          changedIt = true;
       }
       return changedIt;
    }
    function ChoiceScrollbox_GetChosenTextItemIndex()
    {
       return this.chosenTextItemIndex;
    }
    function ChoiceScrollbox_SetItemArray(pArray, pStripCtrl)
    {
       var safeToSet = false;
       if (Object.prototype.toString.call(pArray) === "[object Array]")
       {
          if (pArray.length > 0)
             safeToSet = (typeof(pArray[0]) == "string");
          else
             safeToSet = true; // It's safe to set an empty array
       }
    
       if (safeToSet)
       {
          delete this.txtItemList;
          this.txtItemList = pArray;
    
          var stripCtrl = true;
          if (typeof(pStripCtrl) == "boolean")
             stripCtrl = pStripCtrl;
          if (stripCtrl)
          {
             // Remove attribute/color characters from the text lines in the array
             for (var i = 0; i < this.txtItemList.length; ++i)
                this.txtItemList[i] = strip_ctrl(this.txtItemList[i]);
          }
       }
    
       return safeToSet;
    }
    function ChoiceScrollbox_ClearItems()
    {
       this.txtItemList.length = 0;
    }
    function ChoiceScrollbox_SetEnterKeyOverrideFn(pOverrideFn)
    {
       if (Object.prototype.toString.call(pOverrideFn) == "[object Function]")
          this.enterKeyOverrideFn = pOverrideFn;
    }
    function ChoiceScrollbox_ClearEnterKeyOverrideFn()
    {
       this.enterKeyOverrideFn = null;
    }
    function ChoiceScrollbox_AddInputLoopExitKey(pKeypress)
    {
       this.inputLoopExitKeys[pKeypress] = true;
    }
    function ChoiceScrollbox_SetBottomBorderText(pText, pAddTChars, pAutoStripIfTooLong)
    {
       if (typeof(pText) != "string")
          return;
    
       const innerWidth = (pAddTChars ? this.dimensions.width-4 : this.dimensions.width-2);
    
       if (pAutoStripIfTooLong)
       {
          if (strip_ctrl(pText).length > innerWidth)
             pText = pText.substr(0, innerWidth);
       }
    
       // Re-build the bottom border string based on the new text
       this.bottomBorder = "n" + this.SlyEdCfgObj.genColors.listBoxBorder + LOWER_LEFT_SINGLE;
       if (pAddTChars)
          this.bottomBorder += RIGHT_T_SINGLE;
       if (pText.indexOf("n") != 0)
          this.bottomBorder += "n";
       this.bottomBorder += pText + "n" + this.SlyEdCfgObj.genColors.listBoxBorder;
       if (pAddTChars)
          this.bottomBorder += LEFT_T_SINGLE;
       var numCharsRemaining = this.dimensions.width - strip_ctrl(this.bottomBorder).length - 3;
       for (var i = 0; i < numCharsRemaining; ++i)
          this.bottomBorder += HORIZONTAL_SINGLE;
       this.bottomBorder += LOWER_RIGHT_SINGLE;
    }
    function ChoiceScrollbox_DrawBorder()
    {
       console.gotoxy(this.dimensions.topLeftX, this.dimensions.topLeftY);
       console.print(this.topBorder);
       // Draw the side border characters
       var screenRow = this.dimensions.topLeftY + 1;
       for (var screenRow = this.dimensions.topLeftY+1; screenRow <= this.dimensions.bottomRightY-1; ++screenRow)
       {
          console.gotoxy(this.dimensions.topLeftX, screenRow);
          console.print(VERTICAL_SINGLE);
          console.gotoxy(this.dimensions.bottomRightX, screenRow);
          console.print(VERTICAL_SINGLE);
       }
       // Draw the bottom border
       console.gotoxy(this.dimensions.topLeftX, this.dimensions.bottomRightY);
       console.print(this.bottomBorder);
    }
    function ChoiceScrollbox_RefreshItemCharOnScreen(pItemIndex, pCharIndex)
    {
       if ((typeof(pItemIndex) != "number") || (typeof(pCharIndex) != "number"))
          return;
       if ((pItemIndex < 0) || (pItemIndex >= this.txtItemList.length) ||
           (pItemIndex < this.topItemIndex) || (pItemIndex > this.bottomItemIndex))
       {
          return;
       }
       if ((pCharIndex < 0) || (pCharIndex >= this.txtItemList[pItemIndex].length))
          return;
    
       // Save the current cursor position so that we can restore it later
       const originalCurpos = console.getxy();
       // Go to the character's position on the screen and set the highlight or
       // normal color, depending on whether the item is the currently selected item,
       // then print the character on the screen.
       const charScreenX = this.dimensions.topLeftX + 1 + pCharIndex;
       const itemScreenY = this.dimensions.topLeftY + 1 + (pItemIndex - this.topItemIndex);
       console.gotoxy(charScreenX, itemScreenY);
       if (pItemIndex == this.chosenTextItemIndex)
          console.print(this.SlyEdCfgObj.genColors.listBoxItemHighlight);
       else
          console.print(this.SlyEdCfgObj.genColors.listBoxItemText);
       console.print(this.txtItemList[pItemIndex].charAt(pCharIndex));
       // Move the cursor back to where it was originally
       console.gotoxy(originalCurpos);
    }
    function ChoiceScrollbox_DoInputLoop(pDrawBorder)
    {
    	var retObj = {
    		itemWasSelected: false,
    		selectedIndex: -1,
    		selectedItem: "",
    		lastKeypress: ""
    	};
    
    	// Don't do anything if the item list doesn't contain any items
    	if (this.txtItemList.length == 0)
    		return retObj;
    
    	//////////////////////////////////
    	// Locally-defined functions
    
    	// This function returns the index of the bottommost item that
    	// can be displayed in the box.
    	//
    	// Parameters:
    	//  pArray: The array containing the items
    	//  pTopindex: The index of the topmost item displayed in the box
    	//  pNumItemsPerPage: The number of items per page
    	function getBottommostItemIndex(pArray, pTopIndex, pNumItemsPerPage)
    	{
    		var bottomIndex = pTopIndex + pNumItemsPerPage - 1;
    		// If bottomIndex is beyond the last index, then adjust it.
    		if (bottomIndex >= pArray.length)
    			bottomIndex = pArray.length - 1;
    		return bottomIndex;
    	}
    
    
    
    	//////////////////////////////////
    	// Code
    
    	// Variables for keeping track of the item list
    	const numItemsPerPage = this.dimensions.height - 2;
    	this.topItemIndex = 0;    // The index of the message group at the top of the list
    	// Figure out the index of the last message group to appear on the screen.
    	this.bottomItemIndex = getBottommostItemIndex(this.txtItemList, this.topItemIndex, numItemsPerPage);
    	const numPages = Math.ceil(this.txtItemList.length / numItemsPerPage);
    	const topIndexForLastPage = (numItemsPerPage * numPages) - numItemsPerPage;
    
    	if (pDrawBorder)
    		this.drawBorder();
    
    	// User input loop
    	// For the horizontal location of the page number text for the box border:
    	// Based on the fact that there can be up to 9999 text replacements and 10
    	// per page, there will be up to 1000 pages of replacements.  To write the
    	// text, we'll want to be 20 characters to the left of the end of the border
    	// of the box.
    	const pageNumTxtStartX = this.dimensions.topLeftX + this.dimensions.width - 19;
    	const maxItemWidth = this.dimensions.width - 2;
    	var pageNum = 0;
    	var startArrIndex = 0;
    	this.chosenTextItemIndex = retObj.selectedIndex = 0;
    	var endArrIndex = 0; // One past the last array item
    	var screenY = 0;
    	var curpos = new Object(); // For keeping track of the current cursor position
    	curpos.x = 0;
    	curpos.y = 0;
    	var refreshList = true; // For screen redraw optimizations
    	var continueOn = true;
    	while (continueOn)
    	{
    		if (refreshList)
    		{
    			this.bottomItemIndex = getBottommostItemIndex(this.txtItemList, this.topItemIndex, numItemsPerPage);
    
    			// Write the list of items for the current page
    			startArrIndex = pageNum * numItemsPerPage;
    			endArrIndex = startArrIndex + numItemsPerPage;
    			if (endArrIndex > this.txtItemList.length)
    				endArrIndex = this.txtItemList.length;
    			var selectedItemRow = this.dimensions.topLeftY+1;
    			screenY = this.dimensions.topLeftY + 1;
    			for (var i = startArrIndex; i < endArrIndex; ++i)
    			{
    				console.gotoxy(this.dimensions.topLeftX+1, screenY);
    				if (i == retObj.selectedIndex)
    				{
    					printf(this.listIemHighlightFormatStr, this.txtItemList[i].substr(0, maxItemWidth));
    					selectedItemRow = screenY;
    				}
    				else
    					printf(this.listIemFormatStr, this.txtItemList[i].substr(0, maxItemWidth));
    				++screenY;
    			}
    			// If the current screen row is below the bottom row inside the box,
    			// continue and write blank lines to the bottom of the inside of the box
    			// to blank out any text that might still be there.
    			while (screenY < this.dimensions.topLeftY+this.dimensions.height-1)
    			{
    				console.gotoxy(this.dimensions.topLeftX+1, screenY);
    				printf(this.listIemFormatStr, "");
    				++screenY;
    			}
    
    			// Update the page number in the top border of the box.
    			console.gotoxy(pageNumTxtStartX, this.dimensions.topLeftY);
    			printf("\1n" + this.SlyEdCfgObj.genColors.listBoxBorderText + "Page %4d of %4d", pageNum+1, numPages);
    
    			// Just for sane appearance: Move the cursor to the first character of
    			// the currently-selected row and set the appropriate color.
    			curpos.x = this.dimensions.topLeftX+1;
    			curpos.y = selectedItemRow;
    			console.gotoxy(curpos.x, curpos.y);
    			console.print(this.SlyEdCfgObj.genColors.listBoxItemHighlight);
    
    			refreshList = false;
    		}
    
    		// Get a key from the user (upper-case) and take action based upon it.
    		retObj.lastKeypress = getKeyWithESCChars(K_UPPER|K_NOCRLF|K_NOSPIN, this.SlyEdCfgObj);
    		switch (retObj.lastKeypress)
    		{
    			case 'N': // Next page
    			case KEY_PAGE_DOWN:
    				refreshList = (pageNum < numPages-1);
    				if (refreshList)
    				{
    					++pageNum;
    					this.topItemIndex += numItemsPerPage;
    					this.chosenTextItemIndex = retObj.selectedIndex = this.topItemIndex;
    					// Note: this.bottomItemIndex is refreshed at the top of the loop
    				}
    				break;
    			case 'P': // Previous page
    			case KEY_PAGE_UP:
    				refreshList = (pageNum > 0);
    				if (refreshList)
    				{
    					--pageNum;
    					this.topItemIndex -= numItemsPerPage;
    					this.chosenTextItemIndex = retObj.selectedIndex = this.topItemIndex;
    					// Note: this.bottomItemIndex is refreshed at the top of the loop
    				}
    				break;
    			case 'F': // First page
    				refreshList = (pageNum > 0);
    				if (refreshList)
    				{
    					pageNum = 0;
    					this.topItemIndex = 0;
    					this.chosenTextItemIndex = retObj.selectedIndex = this.topItemIndex;
    					// Note: this.bottomItemIndex is refreshed at the top of the loop
    				}
    				break;
    			case 'L': // Last page
    				refreshList = (pageNum < numPages-1);
    				if (refreshList)
    				{
    					pageNum = numPages-1;
    					this.topItemIndex = topIndexForLastPage;
    					this.chosenTextItemIndex = retObj.selectedIndex = this.topItemIndex;
    					// Note: this.bottomItemIndex is refreshed at the top of the loop
    				}
    				break;
    			case KEY_UP:
    				// Move the cursor up one item
    				if (retObj.selectedIndex > 0)
    				{
    					// If the previous item index is on the previous page, then we'll
    					// want to display the previous page.
    					var previousItemIndex = retObj.selectedIndex - 1;
    					if (previousItemIndex < this.topItemIndex)
    					{
    						--pageNum;
    						this.topItemIndex -= numItemsPerPage;
    						// Note: this.bottomItemIndex is refreshed at the top of the loop
    						refreshList = true;
    					}
    					else
    					{
    						// Display the current line un-highlighted
    						console.gotoxy(this.dimensions.topLeftX+1, curpos.y);
    						printf(this.listIemFormatStr, this.txtItemList[retObj.selectedIndex].substr(0, maxItemWidth));
    						// Display the previous line highlighted
    						curpos.x = this.dimensions.topLeftX+1;
    						--curpos.y;
    						console.gotoxy(curpos);
    						printf(this.listIemHighlightFormatStr, this.txtItemList[previousItemIndex].substr(0, maxItemWidth));
    						console.gotoxy(curpos); // Move the cursor into place where it should be
    						refreshList = false;
    					}
    					this.chosenTextItemIndex = retObj.selectedIndex = previousItemIndex;
    				}
    				break;
    			case KEY_DOWN:
    				// Move the cursor down one item
    				if (retObj.selectedIndex < this.txtItemList.length - 1)
    				{
    					// If the next item index is on the next page, then we'll want to
    					// display the next page.
    					var nextItemIndex = retObj.selectedIndex + 1;
    					if (nextItemIndex > this.bottomItemIndex)
    					{
    						++pageNum;
    						this.topItemIndex += numItemsPerPage;
    						// Note: this.bottomItemIndex is refreshed at the top of the loop
    						refreshList = true;
    					}
    					else
    					{
    						// Display the current line un-highlighted
    						console.gotoxy(this.dimensions.topLeftX+1, curpos.y);
    						printf(this.listIemFormatStr, this.txtItemList[retObj.selectedIndex].substr(0, maxItemWidth));
    						// Display the previous line highlighted
    						curpos.x = this.dimensions.topLeftX+1;
    						++curpos.y;
    						console.gotoxy(curpos);
    						printf(this.listIemHighlightFormatStr, this.txtItemList[nextItemIndex].substr(0, maxItemWidth));
    						console.gotoxy(curpos); // Move the cursor into place where it should be
    						refreshList = false;
    					}
    					this.chosenTextItemIndex = retObj.selectedIndex = nextItemIndex;
    				}
    				break;
    			case KEY_HOME: // Go to the first row in the box
    				if (retObj.selectedIndex > this.topItemIndex)
    				{
    					// Display the current line un-highlighted
    					console.gotoxy(this.dimensions.topLeftX+1, curpos.y);
    					printf(this.listIemFormatStr, this.txtItemList[retObj.selectedIndex].substr(0, maxItemWidth));
    					// Select the top item, and display it highlighted.
    					this.chosenTextItemIndex = retObj.selectedIndex = this.topItemIndex;
    					curpos.x = this.dimensions.topLeftX+1;
    					curpos.y = this.dimensions.topLeftY+1;
    					console.gotoxy(curpos);
    					printf(this.listIemHighlightFormatStr, this.txtItemList[retObj.selectedIndex].substr(0, maxItemWidth));
    					console.gotoxy(curpos); // Move the cursor into place where it should be
    					refreshList = false;
    				}
    				break;
    			case KEY_END: // Go to the last row in the box
    				if (retObj.selectedIndex < this.bottomItemIndex)
    				{
    					// Display the current line un-highlighted
    					console.gotoxy(this.dimensions.topLeftX+1, curpos.y);
    					printf(this.listIemFormatStr, this.txtItemList[retObj.selectedIndex].substr(0, maxItemWidth));
    					// Select the bottommost item, and display it highlighted.
    					this.chosenTextItemIndex = retObj.selectedIndex = this.bottomItemIndex;
    					curpos.x = this.dimensions.topLeftX+1;
    					curpos.y = this.dimensions.bottomRightY-1;
    					console.gotoxy(curpos);
    					printf(this.listIemHighlightFormatStr, this.txtItemList[retObj.selectedIndex].substr(0, maxItemWidth));
    					console.gotoxy(curpos); // Move the cursor into place where it should be
    					refreshList = false;
    				}
    				break;
    			case KEY_ENTER:
    				// If the enter key override function is set, then call it and pass
    				// this object into it.  Otherwise, just select the item and quit.
    				if (this.enterKeyOverrideFn !== null)
    				this.enterKeyOverrideFn(this);
    				else
    				{
    					retObj.itemWasSelected = true;
    					// Note: retObj.selectedIndex is already set.
    					retObj.selectedItem = this.txtItemList[retObj.selectedIndex];
    					refreshList = false;
    					continueOn = false;
    				}
    				break;
    			case KEY_ESC: // Quit
    			case CTRL_A:  // Quit
    			case 'Q':     // Quit
    				this.chosenTextItemIndex = retObj.selectedIndex = -1;
    				refreshList = false;
    				continueOn = false;
    				break;
    			default:
    				// If the keypress is an additional key to exit the input loop, then
    				// do so.
    				if (this.inputLoopExitKeys.hasOwnProperty(retObj.lastKeypress))
    				{
    					this.chosenTextItemIndex = retObj.selectedIndex = -1;
    					refreshList = false;
    					continueOn = false;
    				}
    				else
    				{
    					// Unrecognized command.  Don't refresh the list of the screen.
    					refreshList = false;
    				}
    				break;
    		}
    	}
    
    	console.print("\1n"); // To prevent outputting highlight colors, etc..
    	return retObj;
    }
    
    
    ///////////////////////////////////////////////////////////////////////////////////
    // Functions
    
    // This function takes a string and returns a copy of the string
    // with a color randomly alternating between dim & bright versions.
    //
    // Parameters:
    //  pString: The string to convert
    //  pColor: The color to use (Synchronet color code)
    function randomDimBrightString(pString, pColor)
    {
    	// Return if an invalid string is passed in.
    	if (pString == null)
    		return "";
    	if (typeof(pString) != "string")
    		return "";
    
       // Set the color.  Default to green.
    	var color = "g";
    	if ((pColor != null) && (typeof(pColor) != "undefined"))
          color = pColor;
    
       return(randomTwoColorString(pString, "n" + color, "nh" + color));
    }
    
    // This function takes a string and returns a copy of the string
    // with colors randomly alternating between two given colors.
    //
    // Parameters:
    //  pString: The string to convert
    //  pColor11: The first color to use (Synchronet color code)
    //  pColor12: The second color to use (Synchronet color code)
    function randomTwoColorString(pString, pColor1, pColor2)
    {
    	// Return if an invalid string is passed in.
    	if (pString == null)
    		return "";
    	if (typeof(pString) != "string")
    		return "";
    
    	// Set the colors.  Default to green.
    	var color1 = "ng";
    	if ((pColor1 != null) && (typeof(pColor1) != "undefined"))
          color1 = pColor1;
       var color2 = "ngh";
    	if ((pColor2 != null) && (typeof(pColor2) != "undefined"))
          color2 = pColor2;
    
    	// Create a copy of the string without any control characters,
    	// and then add our coloring to it.
    	pString = strip_ctrl(pString);
    	var returnString = color1;
    	var useColor1 = false;     // Whether or not to use the useColor1 version of the color1
    	var oldUseColor1 = useColor1; // The value of useColor1 from the last pass
    	for (var i = 0; i < pString.length; ++i)
    	{
    		// Determine if this character should be useColor1
    		useColor1 = (Math.floor(Math.random()*2) == 1);
    		if (useColor1 != oldUseColor1)
             returnString += (useColor1 ? color1 : color2);
    
    		// Append the character from pString.
    		returnString += pString.charAt(i);
    
    		oldUseColor1 = useColor1;
    	}
    
    	return returnString;
    }
    
    // Returns the current time as a string, to be displayed on the screen.
    function getCurrentTimeStr()
    {
    	var timeStr = strftime("%I:%M%p", time());
    	timeStr = timeStr.replace("AM", "a");
    	timeStr = timeStr.replace("PM", "p");
    	
    	return timeStr;
    }
    
    // Returns whether or not a character is printable.
    function isPrintableChar(pText)
    {
       // Make sure pText is valid and is a string.
       if (typeof(pText) != "string")
          return false;
       if (pText.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 = pText.charCodeAt(0);
       return ((charCode > 31) && (charCode < 255) && (charCode != 127));
    }
    
    // Removes multiple, leading, and/or trailing spaces
    // The search & replace regular expressions used in this
    // function came from the following URL:
    // http://qodo.co.uk/blog/javascript-trim-leading-and-trailing-spaces
    //
    // Parameters:
    //  pString: The string to trim
    //  pLeading: Whether or not to trim leading spaces (optional, defaults to true)
    //  pMultiple: Whether or not to trim multiple spaces (optional, defaults to true)
    //  pTrailing: Whether or not to trim trailing spaces (optional, defaults to true)
    //
    // Return value: The trimmed string
    function trimSpaces(pString, pLeading, pMultiple, pTrailing)
    {
    	// Make sure pString is a string.
    	if (typeof(pString) == "string")
    	{
    		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;
    }
    
    // Displays the text to display above help screens.
    function displayHelpHeader()
    {
       // Construct the header text lines only once.
       if (typeof(displayHelpHeader.headerLines) == "undefined")
       {
          displayHelpHeader.headerLines = new Array();
    
          var headerText = EDITOR_PROGRAM_NAME + " Help w(y"
                          + (EDITOR_STYLE == "DCT" ? "DCT" : "Ice")
                          + " modew)";
          var headerTextLen = strip_ctrl(headerText).length;
    
          // Top border
          var headerTextStr = "nhc" + UPPER_LEFT_SINGLE;
          for (var i = 0; i < headerTextLen + 2; ++i)
             headerTextStr += HORIZONTAL_SINGLE;
          headerTextStr += UPPER_RIGHT_SINGLE;
          displayHelpHeader.headerLines.push(headerTextStr);
    
          // Middle line: Header text string
          headerTextStr = VERTICAL_SINGLE + "4y " + headerText + " nhc"
                        + VERTICAL_SINGLE;
          displayHelpHeader.headerLines.push(headerTextStr);
    
          // Lower border
          headerTextStr = LOWER_LEFT_SINGLE;
          for (var i = 0; i < headerTextLen + 2; ++i)
             headerTextStr += HORIZONTAL_SINGLE;
          headerTextStr += LOWER_RIGHT_SINGLE;
          displayHelpHeader.headerLines.push(headerTextStr);
       }
    
       // Print the header strings
       for (var index in displayHelpHeader.headerLines)
          console.center(displayHelpHeader.headerLines[index]);
    }
    
    // Displays the command help.
    //
    // Parameters:
    //  pDisplayHeader: Whether or not to display the help header.
    //  pClear: Whether or not to clear the screen first
    //  pPause: Whether or not to pause at the end
    //  pCanCrossPost: Whether or not cross-posting is enabled
    //  pIsSysop: Whether or not the user is the sysop.
    //  pTxtReplacments: Whether or not the text replacements feature is enabled
    //  pUserSettings: Whether or not the user settings feature is enabled
    function displayCommandList(pDisplayHeader, pClear, pPause, pCanCrossPost, pIsSysop,
                                 pTxtReplacments, pUserSettings)
    {
    	if (pClear)
    		console.clear("\1n");
    	if (pDisplayHeader)
    	{
    		displayHelpHeader();
    		console.crlf();
    	}
    
    	var isSysop = (pIsSysop != null ? pIsSysop : user.compare_ars("SYSOP"));
    
    	// This function displays a key and its description with formatting & colors.
    	//
    	// Parameters:
    	//  pKey: The key description
    	//  pDesc: The description of the key's function
    	//  pCR: Whether or not to display a carriage return (boolean).  Optional;
    	//       if not specified, this function won't display a CR.
    	function displayCmdKeyFormatted(pKey, pDesc, pCR)
    	{
    		printf("\1c\1h%-13s\1g: \1n\1c%s", pKey, pDesc);
    		if (pCR)
    			console.crlf();
    	}
    	// This function does the same, but outputs 2 on the same line.
    	function displayCmdKeyFormattedDouble(pKey, pDesc, pKey2, pDesc2, pCR)
    	{
    		var sepChar1 = ":";
    		var sepChar2 = ":";
    		if ((pKey.length == 0) && (pDesc.length == 0))
    			sepChar1 = " ";
    		if ((pKey2.length == 0) && (pDesc2.length == 0))
    			sepChar2 = " ";
    		printf("\1c\1h%-13s\1g" + sepChar1 + " \1n\1c%-28s \1k\1h" + VERTICAL_SINGLE +
    		       " \1c\1h%-8s\1g" + sepChar2 + " \1n\1c%s", pKey, pDesc, pKey2, pDesc2);
    		if (pCR)
    			console.crlf();
    	}
    
    	// Help keys and slash commands
    	printf("\1n\1g%-44s  %-33s\r\n", "Help keys", "Slash commands (on blank line)");
    	printf("\1k\1h%-44s  %-33s\r\n", "", "");
    	displayCmdKeyFormattedDouble("Ctrl-G", "General help", "/A", "Abort", true);
    	displayCmdKeyFormattedDouble("Ctrl-L", "Command key list (this list)", "/S", "Save", true);
    	displayCmdKeyFormattedDouble("Ctrl-R", "Program information", "/Q", "Quote message", true);
    	if (pTxtReplacments)
    		displayCmdKeyFormattedDouble("Ctrl-T", "List text replacements", "/T", "List text replacements", true);
    	if (pUserSettings)
    		displayCmdKeyFormattedDouble("", "", "/U", "Your settings", true);
    	if (pCanCrossPost)
    		displayCmdKeyFormattedDouble("", "", "/C", "Cross-post selection", true);
    	printf(" \1c\1h%-7s\1g  \1n\1c%s", "", "", "/?", "Show help");
    	console.crlf();
    	// Command/edit keys
    	console.print("\1n\1gCommand/edit keys\r\n\1k\1h\r\n");
    	displayCmdKeyFormattedDouble("Ctrl-A", "Abort message", "PageUp", "Page up", true);
    	displayCmdKeyFormattedDouble("Ctrl-Z", "Save message", "PageDown", "Page down", true);
    	displayCmdKeyFormattedDouble("Ctrl-Q", "Quote message", "Ctrl-S", "Search for text", true);
    	displayCmdKeyFormattedDouble("Insert/Ctrl-I", "Toggle insert/overwrite mode",
    	                             "Ctrl-D", "Delete line", true);
    	if (pCanCrossPost)
    		displayCmdKeyFormattedDouble("ESC", "Command menu", "Ctrl-C", "Cross-post selection", true);
    	else
    		displayCmdKeyFormatted("ESC", "Command menu", true);
    	if (isSysop)
    		displayCmdKeyFormattedDouble("Ctrl-O", "Import a file", "Ctrl-X", "Export to file", true);
    
    	if (pUserSettings)
    		displayCmdKeyFormatted("Ctrl-U", "Your settings", true);
    
    	if (pPause)
    	{
    		// TODO: I doubt this needs consolePauseWithESCChars() anymore..
    		// Should be able to use console.pause(), which easily supports
    		// custom pause scripts being loaded.
    		//consolePauseWithESCChars();
    		console.pause();
    	}
    }
    
    // Displays the general help screen.
    //
    // Parameters:
    //  pDisplayHeader: Whether or not to display the help header.
    //  pClear: Whether or not to clear the screen first
    //  pPause: Whether or not to pause at the end
    function displayGeneralHelp(pDisplayHeader, pClear, pPause)
    {
       if (pClear)
          console.clear("\1n");
       if (pDisplayHeader)
          displayHelpHeader();
    
       console.print("\1n\1cSlyEdit is a full-screen message editor that mimics the look & feel of\r\n");
       console.print("IceEdit or DCT Edit, two popular editors.  The editor is currently in " +
                     (EDITOR_STYLE == "DCT" ? "DCT" : "Ice") + "\r\nmode.\r\n");
       console.print("At the top of the screen, information about the message being written (or\r\n");
       console.print("file being edited) is displayed.  The middle section is the edit area,\r\n");
       console.print("where the message/file is edited.  Finally, the bottom section displays\r\n");
       console.print("some of the most common keys and/or status.");
       console.crlf();
       if (pPause)
          console.pause();
    }
    
    // Displays program information.
    //
    // Parameters:
    //  pClear: Whether or not to clear the screen first
    //  pPause: Whether or not to pause at the end
    function displayProgramInfo(pClear, pPause)
    {
       if (pClear)
          console.clear("n");
    
       // Print the program information
       console.center("nhc" + EDITOR_PROGRAM_NAME + "n cVersion g" +
                      EDITOR_VERSION + " wh(b" + EDITOR_VER_DATE + "w)");
       console.center("ncby Eric Oulashin");
       console.crlf();
       console.print("ncSlyEdit is a full-screen message editor for Synchronet that mimics the look &\r\n");
       console.print("feel of IceEdit or DCT Edit.");
       console.crlf();
       if (pPause)
          console.pause();
    }
    
    // Displays the informational screen for the program exit.
    //
    // Parameters:
    //  pClearScreen: Whether or not to clear the screen.
    function displayProgramExitInfo(pClearScreen)
    {
    	if (pClearScreen)
    		console.clear("n");
    
    	/*console.print("ncYou have been using:\r\n");
    	console.print("hk70\r\n");
    	console.print("7 nb7                    hk0\r\n");
    	console.print("7 nb7             hk0\r\n");
    	console.print("7     nb7                    hk0\r\n");
    	console.print("7 nb7             hk0\r\n");
    	console.print("7         nb7                       hk0\r\n");
    	console.print("7        nb7                         hk0\r\n");
    	console.print("\r\n");
    	console.print("ngVersion hy" + EDITOR_VERSION + " nm(" +
    	              EDITOR_VER_DATE + ")");*/
    	console.print("ncYou have been using hSlyEdit ncversion g" + EDITOR_VERSION +
    	              " nm(" + EDITOR_VER_DATE + ")");
    	console.crlf();
    	console.print("ncby Eric Oulashin of chDncigital hDncistortion hBncBS");
    	console.crlf();
    	console.crlf();
    	console.print("ncAcknowledgements for look & feel go to the following people:");
    	console.crlf();
    	console.print("Dan Traczynski: Creator of DCT Edit");
    	console.crlf();
    	console.print("Jeremy Landvoigt: Original creator of IceEdit");
    	console.crlf();
    }
    
    // Writes some text on the screen at a given location with a given pause.
    //
    // Parameters:
    //  pX: The column number on the screen at which to write the message
    //  pY: The row number on the screen at which to write the message
    //  pText: The text to write
    //  pPauseMS: The pause time, in milliseconds
    //  pClearLineAttrib: Optional - The color/attribute to clear the line with.
    //                    If not specified, defaults to normal attribute.
    function writeWithPause(pX, pY, pText, pPauseMS, pClearLineAttrib)
    {
       var clearLineAttrib = "n";
       if ((pClearLineAttrib != null) && (typeof(pClearLineAttrib) == "string"))
          clearLineAttrib = pClearLineAttrib;
       console.gotoxy(pX, pY);
       console.cleartoeol(clearLineAttrib);
       console.print(pText);
       mswait(pPauseMS);
    }
    
    // Prompts the user for a yes/no question.
    //
    // Parameters:
    //  pQuestion: The question to ask the user
    //  pDefaultYes: Boolean - Whether or not the default should be Yes.
    //               For false, the default will be No.
    //  pBoxTitle: For DCT mode, this specifies the title to use for the
    //             prompt box.  This is optional; if this is left out,
    //             the prompt box title will default to "Prompt".
    // pIceRefreshForBothAnswers: In Ice mode, whether or not to refresh the bottom
    //                            line on the screen for a "yes" as well as "no"
    //                            answer.  This is optional.  By default, only
    //                            refreshes for a "no" answer.
    //
    // Return value: Boolean - true for a "Yes" answer, false for "No"
    function promptYesNo(pQuestion, pDefaultYes, pBoxTitle, pIceRefreshForBothAnswers)
    {
       var userResponse = pDefaultYes;
    
       if (EDITOR_STYLE == "DCT")
       {
          // We need to create an object of parameters to pass to the DCT-style
          // Yes/No function.
          var paramObj = new AbortConfirmFuncParams();
          paramObj.editLinesIndex = gEditLinesIndex;
          if (typeof(pBoxTitle) == "string")
             userResponse = promptYesNo_DCTStyle(pQuestion, pBoxTitle, pDefaultYes, paramObj);
          else
             userResponse = promptYesNo_DCTStyle(pQuestion, "Prompt", pDefaultYes, paramObj);
       }
       else if (EDITOR_STYLE == "ICE")
       {
          const originalCurpos = console.getxy();
          // Go to the bottom line on the screen and prompt the user
          console.gotoxy(1, console.screen_rows);
          console.cleartoeol();
          console.gotoxy(1, console.screen_rows);
          userResponse = promptYesNo_IceStyle(pQuestion, pDefaultYes);
          // If the user chose "No", or if we are to refresh the bottom line
          // regardless, then re-display the bottom help line and move the
          // cursor back to its original position.
          if (pIceRefreshForBothAnswers || !userResponse)
          {
             fpDisplayBottomHelpLine(console.screen_rows, gUseQuotes);
             console.gotoxy(originalCurpos);
          }
       }
    
       return userResponse;
    }
    
    // Reads the SlyEdit configuration settings from SlyEdit.cfg.
    //
    // Return value: An object containing the settings as properties.
    function ReadSlyEditConfigFile()
    {
    	var cfgObj = new Object(); // Configuration object
    
    	cfgObj.userIsSysop = user.compare_ars("SYSOP"); // Whether or not the user is a sysop
    	// Default settings
    	cfgObj.thirdPartyLoadOnStart = new Array();
    	cfgObj.runJSOnStart = new Array();
    	cfgObj.thirdPartyLoadOnExit = new Array();
    	cfgObj.runJSOnExit = new Array();
    	cfgObj.displayEndInfoScreen = true;
    	cfgObj.userInputTimeout = true;
    	cfgObj.inputTimeoutMS = 300000;
    	cfgObj.reWrapQuoteLines = true;
    	cfgObj.allowColorSelection = true;
    	cfgObj.useQuoteLineInitials = true;
    	cfgObj.indentQuoteLinesWithInitials = true;
    	cfgObj.allowCrossPosting = true;
    	cfgObj.enableTextReplacements = false;
    	cfgObj.textReplacementsUseRegex = false;
    	cfgObj.enableTaglines = false;
    	cfgObj.tagLineFilename = genFullPathCfgFilename("SlyEdit_Taglines.txt", gStartupPath);
    	cfgObj.taglinePrefix = "... ";
    	cfgObj.quoteTaglines = false;
    	cfgObj.shuffleTaglines = false;
    	cfgObj.allowUserSettings = true;
    	cfgObj.allowEditQuoteLines = true;
    
    	// General SlyEdit color settings
    	cfgObj.genColors = new Object();
    	// Cross-posting UI element colors
    	cfgObj.genColors.listBoxBorder = "\1n\1g";
    	cfgObj.genColors.listBoxBorderText = "\1n\1b\1h";
    	cfgObj.genColors.crossPostMsgAreaNum = "\1n\1h\1w";
    	cfgObj.genColors.crossPostMsgAreaNumHighlight = "\1n\1" + "4\1h\1w";
    	cfgObj.genColors.crossPostMsgAreaDesc = "\1n\1c";
    	cfgObj.genColors.crossPostMsgAreaDescHighlight = "\1n\1" + "4\1c";
    	cfgObj.genColors.crossPostChk = "\1n\1h\1y";
    	cfgObj.genColors.crossPostChkHighlight = "\1n\1" + "4\1h\1y";
    	cfgObj.genColors.crossPostMsgGrpMark = "\1n\1h\1g";
    	cfgObj.genColors.crossPostMsgGrpMarkHighlight = "\1n\1" + "4\1h\1g";
    	// Colors for certain output strings
    	cfgObj.genColors.msgWillBePostedHdr = "\1n\1c";
    	cfgObj.genColors.msgPostedGrpHdr = "\1n\1h\1b";
    	cfgObj.genColors.msgPostedSubBoardName = "\1n\1g";
    	cfgObj.genColors.msgPostedOriginalAreaText = "\1n\1c";
    	cfgObj.genColors.msgHasBeenSavedText = "\1n\1h\1c";
    	cfgObj.genColors.msgAbortedText = "\1n\1m\1h";
    	cfgObj.genColors.emptyMsgNotSentText = "\1n\1m\1h";
    	cfgObj.genColors.genMsgErrorText = "\1n\1m\1h";
    	cfgObj.genColors.listBoxItemText = "\1n\1c";
    	cfgObj.genColors.listBoxItemHighlight = "\1n\1" + "4\1w\1h";
    
    	// Default Ice-style colors
    	cfgObj.iceColors = new Object();
    	cfgObj.iceColors.menuOptClassicColors = true;
    	// Ice color theme file
    	cfgObj.iceColors.ThemeFilename = genFullPathCfgFilename("SlyIceColors_BlueIce.cfg", gStartupPath);
    	// Text edit color
    	cfgObj.iceColors.TextEditColor = "\1n\1w";
    	// Quote line color
    	cfgObj.iceColors.QuoteLineColor = "\1n\1c";
    	// Ice colors for the quote window
    	cfgObj.iceColors.QuoteWinText = "\1n\1h\1w";            // White
    	cfgObj.iceColors.QuoteLineHighlightColor = "\1" + "4\1h\1c"; // High cyan on blue background
    	cfgObj.iceColors.QuoteWinBorderTextColor = "\1n\1c\1h"; // Bright cyan
    	cfgObj.iceColors.BorderColor1 = "\1n\1b";              // Blue
    	cfgObj.iceColors.BorderColor2 = "\1n\1b\1h";          // Bright blue
    	// Ice colors for multi-choice prompts
    	cfgObj.iceColors.SelectedOptionBorderColor = "\1n\1b\1h\1" + "4";
    	cfgObj.iceColors.SelectedOptionTextColor = "\1n\1c\1h\1" + "4"
    	cfgObj.iceColors.UnselectedOptionBorderColor = "\1n\1b";
    	cfgObj.iceColors.UnselectedOptionTextColor = "\1n\1w";
    	// Ice colors for the top info area
    	cfgObj.iceColors.TopInfoBkgColor = "\1" + "4";
    	cfgObj.iceColors.TopLabelColor = "\1c\1h";
    	cfgObj.iceColors.TopLabelColonColor = "\1b\1h";
    	cfgObj.iceColors.TopToColor = "\1w\1h";
    	cfgObj.iceColors.TopFromColor = "\1w\1h";
    	cfgObj.iceColors.TopSubjectColor = "\1w\1h";
    	cfgObj.iceColors.TopTimeColor = "\1g\1h";
    	cfgObj.iceColors.TopTimeLeftColor = "\1g\1h";
    	cfgObj.iceColors.EditMode = "\1c\1h";
    	cfgObj.iceColors.KeyInfoLabelColor = "\1c\1h";
    
    	// Default DCT-style colors
    	cfgObj.DCTColors = new Object();
    	// DCT color theme file
    	cfgObj.DCTColors.ThemeFilename = genFullPathCfgFilename("SlyDCTColors_Default.cfg", gStartupPath);
    	// Text edit color
    	cfgObj.DCTColors.TextEditColor = "\1n\1w";
    	// Quote line color
    	cfgObj.DCTColors.QuoteLineColor = "\1n\1c";
    	// DCT colors for the border stuff
    	cfgObj.DCTColors.TopBorderColor1 = "\1n\1r";
    	cfgObj.DCTColors.TopBorderColor2 = "\1n\1r\1h";
    	cfgObj.DCTColors.EditAreaBorderColor1 = "\1n\1g";
    	cfgObj.DCTColors.EditAreaBorderColor2 = "\1n\1g\1h";
    	cfgObj.DCTColors.EditModeBrackets = "\1n\1k\1h";
    	cfgObj.DCTColors.EditMode = "\1n\1w";
    	// DCT colors for the top informational area
    	cfgObj.DCTColors.TopLabelColor = "\1n\1b\1h";
    	cfgObj.DCTColors.TopLabelColonColor = "\1n\1b";
    	cfgObj.DCTColors.TopFromColor = "\1n\1c\1h";
    	cfgObj.DCTColors.TopFromFillColor = "\1n\1c";
    	cfgObj.DCTColors.TopToColor = "\1n\1c\1h";
    	cfgObj.DCTColors.TopToFillColor = "\1n\1c";
    	cfgObj.DCTColors.TopSubjColor = "\1n\1w\1h";
    	cfgObj.DCTColors.TopSubjFillColor = "\1n\1w";
    	cfgObj.DCTColors.TopAreaColor = "\1n\1g\1h";
    	cfgObj.DCTColors.TopAreaFillColor = "\1n\1g";
    	cfgObj.DCTColors.TopTimeColor = "\1n\1y\1h";
    	cfgObj.DCTColors.TopTimeFillColor = "\1n\1r";
    	cfgObj.DCTColors.TopTimeLeftColor = "\1n\1y\1h";
    	cfgObj.DCTColors.TopTimeLeftFillColor = "\1n\1r";
    	cfgObj.DCTColors.TopInfoBracketColor = "\1n\1m";
    	// DCT colors for the quote window
    	cfgObj.DCTColors.QuoteWinText = "\1n\1" + "7\1k";
    	cfgObj.DCTColors.QuoteLineHighlightColor = "\1n\1w";
    	cfgObj.DCTColors.QuoteWinBorderTextColor = "\1n\1" + "7\1r";
    	cfgObj.DCTColors.QuoteWinBorderColor = "\1n\1k\1" + "7";
    	// DCT colors for the quote window
    	cfgObj.DCTColors.QuoteWinText = "\1n\1" + "7\1b";
    	cfgObj.DCTColors.QuoteLineHighlightColor = "\1n\1w";
    	cfgObj.DCTColors.QuoteWinBorderTextColor = "\1n\1" + "7\1r";
    	cfgObj.DCTColors.QuoteWinBorderColor = "\1n\1k\1" + "7";
    	// DCT colors for the bottom row help text
    	cfgObj.DCTColors.BottomHelpBrackets = "\1n\1k\1h";
    	cfgObj.DCTColors.BottomHelpKeys = "\1n\1r\1h";
    	cfgObj.DCTColors.BottomHelpFill = "\1n\1r";
    	cfgObj.DCTColors.BottomHelpKeyDesc = "\1n\1c";
    	// DCT colors for text boxes
    	cfgObj.DCTColors.TextBoxBorder = "\1n\1k\1" + "7";
    	cfgObj.DCTColors.TextBoxBorderText = "\1n\1r\1" + "7";
    	cfgObj.DCTColors.TextBoxInnerText = "\1n\1b\1" + "7";
    	cfgObj.DCTColors.YesNoBoxBrackets = "\1n\1k\1" + "7";
    	cfgObj.DCTColors.YesNoBoxYesNoText = "\1n\1w\1h\1" + "7";
    	// DCT colors for the menus
    	cfgObj.DCTColors.SelectedMenuLabelBorders = "\1n\1w";
    	cfgObj.DCTColors.SelectedMenuLabelText = "\1n\1k\1" + "7";
    	cfgObj.DCTColors.UnselectedMenuLabelText = "\1n\1w\1h";
    	cfgObj.DCTColors.MenuBorders = "\1n\1k\1" + "7";
    	cfgObj.DCTColors.MenuSelectedItems = "\1n\1w";
    	cfgObj.DCTColors.MenuUnselectedItems = "\1n\1k\1" + "7";
    	cfgObj.DCTColors.MenuHotkeys = "\1n\1w\1h\1" + "7";
    
    	// Open the SlyEdit configuration file
    	var slyEdCfgFileName = genFullPathCfgFilename("SlyEdit.cfg", gStartupPath);
    	var cfgFile = new File(slyEdCfgFileName);
    	if (cfgFile.open("r"))
    	{
    		var settingsMode = "behavior";
    		var fileLine = null;     // A line read from the file
    		var equalsPos = 0;       // Position of a = in the line
    		var commentPos = 0;      // Position of the start of a comment
    		var setting = null;      // A setting name (string)
    		var settingUpper = null; // Upper-case setting name
    		var value = null;        // A value for a setting (string), with spaces trimmed
    		var valueLiteral = null; // The value as it is in the config file, no processing
    		var valueUpper = null;   // Upper-cased value
    		while (!cfgFile.eof)
    		{
    			// Read the next line from the config file.
    			fileLine = cfgFile.readln(2048);
    
    			// fileLine should be a string, but I've seen some cases
    			// where for some reason it isn't.  If it's not a string,
    			// then continue onto the next line.
    			if (typeof(fileLine) != "string")
    				continue;
    
    			// If the line starts with with a semicolon (the comment
    			// character) or is blank, then skip it.
    			if ((fileLine.substr(0, 1) == ";") || (fileLine.length == 0))
    				continue;
    
    			// If in the "behavior" section, then set the behavior-related variables.
    			if (fileLine.toUpperCase() == "[BEHAVIOR]")
    			{
    				settingsMode = "behavior";
    				continue;
    			}
    			else if (fileLine.toUpperCase() == "[ICE_COLORS]")
    			{
    				settingsMode = "ICEColors";
    				continue;
    			}
    			else if (fileLine.toUpperCase() == "[DCT_COLORS]")
    			{
    				settingsMode = "DCTColors";
    				continue;
    			}
    
    			// If the line has a semicolon anywhere in it, then remove
    			// everything from the semicolon onward.
    			commentPos = fileLine.indexOf(";");
    			if (commentPos > -1)
    				fileLine = fileLine.substr(0, commentPos);
    
    			// Look for an equals sign, and if found, separate the line
    			// into the setting name (before the =) and the value (after the
    			// equals sign).
    			equalsPos = fileLine.indexOf("=");
    			if (equalsPos > 0)
    			{
    				// Read the setting & value, and trim leading & trailing spaces.
    				setting = trimSpaces(fileLine.substr(0, equalsPos), true, false, true);
    				settingUpper = setting.toUpperCase();
    				valueLiteral = fileLine.substr(equalsPos+1);
    				value = trimSpaces(valueLiteral, true, false, true);
    				valueUpper = value.toUpperCase();
    
    				if (settingsMode == "behavior")
    				{
    					if (settingUpper == "DISPLAYENDINFOSCREEN")
    						cfgObj.displayEndInfoScreen = (valueUpper == "TRUE");
    					else if (settingUpper == "USERINPUTTIMEOUT")
    						cfgObj.userInputTimeout = (valueUpper == "TRUE");
    					else if (settingUpper == "INPUTTIMEOUTMS")
    						cfgObj.inputTimeoutMS = +value;
    					else if (settingUpper == "REWRAPQUOTELINES")
    						cfgObj.reWrapQuoteLines = (valueUpper == "TRUE");
    					else if (settingUpper == "ALLOWCOLORSELECTION")
    						cfgObj.allowColorSelection = (valueUpper == "TRUE");
    					else if (settingUpper == "USEQUOTELINEINITIALS")
    						cfgObj.useQuoteLineInitials = (valueUpper == "TRUE");
    					else if (settingUpper == "INDENTQUOTELINESWITHINITIALS")
    						cfgObj.indentQuoteLinesWithInitials = (valueUpper == "TRUE");
    					else if (settingUpper == "ADD3RDPARTYSTARTUPSCRIPT")
    						cfgObj.thirdPartyLoadOnStart.push(value);
    					else if (settingUpper == "ADD3RDPARTYEXITSCRIPT")
    						cfgObj.thirdPartyLoadOnExit.push(value);
    					else if (settingUpper == "ADDJSONSTART")
    						cfgObj.runJSOnStart.push(value);
    					else if (settingUpper == "ADDJSONEXIT")
    						cfgObj.runJSOnExit.push(value);
    					else if (settingUpper == "ALLOWCROSSPOSTING")
    						cfgObj.allowCrossPosting = (valueUpper == "TRUE");
    					else if (settingUpper == "ENABLETEXTREPLACEMENTS")
    					{
    						// The enableTxtReplacements setting in the config file can
    						// be regex, true, or false:
    						//  - regex: Text replacement enabled using regular expressions
    						//  - true: Text replacement enabled using exact match
    						//  - false: Text replacement disabled
    						cfgObj.textReplacementsUseRegex = (valueUpper == "REGEX");
    						if (cfgObj.textReplacementsUseRegex)
    							cfgObj.enableTextReplacements = true;
    						else
    							cfgObj.enableTextReplacements = (valueUpper == "TRUE");
    					}
    					else if (settingUpper == "ENABLETAGLINES")
    						cfgObj.enableTaglines = (valueUpper == "TRUE");
    					else if (settingUpper == "TAGLINEFILENAME")
    						cfgObj.tagLineFilename = genFullPathCfgFilename(value, gStartupPath);
    					else if (settingUpper == "TAGLINEPREFIX")
    						cfgObj.taglinePrefix = valueLiteral;
    					else if (settingUpper == "QUOTETAGLINES")
    						cfgObj.quoteTaglines = (valueUpper == "TRUE");
    					else if (settingUpper == "SHUFFLETAGLINES")
    						cfgObj.shuffleTaglines = (valueUpper == "TRUE");
    					else if (settingUpper == "ALLOWUSERSETTINGS")
    						cfgObj.allowUserSettings = (valueUpper == "TRUE");
    					else if (settingUpper == "ALLOWEDITQUOTELINES")
    						cfgObj.allowEditQuoteLines = (valueUpper == "TRUE");
    				}
    				else if (settingsMode == "ICEColors")
    				{
    					if (settingUpper == "THEMEFILENAME")
    						cfgObj.iceColors.ThemeFilename = genFullPathCfgFilename(value, gStartupPath);
    					else if (settingUpper == "MENUOPTCLASSICCOLORS")
    						cfgObj.iceColors.menuOptClassicColors = (valueUpper == "TRUE");
    				}
    				else if (settingsMode == "DCTColors")
    				{
    					if (settingUpper == "THEMEFILENAME")
    						cfgObj.DCTColors.ThemeFilename = genFullPathCfgFilename(value, gStartupPath);
    				}
    			}
    		}
    
    		cfgFile.close();
    
    		// Validate the settings
    		if (cfgObj.inputTimeoutMS < 1000)
    			cfgObj.inputTimeoutMS = 300000;
    	}
    
    	return cfgObj;
    }
    
    // This function reads a configuration file containing
    // setting=value pairs and returns the settings in
    // an Object.
    //
    // Parameters:
    //  pFilename: The name of the configuration file.
    //  pLineReadLen: The maximum number of characters to read from each
    //                line.  This is optional; if not specified, then up
    //                to 512 characters will be read from each line.
    //
    // Return value: An Object containing the value=setting pairs.  If the
    //               file can't be opened or no settings can be read, then
    //               this function will return null.
    function readValueSettingConfigFile(pFilename, pLineReadLen)
    {
       var retObj = null;
    
       var cfgFile = new File(pFilename);
       if (cfgFile.open("r"))
       {
          // Set the number of characters to read per line.
          var numCharsPerLine = 512;
          if (pLineReadLen != null)
             numCharsPerLine = pLineReadLen;
    
          var fileLine = null;     // A line read from the file
          var equalsPos = 0;       // Position of a = in the line
          var commentPos = 0;      // Position of the start of a comment
          var setting = null;      // A setting name (string)
          var settingUpper = null; // Upper-case setting name
          var value = null;        // A value for a setting (string)
          var valueUpper = null;   // Upper-cased value
          while (!cfgFile.eof)
          {
             // Read the next line from the config file.
             fileLine = cfgFile.readln(numCharsPerLine);
    
             // fileLine should be a string, but I've seen some cases
             // where it isn't, so check its type.
             if (typeof(fileLine) != "string")
                continue;
    
             // If the line starts with with a semicolon (the comment
             // character) or is blank, then skip it.
             if ((fileLine.substr(0, 1) == ";") || (fileLine.length == 0))
                continue;
    
             // If the line has a semicolon anywhere in it, then remove
             // everything from the semicolon onward.
             commentPos = fileLine.indexOf(";");
             if (commentPos > -1)
                fileLine = fileLine.substr(0, commentPos);
    
             // Look for an equals sign, and if found, separate the line
             // into the setting name (before the =) and the value (after the
             // equals sign).
             equalsPos = fileLine.indexOf("=");
             if (equalsPos > 0)
             {
                // If retObj hasn't been created yet, then create it.
                if (retObj == null)
                   retObj = new Object();
    
                // Read the setting & value, and trim leading & trailing spaces.  Then
                // set the value in retObj.
                setting = trimSpaces(fileLine.substr(0, equalsPos), true, false, true);
                value = trimSpaces(fileLine.substr(equalsPos+1), true, false, true);
                retObj[setting] = value;
             }
          }
    
          cfgFile.close();
       }
    
       return retObj;
    }
    
    // Splits a string up by a maximum length, preserving whole words.
    //
    // Parameters:
    //  pStr: The string to split
    //  pMaxLen: The maximum length for the strings (strings longer than this
    //           will be split)
    //
    // Return value: An array of strings resulting from the string split
    function splitStrStable(pStr, pMaxLen)
    {
       var strings = new Array();
    
       // Error checking
       if (typeof(pStr) != "string")
       {
          console.print("1 - pStr not a string!\r\n");
          return strings;
       }
    
       // If the string's length is less than or equal to pMaxLen, then
       // just insert it into the strings array.  Otherwise, we'll
       // need to split it.
       if (pStr.length <= pMaxLen)
          strings.push(pStr);
       else
       {
          // Make a copy of pStr so that we don't modify it.
          var theStr = pStr;
    
          var tempStr = "";
          var splitIndex = 0; // Index of a space in a string
          while (theStr.length > pMaxLen)
          {
             // If there isn't a space at the pMaxLen location in theStr,
             // then assume there's a word there and look for a space
             // before it.
             splitIndex = pMaxLen;
             if (theStr.charAt(splitIndex) != " ")
             {
                splitIndex = theStr.lastIndexOf(" ", splitIndex);
                // If a space was not found, then we should split at
                // pMaxLen.
                if (splitIndex == -1)
                   splitIndex = pMaxLen;
             }
    
             // Extract the first part of theStr up to splitIndex into
             // tempStr, and then remove that part from theStr.
             tempStr = theStr.substr(0, splitIndex);
             theStr = theStr.substr(splitIndex+1);
    
             // If tempStr is not blank, then insert it into the strings
             // array.
             if (tempStr.length > 0)
                strings.push(tempStr);
          }
          // Edge case: If theStr is not blank, then insert it into the
          // strings array.
          if (theStr.length > 0)
             strings.push(theStr);
       }
    
       return strings;
    }
    
    // Inserts a string inside another string.
    //
    // Parameters:
    //  pStr: The string inside which to insert the other string
    //  pIndex: The index of pStr at which to insert the other string
    //  pStr2: The string to insert into the first string
    //
    // Return value: The spliced string
    function spliceIntoStr(pStr, pIndex, pStr2)
    {
       // Error checking
       var typeofPStr = typeof(pStr);
       var typeofPStr2 = typeof(pStr2);
       if ((typeofPStr != "string") && (typeofPStr2 != "string"))
          return "";
       else if ((typeofPStr == "string") && (typeofPStr2 != "string"))
          return pStr;
       else if ((typeofPStr != "string") && (typeofPStr2 == "string"))
          return pStr2;
       // If pIndex is beyond the last index of pStr, then just return the
       // two strings concatenated.
       if (pIndex >= pStr.length)
          return (pStr + pStr2);
       // If pIndex is below 0, then just return pStr2 + pStr.
       else if (pIndex < 0)
          return (pStr2 + pStr);
    
       return (pStr.substr(0, pIndex) + pStr2 + pStr.substr(pIndex));
    }
    
    // Fixes the text lines in the gEditLines array so that they all
    // have a maximum width to fit within the edit area.
    //
    // Parameters:
    //  pTextLineArray: An array of TextLine objects to adjust
    //  pStartIndex: The index of the line in the array to start at.
    //  pEndIndex: One past the last index of the line in the array to end at.
    //  pEditWidth: The width of the edit area (AKA the maximum line length + 1)
    //
    // Return value: Boolean - Whether or not any text was changed.
    function reAdjustTextLines(pTextLineArray, pStartIndex, pEndIndex, pEditWidth)
    {
       // Returns without doing anything if any of the parameters are not
       // what they should be. (Note: Not checking pTextLineArray for now..)
       if (typeof(pStartIndex) != "number")
          return false;
       if (typeof(pEndIndex) != "number")
          return false;
       if (typeof(pEditWidth) != "number")
          return false;
       // Range checking
       if ((pStartIndex < 0) || (pStartIndex >= pTextLineArray.length))
          return false;
       if ((pEndIndex <= pStartIndex) || (pEndIndex < 0))
          return false;
       if (pEndIndex > pTextLineArray.length)
          pEndIndex = pTextLineArray.length;
       if (pEditWidth <= 5)
          return false;
    
       var textChanged = false; // We'll return this upon function exit.
    
       var nextLineIndex = 0;
       var charsToRemove = 0;
       var splitIndex = 0;
       var spaceFound = false;      // Whether or not a space was found in a text line
       var splitIndexOriginal = 0;
       var tempText = null;
       var appendedNewLine = false; // If we appended another line
       for (var i = pStartIndex; i < pEndIndex; ++i)
       {
          // As an extra precaution, check to make sure this array element is defined.
          if (pTextLineArray[i] == undefined)
             continue;
    
          nextLineIndex = i + 1;
          // If the line's text is longer or equal to the edit width, then if
          // possible, move the last word to the beginning of the next line.
          if (pTextLineArray[i].text.length >= pEditWidth)
          {
             charsToRemove = pTextLineArray[i].text.length - pEditWidth + 1;
             splitIndex = pTextLineArray[i].text.length - charsToRemove;
             splitIndexOriginal = splitIndex;
             // If the character in the text line at splitIndex is not a space,
             // then look for a space before splitIndex.
             spaceFound = (pTextLineArray[i].text.charAt(splitIndex) == " ");
             if (!spaceFound)
             {
                splitIndex = pTextLineArray[i].text.lastIndexOf(" ", splitIndex-1);
                spaceFound = (splitIndex > -1);
                if (!spaceFound)
                   splitIndex = splitIndexOriginal;
             }
             tempText = pTextLineArray[i].text.substr(spaceFound ? splitIndex+1 : splitIndex);
             pTextLineArray[i].text = pTextLineArray[i].text.substr(0, splitIndex);
             textChanged = true;
             // If we're on the last line, or if the current line has a hard
             // newline or is a quote line, then append a new line below.
             appendedNewLine = false;
             if ((nextLineIndex == pTextLineArray.length) || pTextLineArray[i].hardNewlineEnd ||
                 isQuoteLine(pTextLineArray, i))
             {
                pTextLineArray.splice(nextLineIndex, 0, new TextLine());
                pTextLineArray[nextLineIndex].hardNewlineEnd = pTextLineArray[i].hardNewlineEnd;
                pTextLineArray[i].hardNewlineEnd = false;
                pTextLineArray[nextLineIndex].isQuoteLine = pTextLineArray[i].isQuoteLine;
                appendedNewLine = true;
             }
    
             // Move the text around and adjust the line properties.
             if (appendedNewLine)
                pTextLineArray[nextLineIndex].text = tempText;
             else
             {
                // If we're in insert mode, then insert the text at the beginning of
                // the next line.  Otherwise, overwrite the text in the next line.
                if (inInsertMode())
                   pTextLineArray[nextLineIndex].text = tempText + " " + pTextLineArray[nextLineIndex].text;
                else
                {
                   // We're in overwrite mode, so overwite the first part of the next
                   // line with tempText.
                   if (pTextLineArray[nextLineIndex].text.length < tempText.length)
                      pTextLineArray[nextLineIndex].text = tempText;
                   else
                   {
                      pTextLineArray[nextLineIndex].text = tempText
                                               + pTextLineArray[nextLineIndex].text.substr(tempText.length);
                   }
                }
             }
          }
          else
          {
             // pTextLineArray[i].text.length is < pEditWidth, so try to bring up text
             // from the next line.
    
             // Only do it if the line doesn't have a hard newline and it's not a
             // quote line and there is a next line.
             if (!pTextLineArray[i].hardNewlineEnd && !isQuoteLine(pTextLineArray, i) &&
                 (i < pTextLineArray.length-1))
             {
                if (pTextLineArray[nextLineIndex].text.length > 0)
                {
                   splitIndex = pEditWidth - pTextLineArray[i].text.length - 2;
                   // If splitIndex is negative, that means the entire next line
                   // can fit on the current line.
                   if ((splitIndex < 0) || (splitIndex > pTextLineArray[nextLineIndex].text.length))
                      splitIndex = pTextLineArray[nextLineIndex].text.length;
                   else
                   {
                      // If the character in the next line at splitIndex is not a
                      // space, then look for a space before it.
                      if (pTextLineArray[nextLineIndex].text.charAt(splitIndex) != " ")
                         splitIndex = pTextLineArray[nextLineIndex].text.lastIndexOf(" ", splitIndex);
                      // If no space was found, then skip to the next line (we don't
                      // want to break up words from the next line).
                      if (splitIndex == -1)
                         continue;
                   }
    
                   // Get the text to bring up to the current line.
                   // If the current line does not end with a space and the next line
                   // does not start with a space, then add a space between this line
                   // and the next line's text.  This is done to avoid joining words
                   // accidentally.
                   tempText = "";
                   if ((pTextLineArray[i].text.charAt(pTextLineArray[i].text.length-1) != " ") &&
                       (pTextLineArray[nextLineIndex].text.substr(0, 1) != " "))
                   {
                      tempText = " ";
                   }
                   tempText += pTextLineArray[nextLineIndex].text.substr(0, splitIndex);
                   // Move the text from the next line to the current line, if the current
                   // line has room for it.
                   if (pTextLineArray[i].text.length + tempText.length < pEditWidth)
                   {
                      pTextLineArray[i].text += tempText;
                      pTextLineArray[nextLineIndex].text = pTextLineArray[nextLineIndex].text.substr(splitIndex+1);
                      textChanged = true;
    
                      // If the next line is now blank, then remove it.
                      if (pTextLineArray[nextLineIndex].text.length == 0)
                      {
                         // The current line should take on the next line's
                         // hardnewlineEnd property before removing the next line.
                         pTextLineArray[i].hardNewlineEnd = pTextLineArray[nextLineIndex].hardNewlineEnd;
                         pTextLineArray.splice(nextLineIndex, 1);
                      }
                   }
                }
                else
                {
                   // The next line's text string is blank.  If its hardNewlineEnd
                   // property is false, then remove the line.
                   if (!pTextLineArray[nextLineIndex].hardNewlineEnd)
                   {
                      pTextLineArray.splice(nextLineIndex, 1);
                      textChanged = true;
                   }
                }
             }
          }
       }
    
       return textChanged;
    }
    
    // Returns indexes of the first unquoted text line and the next
    // quoted text line in an array of text lines.
    //
    // Parameters:
    //  pTextLineArray: An array of TextLine objects
    //  pStartIndex: The index of where to start looking in the array
    //  pQuotePrefix: The quote line prefix (string)
    //
    // Return value: An object containing the following properties:
    //               noQuoteLineIndex: The index of the next non-quoted line.
    //                                 Will be -1 if none are found.
    //               nextQuoteLineIndex: The index of the next quoted line.
    //                                   Will be -1 if none are found.
    function quotedLineIndexes(pTextLineArray, pStartIndex, pQuotePrefix)
    {
       var retObj = new Object();
       retObj.noQuoteLineIndex = -1;
       retObj.nextQuoteLineIndex = -1;
    
       if (pTextLineArray.length == 0)
          return retObj;
       if (typeof(pStartIndex) != "number")
          return retObj;
       if (pStartIndex >= pTextLineArray.length)
          return retObj;
    
       var startIndex = (pStartIndex > -1 ? pStartIndex : 0);
    
       // Look for the first non-quoted line in the array.
       retObj.noQuoteLineIndex = startIndex;
       for (; retObj.noQuoteLineIndex < pTextLineArray.length; ++retObj.noQuoteLineIndex)
       {
          if (pTextLineArray[retObj.noQuoteLineIndex].text.indexOf(pQuotePrefix) == -1)
             break;
       }
       // If the index is pTextLineArray.length, then what we're looking for wasn't
       // found, so set the index to -1.
       if (retObj.noQuoteLineIndex == pTextLineArray.length)
          retObj.noQuoteLineIndex = -1;
    
       // Look for the next quoted line in the array.
       // If we found a non-quoted line, then use that index; otherwise,
       // start at the first line.
       if (retObj.noQuoteLineIndex > -1)
          retObj.nextQuoteLineIndex = retObj.noQuoteLineIndex;
       else
          retObj.nextQuoteLineIndex = 0;
       for (; retObj.nextQuoteLineIndex < pTextLineArray.length; ++retObj.nextQuoteLineIndex)
       {
          if (pTextLineArray[retObj.nextQuoteLineIndex].text.indexOf(pQuotePrefix) == 0)
             break;
       }
       // If the index is pTextLineArray.length, then what we're looking for wasn't
       // found, so set the index to -1.
       if (retObj.nextQuoteLineIndex == pTextLineArray.length)
          retObj.nextQuoteLineIndex = -1;
    
       return retObj;
    }
    
    // Returns whether a line in an array of TextLine objects is a quote line.
    // This is true if the line's isQuoteLine property is true or the line's text
    // starts with > (preceded by any # of spaces).
    //
    // Parameters:
    //  pLineArray: An array of TextLine objects
    //  pLineIndex: The index of the line in gEditLines
    function isQuoteLine(pLineArray, pLineIndex)
    {
       if (typeof(pLineArray) == "undefined")
          return false;
       if (typeof(pLineIndex) != "number")
          return false;
    
       var lineIsQuoteLine = false;
       if (typeof(pLineArray[pLineIndex]) != "undefined")
       {
          /*
          lineIsQuoteLine = ((pLineArray[pLineIndex].isQuoteLine) ||
                         (/^ *>/.test(pLineArray[pLineIndex].text)));
          */
          lineIsQuoteLine = (pLineArray[pLineIndex].isQuoteLine);
       }
       return lineIsQuoteLine;
    }
    
    // Replaces an attribute in a text attribute string.
    //
    // Parameters:
    //  pAttrType: Numeric:
    //             FORE_ATTR: Foreground attribute
    //             BKG_ATTR: Background attribute
    //             3: Special attribute
    //  pAttrs: The attribute string to change
    //  pNewAttr: The new attribute to put into the attribute string (without the
    //            control character)
    function toggleAttr(pAttrType, pAttrs, pNewAttr)
    {
       // Removes an attribute from an attribute string, if it
       // exists.  Returns the new attribute string.
       function removeAttrIfExists(pAttrs, pNewAttr)
       {
          var index = pAttrs.search(pNewAttr);
          if (index > -1)
             pAttrs = pAttrs.replace(pNewAttr, "");
          return pAttrs;
       }
    
       // Convert pAttrs and pNewAttr to all uppercase for ease of searching
       pAttrs = pAttrs.toUpperCase();
       pNewAttr = pNewAttr.toUpperCase();
    
       // If pAttrs starts with the normal attribute, then
       // remove it (we'll put it back on later).
       var normalAtStart = false;
       if (pAttrs.search(/^N/) == 0)
       {
          normalAtStart = true;
          pAttrs = pAttrs.substr(2);
       }
    
       // Prepend the attribute control character to the new attribute
       var newAttr = "" + pNewAttr;
    
       // Set a regex for searching & replacing
       var regex = "";
       switch (pAttrType)
       {
          case FORE_ATTR: // Foreground attribute
             regex = /K|R|G|Y|B|M|C|W/g;
             break;
          case BKG_ATTR: // Background attribute
             regex = /0|1|2|3|4|5|6|7/g;
             break;
          case SPECIAL_ATTR: // Special attribute
             //regex = /H|I|N/g;
             index = pAttrs.search(newAttr);
             if (index > -1)
                pAttrs = pAttrs.replace(newAttr, "");
             else
                pAttrs += newAttr;
             break;
          default:
             break;
       }
    
       // If regex is not blank, then search & replace on it in
       // pAttrs.
       if (regex != "")
       {
          pAttrs = removeAttrIfExists(pAttrs, newAttr);
          // If the regex is found, then replace it.  Otherwise,
          // add pNewAttr to the attribute string.
          if (pAttrs.search(regex) > -1)
             pAttrs = pAttrs.replace(regex, "" + pNewAttr);
          else
             pAttrs += "" + pNewAttr;
       }
    
       // If pAttrs started with the normal attribute, then
       // put it back on.
       if (normalAtStart)
          pAttrs = "N" + pAttrs;
    
       return pAttrs;
    }
    
    // This function wraps an array of strings based on a line width.
    //
    // Parameters:
    //  pLineArr: An array of strings
    //  pStartLineIndex: The index of the text line in the array to start at
    //  pEndIndex: The index of where to stop in the array.  This is one past
    //             the last line in the array.  For example, to end at the
    //             last line in the array, use the array's .length property
    //             for this parameter.
    //  pLineWidth: The maximum width of each line
    //  pIdxesRequiringNL (OUT): Optional - An array to contain the indexes of original
    //                           wrapped lines that required a new line to be added.
    //  pLineInfos (IN/OUT): Optional - An array of lineInfo objects previously generated
    //                       for the unwrapped lines - This will be updated if lines are
    //                       wrapped.
    //
    // Return value: The number of new lines added
    function wrapTextLines(pLineArr, pStartLineIndex, pEndIndex, pLineWidth, pIdxesRequiringNL, pLineInfos)
    {
    	// Validate parameters
    	if (pLineArr == null)
    		return 0;
    	if (typeof(pLineWidth) != "number")
    		return 0;
    	if (pLineWidth < 0)
    		return 0;
    	if ((pStartLineIndex == null) || (typeof(pStartLineIndex) != "number") || (pStartLineIndex < 0))
    		pStartLineIndex = 0;
    	if (pStartLineIndex >= pLineArr.length)
    		return pLineArr.length;
    	if ((typeof(pEndIndex) != "number") || (pEndIndex == null) || (pEndIndex > pLineArr.length))
    		pEndIndex = pLineArr.length;
    
    	// Determine whether pIdxesRequiringNL is an array (actually, the most we can
    	// do is check whether it's an object).
    	var pNewLineIndexesIsArray = (typeof(pIdxesRequiringNL) == "object");
    	if (pNewLineIndexesIsArray)
    		pIdxesRequiringNL.length = 0;
    
    	// Wrap the text lines
    	var origNumLines = pLineArr.length; // So we can return the # of lines added
    	var trimLen = 0;   // The number of characters to trim from the end of a string
    	var trimIndex = 0; // The index of where to start trimming
    	for (var i = pStartLineIndex; i < pEndIndex; ++i)
    	{
    		// If the object in pLineArr is not a string for some reason, then skip it.
    		if (typeof(pLineArr[i]) != "string")
    			continue;
    
    		if (pLineArr[i].length > pLineWidth)
    		{
    			trimLen = pLineArr[i].length - pLineWidth;
    			trimIndex = pLineArr[i].lastIndexOf(" ", pLineArr[i].length - trimLen);
    			if (trimIndex == -1)
    				trimIndex = pLineArr[i].length - trimLen;
    			// Trim the text, and remove leading spaces from it too.
    			trimmedText = pLineArr[i].substr(trimIndex).replace(/^ +/, "");
    			pLineArr[i] = pLineArr[i].substr(0, trimIndex);
    			if (i < pLineArr.length - 1)
    			{
    				// Append a space to the end of the trimmed text if it doesn't have one.
    				if ((trimmedText.length > 0) && (trimmedText.charAt(trimmedText.length-1) != " "))
    					trimmedText += " "
    				// Prepend the trimmed text to the next line.  If the next line's index
    				// is within the paragraph we're wrapping, then go ahead and prepend the
    				// text to the next line.  Otherwise, add a new line to the array and
    				// add the text to the new line.
    				if (i+1 < pEndIndex)
    				{
    					var nextLineWasBlank = (pLineArr[i+1].length == 0);
    					pLineArr[i+1] = trimmedText + pLineArr[i+1];
    					if (nextLineWasBlank)
    						pLineArr.splice(i+2, 0, "");
    					// Copy the current line's lineInfo object to the next
    					// one in the array
    					if (typeof(pLineInfos) == "object")
    					{
    						if (pLineInfos.length > i+1)
    						{
    							pLineInfos[i+1].startIndex = pLineInfos[i].startIndex;
    							pLineInfos[i+1].quoteLevel = pLineInfos[i].quoteLevel;
    							pLineInfos[i+1].begOfLine = pLineInfos[i].begOfLine;
    						}
    						else
    						{
    							// pLineInfos doesn't have enough objects..  This probably
    							// shouldn't happen, as the caller should fill it up to
    							// the correct number of objects.
    							var numToAdd = (i+1) - pLineInfos.length + 1;
    							for (var idx = 0; idx < numToAdd; ++idx)
    								pLineInfos.push(getDefaultQuoteStrObj());
    						}
    						// If the next line was blank before adding text to it,
    						// then splice a new lineInfo object into pLineInfos as
    						// a copy of the lineInfo object before it.
    						if (nextLineWasBlank)
    						{
    							pLineInfos.splice(i+2, 0, getDefaultQuoteStrObj());
    							pLineInfos[i+2].startIndex = pLineInfos[i+1].startIndex;
    							pLineInfos[i+2].quoteLevel = pLineInfos[i+1].quoteLevel;
    							pLineInfos[i+2].begOfLine = pLineInfos[i+1].begOfLine;
    						}
    					}
    				}
    				else
    				{
    					// Add the trimmed text on a new line in the array.  Then, if the
    					// trimmed text's length is longer then the allowed line width, then
    					// we'll want to extend the end index so we can continue wrapping the
    					// lines in the current paragraph.  Otherwise, add the current line's
    					// index to the array of lines requiring a newline.
    					pLineArr.splice(i+1, 0, trimmedText);
    					if (trimmedText.length > pLineWidth)
    						++pEndIndex;
    					else
    					{
    						if (pNewLineIndexesIsArray)
    							pIdxesRequiringNL.push(i);
    					}
    					// Append a lineInfo object to pLineInfos as a copy of the
    					// last one in the array.
    					if (typeof(pLineInfos) == "object")
    					{
    						// Save the last lineInfo object's values
    						var lastLineInfoObj = {
    							startIndex: pLineInfos[pLineInfos.length-1].startIndex,
    							quoteLevel: pLineInfos[pLineInfos.length-1].quoteLevel,
    							begOfLine: pLineInfos[pLineInfos.length-1].begOfLine
    						};
    						// Append a new lineInfo object to pLineInfos and copy
    						// the last one's values into it
    						pLineInfos.push(getDefaultQuoteStrObj());
    						pLineInfos[pLineInfos.length-1].startIndex = lastLineInfoObj.startIndex;
    						pLineInfos[pLineInfos.length-1].quoteLevel = lastLineInfoObj.quoteLevel;
    						pLineInfos[pLineInfos.length-1].begOfLine = lastLineInfoObj.begOfLine;
    					}
    				}
    			}
    			else
    			{
    				// Remove any leading spaces 
    				pLineArr.push(trimmedText);
    				// If the current line index is before the specified end index, then
    				// increment the end index since we've added a line in order to continue
    				// wrapping the lines.
    				if (i < pEndIndex-1)
    					++pEndIndex;
    
    				if (pNewLineIndexesIsArray)
    					pIdxesRequiringNL.push(i);
    			}
    		}
    	}
    
    	return(pLineArr.length - origNumLines);
    }
    
    // Returns an object containing default quote string information.
    //
    // Return value: An object containing the following properties:
    //               startIndex: The index of the first non-quote character in the string.
    //                           Defaults to -1.
    //               quoteLevel: The number of > characters at the start of the string
    //               begOfLine: Normally, the quote text at the beginng of the line.
    //                          This defaults to a blank string.
    function getDefaultQuoteStrObj()
    {
    	var retObj = {
    		startIndex: -1,
    		quoteLevel: 0,
    		begOfLine: "", // Will store the beginning of the line, before the >
    		copy: function(pThatQuoteStrObj) {
    			this.startIndex = pThatQuoteStrObj.startIndex;
    			this.quoteLevel = pThatQuoteStrObj.quoteLevel;
    			this.begOfLine = pThatQuoteStrObj.begOfLine;
    		}
    	};
    	return retObj;
    }
    
    // Searches a string for the index of the first non-quote character; also finds
    // the quote level (number of times quoted) and the beginning-of-line text (the
    // text before the quote characters).
    //
    // Parameters:
    //  pStr: A string to check
    //  pUseAuthorInitials: Whether or not SlyEdit is configured to prefix
    //                      quote lines with author's initials
    //  pIndentQuoteLinesWithInitials: Whether or not indenting is enabled for
    //                                 quote lines with initials
    //
    // Return value: An object containing the following properties:
    //               startIndex: The index of the first non-quote character in the string.
    //                           If pStr is an invalid string, or if a non-quote character
    //                           is not found, this will be -1.
    //               quoteLevel: The number of > characters at the start of the string
    //               begOfLine: The quote text at the beginning of the line
    function firstNonQuoteTxtIndex(pStr, pUseAuthorInitials, pIndentQuoteLinesWithInitials)
    {
    	// Create the return object with initial values.
    	var retObj = getDefaultQuoteStrObj();  
    
    	// If pStr is not a valid positive-length string, then just return.
    	if ((pStr == null) || (typeof(pStr) != "string") || (pStr.length == 0))
    		return retObj;
    
    	// If using author initials, then do some special checking: If the first >
    	// character is preceded by something other than spaces or 3 non-space characters,
    	// then this string is probably not quoted, so return an object that signifies
    	// such.
    	if (pUseAuthorInitials)
    	{
    		var firstGTCharIdx = pStr.indexOf(">");
    		if (firstGTCharIdx > -1)
    		{
    			// double-quoted text: If there are only spaces, > characters, or
    			// up to 3 characters directly before the >> (without spaces), then
    			// take this as a valid instance of double-quoted text.
    			var upToThreeNonSpacesBefore = false;
    			var onlySpaces = true;
    			var currentChar;
    			for (var srchIdx = 0; (srchIdx < pStr.length) && onlySpaces; ++srchIdx)
    				onlySpaces = (pStr.charAt(srchIdx) == " ");
    			if (!onlySpaces)
    			{
    				var startIdxBeforeGT = firstGTCharIdx - 4;
    				if (startIdxBeforeGT < 0)
    					startIdxBeforeGT = 0;
    				// If the string don't contain a non > followed by a space before the >, then
    				// go ahead and check the first 3 characters before the >.  Otherwise, it's
    				// already disqualified.
    				if (!/[^>] /.test(pStr.substr(startIdxBeforeGT, firstGTCharIdx-startIdxBeforeGT)))
    				{
    					upToThreeNonSpacesBefore = true;
    					var numNonSpaceChars = 0;
    					for (var srchIdx = firstGTCharIdx-1; srchIdx >= startIdxBeforeGT; --srchIdx)
    					{
    						if (pStr.charAt(srchIdx) != " ")
    							++numNonSpaceChars;
    					}
    					upToThreeNonSpacesBefore = (numNonSpaceChars < 4);
    				}
    			}
    
    			// If there aren't just spaces or up to 3 non-space characters just before
    			// the first >, then return an object that signifies this situation properly.
    			if (!onlySpaces && !upToThreeNonSpacesBefore)
    			{
    				retObj.startIndex = 0;
    				retObj.quoteLevel = 0;
    				retObj.begOfLine = "";
    				return retObj;
    			}
    		}
    	}
    
    	// Look for quote lines that begin with 1 or 2 initials followed by a > (i.e.,
    	// "EO>" or "E>" at the start of the line.  If found, set an index to look for
    	// & count the > characters from the >.
    	var searchStartIndex = 0;
    	// Regex notes:
    	//  \w: Matches any alphanumeric character (word characters) including underscore (short for [a-zA-Z0-9_])
    	//  ?: Supposed to match 0 or 1 occurance, but seems to match 1 or 2
    	// First, look for spaces then 1 or 2 initials followed by a non-space followed
    	// by a >.  If not found, then look for ">>".  If that isn't found, then look
    	// for just 2 characters followed by a >.
    	var lineStartsWithQuoteText = /^ *\w?[^ ]>/.test(pStr);
    	if (pUseAuthorInitials)
    	{
    		if (!lineStartsWithQuoteText)
    			lineStartsWithQuoteText = (pStr.lastIndexOf(">>") > -1);
    		if (!lineStartsWithQuoteText)
    			lineStartsWithQuoteText = /\w{2}>/.test(pStr);
    	}
    	if (lineStartsWithQuoteText)
    	{
    		if (pUseAuthorInitials)
    		{
    			// If the string is an origin line (starting with " * Origin:"), then don't
    			// do much with this line..  Just set the first non-space character in retObj.
    			if (/^ \* Origin:/.test(pStr))
    				retObj.startIndex = 1;
    			else
    			{
    				// First, look for the last instance of ">> " (signifying a multi-quoted line).
    				// If found, increment searchStartIndex by 2 to get past the ">>".
    				var validDoubleQuoteChars = false;
    				searchStartIndex = pStr.lastIndexOf(">> ");
    				if (searchStartIndex > -1)
    					searchStartIndex += 2;
    				else
    				{
    					// If pStr is at least 3 characters long, then starting with the
    					// last 3 characters in pStr, look for an instance of 2 letters
    					// or numbers or underscores followed by a >.  Keep moving back
    					// 1 character at a time until found or until the beginning of
    					// the string is reached.
    					if (pStr.length >= 3)
    					{
    						// Regex notes:
    						//  \w: Matches any alphanumeric character (word characters) including underscore (short for [a-zA-Z0-9_])
    						var substrStartIndex = pStr.length - 3;
    						for (; (substrStartIndex >= 0) && (searchStartIndex < 0); --substrStartIndex)
    							searchStartIndex = pStr.substr(substrStartIndex, 3).search(/^\w{2}>$/);
    						++substrStartIndex; // To fix off-by-one
    						if (searchStartIndex > -1)
    						{
    							searchStartIndex += substrStartIndex + 3; // To get past the "..>"
    							// New (2017-12-24):
    							// If the instance(s) of a > has 3 non-space characters
    							// before it, then assume the > is not part of a quote
    							// prefix, and look for another > earlier in the text string.
    							// When using author initials, SlyEdit assumes a quote prefix
    							// has up to 2 characters before the >.
    							while ((searchStartIndex >= 4) && (pStr.substr(searchStartIndex-4, 4).search(/^[^\s]{3}>$/) >= 0))
    							{
    								searchStartIndex = pStr.lastIndexOf(">", searchStartIndex-2);
    								if (searchStartIndex == -1)
    									searchStartIndex = 0;
    								else
    									++searchStartIndex; // To fix off-by-one
    							}
    						}
    						// Note: I originally had + 4 here..
    						if (searchStartIndex < 0)
    						{
    							searchStartIndex = pStr.indexOf(">");
    							if (searchStartIndex < 0)
    								searchStartIndex = 0;
    						}
    					}
    					else
    					{
    						searchStartIndex = pStr.indexOf(">");
    						if (searchStartIndex < 0)
    							searchStartIndex = 0;
    					}
    				}
    			}
    		}
    		else
    		{
    			// SlyEdit is not prefixing quote lines with author's initials.
    			searchStartIndex = pStr.indexOf(">");
    			if (searchStartIndex < 0)
    				searchStartIndex = 0;
    		}
    	}
    
    	// Find the quote level and the beginning of the line.
    	// Look for the first non-quote text and quote level in the string.
    	var strChar = "";
    	var j = 0;
    	for (var i = searchStartIndex; i < pStr.length; ++i)
    	{
    		strChar = pStr.charAt(i);
    		if ((strChar != " ") && (strChar != ">"))
    		{
    			// New (2017-12-24):
    			// If using author initials and there are 3 non-space characters
    			// before the >, then continue to the next character.
    			if (i >= 3)
    			{
    				if (pUseAuthorInitials && (pStr.substr(i-3, 4).search(/^[^\s]{3}>$/) >= 0))
    					continue;
    			}
    
    			// We've found the first non-quote character.
    			retObj.startIndex = i;
    			// Count the number of times the > character appears at the start of
    			// the line, and set quoteLevel to that.
    			if (i >= 0)
    			{
    				for (j = 0; j < i; ++j)
    				{
    					if (pStr.charAt(j) == ">")
    					{
    						// New (2017-12-24):
    						// If using author initials, then increment the quote level
    						// only if there are not 3 non-space characters before the >
    						if (pUseAuthorInitials && (j >= 3))
    						{
    							if (pStr.substr(j-3, 4).search(/^[^\s]{3}>$/) < 0)
    								++retObj.quoteLevel;
    						}
    						else
    							++retObj.quoteLevel;
    					}
    				}
    			}
    			// Store the beginning of the line in retObj.begOfLine.  And if
    			// SlyEdit is configured to indent quote lines with author initials,
    			// and if the beginning of the line doesn't begin with a space,
    			// then add a space to the beginning of it.
    			retObj.begOfLine = pStr.substr(0, retObj.startIndex);
    			if (pUseAuthorInitials && pIndentQuoteLinesWithInitials && (retObj.begOfLine.length > 0) && (retObj.begOfLine.charAt(0) != " "))
    				retObj.begOfLine = " " + retObj.begOfLine;
    			break;
    		}
    	}
    
    	// If we haven't found non-quote text but the line starts with quote text,
    	// then set the starting index & quote level in retObj.
    	if (lineStartsWithQuoteText && ((retObj.startIndex == -1) || (retObj.quoteLevel == 0)))
    	{
    		retObj.startIndex = pStr.indexOf(">") + 1;
    		// New (2017-12-24):
    		var setQuoteLevel = true;
    		// When using author initials in quote lines: If there are 3 non-space
    		// characters before the >, then it's not an actual quote (SlyEdit
    		// considers quote lines with initials to have only 2 characters before
    		// the >).
    		if (pUseAuthorInitials && retObj.startIndex >= 4)
    		{
    			if (pStr.substr(retObj.startIndex-4, 4).search(/^[^\s]{3}>$/) >= 0)
    			{
    				retObj.startIndex = 0;
    				setQuoteLevel = false;
    			}
    		}
    		if (setQuoteLevel)
    			retObj.quoteLevel = 1;
    	}
    
    	return retObj;
    }
    
    // Performs text wrapping on the quote lines.
    //
    // Parameters:
    //  pUseAuthorInitials: Whether or not to prefix quote lines with the last author's
    //                      initials
    // pIndentQuoteLinesWithInitials: If prefixing the quote lines with the
    //                                last author's initials, this parameter specifies
    //                                whether or not to also prefix the quote lines with
    //                                a space.
    //  pTrimSpacesFromQuoteLines: Whether or not to trim spaces from quote lines (for when people
    //                             indent the first line of their reply, etc.).  Defaults to true.
    function wrapQuoteLines(pUseAuthorInitials, pIndentQuoteLinesWithInitials, pTrimSpacesFromQuoteLines)
    {
    	var useAuthorInitials = true;
    	var indentQuoteLinesWithInitials = false;
    	if (typeof(pUseAuthorInitials) != "undefined")
    		useAuthorInitials = pUseAuthorInitials;
    	if (typeof(pIndentQuoteLinesWithInitials) != "undefined")
    		indentQuoteLinesWithInitials = pIndentQuoteLinesWithInitials;
    
    	var trimSpacesFromQuoteLines = (typeof(pTrimSpacesFromQuoteLines) == "boolean" ? pTrimSpacesFromQuoteLines : true);
    	if (useAuthorInitials)
    		wrapQuoteLinesUsingAuthorInitials(pIndentQuoteLinesWithInitials, trimSpacesFromQuoteLines);
    	else
    		wrapQuoteLines_NoAuthorInitials(trimSpacesFromQuoteLines);
    }
    
    // For wrapping quote lines: This function checks if a string has only > characters
    // separated by whitespace and returns a version where the > characters are only
    // separated by one space each, and if the line starts with " >", the leading space
    // will be removed.
    function normalizeGTChars(pStr)
    {
    	if (/^\s*>\s*$/.test(pStr))
    		pStr = ">";
    	else
    	{
    		pStr = pStr.replace(/>\s*>/g, "> >")
    		           .replace(/^\s>/, ">")
    		           .replace(/^\s*$/, "");
    	}
    	return pStr;
    }
    
    // Wraps quote lines and prefixes them with the original author's initials.
    // Assumes gQuotePrefix contains the author's initials.
    //
    // Parameters:
    //  pIndentQuoteLines: Whether or not to indent the quote lines
    //  pTrimSpacesFromQuoteLines: Whether or not to trim spaces from quote lines (for when people
    //                             indent the first line of their reply, etc.).  Defaults to true.
    function wrapQuoteLinesUsingAuthorInitials(pIndentQuoteLines, pTrimSpacesFromQuoteLines)
    {
    	if (gQuoteLines.length == 0)
    		return;
    
    	// Steps for wrapping quote lines:
    	// 1. Get information for each line (quote level, beginning of line, etc.)
    	// 2. Based on the line info, find the different sections of the quote lines
    	// 3. Go through each section of the quote lines and quote appropriately
    
    	// Note: gQuotePrefix is declared in SlyEdit.js.
    	// Make another copy of it without its leading space for searching the
    	// quote lines later.
    	var quotePrefixWithoutLeadingSpace = gQuotePrefix.replace(/^ /, "");
    
    	// 1. Get information for each line (quote level, beginning of line, etc.)
    	var lineInfos = new Array();
    	for (var quoteLineIndex = 0; quoteLineIndex < gQuoteLines.length; ++quoteLineIndex)
    		lineInfos.push(firstNonQuoteTxtIndex(gQuoteLines[quoteLineIndex], true, pIndentQuoteLines));
    
    	// 2. Based on the line info, find the different sections of the quote lines
    	var quoteSections = new Array();
    	var startArrIndex = 0;
    	var endArrIndex = -1;
    	var lastQuoteLevel = lineInfos[0].quoteLevel;
    	for (var quoteLineIndex = 1; quoteLineIndex < gQuoteLines.length; ++quoteLineIndex)
    	{
    		endArrIndex = -1; // Resetting to help ensure that we get the last section sometimes
    
    		if (gQuoteLines[quoteLineIndex].length == 0)
    			continue;
    
    		// If this line has a different quote level than the previous line, then
    		// it marks a new section.
    		if (lineInfos[quoteLineIndex].quoteLevel != lastQuoteLevel)
    		{
    			endArrIndex = quoteLineIndex;
    			var sectionInfo = new Object();
    			sectionInfo.startArrIndex = startArrIndex;
    			sectionInfo.endArrIndex = endArrIndex;
    			sectionInfo.quoteLevel = lastQuoteLevel;
    			// If the end array index is for a blank quote line, then
    			// adjust it to the first non-blank quote line before it.
    			while ((sectionInfo.endArrIndex-1 >= 0) &&
    			       (typeof(gQuoteLines[sectionInfo.endArrIndex-1]) == "string") &&
    			       gQuoteLines[sectionInfo.endArrIndex-1].length == 0)
    			{
    				--sectionInfo.endArrIndex;
    			}
    			// If we moved sectionInfo.endArrIndex back too far, then increment it.
    			while (typeof(gQuoteLines[sectionInfo.endArrIndex]) != "string")
    				++sectionInfo.endArrIndex;
    
    			quoteSections.push(sectionInfo);
    			startArrIndex = quoteLineIndex;
    			lastQuoteLevel = lineInfos[quoteLineIndex].quoteLevel;
    		}
    		// For lines with a quote level of 0, if this line's indentation differs from
    		// the previous line's indentation, then that marks a new section.
    		else if ((lineInfos[quoteLineIndex].quoteLevel == 0) && (lastQuoteLevel == 0) &&
    		         (lineInfos[quoteLineIndex].startIndex > lineInfos[quoteLineIndex-1].startIndex))
    		{
    			endArrIndex = quoteLineIndex; // One past the last index of the current paragraph
    			var sectionInfo = new Object();
    			sectionInfo.startArrIndex = startArrIndex;
    			sectionInfo.endArrIndex = endArrIndex;
    			sectionInfo.quoteLevel = 0;
    			// If the end array index is for a blank quote line, then
    			// adjust it to the first non-blank quote line before it.
    			while ((sectionInfo.endArrIndex-1 >= 0) &&
    			       (typeof(gQuoteLines[sectionInfo.endArrIndex-1]) == "string") &&
    			gQuoteLines[sectionInfo.endArrIndex-1].length == 0)
    			{
    				--sectionInfo.endArrIndex;
    			}
    			// If we moved sectionInfo.endArrIndex back too far, then increment it.
    			while (typeof(gQuoteLines[sectionInfo.endArrIndex]) != "string")
    				++sectionInfo.endArrIndex;
    
    			quoteSections.push(sectionInfo);
    			startArrIndex = quoteLineIndex;
    		}
    	}
    	// If we only found one section or we're at the last section, then add it to
    	// quoteSections.
    	if ((endArrIndex == -1) || (endArrIndex == gQuoteLines.length-1))
    	{
    		var sectionInfo = new Object();
    		sectionInfo.startArrIndex = startArrIndex;
    		sectionInfo.endArrIndex = gQuoteLines.length;
    		sectionInfo.quoteLevel = lastQuoteLevel;
    		// If the end array index is for a blank quote line, then
    		// adjust it to the first non-blank quote line before it.
    		while ((sectionInfo.endArrIndex > 0) && (gQuoteLines[sectionInfo.endArrIndex-1].length == 0))
    			--sectionInfo.endArrIndex;
    		quoteSections.push(sectionInfo);
    	}
    
    	// 3. Go through each section of the quote lines and wrap & quote appropriately
    	var trimSpacesFromQuoteLines = (typeof(pTrimSpacesFromQuoteLines) == "boolean" ? pTrimSpacesFromQuoteLines : true);
    	for (var sIndex = 0; sIndex < quoteSections.length; ++sIndex)
    	{
    		// If the section is not quoted text (in other words, it was written by
    		// author of the message), then remove leading whitespace from the text
    		// lines in this section to leave more room for wrapping and so that we
    		// don't end up with a section of quote lines that all start with several
    		// spaces.
    		if (quoteSections[sIndex].quoteLevel == 0)
    		{
    			for (var i = quoteSections[sIndex].startArrIndex; i < quoteSections[sIndex].endArrIndex; ++i)
    			{
    				if (trimSpacesFromQuoteLines)
    					gQuoteLines[i] = trimSpaces(gQuoteLines[i], true, true, false);
    				lineInfos[i].startIndex = 0;
    				lineInfos[i].begOfLine = "";
    			}
    		}
    
    		// Remove the quote strings from the lines we're about to wrap
    		var maxBegOfLineLen = 0;
    		for (var i = quoteSections[sIndex].startArrIndex; i < quoteSections[sIndex].endArrIndex; ++i)
    		{
    			if (lineInfos[i] != null)
    			{
    				if (lineInfos[i].startIndex > -1)
    					gQuoteLines[i] = gQuoteLines[i].substr(lineInfos[i].startIndex);
    				else
    					gQuoteLines[i] = normalizeGTChars(gQuoteLines[i]);
    
    				// If the quote line now only consists of spaces after removing the quote
    				// characters, then make it blank.
    				if (/^ +$/.test(gQuoteLines[i]))
    					gQuoteLines[i] = "";
    				// Change multiple spaces to single spaces in the beginning-of-line
    				// string.  Also, if not prefixing quote lines w/ initials with a
    				// space, then also trim leading spaces.
    				if (pIndentQuoteLines)
    					lineInfos[i].begOfLine = trimSpaces(lineInfos[i].begOfLine, false, true, false);
    				else
    					lineInfos[i].begOfLine = trimSpaces(lineInfos[i].begOfLine, true, true, false);
    
    				// See if we need to update maxBegOfLineLen, and if so, do it.
    				if (lineInfos[i].begOfLine.length > maxBegOfLineLen)
    					maxBegOfLineLen = lineInfos[i].begOfLine.length;
    			}
    		}
    		// If maxBegOfLineLen is positive, then add 1 more to it because
    		// we'll be adding a > character to the quote lines to signify one
    		// more level of quoting.
    		if (maxBegOfLineLen > 0)
    			++maxBegOfLineLen;
    		// Add gQuotePrefix's length to maxBegOfLineLen to account for that
    		// for wrapping the text. Note: In future versions, if we don't want
    		// to add the previous author's initials to all lines, then we might
    		// not automatically want to add this to every line.
    		maxBegOfLineLen += gQuotePrefix.length;
    
    		// Wrap the current section of quote lines
    		var maxLineWidth = 79 - maxBegOfLineLen;
    		if (maxLineWidth < 0)
    			maxLineWidth = 0;
    		var idxesAddedNL = new Array();
    		var numLinesAdded = 0;
    		if (maxLineWidth > 0)
    		{
    			numLinesAdded = wrapTextLines(gQuoteLines, quoteSections[sIndex].startArrIndex,
    			                              quoteSections[sIndex].endArrIndex, maxLineWidth,
    			                              idxesAddedNL, lineInfos);
    		}
    
    		// If quote lines were added as a result of wrapping, then determine the
    		// number of lines added and update the end index of this object in
    		// quoteSections and the start & end indexes of the subsequent objects in
    		// quoteSections.
    		if (numLinesAdded > 0)
    		{
    			// Splice new lineInfo objects into the lineInfos array at the end of this
    			// section for each new line added in this section.
    			for (var counter = 0; counter < numLinesAdded; ++counter)
    				lineInfos.splice(quoteSections[sIndex].endArrIndex, 0, getDefaultQuoteStrObj());
    			// Now we can update this section's end index.  Then, after each index that
    			// required a new line to be added, move the lineInfo information down one line.
    			quoteSections[sIndex].endArrIndex += numLinesAdded;
    			for (var NLArrIdx = 0; NLArrIdx < idxesAddedNL.length; ++NLArrIdx)
    			{
    				for (var lnInfoMoveIdx = quoteSections[sIndex].endArrIndex-1;
    				     lnInfoMoveIdx > idxesAddedNL[NLArrIdx]; --lnInfoMoveIdx)
    				{
    					lineInfos[lnInfoMoveIdx].copy(lineInfos[lnInfoMoveIdx-1]);
    				}
    			}
    
    			// Update the start & end indexes of the following sections.
    			for (var sIndex2 = sIndex+1; sIndex2 < quoteSections.length; ++sIndex2)
    			{
    				quoteSections[sIndex2].startArrIndex += numLinesAdded;
    				quoteSections[sIndex2].endArrIndex += numLinesAdded;
    			}
    
    			// Go through this section's quote lines, and for each quote line that is
    			// non-blank and has a lineInfo object with a blank beginning-of-line text,
    			// set its beginning-of-line text to the first non-blank beginning-of-line
    			// text from a line preceding it.
    			for (var lnIdx = quoteSections[sIndex].startArrIndex+1; lnIdx < quoteSections[sIndex].endArrIndex; ++lnIdx)
    			{
    				if ((gQuoteLines[lnIdx].length > 0) && (lineInfos[lnIdx].begOfLine.length == 0))
    				{
    					var nonBlankIdx = lnIdx - 1;
    					while ((lineInfos[nonBlankIdx].begOfLine.length == 0) && (nonBlankIdx > quoteSections[sIndex].startArrIndex))
    						--nonBlankIdx;
    					if (lineInfos[nonBlankIdx].begOfLine.length > 0)
    						lineInfos[lnIdx].begOfLine = lineInfos[nonBlankIdx].begOfLine;
    				}
    			}
    		}
    
    		// Go through this section's (now wrapped) quote lines and quote them
    		for (var quoteLnIdx = quoteSections[sIndex].startArrIndex; quoteLnIdx < quoteSections[sIndex].endArrIndex; ++quoteLnIdx)
    		{
    			if (lineInfos[quoteLnIdx] != null)
    			{
    				if (gQuoteLines[quoteLnIdx].length == 0)
    					continue;
    
    				// If the quote level in this section is positive and the beginning
    				// of the line has a non-zero length, then add a > at the end to
    				// signify that this line is being quoted again.
    				var begOfLineLen = lineInfos[quoteLnIdx].begOfLine.length;
    				if ((begOfLineLen > 0) && (quoteSections[sIndex].quoteLevel > 0))
    				{
    					if (lineInfos[quoteLnIdx].begOfLine.charAt(begOfLineLen-1) == " ")
    						lineInfos[quoteLnIdx].begOfLine = lineInfos[quoteLnIdx].begOfLine.substr(0, begOfLineLen-1) + "> ";
    					else
    						lineInfos[quoteLnIdx].begOfLine += ">";
    				}
    				// Re-assemble the quote line
    				gQuoteLines[quoteLnIdx] = lineInfos[quoteLnIdx].begOfLine + gQuoteLines[quoteLnIdx];
    				if (quoteSections[sIndex].quoteLevel == 0)
    					gQuoteLines[quoteLnIdx] = gQuotePrefix + gQuoteLines[quoteLnIdx];
    			}
    			else
    			{
    				// Old style: Put quote strings ("> ") back into the lines we just wrapped.
    				var quotePrefix = "";
    				for (var counter = 0; counter < lastQuoteLevel; ++counter)
    					quotePrefix += "> ";
    				gQuoteLines[quoteLnIdx] = quotePrefix + gQuoteLines[quoteLnIdx].replace(/^\s*>/, ">");
    			}
    		}
    	}
    }
    
    // Wraps the quote lines without using the originals author's initials
    // (classic quoting).
    // Assumes gQuotePrefix does not contains the author's initials.
    //
    // Parameters:
    //  pTrimSpacesFromQuoteLines: Whether or not to trim spaces from quote lines (for when people
    //                             indent the first line of their reply, etc.).  Defaults to true.
    function wrapQuoteLines_NoAuthorInitials(pTrimSpacesFromQuoteLines)
    {
    	if (gQuoteLines.length == 0)
    		return;
    
    	// Create an array for line information objects.
    	var lineInfos = new Array();
    	for (var quoteLineIndex = 0; quoteLineIndex < gQuoteLines.length; ++quoteLineIndex)
    		lineInfos.push(firstNonQuoteTxtIndex(gQuoteLines[quoteLineIndex], false, false));
    
    	// Set an initial value for lastQuoteLevel, which will be used to compare the
    	// quote levels of each line.
    	var lastQuoteLevel = lineInfos[0].quoteLevel;
    
    	// Loop through the array starting at the 2nd line and wrap the lines
    	var startArrIndex = 0;
    	var endArrIndex = 0;
    	var quoteStr = "";
    	var quoteLevel = 0;
    	var i = 0; // Index variable
    	for (var quoteLineIndex = 1; quoteLineIndex < gQuoteLines.length; ++quoteLineIndex)
    	{
    		if (lineInfos[quoteLineIndex].quoteLevel != lastQuoteLevel)
    		{
    			endArrIndex = quoteLineIndex;
    			// Remove the quote strings from the lines we're about to wrap
    			for (i = startArrIndex; i < endArrIndex; ++i)
    			{
    				if (lineInfos[i] != null)
    				{
    					if (lineInfos[i].startIndex > -1)
    						gQuoteLines[i] = gQuoteLines[i].substr(lineInfos[i].startIndex);
    					else
    						gQuoteLines[i] = normalizeGTChars(gQuoteLines[i]);
    					// If the quote line now only consists of spaces after removing the quote
    					// characters, then make it blank.
    					if (/^ +$/.test(gQuoteLines[i]))
    						gQuoteLines[i] = "";
    				}
    			}
    			// Wrap the text lines in the range we've seen.
    			// Note: 79 is assumed as the maximum line length because
    			// that seems to be a commonly-accepted message width for
    			// BBSes.  Also, the following length is subtracted from it:
    			// (2*(lastQuoteLevel+1) + gQuotePrefix.length)
    			// That is because we'll be prepending "> " to the quote lines,
    			// and then SlyEdit will prepend gQuotePrefix to them during quoting.
    			var numLinesAdded =  wrapTextLines(gQuoteLines, startArrIndex, endArrIndex,
    			                                   79 - (2*(lastQuoteLevel+1) + gQuotePrefix.length));
    			// If quote lines were added as a result of wrapping, then
    			// determine the number of lines added, and update endArrIndex
    			// and quoteLineIndex accordingly.
    			if (numLinesAdded > 0)
    			{
    				endArrIndex += numLinesAdded;
    				quoteLineIndex += (numLinesAdded-1); // - 1 because quoteLineIndex will be incremented by the for loop
    				// Splice new lineInfo objects into the lineInfos array at the end of this
    				// section for each new line added in this section.
    				for (var counter = 0; counter < numLinesAdded; ++counter)
    					lineInfos.splice(endArrIndex, 0, getDefaultQuoteStrObj());
    			}
    			// Put quote strings ("> ") back into the lines we just wrapped
    			if ((quoteLineIndex > 0) && (lastQuoteLevel > 0))
    			{
    				quoteStr = "";
    				for (i = 0; i < lastQuoteLevel; ++i)
    					quoteStr += "> ";
    				for (i = startArrIndex; i < endArrIndex; ++i)
    					gQuoteLines[i] = quoteStr + gQuoteLines[i].replace(/^\s*>/, ">");
    			}
    			lastQuoteLevel = lineInfos[quoteLineIndex].quoteLevel;
    			startArrIndex = quoteLineIndex;
    		}
    		// For lines with a quote level of 0, if this line's indentation differs from
    		// the previous line's indentation, then that marks a new section.
    		else if ((lineInfos[quoteLineIndex].quoteLevel == 0) && (lastQuoteLevel == 0) &&
    		         (lineInfos[quoteLineIndex].startIndex > lineInfos[quoteLineIndex-1].startIndex))
    		{
    			endArrIndex = quoteLineIndex;
    
    			// Remove leading whitespace from the text lines in this section to leave
    			// more room for wrapping and so that we don't end up with a section of
    			// quote lines that all start with several spaces.
    			var trimSpacesFromQuoteLines = (typeof(pTrimSpacesFromQuoteLines) == "boolean" ? pTrimSpacesFromQuoteLines : true);
    			for (var i = startArrIndex; i < endArrIndex; ++i)
    			{
    				if (trimSpacesFromQuoteLines)
    					gQuoteLines[i] = trimSpaces(gQuoteLines[i], true, true, false);
    				lineInfos[i].startIndex = 0;
    				lineInfos[i].begOfLine = "";
    			}
    
    			// Wrap the text lines in the range we've seen.
    			// Note: 79 is assumed as the maximum line length because
    			// that seems to be a commonly-accepted message width for
    			// BBSes.
    			var numLinesAdded = wrapTextLines(gQuoteLines, startArrIndex, endArrIndex, 79);
    			// If quote lines were added as a result of wrapping, then
    			// determine the number of lines added, and update endArrIndex
    			// and quoteLineIndex accordingly.
    			if (numLinesAdded > 0)
    			{
    				endArrIndex += numLinesAdded;
    				quoteLineIndex += (numLinesAdded-1); // - 1 because quoteLineIndex will be incremented by the for loop
    				// Splice new lineInfo objects into the lineInfos array at the end of this
    				// section for each new line added in this section.
    				for (var counter = 0; counter < numLinesAdded; ++counter)
    					lineInfos.splice(endArrIndex, 0, getDefaultQuoteStrObj());
    			}
    			startArrIndex = quoteLineIndex;
    		}
    	}
    	// Wrap the last block of lines
    	wrapTextLines(gQuoteLines, startArrIndex, gQuoteLines.length, 79 - (2*(lastQuoteLevel+1) + gQuotePrefix.length));
    
    	// Go through the quote lines again, and for ones that start with " >", remove
    	// the leading whitespace.  This is because the quote string is " > ", so it
    	// would insert an extra space before the first > in the quote line.
    	for (i = 0; i < gQuoteLines.length; ++i)
    		gQuoteLines[i] = gQuoteLines[i].replace(/^\s*>/, ">");
    }
    
    // Returns an object containing the following properties:
    //  lastMsg: The last message in the sub-board (i.e., bbs.smb_last_msg)
    //  totalNumMsgs: The total number of messages in the sub-board (i.e., bbs.smb_total_msgs)
    //  curMsgNum: The number/index of the current message being read.  Starting
    //             with Synchronet 3.16 on May 12, 2013, this is the absolute
    //             message number (bbs.msg_number).  For Synchronet builds before
    //             May 12, 2013, this is bbs.smb_curmsg.  Starting on May 12, 2013,
    //             bbs.msg_number is preferred because it works properly in all
    //             situations, whereas in earlier builds, bbs.msg_number was
    //             always given to JavaScript scripts as 0.
    //  msgNumIsOffset: Boolean - Whether or not the message number is an offset.
    //                  If not, then it is the absolute message number (i.e.,
    //                  bbs.msg_number).
    //  subBoardCode: The current sub-board code (i.e., bbs.smb_sub_code)
    //  grpIndex: The message group index for the sub-board
    //
    // This function First tries to read the values from the file
    // DDML_SyncSMBInfo.txt in the node directory (written by the Digital
    // Distortion Message Lister v1.31 and higher).  If that file can't be read,
    // the values will default to the values of bbs.smb_last_msg,
    // bbs.smb_total_msgs, and bbs.msg_number/bbs.smb_curmsg.
    //
    // Parameters:
    //  pMsgAreaName: The name of the message area being posted to
    function getCurMsgInfo(pMsgAreaName)
    {
      var retObj = new Object();
      retObj.msgNumIsOffset = false;
      if (bbs.smb_sub_code.length > 0)
      {
        retObj.lastMsg = bbs.smb_last_msg;
        retObj.totalNumMsgs = bbs.smb_total_msgs;
        // If bbs.msg_number is valid (greater than 0), then use it.  Otherwise,
        // use the older behavior of using bbs.smb_curmsg (the offset) instead.
        // bbs.msg_number was correct in Synchronet 3.16 builds starting on
        // May 12, 2013.
        //retObj.curMsgNum = (bbs.msg_number > 0 ? bbs.msg_number : bbs.smb_curmsg);
        if (bbs.msg_number > 0)
          retObj.curMsgNum = bbs.msg_number;
        else
        {
          retObj.curMsgNum = bbs.smb_curmsg;
          retObj.msgNumIsOffset = true;
        }
        retObj.subBoardCode = bbs.smb_sub_code;
        retObj.grpIndex = msg_area.sub[bbs.smb_sub_code].grp_index;
      }
      else
      {
        retObj.lastMsg = -1;
        retObj.curMsgNum = -1;
        // If the user has a valid current sub-board code, then use it;
        // otherwise, find the first sub-board the user is able to post
        // in and use that.
        if (typeof(msg_area.sub[bbs.cursub_code]) != "undefined")
        {
          retObj.subBoardCode = bbs.cursub_code;
          retObj.grpIndex = msg_area.sub[bbs.cursub_code].grp_index;
        }
        else
        {
          var firstPostableSubInfo = getFirstPostableSubInfo();
          retObj.subBoardCode = firstPostableSubInfo.subCode;
          retObj.grpIndex = firstPostableSubInfo.grpIndex;
        }
    
        // If we got a valid sub-board code, then open that sub-board
        // and get the total number of messages from it.
        if (retObj.subBoardCode.length > 0)
        {
          var tmpMsgBaseObj = new MsgBase(retObj.subBoardCode);
          if (tmpMsgBaseObj.open())
          {
            retObj.totalNumMsgs = tmpMsgBaseObj.total_msgs;
            tmpMsgBaseObj.close();
          }
          else
            retObj.totalNumMsgs = 0;
        }
        else
           retObj.totalNumMsgs = 0;
      }
      // If pMsgAreaName is valid, then if it specifies a message area name that is
      // different from what's in retObj, then we probably want to use bbs.cursub_code
      // instead of bbs.smb_sub_code, etc.
      // Note: As of the May 8, 2013 build of Synchronet (3.16), the bbs.smb_sub*
      // properties reflect the current sub-board being posted to, always.
      // Digital Man committed a change in CVS for this on May 7, 2013.
      if ((typeof(pMsgAreaName) == "string") && (pMsgAreaName.length > 0))
      {
        if (msg_area.sub[retObj.subBoardCode].name.indexOf(pMsgAreaName) == -1)
        {
          retObj.lastMsg = -1;
          retObj.curMsgNum = -1;
          // If the user has a valid current sub-board code, then use it;
          // otherwise, find the first sub-board the user is able to post
          // in and use that.
          if (typeof(msg_area.sub[bbs.cursub_code]) != "undefined")
          {
            retObj.subBoardCode = bbs.cursub_code;
            retObj.grpIndex = msg_area.sub[bbs.cursub_code].grp_index;
          }
          else
          {
            var firstPostableSubInfo = getFirstPostableSubInfo();
            retObj.subBoardCode = firstPostableSubInfo.subCode;
            retObj.grpIndex = firstPostableSubInfo.grpIndex;
          }
    
          // If we got a valid sub-board code, then open that sub-board
          // and get the total number of messages from it.
          if (retObj.subBoardCode.length > 0)
          {
            var tmpMsgBaseObj = new MsgBase(retObj.subBoardCode);
            if (tmpMsgBaseObj.open())
            {
              retObj.totalNumMsgs = tmpMsgBaseObj.total_msgs;
              tmpMsgBaseObj.close();
            }
            else
              retObj.totalNumMsgs = 0;
          }
          else
           retObj.totalNumMsgs = 0;
        }
      }
    
      // If the Digital Distortion Message Lister drop file exists,
      // then use the message information from that file instead.
      if (file_exists(gDDML_DROP_FILE_NAME))
      {
        var SMBInfoFile = new File(gDDML_DROP_FILE_NAME);
        if (SMBInfoFile.open("r"))
        {
          var fileLine = null; // A line read from the file
          var lineNum = 0; // Will be incremented at the start of the while loop, to start at 1.
          while (!SMBInfoFile.eof)
          {
             ++lineNum;
    
             // Read the next line from the config file.
             fileLine = SMBInfoFile.readln(2048);
    
             // fileLine should be a string, but I've seen some cases
             // where for some reason it isn't.  If it's not a string,
             // then continue onto the next line.
             if (typeof(fileLine) != "string")
                continue;
    
              // Depending on the line number, set the appropriate value
              // in retObj.
              switch (lineNum)
              {
                case 1:
                  retObj.lastMsg = +fileLine;
                  break;
                case 2:
                  retObj.totalNumMsgs = +fileLine;
                  break;
                case 3:
                  retObj.curMsgNum = +fileLine;
                  retObj.msgNumIsOffset = false; // For Message Lister 1.36 and newer
                  break;
                case 4:
                  retObj.subBoardCode = fileLine;
                  retObj.grpIndex = msg_area.sub[retObj.subBoardCode].grp_index;
                  break;
                default:
                  break;
              }
           }
           SMBInfoFile.close();
        }
      }
    
      return retObj;
    }
    
    // Gets the "From" name of the current message being replied to.
    // Only for use when replying to a message in a public sub-board.
    // The message information is retrieved from DDML_SyncSMBInfo.txt
    // in the node dir if it exists or from the bbs object's properties.
    // On error, the string returned will be blank.
    //
    // Parameters:
    //  pMsgInfo: Optional: An object returned by getCurMsgInfo().  If this
    //            parameter is not specified, this function will call
    //            getCurMsgInfo() to get it.
    //
    // Return value: The from name of the current message being replied
    //               to (a string).
    function getFromNameForCurMsg(pMsgInfo)
    {
      var fromName = "";
    
      // Get the information about the current message from
      // DDML_SyncSMBInfo.txt in the node dir if it exists or from
      // the bbs object's properties.  Then open the message header
      // and get the 'from' name from it.
      var msgInfo = null;
      if ((pMsgInfo != null) && (typeof(pMsgInfo) != "undefined"))
        msgInfo = pMsgInfo;
      else
        msgInfo = getCurMsgInfo();
    
      if (msgInfo.subBoardCode.length > 0)
      {
        var msgBase = new MsgBase(msgInfo.subBoardCode);
        if (msgBase != null)
        {
          msgBase.open();
          var hdr = msgBase.get_msg_header(msgInfo.msgNumIsOffset, msgInfo.curMsgNum, true);
          if (hdr != null)
            fromName = hdr.from;
          msgBase.close();
        }
      }
    
      return fromName;
    }
    
    // Calculates & returns a page number.
    //
    // Parameters:
    //  pTopIndex: The index (0-based) of the topmost item on the page
    //  pNumPerPage: The number of items per page
    //
    // Return value: The page number
    function calcPageNum(pTopIndex, pNumPerPage)
    {
      return ((pTopIndex / pNumPerPage) + 1);
    }
    
    // Returns whether or not the user is posting in a message sub-board.
    // If false, that means the user is sending private email or netmail.
    // This function determines whether the user is posting in a message
    // sub-board if the message area name is not "Electronic Mail" and
    // is not "NetMail".
    //
    // Parameters:
    //  pMsgAreaName: The name of the message area.
    function postingInMsgSubBoard(pMsgAreaName)
    {
    	if (typeof(pMsgAreaName) != "string")
    		return false;
    	if (pMsgAreaName.length == 0)
    		return false;
    
    	return ((pMsgAreaName != "Electronic Mail") && (pMsgAreaName != "NetMail"));
    }
    
    // Returns the number of properties of an object.
    //
    // Parameters:
    //  pObject: The object for which to count properties
    //
    // Return value: The number of properties of the object
    function numObjProperties(pObject)
    {
      if (pObject == null)
        return 0;
    
      var numProps = 0;
      for (var prop in pObject) ++numProps;
      return numProps;
    }
    
    //
    // Paramters:
    //  pSubBoardCode: Synchronet's internal code for the sub-board to post in
    //  pTo: The name of the person to send the message to
    //  pSubj: The subject of the email
    //  pMessage: The email message
    //  pFromUserNum: The number of the user to use as the message sender.
    //                This is optional; if not specified, the current user
    //                will be used.
    //
    // Return value: String - Blank on success, or message on failure.
    function postMsgToSubBoard(pSubBoardCode, pTo, pSubj, pMessage, pFromUserNum)
    {
    	// Return if the parameters are invalid.
    	if (typeof(pSubBoardCode) != "string")
    		return ("Sub-board code is not a string");
    	if (typeof(pTo) != "string")
    		return ("To name is not a string");
    	if (pTo.length == 0)
    		return ("The 'to' user name is blank");
    	if (typeof(pSubj) != "string")
    		return ("Subject is not a string");
    	if (pSubj.length == 0)
    		return ("The subject is blank");
    	if (typeof(pMessage) != "string")
    		return ("Message is not a string");
    	if (pMessage.length == 0)
    		return ("Not sending an empty message");
    	if (typeof(pFromUserNum) != "number")
    		return ("From user number is not a number");
    	if ((pFromUserNum <= 0) || (pFromUserNum > system.lastuser))
    		return ("Invalid user number");
    
    	// LOad the user record specified by pFromUserNum.  If it's a deleted user,
    	// then return an error.
    	var fromUser = new User(pFromUserNum);
    	if (fromUser.settings & USER_DELETED)
    		return ("The 'from' user is marked as deleted");
    
    	// Open the sub-board so that the message can be posted there.
    	var msgbase = new MsgBase(pSubBoardCode);
    	if (!msgbase.open())
    		return ("Error opening the message area: " + msgbase.last_error);
    
    	// Create the message header, and send the message.
    	var header = new Object();
    	header.to = pTo;
    	header.from_net_type = NET_NONE;
    	header.to_net_type = NET_NONE;
    	// For the 'From' name, use the user's real name if the sub-board is set
    	// up to post using real names; otherwise, use the user's alias.
    	if ((msgbase.cfg.settings & SUB_NAME) == SUB_NAME)
    		header.from = fromUser.name;
    	else
    		header.from = fromUser.alias;
    	header.from_ext = fromUser.number;
    	header.from_net_addr = fromUser.netmail;
    	header.subject = pSubj;
    	var saveRetval = msgbase.save_msg(header, pMessage);
    	msgbase.close();
    
    	if (!saveRetval)
    		return ("Error saving the message: " + msgbase.last_error);
    
    	return "";
    }
    
    // Reads the current user's message signature file (if it exists)
    // and returns its contents.
    //
    // Return value: An object containing the following properties:
    //               sigFileExists: Boolean - Whether or not the user's signature file exists
    //               sigContents: String - The user's message signature
    function readUserSigFile()
    {
      var retObj = new Object();
      retObj.sigFileExists = false;
      retObj.sigContents = "";
    
      // The user signature files are located in sbbs/data/user, and the filename
      // is the user number (zero-padded up to 4 digits) + .sig
      var userSigFilename = backslash(system.data_dir + "user") + format("%04d.sig", user.number);
      retObj.sigFileExists = file_exists(userSigFilename);
      if (retObj.sigFileExists)
      {
        var msgSigFile = new File(userSigFilename);
        if (msgSigFile.open("r"))
        {
          var fileLine = ""; // A line read from the file
          while (!msgSigFile.eof)
          {
            fileLine = msgSigFile.readln(2048);
            // fileLine should be a string, but I've seen some cases
            // where for some reason it isn't.  If it's not a string,
            // then continue onto the next line.
            if (typeof(fileLine) != "string")
              continue;
    
            retObj.sigContents += fileLine + "\r\n";
          }
    
          msgSigFile.close();
        }
      }
    
      return retObj;
    }
    
    // Returns the sub-board code and group index for the first sub-board
    // the user is allowed to post in.  If none are found, the sub-board
    // code will be a blank string and the group index will be 0.
    //
    // Return value: An object with the following properties:
    //               subCode: The sub-board code
    //               grpIndex: The group index of the sub-board
    function getFirstPostableSubInfo()
    {
      var retObj = new Object();
      retObj.subCode = "";
      retObj.grpIndex = 0;
    
      var continueOn = true;
      for (var groupIdx = 0; (groupIdx < msg_area.grp_list.length) && continueOn; ++groupIdx)
      {
         for (var subIdx = 0; (subIdx < msg_area.grp_list[groupIdx].sub_list.length) && continueOn; ++subIdx)
         {
            if (user.compare_ars(msg_area.grp_list[groupIdx].sub_list[subIdx].ars) &&
                user.compare_ars(msg_area.grp_list[groupIdx].sub_list[subIdx].post_ars))
            {
               retObj.subCode = msg_area.grp_list[groupIdx].sub_list[subIdx].code;
               retObj.grpIndex = groupIdx;
               continueOn = false;
               break;
            }
         }
      }
    
      return retObj;
    }
    
    // Reads SlyEdit_TextReplacements.cfg (from sbbs/mods, sbbs/ctrl, or the
    // script's directory) and populates an associative array with the WORD=text
    // pairs.  When not using regular expressions, the key will be in all uppercase
    // and the value in lowercase.  This function will read up to 9999 replacements.
    //
    // Parameters:
    //  pArray: The array to populate.  Must be created as "new Array()".
    //  pRegex: Whether or not the text replace feature is configured to use regular
    //          expressions.  If so, then the search words in the array will not
    //          be converted to uppercase and the replacement text will not be
    //          converted to lowercase.
    //
    // Return value: The number of text replacements added to the array.
    function populateTxtReplacements(pArray, pRegex)
    {
       var numTxtReplacements = 0;
    
       // Note: Limited to words without spaces.
       // Open the word replacements configuration file
       var wordReplacementsFilename = genFullPathCfgFilename("SlyEdit_TextReplacements.cfg", gStartupPath);
       var arrayPopulated = false;
       var wordFile = new File(wordReplacementsFilename);
       if (wordFile.open("r"))
       {
          var fileLine = null;      // A line read from the file
          var equalsPos = 0;        // Position of a = in the line
          var wordToSearch = null; // A word to be replaced
          var wordToSearchUpper = null;
          var substWord = null;    // The word to substitue
          // This tests numTxtReplacements < 9999 so that the 9999th one is the last
          // one read.
          while (!wordFile.eof && (numTxtReplacements < 9999))
          {
             // Read the next line from the config file.
             fileLine = wordFile.readln(2048);
    
             // fileLine should be a string, but I've seen some cases
             // where for some reason it isn't.  If it's not a string,
             // then continue onto the next line.
             if (typeof(fileLine) != "string")
                continue;
             // If the line starts with with a semicolon (the comment
             // character) or is blank, then skip it.
             if ((fileLine.substr(0, 1) == ";") || (fileLine.length == 0))
                continue;
    
             // Look for an equals sign, and if found, separate the line
             // into the setting name (before the =) and the value (after the
             // equals sign).
             equalsPos = fileLine.indexOf("=");
             if (equalsPos <= 0)
                continue; // = not found or is at the beginning, so go on to the next line
    
             // Extract the word to search and substitution word from the line.  If
             // not using regular expressions, then convert the word to search to
             // all uppercase for case-insensitive searching.
             wordToSearch = trimSpaces(fileLine.substr(0, equalsPos), true, false, true);
             wordToSearchUpper = wordToSearch.toUpperCase();
             substWord = strip_ctrl(trimSpaces(fileLine.substr(equalsPos+1), true, false, true));
             // Make sure substWord only contains printable characters.  If not, then
             // skip this one.
             var substIsPrintable = true;
             for (var i = 0; (i < substWord.length) && substIsPrintable; ++i)
                substIsPrintable = isPrintableChar(substWord.charAt(i));
             if (!substIsPrintable)
                continue;
    
             // And add the search word and replacement text to pArray.
             if (wordToSearchUpper != substWord.toUpperCase())
             {
                if (pRegex)
                   pArray[wordToSearch] = substWord;
                else
                   pArray[wordToSearchUpper] = substWord;
                ++numTxtReplacements;
             }
          }
    
          wordFile.close();
       }
    
       return numTxtReplacements;
    }
    
    function moveGenColorsToGenSettings(pColorsArray, pCfgObj)
    {
       // Set up an array of color setting names
       var colorSettingStrings = new Array();
       colorSettingStrings.push("crossPostBorder"); // Deprecated
       colorSettingStrings.push("crossPostBorderText"); // Deprecated
       colorSettingStrings.push("listBoxBorder");
       colorSettingStrings.push("listBoxBorderText");
       colorSettingStrings.push("crossPostMsgAreaNum");
       colorSettingStrings.push("crossPostMsgAreaNumHighlight");
       colorSettingStrings.push("crossPostMsgAreaDesc");
       colorSettingStrings.push("crossPostMsgAreaDescHighlight");
       colorSettingStrings.push("crossPostChk");
       colorSettingStrings.push("crossPostChkHighlight");
       colorSettingStrings.push("crossPostMsgGrpMark");
       colorSettingStrings.push("crossPostMsgGrpMarkHighlight");
       colorSettingStrings.push("msgWillBePostedHdr");
       colorSettingStrings.push("msgPostedGrpHdr");
       colorSettingStrings.push("msgPostedSubBoardName");
       colorSettingStrings.push("msgPostedOriginalAreaText");
       colorSettingStrings.push("msgHasBeenSavedText");
       colorSettingStrings.push("msgAbortedText");
       colorSettingStrings.push("emptyMsgNotSentText");
       colorSettingStrings.push("genMsgErrorText");
       colorSettingStrings.push("listBoxItemText");
       colorSettingStrings.push("listBoxItemHighlight");
    
       var colorName = "";
       for (var i = 0; i < colorSettingStrings.length; ++i)
       {
          colorName = colorSettingStrings[i];
          if (pColorsArray.hasOwnProperty(colorName))
          {
             pCfgObj.genColors[colorName] = pColorsArray[colorName];
             delete pColorsArray[colorName];
          }
       }
       // If listBoxBorder and listBoxBorderText exist in the general colors settings,
       // then remove crossPostBorder and crossPostBorderText if they exist.
       if (pCfgObj.genColors.hasOwnProperty["listBoxBorder"] && pCfgObj.genColors.hasOwnProperty["crossPostBorder"])
       {
          // Favor crossPostBorder to preserve backwards compatibility.
          pCfgObj.genColors["listBoxBorder"] = pCfgObj.genColors["crossPostBorder"];
          delete pCfgObj.genColors["crossPostBorder"];
       }
       if (pCfgObj.genColors.hasOwnProperty["listBoxBorderText"] && pCfgObj.genColors.hasOwnProperty["crossPostBorderText"])
       {
          // Favor crossPostBorderText to preserve backwards compatibility.
          pCfgObj.genColors["listBoxBorderText"] = pCfgObj.genColors["crossPostBorderText"];
          delete pCfgObj.genColors["crossPostBorderText"];
       }
    }
    
    // Returns whether or not a character is a letter.
    //
    // Parameters:
    //  pChar: The character to test
    //
    // Return value: Boolean - Whether or not the character is a letter
    function charIsLetter(pChar)
    {
       return /^[ABCDEFGHIJKLMNOPQRSTUVWXYZ]$/.test(pChar.toUpperCase());
    }
    
    // For configuration files, this function returns a fully-pathed filename.
    // This function first checks to see if the file exists in the sbbs/mods
    // directory, then the sbbs/ctrl directory, and if the file is not found there,
    // this function defaults to the given default path.
    //
    // Parameters:
    //  pFilename: The name of the file to look for
    //  pDefaultPath: The default directory (must have a trailing separator character)
    function genFullPathCfgFilename(pFilename, pDefaultPath)
    {
       var fullyPathedFilename = system.mods_dir + pFilename;
       if (!file_exists(fullyPathedFilename))
          fullyPathedFilename = system.ctrl_dir + pFilename;
       if (!file_exists(fullyPathedFilename))
       {
          if (typeof(pDefaultPath) == "string")
          {
             // Make sure the default path has a trailing path separator
             var defaultPath = backslash(pDefaultPath);
             fullyPathedFilename = defaultPath + pFilename;
          }
          else
             fullyPathedFilename = pFilename;
       }
       return fullyPathedFilename;
    }
    
    // Returns the first letter found in a string and its index.  If a letter is
    // not found, the string returned will be blank, and the index will be -1.
    //
    // Parameters:
    //  pString: The string to search
    //
    // Return value: An object with the following properties:
    //               letter: The first letter found in the string, or a blank string if none was found
    //               idx: The index of the first letter found, or -1 if none was found
    function getFirstLetterFromStr(pString)
    {
       var retObj = new Object;
       retObj.letter = "";
       retObj.idx = -1;
    
       var theChar = "";
       for (var i = 0; (i < pString.length) && (retObj.idx == -1); ++i)
       {
          theChar = pString.charAt(i);
          if (charIsLetter(theChar))
          {
             retObj.idx = i;
             retObj.letter = theChar;
          }
       }
    
       return retObj;
    }
    
    // Returns whether or not the first letter in a string is uppercase.  If the
    // string doesn't contain any letters, then this function will return false.
    //
    // Parameters:
    //  pString: The string to search
    //
    // Return value: Boolean - Whether or not the first letter in the string is uppercase
    function firstLetterIsUppercase(pString)
    {
       var firstIsUpper = false;
       var letterObj = getFirstLetterFromStr(pString);
       if (letterObj.idx > -1)
       {
          var theLetter = pString.charAt(letterObj.idx);
          firstIsUpper = (theLetter == theLetter.toUpperCase());
       }
       return firstIsUpper;
    }
    
    // Gets a keypress from the user.  Uses the configured timeout if configured to
    // do so and the user is not a sysop; otherwise (no timeout configured or the
    // user is a sysop), the configured input timeout will be used.
    //
    // Parameters:
    //  pMode: The input mode flag(s)
    //  pCfgObj: The configuration object (stores the input timeout setting)
    //
    // Return value: The user's keypress (the return value of console.getkey()
    //               or console.inkey()).
    function getUserKey(pMode, pCfgObj)
    {
       var defaultTimeoutMS = 300000;
       var userKey = "";
    
       if (typeof(pCfgObj) == "object")
       {
          // If the user is a sysop, don't use an input timeout.
          if ((typeof(pCfgObj.userIsSysop) == "boolean") && pCfgObj.userIsSysop)
             userKey = console.getkey(pMode);
          else if (typeof(pCfgObj.userInputTimeout) == "number")
             userKey = console.inkey(pMode, pCfgObj.inputTimeoutMS);
          else
             userKey = console.inkey(pMode, defaultTimeoutMS);
       }
       else if (typeof(pCfgObj) == "boolean")
       {
          // pCfgObj is a boolean that specifies whether or not the user is a sysop.
          // If so, then use console.getkey().  If the user isn't a sysop, use a
          // timeout of 5 minutes.
          if (pCfgObj)
             userKey = console.getkey(pMode);
          else
             userKey = console.inkey(pMode, defaultTimeoutMS);
       }
       else // pCfgObj is not a known type, so use the default input timeout.
          userKey = console.inkey(pMode, defaultTimeoutMS);
    
       return userKey;
    }
    
    // Gets a string of user input such that each character is validated against a
    // set of strings.  Each string contains a list of valid characters, and each
    // set of potential valid characters can only appear once in the user input.
    // This function was written to be used in color selection in lieu of
    // console.getkeys(), which outputs a carriage return at the end, which was not
    // desirable.  For instance, color selection involves prompting the user for
    // 3 characters (foreground, background, and special).
    //
    // Parameters:
    //  pMode: The input mode bit(s).  See K_* in sbbsdefs.js for mode bits.
    //  pValidKeyStrs: An array of strings containing valid keys
    //  pCfgObj: The SlyEdit configuration settings object
    //  pCurPos: Optional.  Contains x and y coordinates specifying the current
    //           cursor position.  If this is not specified, this function will
    //           get the cursor position by calling console.getxy().
    //
    // Return value: The user's input, as a string.  If nothing is input, this
    //               function will return an empty string.
    function getUserInputWithSetOfInputStrs(pMode, pValidKeyStrs, pCfgObj, pCurPos)
    {
       if (pValidKeyStrs == null)
          return "";
       if (pValidKeyStrs.length == 0)
          return "";
    
       // Get the current cursor position, either from pCurPos or console.getxy().
       var curPos = (pCurPos != null ? pCurPos : console.getxy());
    
       // Build one string containing all the valid keys.
       var allValidKeys = "";
       for (var i = 0; i < pValidKeyStrs.length; ++i)
          allValidKeys += pValidKeyStrs[i];
    
       // User input loop
       var displayChars = !((pMode & K_NOECHO) == K_NOECHO);
       var userInput = "";
       var inputKey = "";
       var lastKey = "";
       var validKey = false;
       var idx = 0;
       var continueOn = true;
       while (continueOn)
       {
          inputKey = getUserKey(pMode|K_NOECHO, pCfgObj);
          // If userInput is blank, then the timeout was probably reached, so don't
          // continue inputting characters.
          if (inputKey.length == 0)
             break;
    
          switch (inputKey)
          {
             case BACKSPACE:
                // See if lastKey is in any of the strings in pValidKeyStrs.  If so,
                // then append that string back onto allValidKeys.
                for (var i = 0; i < pValidKeyStrs.length; ++i)
                {
                   if (pValidKeyStrs[i].indexOf(lastKey) > -1)
                   {
                      allValidKeys += pValidKeyStrs[i];
                      break;
                   }
                }
    
                // If userInput has some characters in it, then remove the last
                // one and move the cursor back one space on the screen.
                if (userInput.length > 0)
                {
                   userInput = userInput.substr(0, userInput.length-1);
                   // If we are to display the input characters, then also blank out
                   // the character on the screen and make sure the cursor is placed
                   // properly on the screen.
                   if (displayChars)
                   {
                      console.gotoxy(--curPos.x, curPos.y);
                      console.print(" ");
                      console.gotoxy(curPos);
                   }
                }
                break;
             // ESC and Ctrl-K: Cancel out of color selection, whereas
             // ENTER will save the user's input before returning.
             case KEY_ESC:
             case CTRL_K:
                userInput = "";
             case KEY_ENTER:
                continueOn = false;
                break;
             default:
                validKey = (allValidKeys.indexOf(inputKey) > -1);
                if (validKey)
                {
                   // Find the key in one of the strings in pValidKeyStrs.  When
                   // found, remove that string from allValidKeys.
                   for (var i = 0; i < pValidKeyStrs.length; ++i)
                   {
                      validKey = (pValidKeyStrs[i].indexOf(inputKey) > -1);
                      if (validKey)
                      {
                         // Remove the current string from allValidKeys
                         idx = allValidKeys.indexOf(pValidKeyStrs[i]);
                         if (idx > -1)
                         {
                            allValidKeys = allValidKeys.substr(0, idx)
                                         + allValidKeys.substr(idx + pValidKeyStrs[i].length);
                         }
    
                         break;
                      }
                   }
                }
    
                // If the user pressed a valid key (found in the input strings), then
                // append it to userInput.
                if (validKey)
                {
                   // If K_NOECHO wasn't passed in pMode, then output the keypress
                   if (displayChars)
                   {
                      console.print(inputKey);
                      ++curPos.x;
                   }
                   userInput += inputKey;
                }
                break;
          }
    
          // Update lastKey.  Default to the last keypress, but if there is anything
          // in userInput, then set lastKey to the last character in userInput.
          lastKey = inputKey;
          if (userInput.length > 0)
             lastKey = userInput.substr(userInput.length-1);
       }
       return userInput;
    }
    
    // Reads a text file and returns an array of strings containing the lines from
    // the text file.
    //
    // Parameters:
    //  pFilename: The name of the file to read
    //  pStripCtrl: Boolean - Whether or not to strip control characters from the lines
    //  pIgnoreBlankLines: Boolean - Whether or not to ignore blank lines
    //  pMaxNumLines: Optional - The maximum number of lines to read from the file
    //
    // Return value: An array of strings read from the file
    function readTxtFileIntoArray(pFilename, pStripCtrl, pIgnoreBlankLines, pMaxNumLines)
    {
       var fileStrs = new Array();
    
       var maxNumLines = -1;
       if (typeof(pMaxNumLines) == "number")
       {
          if (pMaxNumLines > -1)
             maxNumLines = pMaxNumLines;
       }
       if (maxNumLines == 0)
          return fileStrs;
    
       var txtFile = new File(pFilename);
       if (txtFile.open("r"))
       {
          var fileLine = null;  // A line read from the file
          var numLinesRead = 0;
          while (!txtFile.eof)
          {
             // Read the next line from the config file.
             fileLine = txtFile.readln(2048);
    
             // fileLine should be a string, but I've seen some cases
             // where for some reason it isn't.  If it's not a string,
             // then continue onto the next line.
             if (typeof(fileLine) != "string")
                continue;
    
             if (pStripCtrl)
                fileLine = strip_ctrl(fileLine);
    
             if (pIgnoreBlankLines && (fileLine.length == 0))
                continue;
    
             fileStrs.push(fileLine);
    
             ++numLinesRead;
             if ((maxNumLines > 0) && (numLinesRead >= maxNumLines))
                break;
          }
    
          txtFile.close();
       }
    
       return fileStrs;
    }
    
    // Returns whether or not a text file contains lines in it.
    //
    // Parameters:
    //  pFilename: The name of the file to test
    //
    // Return value: Boolean - Whether or not the file contains lines
    function txtFileContainsLines(pFilename)
    {
       var fileContainsLines = false;
    
       var txtFile = new File(pFilename);
       if (txtFile.open("r"))
       {
          var fileLine = null;  // A line read from the file
          var numLinesRead = 0;
          while (!txtFile.eof && (numLinesRead == 0))
          {
             // Read the next line from the config file.
             fileLine = txtFile.readln(2048);
    
             // fileLine should be a string, but I've seen some cases
             // where for some reason it isn't.  If it's not a string,
             // then continue onto the next line.
             if (typeof(fileLine) != "string")
                continue;
             ++numLinesRead;
          }
          fileContainsLines = (numLinesRead > 0);
    
          txtFile.close();
       }
    
       return fileContainsLines;
    }
    
    // Reads the user settings file and returns an object containing the user's
    // settings.
    //
    // Parameters:
    //  pSlyEdCfgObj: The SlyEdit configuration object, which contains defaults
    //                for some of the user settings.  This settings object should
    //                be populated before this function is called.
    //
    // Return value: An object containing the user's settings as properties.
    function ReadUserSettingsFile(pSlyEdCfgObj)
    {
    	// Initialize the settings object with the default settings
    	var userSettingsObj = {
    		enableTaglines: pSlyEdCfgObj.enableTaglines,
    		useQuoteLineInitials: pSlyEdCfgObj.useQuoteLineInitials,
    		// The next setting specifies whether or not quote lines should be
    		// prefixed with a space when using author initials.
    		indentQuoteLinesWithInitials: pSlyEdCfgObj.indentQuoteLinesWithInitials,
    		// Whether or not to trim spaces from quoted lines
    		trimSpacesFromQuoteLines: true,
    		autoSignMessages: false,
    		autoSignRealNameOnlyFirst: true,
    		autoSignEmailsRealName: true
    	};
    
    	// Open the user settings file
    	var userSettingsFile = new File(gUserSettingsFilename);
    	if (userSettingsFile.open("r"))
    	{
    		var settingsMode = "behavior";
    		var fileLine = null;     // A line read from the file
    		var equalsPos = 0;       // Position of a = in the line
    		var commentPos = 0;      // Position of the start of a comment
    		var setting = null;      // A setting name (string)
    		var settingUpper = null; // Upper-case setting name
    		var value = null;        // A value for a setting (string)
    		var valueUpper = null;   // Upper-cased value
    		while (!userSettingsFile.eof)
    		{
    			// Read the next line from the config file.
    			fileLine = userSettingsFile.readln(2048);
    
    			// fileLine should be a string, but I've seen some cases
    			// where for some reason it isn't.  If it's not a string,
    			// then continue onto the next line.
    			if (typeof(fileLine) != "string")
    				continue;
    
    			// If the line starts with with a semicolon (the comment
    			// character) or is blank, then skip it.
    			if ((fileLine.substr(0, 1) == ";") || (fileLine.length == 0))
    				continue;
    
    			// If in the "behavior" section, then set the behavior-related variables.
    			if (fileLine.toUpperCase() == "[BEHAVIOR]")
    			{
    				settingsMode = "behavior";
    				continue;
    			}
    
    			// If the line has a semicolon anywhere in it, then remove
    			// everything from the semicolon onward.
    			commentPos = fileLine.indexOf(";");
    			if (commentPos > -1)
    				fileLine = fileLine.substr(0, commentPos);
    
    			// Look for an equals sign, and if found, separate the line
    			// into the setting name (before the =) and the value (after the
    			// equals sign).
    			equalsPos = fileLine.indexOf("=");
    			if (equalsPos > 0)
    			{
    				// Read the setting & value, and trim leading & trailing spaces.
    				setting = trimSpaces(fileLine.substr(0, equalsPos), true, false, true);
    				settingUpper = setting.toUpperCase();
    				value = trimSpaces(fileLine.substr(equalsPos+1), true, false, true);
    				valueUpper = value.toUpperCase();
    
    				if (settingsMode == "behavior")
    				{
    					if (settingUpper == "ENABLETAGLINES")
    						userSettingsObj.enableTaglines = (valueUpper == "TRUE");
    					else if (settingUpper == "USEQUOTELINEINITIALS")
    						userSettingsObj.useQuoteLineInitials = (valueUpper == "TRUE");
    					else if (settingUpper == "INDENTQUOTELINESWITHINITIALS")
    						userSettingsObj.indentQuoteLinesWithInitials = (valueUpper == "TRUE");
    					else if (settingUpper == "TRIMSPACESFROMQUOTELINES")
    						userSettingsObj.trimSpacesFromQuoteLines = (valueUpper == "TRUE");
    					else if (settingUpper == "AUTOSIGNMESSAGES")
    						userSettingsObj.autoSignMessages = (valueUpper == "TRUE");
    					else if (settingUpper == "AUTOSIGNREALNAMEONLYFIRST")
    						userSettingsObj.autoSignRealNameOnlyFirst = (valueUpper == "TRUE");
    					else if (settingUpper == "AUTOSIGNEMAILSREALNAME")
    						userSettingsObj.autoSignEmailsRealName = (valueUpper == "TRUE");
    				}
    			}
    		}
    		userSettingsFile.close();
    	}
    	else
    	{
    		// We couldn't read the user settings file - So this is probably the
    		// first time the user has run SlyEdit.  So, save the settings to the
    		// file.
    		var saveSucceeded = WriteUserSettingsFile(userSettingsObj);
    	}
    
    	return userSettingsObj;
    }
    
    // Writes the user settings to the user settings file, overwriting the
    // existing file.
    //
    // Parameters:
    //  pUserSettingsObj: The object containing the user's settings
    //
    // Return value: Boolean - Whether or not this function succeeded in writing the file.
    function WriteUserSettingsFile(pUserSettingsObj)
    {
    	var writeSucceeded = false;
    
    	var userSettingsFile = new File(gUserSettingsFilename);
    	if (userSettingsFile.open("w"))
    	{
    		const behaviorBoolSettingNames = ["enableTaglines",
    		                                  "useQuoteLineInitials",
    		                                  "indentQuoteLinesWithInitials",
    		                                  "trimSpacesFromQuoteLines",
    		                                  "autoSignMessages",
    		                                  "autoSignRealNameOnlyFirst",
    		                                  "autoSignEmailsRealName"];
    		userSettingsFile.writeln("[BEHAVIOR]");
    		for (var i = 0; i < behaviorBoolSettingNames.length; ++i)
    		{
    			if (pUserSettingsObj.hasOwnProperty(behaviorBoolSettingNames[i]))
    				userSettingsFile.writeln(behaviorBoolSettingNames[i] + "=" + (pUserSettingsObj[behaviorBoolSettingNames[i]] ? "true" : "false"));
    		}
    
    		userSettingsFile.close();
    		writeSucceeded = true;
    	}
    
    	return writeSucceeded;
    }
    
    // Changes a character in a string, and returns the new string.  If any of the
    // parameters are invalid, then the original string will be returned.
    //
    // Parameters:
    //  pStr: The original string
    //  pCharIndex: The index of the character to replace
    //  pNewText: The new character or text to place at that position in the string
    //
    // Return value: The new string
    function chgCharInStr(pStr, pCharIndex, pNewText)
    {
       if (typeof(pStr) != "string")
          return "";
       if ((pCharIndex < 0) || (pCharIndex >= pStr.length))
          return pStr;
       if (typeof(pNewText) != "string")
          return pStr;
    
       return (pStr.substr(0, pCharIndex) + pNewText + pStr.substr(pCharIndex+1));
    }
    
    // Shuffles (randomizes) the contents of an array and returns the new
    // array.  This function came from the following web page:
    // http://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array-in-javascript
    //
    // Parameters:
    //  pArray: The array to shuffle
    //
    // Return value: The new array
    function shuffleArray(pArray)
    {
        var counter = pArray.length, temp, index;
    
        // While there are elements in the pArray
        while (counter--)
        {
            // Pick a random index
            index = (Math.random() * (counter + 1)) | 0;
    
            // And swap the last element with it
            temp = pArray[counter];
            pArray[counter] = pArray[index];
            pArray[index] = temp;
        }
    
        return pArray;
    }
    
    // Performs the same function as console.pause(), but also allows input of multi-key
    // sequences such as PageUp, PageDown, F1, etc. without writing extra characters on
    // the screen.
    //
    // Parameters:
    //  pCfgObj: Optional - The configuration object, which specifies the input timeout.
    function consolePauseWithESCChars(pCfgObj)
    {
    	// Get the pause prompt text from text.dat.  In case that text contains
    	// "@EXEC:" (to execute a script), default to a "press a key" message.
    	var pausePromptText = bbs.text(Pause); // 563: The "Press a key" text in text.dat
    	if (pausePromptText.toUpperCase().indexOf("@EXEC:") > -1)
    		pausePromptText = "\1n\1c[ Press a key ] ";
    	console.print("\1n" + pausePromptText);
    	getKeyWithESCChars(K_NOSPIN|K_NOCRLF|K_NOECHO, pCfgObj);
    }
    
    // Inputs a keypress from the user and handles some ESC-based
    // characters such as PageUp, PageDown, and ESC.  If PageUp
    // or PageDown are pressed, this function will return the
    // string defined by KEY_PAGE_UP or EY_PAGE_DOWN,
    // respectively.  Also, F1-F5 will be returned as "\1F1"
    // through "\1F5", respectively.
    // Thanks goes to Psi-Jack for the original impementation
    // of this function.
    //
    // Parameters:
    //  pGetKeyMode: Optional - The mode bits for console.getkey().
    //               If not specified, K_NONE will be used.
    //  pCfgObj: The configuration object (stores the input timeout setting)
    //
    // Return value: The user's keypress
    function getKeyWithESCChars(pGetKeyMode, pCfgObj)
    {
    	var getKeyMode = (typeof(pGetKeyMode) == "number" ? pGetKeyMode : K_NONE);
    	var userInput = getUserKey(getKeyMode, pCfgObj);
    	if (userInput == KEY_ESC)
    	{
    		switch (console.inkey(K_NOECHO|K_NOSPIN, 2))
    		{
    			case '[':
    				switch (console.inkey(K_NOECHO|K_NOSPIN, 2))
    				{
    					case 'V':
    						userInput = KEY_PAGE_UP;
    						break;
    					case 'U':
    						userInput = KEY_PAGE_DOWN;
    						break;
    				}
    				break;
    			case 'O':
    				switch (console.inkey(K_NOECHO|K_NOSPIN, 2))
    				{
    					case 'P':
    						userInput = KEY_F1;
    						break;
    					case 'Q':
    						userInput = KEY_F2;
    						break;
    					case 'R':
    						userInput = KEY_F3;
    						break;
    					case 'S':
    						userInput = KEY_F4;
    						break;
    					case 't':
    						userInput = KEY_F5;
    						break;
    				}
    			default:
    				break;
    		}
    	}
    
    	return userInput;
    }
    
    // This function displays debug text at a given location on the screen, then
    // moves the cursor back to a given location.
    //
    // Parameters:
    //  pDebugX: The X lcoation of where to write the debug text
    //  pDebugY: The Y lcoation of where to write the debug text
    //  pText: The text to write at the debug location
    //  pOriginalPos: An object with x and y properties containing the original cursor position
    //  pClearDebugLineFirst: Whether or not to clear the debug line before writing the text
    //  pPauseAfter: Whether or not to pause after displaying the text
    function displayDebugText(pDebugX, pDebugY, pText, pOriginalPos, pClearDebugLineFirst, pPauseAfter)
    {
    	console.gotoxy(pDebugX, pDebugY);
    	if (pClearDebugLineFirst)
    		console.clearline();
    	// Output the text
    	console.print(pText);
    	if (pPauseAfter)
          console.pause();
    	if ((typeof(pOriginalPos) != "undefined") && (pOriginalPos != null))
    		console.gotoxy(pOriginalPos);
    }