Skip to content
Snippets Groups Projects
DDMsgReader.js 704 KiB
Newer Older
// (according to this.searchType) and the search result objects are defined for
// the current sub-board (as specified by this.subBoardCode).
//
// Return value: Boolean - Whether or not there is a search specified and the
//               search result objects are defined for the current sub-board
//               (as specified by this.subBoardCode).
function DigDistMsgReader_SearchingAndResultObjsDefinedForCurSub()
{
	return (this.SearchTypePopulatesSearchResults() && (this.msgSearchHdrs != null) &&
	         this.msgSearchHdrs.hasOwnProperty(this.subBoardCode) &&
	         (typeof(this.msgSearchHdrs[this.subBoardCode]) == "object") &&
	         (typeof(this.msgSearchHdrs[this.subBoardCode].indexed) != "undefined"));
}

// For the DigDistMsgReader class: Removes a message header from the search
// results array for the current sub-board.
//
// Parameters:
//  pMsgIdx: The index of the message header to remove (in the indexed messages,
//           not necessarily the actual message offset in the messagebase)
function DigDistMsgReader_RemoveFromSearchResults(pMsgIdx)
{
	if (typeof(pMsgIdx) != "number")
		return;

	if ((typeof(this.msgSearchHdrs) == "object") &&
	    this.msgSearchHdrs.hasOwnProperty(this.subBoardCode) &&
	    (typeof(this.msgSearchHdrs[this.subBoardCode].indexed) != "undefined"))
	{
		if ((pMsgIdx >= 0) && (pMsgIdx < this.msgSearchHdrs[this.subBoardCode].indexed.length))
			this.msgSearchHdrs[this.subBoardCode].indexed.splice(pMsgIdx, 1);
	}
}

// For the DigDistMsgReader class: Looks for the next message in the thread of
// a given message (by its header).
//
// Paramters:
//  pMsgHdr: A message header object - The next message in the thread will be
//           searched starting from this message
//  pThreadType: The type of threading to use.  Can be THREAD_BY_ID, THREAD_BY_TITLE,
//               THREAD_BY_AUTHOR, or THREAD_BY_TO_USER.
//  pPositionCursorForStatus: Optional boolean - Whether or not to move the cursor
//                            to the bottom row before outputting status messages.
//                            Defaults to false.
//
// Return value: The offset (index) of the next message thread, or -1 if none
//               was found.
function DigDistMsgReader_FindThreadNextOffset(pMsgHdr, pThreadType, pPositionCursorForStatus)
{
	if ((this.msgbase == null) || (!this.msgbase.is_open))
		return -1;
	if ((pMsgHdr == null) || (typeof(pMsgHdr) != "object"))
		return -1;

	var newMsgOffset = -1;

			// The thread_id field was introduced in Synchronet 3.16.  So, if
			// the Synchronet version is 3.16 or higher and the message header
			// has a thread_id field, then look for the next message with the
			// same thread ID.  If the Synchronet version is below 3.16 or there
			// is no thread ID, then fall back to using the header's thread_next,
			// if it exists.
			if ((system.version_num >= 31600) && (typeof(pMsgHdr.thread_id) == "number"))
			{
				// Look for the next message with the same thread ID.
				// Write "Searching.."  in case searching takes a while.
				console.print("\1n");
				if (pPositionCursorForStatus)
				{
					console.gotoxy(1, console.screen_rows);
					console.cleartoeol();
					console.gotoxy(this.msgAreaLeft, console.screen_rows);
				}
				console.print("\1h\1ySearching\1i...\1n");
				// Look for the next message in the thread
				var nextMsgOffset = -1;
				var numOfMessages = this.NumMessages();
				if (pMsgHdr.offset < numOfMessages - 1)
				{
					var nextMsgHdr;
					for (var messageIdx = pMsgHdr.offset+1; (messageIdx < numOfMessages) && (nextMsgOffset == -1); ++messageIdx)
					{
						nextMsgHdr = this.GetMsgHdrByIdx(messageIdx);
						if (((nextMsgHdr.attr & MSG_DELETE) == 0) && (typeof(nextMsgHdr.thread_id) == "number") && (nextMsgHdr.thread_id == pMsgHdr.thread_id))
							nextMsgOffset = nextMsgHdr.offset;
					}
				}
				if (nextMsgOffset > -1)
					newMsgOffset = nextMsgOffset;
			}
			// Fall back to thread_next if the Synchronet version is below 3.16 or there is
			// no thread_id field in the header
			else if ((typeof(pMsgHdr.thread_next) == "number") && (pMsgHdr.thread_next > 0))
				newMsgOffset = this.AbsMsgNumToIdx(pMsgHdr.thread_next);
			break;
		case THREAD_BY_TITLE:
		case THREAD_BY_AUTHOR:
		case THREAD_BY_TO_USER:
			// Title (subject) searching will look for the subject anywhere in the
			// other messages' subjects (not a fully exact subject match), so if
			// the message subject is blank, we won't want to do the search.
			var doSearch = true;
			if ((pThreadType == THREAD_BY_TITLE) && (pMsgHdr.subject.length == 0))
				doSearch = false;
			if (doSearch)
				var subjUppercase = "";
				var fromNameUppercase = "";
				var toNameUppercase = "";

				// Set up a message header matching function, depending on
				// which field of the header we want to match
				var msgHdrMatch;
				if (pThreadType == THREAD_BY_TITLE)
				{
					subjUppercase = pMsgHdr.subject.toUpperCase();
					// Remove any leading instances of "RE:" from the subject
					while (/^RE:/.test(subjUppercase))
						subjUppercase = subjUppercase.substr(3);
					while (/^RE: /.test(subjUppercase))
						subjUppercase = subjUppercase.substr(4);
					// Remove any leading & trailing whitespace from the subject
					subjUppercase = trimSpaces(subjUppercase, true, true, true);
					msgHdrMatch = function(pMsgHdr) {
						return (((pMsgHdr.attr & MSG_DELETE) == 0) && (pMsgHdr.subject.toUpperCase().indexOf(subjUppercase, 0) > -1));
					};
				}
				else if (pThreadType == THREAD_BY_AUTHOR)
				{
					fromNameUppercase = pMsgHdr.from.toUpperCase();
					msgHdrMatch = function(pMsgHdr) {
						return (((pMsgHdr.attr & MSG_DELETE) == 0) && (pMsgHdr.from.toUpperCase() == fromNameUppercase));
					};
				}
				else if (pThreadType == THREAD_BY_TO_USER)
				{
					toNameUppercase = pMsgHdr.to.toUpperCase();
					msgHdrMatch = function(pMsgHdr) {
						return (((pMsgHdr.attr & MSG_DELETE) == 0) && (pMsgHdr.to.toUpperCase() == toNameUppercase));
					};
				}

				// Perform the search
				// Write "Searching.."  in case searching takes a while.
				console.print("\1n");
				if (pPositionCursorForStatus)
				{
					console.gotoxy(1, console.screen_rows);
					console.cleartoeol();
					console.gotoxy(this.msgAreaLeft, console.screen_rows);
				}
				console.print("\1h\1ySearching\1i...\1n");
				// Look for the next message that contains the given message's subject
				var nextMsgOffset = -1;
				var numOfMessages = this.NumMessages();
				if (pMsgHdr.offset < numOfMessages - 1)
					var nextMsgHdr;
					for (var messageIdx = pMsgHdr.offset+1; (messageIdx < numOfMessages) && (nextMsgOffset == -1); ++messageIdx)
					{
						nextMsgHdr = this.GetMsgHdrByIdx(messageIdx);
						if (msgHdrMatch(nextMsgHdr))
							nextMsgOffset = nextMsgHdr.offset;
					}
				if (nextMsgOffset > -1)
					newMsgOffset = nextMsgOffset;
	// If no messages were found, then output a message to say so with a momentary pause.
	if (newMsgOffset == -1)
	{
		if (pPositionCursorForStatus)
		{
			console.gotoxy(1, console.screen_rows);
			console.cleartoeol();
			console.gotoxy(this.msgAreaLeft, console.screen_rows);
		}
		else
			console.crlf();
		console.print("\1n\1h\1yNo messages found.\1n");
		mswait(ERROR_PAUSE_WAIT_MS);
	}

	return newMsgOffset;
}

// For the DigDistMsgReader class: Looks for the previous message in the thread of
// a given message (by its header).
//
// Paramters:
//  pMsgHdr: A message header object - The previous message in the thread will be
//           searched starting from this message
//  pThreadType: The type of threading to use.  Can be THREAD_BY_ID, THREAD_BY_TITLE,
//               THREAD_BY_AUTHOR, or THREAD_BY_TO_USER.
//  pPositionCursorForStatus: Optional boolean - Whether or not to move the cursor
//                            to the bottom row before outputting status messages.
//                            Defaults to false.
//
// Return value: The offset (index) of the previous message thread, or -1 if
//               none was found.
function DigDistMsgReader_FindThreadPrevOffset(pMsgHdr, pThreadType, pPositionCursorForStatus)
{
	if ((this.msgbase == null) || (!this.msgbase.is_open))
		return -1;
	if ((pMsgHdr == null) || (typeof(pMsgHdr) != "object"))
		return -1;

	var newMsgOffset = -1;

			// The thread_id field was introduced in Synchronet 3.16.  So, if
			// the Synchronet version is 3.16 or higher and the message header
			// has a thread_id field, then look for the previous message with the
			// same thread ID.  If the Synchronet version is below 3.16 or there
			// is no thread ID, then fall back to using the header's thread_next,
			// if it exists.
			if ((system.version_num >= 31600) && (typeof(pMsgHdr.thread_id) == "number"))
			{
				// Look for the previous message with the same thread ID.
				// Write "Searching.." in case searching takes a while.
				console.print("\1n");
				if (pPositionCursorForStatus)
				{
					console.gotoxy(1, console.screen_rows);
					console.cleartoeol();
					console.gotoxy(this.msgAreaLeft, console.screen_rows);
				}
				console.print("\1h\1ySearching\1i...\1n");
				// Look for the previous message in the thread
				var nextMsgOffset = -1;
				if (pMsgHdr.offset > 0)
				{
					var prevMsgHdr;
					for (var messageIdx = pMsgHdr.offset-1; (messageIdx >= 0) && (nextMsgOffset == -1); --messageIdx)
					{
						prevMsgHdr = this.GetMsgHdrByIdx(messageIdx);
						if (((prevMsgHdr.attr & MSG_DELETE) == 0) && (typeof(prevMsgHdr.thread_id) == "number") && (prevMsgHdr.thread_id == pMsgHdr.thread_id))
							nextMsgOffset = prevMsgHdr.offset;
					}
				}
				if (nextMsgOffset > -1)
					newMsgOffset = nextMsgOffset;
			}
			// Fall back to thread_next if the Synchronet version is below 3.16 or there is
			// no thread_id field in the header
			else if ((typeof(pMsgHdr.thread_back) == "number") && (pMsgHdr.thread_back > 0))
				newMsgOffset = this.AbsMsgNumToIdx(pMsgHdr.thread_back);

			/*
			// If thread_back is valid for the message header, then use that.
			if ((typeof(pMsgHdr.thread_back) == "number") && (pMsgHdr.thread_back > 0))
				newMsgOffset = this.AbsMsgNumToIdx(pMsgHdr.thread_back);
			else
				// If thread_id is defined and the index of the first message
				// in the thread is before the current message, then search
				// backwards for messages with a matching thread_id.
				var firstThreadMsgIdx = this.AbsMsgNumToIdx(pMsgHdr.thread_first);
				if ((typeof(pMsgHdr.thread_id) == "number") && (firstThreadMsgIdx < pMsgHdr.offset))
				{
					// Note (2014-10-11): Digital Man said thread_id was
					// introduced in Synchronet version 3.16 and was still
					// a work in progress and isn't 100% accurate for
					// networked sub-boards.

					// Look for the previous message with the same thread ID.
					// Note: I'm not sure when thread_id was introduced in
					// Synchronet, so I'm not sure of the minimum version where
					// this will work.
					// Write "Searching.." in case searching takes a while.
					console.print("\1n");
					if (pPositionCursorForStatus)
					{
						console.gotoxy(1, console.screen_rows);
						console.cleartoeol();
						console.gotoxy(this.msgAreaLeft, console.screen_rows);
					}
					console.print("\1h\1ySearching\1i...\1n");
					// Look for the previous message in the thread
					var nextMsgOffset = -1;
					if (pMsgHdr.offset > 0)
					{
						for (var messageIdx = pMsgHdr.offset-1; (messageIdx >= 0) && (nextMsgOffset == -1); --messageIdx)
						{
							var prevMsgHdr = this.GetMsgHdrByIdx(messageIdx);
							if (((prevMsgHdr.attr & MSG_DELETE) == 0) && (typeof(prevMsgHdr.thread_id) == "number") && (prevMsgHdr.thread_id == pMsgHdr.thread_id))
								nextMsgOffset = prevMsgHdr.offset;
						}
					}
					if (nextMsgOffset > -1)
						newMsgOffset = nextMsgOffset;
				}
			break;
		case THREAD_BY_TITLE:
		case THREAD_BY_AUTHOR:
		case THREAD_BY_TO_USER:
			// Title (subject) searching will look for the subject anywhere in the
			// other messages' subjects (not a fully exact subject match), so if
			// the message subject is blank, we won't want to do the search.
			var doSearch = true;
			if ((pThreadType == THREAD_BY_TITLE) && (pMsgHdr.subject.length == 0))
				doSearch = false;
			if (doSearch)
				var subjUppercase = "";
				var fromNameUppercase = "";
				var toNameUppercase = "";

				// Set up a message header matching function, depending on
				// which field of the header we want to match
				var msgHdrMatch;
				if (pThreadType == THREAD_BY_TITLE)
				{
					subjUppercase = pMsgHdr.subject.toUpperCase();
					// Remove any leading instances of "RE:" from the subject
					while (/^RE:/.test(subjUppercase))
						subjUppercase = subjUppercase.substr(3);
					while (/^RE: /.test(subjUppercase))
						subjUppercase = subjUppercase.substr(4);
					// Remove any leading & trailing whitespace from the subject
					subjUppercase = trimSpaces(subjUppercase, true, true, true);
					msgHdrMatch = function(pMsgHdr) {
						return (((pMsgHdr.attr & MSG_DELETE) == 0) && (pMsgHdr.subject.toUpperCase().indexOf(subjUppercase, 0) > -1));
					};
				}
				else if (pThreadType == THREAD_BY_AUTHOR)
				{
					fromNameUppercase = pMsgHdr.from.toUpperCase();
					msgHdrMatch = function(pMsgHdr) {
						return (((pMsgHdr.attr & MSG_DELETE) == 0) && (pMsgHdr.from.toUpperCase() == fromNameUppercase));
					};
				}
				else if (pThreadType == THREAD_BY_TO_USER)
				{
					toNameUppercase = pMsgHdr.to.toUpperCase();
					msgHdrMatch = function(pMsgHdr) {
						return (((pMsgHdr.attr & MSG_DELETE) == 0) && (pMsgHdr.to.toUpperCase() == toNameUppercase));
					};
				}

				// Perform the search
				// Write "Searching.."  in case searching takes a while.
				console.print("\1n");
				if (pPositionCursorForStatus)
				{
					console.gotoxy(1, console.screen_rows);
					console.cleartoeol();
					console.gotoxy(this.msgAreaLeft, console.screen_rows);
				}
				console.print("\1h\1ySearching\1i...\1n");
				// Look for the next message that contains the given message's subject
				var nextMsgOffset = -1;
				if (pMsgHdr.offset > 0)
					var nextMsgHdr;
					for (var messageIdx = pMsgHdr.offset-1; (messageIdx >= 0) && (nextMsgOffset == -1); --messageIdx)
					{
						nextMsgHdr = this.GetMsgHdrByIdx(messageIdx);
						if (msgHdrMatch(nextMsgHdr))
							nextMsgOffset = nextMsgHdr.offset;
					}
				if (nextMsgOffset > -1)
					newMsgOffset = nextMsgOffset;
	// If no messages were found, then output a message to say so with a momentary pause.
	if (newMsgOffset == -1)
	{
		if (pPositionCursorForStatus)
		{
			console.gotoxy(1, console.screen_rows);
			console.cleartoeol();
			console.gotoxy(this.msgAreaLeft, console.screen_rows);
		}
		else
			console.crlf();
		console.print("\1n\1h\1yNo messages found.\1n");
		mswait(ERROR_PAUSE_WAIT_MS);
	}

	return newMsgOffset;
}

// For the DigDistMsgReader class: Calculates the top message index for a page,
// for the traditional-style message list.
//
// Parameters:
//  pPageNum: A page number (1-based)
function DigDistMsgReader_CalcTraditionalMsgListTopIdx(pPageNum)
{
      this.tradListTopMsgIdx = this.NumMessages() - (this.tradMsgListNumLines * (pPageNum-1)) - 1;
   else
      this.tradListTopMsgIdx = (this.tradMsgListNumLines * (pPageNum-1));
}

// For the DigDistMsgReader class: Calculates the top message index for a page,
// for the lightbar message list.
//
// Parameters:
//  pPageNum: A page number (1-based)
function DigDistMsgReader_CalcLightbarMsgListTopIdx(pPageNum)
{
      this.lightbarListTopMsgIdx = this.NumMessages() - (this.lightbarMsgListNumLines * (pPageNum-1)) - 1;
   else
      this.lightbarListTopMsgIdx = (this.lightbarMsgListNumLines * (pPageNum-1));
}

// For the DigDistMsgReader class: Given a message number (1-based), this calculates
// the screen index veriables (stored in the object) for the message list.  This is
// used for the enhanced reader mode when we want the message list to be in the
// correct place for the message being read.
//
// Parameters:
//  pMsgNum: The message number (1-based)
function DigDistMsgReader_CalcMsgListScreenIdxVarsFromMsgNum(pMsgNum)
{
	// Calculate the message list variables
   var numItemsPerPage = this.tradMsgListNumLines;
   if (this.msgListUseLightbarListInterface && canDoHighASCIIAndANSI())
      numItemsPerPage = this.lightbarMsgListNumLines;
   var newPageNum = findPageNumOfItemNum(pMsgNum, numItemsPerPage, this.NumMessages(), this.reverseListOrder);
   this.CalcTraditionalMsgListTopIdx(newPageNum);
   this.CalcLightbarMsgListTopIdx(newPageNum);
	this.lightbarListSelectedMsgIdx = pMsgNum - 1;
   if (this.lightbarListCurPos == null)
      this.lightbarListCurPos = new Object();
   this.lightbarListCurPos.x = 1;
	this.lightbarListCurPos.y = this.lightbarMsgListStartScreenRow + ((pMsgNum-1) - this.lightbarListTopMsgIdx);
}

// For the DigDistMsgReader class: Validates a user's choice in message area.
// Returns a status/error message for the caller to display if there's an
// error.  This function outputs intermediate status messages (i.e.,
// "Searching..").
//
// Parameters:
//  pGrpIdx: The message group index (i.e., bbs.curgrp)
//  pSubIdx: The message sub-board index (i.e., bbs.cursub)
//  pCurPos: Optional - An object containing x and y properties representing
//           the cursor position.  Used for outputting intermediate status
//           messages, but not for outputting the error message.
//
// Return value: An object containing the following properties:
//               msgAreaGood: A boolean to indicate whether the message area
//                            can be selected
//               errorMsg: If the message area can't be selected, this string
//                         will contain an eror message.  Otherwise, this will
//                         be an empty string.
function DigDistMsgReader_ValidateMsgAreaChoice(pGrpIdx, pSubIdx, pCurPos)
{
	var retObj = new Object();
	retObj.msgAreaGood = true;
	retObj.errorMsg = "";

	// Get the internal code of the sub-board from the given group & sub-board
	// indexes
	var subCode = msg_area.grp_list[pGrpIdx].sub_list[pSubIdx].code;

	// If a search is specified that would populate the search results, then do
	// a search in the given sub-board.
	if (this.SearchTypePopulatesSearchResults())
	{
		// See if we can use pCurPos to move the cursor before displaying messages
		var useCurPos = (console.term_supports(USER_ANSI) && (typeof(pCurPos) == "object") &&
		                 (typeof(pCurPos.x) == "number") && (typeof(pCurPos.y) == "number"));

		// TODO: In case new messages were posted in this sub-board, it might help
		// to check the current number of messages vs. the previous number of messages
		// and search the new messages if there are more.

		// Determine whether or not to search - If there are no search results for
		// the given sub-board already, then do a search in the sub-board.
		var doSearch = true;
		if (this.msgSearchHdrs.hasOwnProperty(subCode) &&
		    (typeof(this.msgSearchHdrs[subCode]) == "object") &&
		    (typeof(this.msgSearchHdrs[subCode].indexed) != "undefined"))
		{
			doSearch = (this.msgSearchHdrs[subCode].indexed.length == 0);
		}
		if (doSearch)
		{
			if (useCurPos)
			{
				console.gotoxy(pCurPos);
				console.cleartoeol("\1n");
				console.gotoxy(pCurPos);
			}
			console.print("\1n\1h\1wSearching\1i...\1n");
			var msgBase = new MsgBase(subCode);
			if (msgBase.open())
			{
				this.msgSearchHdrs[subCode] = searchMsgbase(subCode, msgBase, this.searchType, this.searchString, this.readingPersonalEmailFromUser);
				msgBase.close();
				// If there are no messages, then set the return object variables to indicate so.
				if (this.msgSearchHdrs[subCode].indexed.length == 0)
				{
					retObj.msgAreaGood = false;
					retObj.errorMsg = "No search results found";
				}
			}
			else
			{
				retObj.msgAreaGood = false;
				retObj.errorMsg = "Unable to open message base (for searching)!";
			}
		}
	}
	else
	{
		// No search is specified.  Just check to see if there are any messages
		// to read in the given sub-board.
		var msgBase = new MsgBase(subCode);
		if (msgBase.open())
		{
			if (msgBase.total_msgs == 0)
			{
				retObj.msgAreaGood = false;
				retObj.errorMsg = "No messages in that message area";
			}
			msgBase.close();
		}
	}

	return retObj;
}

// For the DigDistMsgReader class: Writes message lines to a file on the BBS
// machine.
//
// Parameters:
//  pMsgHdr: The header object for the message
//  pFilename: The name of the file to write the message to
//  pStripCtrl: Boolean - Whether or not to remove Synchronet control
//              codes from the message lines
//  pMsgLines: An array containing the message lines
//  pAttachments: An array containing attachment information (as returned by determineMsgAttachments())
//
// Return value: An object containing the following properties:
//               succeeded: Boolean - Whether or not the file was successfully written
//               errorMsg: String - On failure, will contain the reason it failed
function DigDistMsgReader_SaveMsgToFile(pMsgHdr, pFilename, pStripCtrl, pMsgLines, pAttachments)
{
	// Sanity checking
	if (typeof(pFilename) != "string")
		return({ succeeded: false, errorMsg: "Filename parameter not a string"});
	if (pFilename.length == 0)
		return({ succeeded: false, errorMsg: "Empty filename given"});

	// If no message lines are passed in, then get the message lines now.
	var msgLines = pMsgLines;
	var attachments = pAttachments;
	if ((pMsgLines == null) || (typeof(pMsgLines) != "object"))
	{
		if (typeof(pMsgHdr) == "object")
		{
			// Get the message text, interpret any @-codes in it, replace tabs with spaces
			// to prevent weirdness when displaying the message lines, and word-wrap the
			// text so that it looks good on the screen,
			//GetMsgInfoForEnhancedReader(pMsgHdr, pWordWrap, pDetermineAttachments, pGetB64Data)
			//var msgInfo = this.GetMsgInfoForEnhancedReader(pMsgHdr, false, false, false);
			var msgInfo = this.GetMsgInfoForEnhancedReader(pMsgHdr, true, true, true);
			msgLines = msgInfo.messageLines;
			if (msgInfo.hasOwnProperty("attachments"))
				attachments = msgInfo.attachments;
		}
		else
			return({ succeeded: false, errorMsg: "No message lines and null header object"});
	}

	var retObj = new Object();
	retObj.succeeded = true;
	retObj.errorMsg = "";

	// If there are message attachments, then treat pFilename as a directory and
	// create the directory for saving both the message text & attachments.
	// Then, save the attachments to that directory.
	var msgTextFilename = pFilename;
	if ((attachments != null) && (attachments.length > 0))
	{
		if (file_isdir(pFilename))
		{
			if (file_exists(pFilename))
				return({ succeeded: false, errorMsg: "Can't make directory: File with that name exists"});
		}
		else
		{
			if (!mkdir(pFilename))
				return({ succeeded: false, errorMsg: "Failed to create directory"});
		}

		// The name of the file to save the message text will be called "messageText.txt"
		// in the save directory.
		var savePathWithTrailingSlash = backslash(pFilename);
		msgTextFilename = savePathWithTrailingSlash + "messageText.txt";

		// Save the attachments to the directory
		var saveFileError = "";
		for (var attachIdx = 0; (attachIdx < attachments.length) && (saveFileError.length == 0); ++attachIdx)
		{
			var destFilename = savePathWithTrailingSlash + attachments[attachIdx].filename;
			// If the file info has base64 data, then decode & save it to the directory.
			// Otherwise, the file was probably uploaded to the user's mailbox in Synchronet,
			// so copy the file to the save directory.
			if (attachments[attachIdx].hasOwnProperty("B64Data"))
			{
				var attachedFile = new File(destFilename);
				if (attachedFile.open("wb"))
				{
					attachedFile.base64 = true;
					if (!attachedFile.write(attachments[attachIdx].B64Data))
						saveFileError = "\1n\1cFailed to save " + attachments[attachIdx].filename;
					attachedFile.close();
				}
			}
			else
			{
				// There is no base64 data for the file, so it's probably in the
				// user's mailbox in Synchronet, so copy it to the save directory.
				if (file_exists(attachments[attachIdx].fullyPathedFilename))
				{
					if (!file_copy(attachments[attachIdx].fullyPathedFilename, destFilename))
						saveFileError = "Failed to copy " + attachments[attachIdx].filename;
				}
				else
					saveFileError = "File " + attachments[attachIdx].fullyPathedAttachmentFilename + " doesn't exist";
			}
		}
		if (saveFileError.length > 0)
			return({ succeeded: false, errorMsg: saveFileError });
	}

	var messageSaveFile = new File(msgTextFilename);
	if (messageSaveFile.open("w"))
	{
		// Write some header information to the file
		if (typeof(pMsgHdr) == "object")
		{
			if (pMsgHdr.hasOwnProperty("from"))
				messageSaveFile.writeln("From: " + pMsgHdr.from);
			if (pMsgHdr.hasOwnProperty("to"))
				messageSaveFile.writeln("  To: " + pMsgHdr.to);
			if (pMsgHdr.hasOwnProperty("subject"))
				messageSaveFile.writeln("Subj: " + pMsgHdr.subject);
			/*
			if (pMsgHdr.hasOwnProperty("when_written_time"))
				messageSaveFile.writeln(strftime("Date: %Y-%m-%d %H:%M:%S", msgHeader.when_written_time));
			*/
			if (pMsgHdr.hasOwnProperty("date"))
				messageSaveFile.writeln("Date: " + pMsgHdr.date);
			if (pMsgHdr.hasOwnProperty("from_net_addr"))
				messageSaveFile.writeln("From net address: " + pMsgHdr.from_net_addr);
			if (pMsgHdr.hasOwnProperty("to_net_addr"))
				messageSaveFile.writeln("To net address: " + pMsgHdr.to_net_addr);
			if (pMsgHdr.hasOwnProperty("id"))
				messageSaveFile.writeln("ID: " + pMsgHdr.id);
			if (pMsgHdr.hasOwnProperty("reply_id"))
				messageSaveFile.writeln("Reply ID: " + pMsgHdr.reply_id);
			messageSaveFile.writeln("===============================");
		}
		// Write the message body to the file
		if (pStripCtrl)
		{
			for (var msgLineIdx = 0; msgLineIdx < msgLines.length; ++msgLineIdx)
				messageSaveFile.writeln(strip_ctrl(msgLines[msgLineIdx]));
		}
		else
		{
			for (var msgLineIdx = 0; msgLineIdx < msgLines.length; ++msgLineIdx)
				messageSaveFile.writeln(msgLines[msgLineIdx]);
		}
		messageSaveFile.close();
	}
	else
	{
		retObj.succeeded = false;
		retObj.errorMsg = "Unable to open the message file for writing";
	}

	return retObj;
}

// For the DigDistMsgReader class: Toggles whether a message has been 'selected'
// (i.e., for things like batch delete, etc.)
//
// Parameters:
//  pSubCode: The internal sub-board code of the message sub-board where the
//            message resides
//  pMsgIdx: The index of the message to toggle
//  pSelected: Optional boolean to explictly specify whether the message should
//             be selected.  If this is not provided (or is null), then this
//             message will simply toggle the selection state of the message.
function DigDistMsgReader_ToggleSelectedMessage(pSubCode, pMsgIdx, pSelected)
{
	// Sanity checking
	if (typeof(pSubCode) != "string") return;
	if (typeof(pMsgIdx) != "number") return;

	// If the 'selected message' object doesn't have the sub code index,
	// then add it.
	if (!this.selectedMessages.hasOwnProperty(pSubCode))
		this.selectedMessages[pSubCode] = new Object();

	// If pSelected is a boolean, then it specifies the specific selection
	// state of the message (true = selected, false = not selected).
	if (typeof(pSelected) == "boolean")
	{
		if (pSelected)
		{
			if (!this.selectedMessages[pSubCode].hasOwnProperty(pMsgIdx))
				this.selectedMessages[pSubCode][pMsgIdx] = true;
		}
		else
		{
			if (this.selectedMessages[pSubCode].hasOwnProperty(pMsgIdx))
				delete this.selectedMessages[pSubCode][pMsgIdx];
		}
	}
	else
	{
		// pSelected is not a boolean, so simply toggle the selection state of
		// the message.
		// If the object for the given sub-board code contains the message
		// index, then remove it.  Otherwise, add it.
		if (this.selectedMessages[pSubCode].hasOwnProperty(pMsgIdx))
			delete this.selectedMessages[pSubCode][pMsgIdx];
		else
			this.selectedMessages[pSubCode][pMsgIdx] = true;
	}
}

// For the DigDistMsgReader class: Returns whether a message (by sub-board code & index)
// is selected (i.e., for batch delete, etc.).
//
// Parameters:
//  pSubCode: The internal sub-board code of the message sub-board where the
//            message resides
//  pMsgIdx: The index of the message to toggle
//
// Return value: Boolean - Whether or not the given message has been selected
function DigDistMsgReader_MessageIsSelected(pSubCode, pMsgIdx)
{
	return (this.selectedMessages.hasOwnProperty(pSubCode) && this.selectedMessages[pSubCode].hasOwnProperty(pMsgIdx));
}

// For the DigDistMsgReader class: Checks to see if all selected messages can
// be deleted (i.e., whether the user has permission to delete all of them).
function DigDistMsgReader_AllSelectedMessagesCanBeDeleted()
{
	// If the user has sysop access, then they should be able to delete messages.
	if (gIsSysop)
		return true;

	var userCanDeleteAllSelectedMessages = true;

	var msgBase = null;
	for (var subBoardCode in this.selectedMessages)
	{
		// If the current sub-board is personal mail, then the user can delete
		// those messages.  Otherwise, check the sub-board configuration to see
		// if the user can delete messages in the sub-board.
		if (subBoardCode != "mail")
		{
			msgBase = new MsgBase(subBoardCode);
			if (msgBase.open())
			{
				userCanDeleteAllSelectedMessages = userCanDeleteAllSelectedMessages && ((msgBase.cfg.settings & SUB_DEL) == SUB_DEL);
				msgBase.close();
			}
		}
		if (!userCanDeleteAllSelectedMessages)
			break;
	}
	
	return userCanDeleteAllSelectedMessages;
}

// For the DigDistMsgReader class: Marks the 'selected messages' (in
// this.selecteMessages) as deleted.  Returns an object with the following
// properties:
//  deletedAll: Boolean - Whether or not all messages were successfully marked
//              for deletion
//  failureList: An object containing indexes of messages that failed to get
//               marked for deletion, indexed by internal sub-board code, then
//               containing messages indexes as properties.  Reasons for failing
//               to mark messages deleted can include the user not having permission
//               to delete in a sub-board, failure to open the sub-board, etc.
function DigDistMsgReader_DeleteSelectedMessages()
{
	var retObj = new Object();
	retObj.deletedAll = true;
	retObj.failureList = new Object();

	var msgBase = null;
	var msgHdr = null;
	var msgWasDeleted = false;
	for (var subBoardCode in this.selectedMessages)
	{
		msgBase = new MsgBase(subBoardCode);
		if (msgBase.open())
		{
			// Allow the user to delete the messages if they're the sysop, they're
			// reading their personal mail, or the sub-board allows deleting messages.
			if (gIsSysop || (subBoardCode == "mail") || ((msgBase.cfg.settings & SUB_DEL) == SUB_DEL))
			{
				for (var msgIdx in this.selectedMessages[subBoardCode])
				{
					// It seems that msgIdx is a string, so make sure we have a
					// numeric version.
					var msgIdxNumber = +msgIdx;
					// If doing a search (this.msgSearchHdrs has the sub-board code),
					// then get the message header by index from there.  Otherwise,
					// use the message base object to get the message by index.
					if (this.msgSearchHdrs.hasOwnProperty(subBoardCode) &&
					    (this.msgSearchHdrs[subBoardCode].indexed.length > 0))
					{
						if ((msgIdxNumber >= 0) && (msgIdxNumber < this.msgSearchHdrs[subBoardCode].indexed.length))
							msgHdr = this.msgSearchHdrs[subBoardCode].indexed[msgIdxNumber];
					}
					else
					{
						if ((msgIdxNumber >= 0) && (msgIdxNumber < msgBase.total_msgs))
							msgHdr = msgBase.get_msg_header(true, msgIdxNumber, false);
					}
					// If we got the message header, then mark it for deletion.
					// If the message header wasn't marked as deleted, then add
					// the message index to the return object.
					if (msgHdr != null)
						msgWasDeleted = msgBase.remove_msg(true, msgHdr.offset);
					else
						msgWasDeleted = false;
					if (msgWasDeleted)
					{
						// Refresh the message header in the search results (if it
						// exists there) and remove the message index from the
						// selectedMessages object
						this.RefreshSearchResultMsgHdr(msgIdxNumber, MSG_DELETE, subBoardCode);
						delete this.selectedMessages[subBoardCode][msgIdx];
					}
					else
					{
						retObj.deletedAll = false;
						if (!retObj.failureList.hasOwnProperty(subBoardCode))
							retObj.failureList[subBoardCode] = new Object();
						retObj.failureList[subBoardCode].push(msgIdxNumber);
					}
				}
				// If the sub-board index array no longer has any properties (i.e.,
				// all messages in the sub-board were marked as deleted), then remove
				// the sub-board property from this.selectedMessages
				if (Object.keys(this.selectedMessages[subBoardCode]).length == 0)
					delete this.selectedMessages[subBoardCode];
			}
			else
			{
				// The user doesn't have permission to delete messages
				// in this sub-board.
				// Create an entry in retObj.failureList indexed by the
				// sub-board code to indicate failure to delete all
				// messages in the sub-board.
				retObj.deletedAll = false;
				retObj.failureList[subBoardCode] = new Object();
			}

			msgBase.close();
		}
		else
		{
			// Failure to open the sub-board.
			// Create an entry in retObj.failureList indexed by the
			// sub-board code to indicate failure to delete all messages
			// in the sub-board.
			retObj.deletedAll = false;
			retObj.failureList[subBoardCode] = new Object();
		}
	}

	return retObj;
}

// For the DigDistMsgReader class: Returns the number of selected messages
function DigDistMsgReader_NumSelectedMessages()
{
	var numSelectedMsgs = 0;

	for (var subBoardCode in this.selectedMessages)
		numSelectedMsgs += Object.keys(this.selectedMessages[subBoardCode]).length;

	return numSelectedMsgs;
}

///////////////////////////////////////////////////////////////////////////////////
// Helper functions

// Displays the program information.
function DisplayProgramInfo()
{
   displayTextWithLineBelow("Digital Distortion Message Reader", true, "\1n\1c\1h", "\1k\1h")
	console.center("\1n\1cVersion \1g" + READER_VERSION + " \1w\1h(\1b" + READER_DATE + "\1w)");
	console.crlf();
}

// This function returns an array of default colors used in the
// DigDistMessageReader class.
function getDefaultColors()
{
	var colorArray = new Array();

	// Header line: "Current msg group:"
	colorArray["msgListHeaderMsgGroupTextColor"] = "\1n\1" + "4\1c"; // Normal cyan on blue background
	//colorArray["msgListHeaderMsgGroupTextColor"] = "\1n\1" + "4\1w"; // Normal white on blue background

	// Header line: Message group name
	colorArray["msgListHeaderMsgGroupNameColor"] = "\1h\1c"; // High cyan
	//colorArray["msgListHeaderMsgGroupNameColor"] = "\1h\1w"; // High white

	// Header line: "Current sub-board:"
	colorArray["msgListHeaderSubBoardTextColor"] = "\1n\1" + "4\1c"; // Normal cyan on blue background
	//colorArray["msgListHeaderSubBoardTextColor"] = "\1n\1" + "4\1w"; // Normal white on blue background

	// Header line: Message sub-board name
	colorArray["msgListHeaderMsgSubBoardName"] = "\1h\1c"; // High cyan
	//colorArray["msgListHeaderMsgSubBoardName"] = "\1h\1w"; // High white
	// Line with column headers
	//colorArray["msgListColHeader"] = "\1h\1w"; // High white (keep blue background)
	colorArray["msgListColHeader"] = "\1n\1h\1w"; // High white on black background
	//colorArray["msgListColHeader"] = "\1h\1c"; // High cyan (keep blue background)
	//colorArray["msgListColHeader"] = "\1" + "4\1h\1y"; // High yellow (keep blue background)

	// Message list information
	colorArray["msgListMsgNumColor"] = "\1n\1h\1y";
	colorArray["msgListFromColor"] = "\1n\1c";
	colorArray["msgListToColor"] = "\1n\1c";
	colorArray["msgListSubjectColor"] = "\1n\1c";
	colorArray["msgListDateColor"] = "\1h\1b";
	colorArray["msgListTimeColor"] = "\1h\1b";
	// Message information for messages written to the user
	colorArray["msgListToUserMsgNumColor"] = "\1n\1h\1y";
	colorArray["msgListToUserFromColor"] = "\1h\1g";
	colorArray["msgListToUserToColor"] = "\1h\1g";
	colorArray["msgListToUserSubjectColor"] = "\1h\1g";
	colorArray["msgListToUserDateColor"] = "\1h\1b";
	colorArray["msgListToUserTimeColor"] = "\1h\1b";
	// Message information for messages from the user
	colorArray["msgListFromUserMsgNumColor"] = "\1n\1h\1y";
	colorArray["msgListFromUserFromColor"] = "\1n\1c";
	colorArray["msgListFromUserToColor"] = "\1n\1c";
	colorArray["msgListFromUserSubjectColor"] = "\1n\1c";
	colorArray["msgListFromUserDateColor"] = "\1h\1b";
	colorArray["msgListFromUserTimeColor"] = "\1h\1b";

	// Message list highlight colors
	colorArray["msgListHighlightBkgColor"] = "\1" + "4"; // Background
	colorArray["msgListMsgNumHighlightColor"] = "\1h\1y";
	colorArray["msgListFromHighlightColor"] = "\1h\1c";
	colorArray["msgListToHighlightColor"] = "\1h\1c";
	colorArray["msgListSubjHighlightColor"] = "\1h\1c";
	colorArray["msgListDateHighlightColor"] = "\1h\1w";
	colorArray["msgListTimeHighlightColor"] = "\1h\1w";

	// Lightbar message list help line colors
	colorArray["lightbarMsgListHelpLineBkgColor"] = "\1" + "7"; // Background
	colorArray["lightbarMsgListHelpLineGeneralColor"] = "\1b";
	colorArray["lightbarMsgListHelpLineHotkeyColor"] = "\1r";
	colorArray["lightbarMsgListHelpLineParenColor"] = "\1m";

	// Continue prompt colors
	colorArray["tradInterfaceContPromptMainColor"] = "\1n\1g"; // Main text color
	colorArray["tradInterfaceContPromptHotkeyColor"] = "\1h\1c"; // Hotkey color
	colorArray["tradInterfaceContPromptUserInputColor"] = "\1h\1g"; // User input color

	// Message body color
	colorArray["msgBodyColor"] = "\1n\1w";

	// Read message confirmation colors
	colorArray["readMsgConfirmColor"] = "\1n\1c";
	colorArray["readMsgConfirmNumberColor"] = "\1h\1c";
	// Prompt for continuing to list messages after reading a message
	colorArray["afterReadMsg_ListMorePromptColor"] = "\1n\1c";

	// Help screen text color
	colorArray["tradInterfaceHelpScreenColor"] = "\1n\1h\1w";