/** * Process text/plain message bodies for quoting / signatures. * * We have two main goals in our processing: * * 1) Improve display by being able to automatically collapse excessively quoted * blocks and large/redundant signature blocks and hide them entirely from snippet * generation. * * 2) Allow us to reply to messages and provide automatically limited quoting. * Specifically, we want to provide one message's worth of context when replying * to a message. We also want to avoid messages in a thread indefinitely * growing in size because all users keep replying and leaving default quoting * intact. * * The quotechew representation is a flat array consisting of (flattened) pairs * of an encoded content type integer and a string that is the content whose * nature is described by that block. * * NOTE: This representation was okay, but arguably a compromise between: * - premature optimization storage concerns * - wide eyes looking at a future where we could be more clever about quoting * than we ended up being able to be * - wanting to be able to efficiently render the quoted text into the DOM * without having complicated render code or having to worry about security * issues. * * Going forward, a more DOM-y representation that could allow the UI to * more uniformly treat text/plain and text/html for display purposes could be * nice. In particular, I think it would be great to be able to have quotes * tagged as such and things like boilerplate be indicated both at the top-level * and inside quotes. This would be great for smart-collapsing of quotes and * things no one cares about. **/ define( [ 'exports' ], function( exports ) { //////////////////////////////////////////////////////////////////////////////// // Content Type Encoding // // We encode content type values as integers in an attempt to have the serialized // form not be super huge and be pretty quick to check without generating garbage // objects. // // The low-order nibble encodes the type for styling purposes; everything above // that nibble is per-type and may encode integer values or use hot bits to // indicate type. /** * Actual content of the message written by the user. */ var CT_AUTHORED_CONTENT = 0x1; /** * Niceties like greetings/thanking someone/etc. These are things that we want to * show when displaying the message, but that arguably are of lower importance and * might want to be elided for snippet purposes, etc. */ var CT_AUTHORED_NICETIES = 0x11; /** * The signature of the message author; might contain useful information in it. */ var CT_SIGNATURE = 0x2; /** * The line that says "Blah wrote:" that precedes a quote. It's not part of the * user content, but it's also not part of the quote. */ var CT_LEADIN_TO_QUOTE = 0x3; var CT_QUOTED_TYPE = 0x4; /** * A quoted reply; eligible for collapsing. Depth of quoting will also be * encoded in the actual integer value. */ var CT_QUOTED_REPLY = 0x14; /** * A quoted forwarded message; we would guess that the user has not previously seen * the message and the quote wants to be displayed. */ var CT_QUOTED_FORWARD = 0x24; /** * Quoted content that has not been pruned. Aspirational! */ var CT_QUOTED_IN_ENTIRETY = 0x40; /** * The quote has been subjected to some level of manual intervention. Aspirational! */ var CT_QUOTED_GARDENED = 0x80; var CT_QUOTE_DEPTH_MASK = 0xff00; /** * Legal-ish boilerplate about how it's only for the recipient, etc. etc. * Generally going to be long and boring. */ var CT_BOILERPLATE_DISCLAIMER = 0x5; /** * Boilerplate about the message coming from a mailing list, info about the * mailing list. */ var CT_BOILERPLATE_LIST_INFO = 0x6; /** * Product branding boilerplate that may or may not indicate that the composing * device was a mobile device (which is useful). */ var CT_BOILERPLATE_PRODUCT = 0x7; /** * Advertising automatically inserted by the mailing list or free e-mailing service, * etc. This is assumed to be boring. */ var CT_BOILERPLATE_ADS = 0x8; var CHARCODE_GT = ('>').charCodeAt(0), CHARCODE_SPACE = (' ').charCodeAt(0), CHARCODE_NBSP = ('\xa0').charCodeAt(0), CHARCODE_NEWLINE = ('\n').charCodeAt(0); var RE_ORIG_MESAGE_DELIM = /^-{5} Original Message -{5}$/; var RE_ALL_WS = /^\s+$/; var RE_SECTION_DELIM = /^[_-]{6,}$/; var RE_LIST_BOILER = /mailing list$/; /** * Non-localized hard-coded heuristic to help us detect whether a line is the * lead-in to a quote block. This is only checked against the last content line * preceding a quote block. This only results in styling guidance, so if it's * too aggresive or insufficiently aggressive, the worst that happens is the * line will be dark gray instead of black. (For now! Moohoohahahahahaha.) */ var RE_WROTE_LINE = /wrote/; /** * Hacky reply helper to check if the last line in a content block contains the * words wrote. In a nutshell: * - The current quoting logic can only detect a quoting lead-in in non-quoted * text and I'm not crazy enough to enhance this at this time. (The current * mechanism is already complex and assumes it's not dealing with '>' * characters. An enhanced mechanism could operate on a normalized * representation where the quoting has been normalized to no longer include * the > characters. That would be a sortof multi-pass AST/tree-transformer. * Future work.) * - The reply logic performs normalization on output, but without knowing about * lead-ins, it makes the wrong normalization call for these wrote lines, * inserting wasted whitespace. This regex is an attempt to retroactively do * the cool thing the former bullet proposes. And it does indeed work out * because of our aggressive newline normalization logic. * - Obviously this is not localized at all, but neither is the above. * * To keep the regex cost-effective and doing what we want, we anchor it to the * end of the string and ensure there are no newline characters before wrote. * (And we check for wrote without any other constraints because that's what the * above does.) * * Again, since this only controls whether a newline is inserted or not, it's * really not the end of the world. */ var RE_REPLY_LAST_LINE_IN_BLOCK_CONTAINS_WROTE = /wrote[^\n]+$/; var RE_SIGNATURE_LINE = /^-- $/; /** * The maximum number of lines that can be in a boilerplate chunk. We expect * disclaimer boilerplate to be what drives this. */ var MAX_BOILERPLATE_LINES = 20; /** * Catch various common well-known product branding lines: * - "Sent from my iPhone/iPad/mobile device". Apple, others. * - "Sent from my Android ...". Common prefix for wildly varying Android * strings. * - "Sent from my ...". And there are others that don't match the above but * that match the prefix. * - "Sent from Mobile" */ var RE_PRODUCT_BOILER = /^(?:Sent from (?:Mobile|my .+))$/; var RE_LEGAL_BOILER_START = /^(?:This message|Este mensaje)/; /** * String.indexOf helper that will return the provided default value when -1 * would otherwise be returned. */ function indexOfDefault(string, search, startIndex, defVal) { var idx = string.indexOf(search, startIndex); if (idx === -1) return defVal; return idx; } var NEWLINE = '\n', RE_NEWLINE = /\n/g; /** * Count the number of newlines in the string in the [startIndex, endIndex) * characterized region. (Using mathy syntax there: startIndex is inclusive, * endIndex is exclusive.) */ function countNewlinesInRegion(string, startIndex, endIndex) { var idx = startIndex - 1, count = 0; for (;;) { idx = string.indexOf(NEWLINE, idx + 1); if (idx === -1 || idx >= endIndex) return count; count++; } return null; } /** * Process the contents of a text body for quoting purposes. * * Key behaviors: * * - Whitespace is trimmed at the boundaries of regions. Our CSS styling will * take care of making sure there is appropriate whitespace. This is an * intentional normalization that should cover both people who fail to put * whitespace in their messages (jerks) and people who put whitespace in. * * - Newlines are maintained inside of blocks. * * - We look backwards for boilerplate blocks once we encounter the first quote * block or the end of the message. We keep incrementally looking backwards * until we reach something that we don't think is boilerplate. */ exports.quoteProcessTextBody = function quoteProcessTextBody(fullBodyText) { // Our serialized representation of the body, incrementally built. Consists // of pairwise integer codes and content string blocks. var contentRep = []; // The current content line being parsed by our parsing loop, does NOT include // the trailing newline. var line; /** * Count the number of '>' quoting characters in the line, mutating `line` to * not include the quoting characters. Some clients will place a single space * between each '>' at higher depths, and we support that. But any more spaces * than that and we decide we've reached the end of the quote marker. */ function countQuoteDepthAndNormalize() { // We know that the first character is a '>' already. var count = 1; var lastStartOffset = 1, spaceOk = true; for (var i = 1; i < line.length; i++) { var c = line.charCodeAt(i); if (c === CHARCODE_GT) { count++; lastStartOffset++; spaceOk = true; } else if (c === CHARCODE_SPACE) { if (!spaceOk) break; lastStartOffset++; spaceOk = false; } else { break; } } if (lastStartOffset) line = line.substring(lastStartOffset); return count; } /** * Scan backwards line-by-line through a chunk of content (AKA non-quoted) * text looking for boilerplate chunks. We can stop once we determine we're * not in boilerplate. * * Note: The choice to not run us against quoted blocks was an intentional * simplification at the original time of implementation, presumably because * knowing something was a quote was sufficient and realistically all our * styling could hope to handle. Also, the ontological commitment to a flat * document representation. In retrospect, I think it could make sense to * use a tree that marks up each layer as quote/content/boilerplate/etc. in a * more DOM-like fashion. * * - Product blurbs must be the first non-whitespace line seen to be detected; * they do not have to be delimited by an ASCII line. * * - Legal boilerplate must be delimited by an ASCII line. */ function lookBackwardsForBoilerplate(chunk) { var idxLineStart, idxLineEnd, line, idxRegionEnd = chunk.length, scanLinesLeft = MAX_BOILERPLATE_LINES, sawNonWhitespaceLine = false, lastContentLine = null, lastBoilerplateStart = null, sawProduct = false, insertAt = contentRep.length; function pushBoilerplate(contentType, merge) { var boilerChunk = chunk.substring(idxLineStart, idxRegionEnd); var idxChunkEnd = idxLineStart - 1; // We used to do a trimRight here, but that would eat spaces in addition // to newlines. This was undesirable for both roundtripping purposes and // mainly because the "-- " signature marker has a significant space // character on the end there. while (chunk.charCodeAt(idxChunkEnd - 1) === CHARCODE_NEWLINE) { idxChunkEnd--; } var newChunk = chunk.substring(0, idxChunkEnd); var ate = countNewlinesInRegion(chunk, newChunk.length, idxLineStart - 1); chunk = newChunk; idxRegionEnd = chunk.length; if (!merge) { contentRep.splice(insertAt, 0, ((ate&0xff) << 8) | contentType, boilerChunk); } else { // nb: this merge does not properly reuse the previous existing 'ate' // value; if we start doing more complex merges, the hardcoded '\n' // below will need to be computed. contentRep[insertAt] = ((ate&0xff) << 8) | (contentRep[insertAt]&0xff); contentRep[insertAt + 1] = boilerChunk + '\n' + contentRep[insertAt + 1]; } sawNonWhitespaceLine = false; scanLinesLeft = MAX_BOILERPLATE_LINES; lastContentLine = null; lastBoilerplateStart = idxLineStart; } for (idxLineStart = chunk.lastIndexOf('\n') + 1, idxLineEnd = chunk.length; idxLineEnd > 0 && scanLinesLeft; idxLineEnd = idxLineStart - 1, idxLineStart = chunk.lastIndexOf('\n', idxLineEnd - 1) + 1, scanLinesLeft--) { // (do not include the newline character) line = chunk.substring(idxLineStart, idxLineEnd); // - Skip whitespace lines. if (!line.length || (line.length === 1 && line.charCodeAt(0) === CHARCODE_NBSP)) continue; // - Explicit signature demarcation if (RE_SIGNATURE_LINE.test(line)) { // Check if this is just tagging something we decided was boilerplate in // a proper signature wrapper. If so, then execute a boilerplate merge. if (idxLineEnd + 1 === lastBoilerplateStart) { pushBoilerplate(null, true); } else { pushBoilerplate(CT_SIGNATURE); } continue; } // - Section delimiter; try and classify what lives in this section if (RE_SECTION_DELIM.test(line)) { if (lastContentLine) { // - Look for a legal disclaimer sequentially following the line. if (RE_LEGAL_BOILER_START.test(lastContentLine)) { pushBoilerplate(CT_BOILERPLATE_DISCLAIMER); continue; } // - Look for mailing list if (RE_LIST_BOILER.test(lastContentLine)) { pushBoilerplate(CT_BOILERPLATE_LIST_INFO); continue; } } // The section was not boilerplate, so thus ends the reign of // boilerplate. Bail. return chunk; } // - A line with content! if (!sawNonWhitespaceLine) { // - Product boilerplate (must be first/only non-whitespace line) if (!sawProduct && RE_PRODUCT_BOILER.test(line)) { pushBoilerplate(CT_BOILERPLATE_PRODUCT); sawProduct = true; continue; } sawNonWhitespaceLine = true; } lastContentLine = line; } return chunk; } /** * Interpret the current content region starting at (from the loop) * idxRegionStart as a user-authored content-region which may have one or more * segments of boilerplate on the end that should be chopped off and marked as * such. * * We are called by the main parsing loop and assume that all the shared state * variables that we depend on have been properly maintained and we will, * in-turn, mutate them. */ function pushContent(considerForBoilerplate, upToPoint, forcePostLine) { if (idxRegionStart === null) { if (atePreLines) { // decrement atePreLines if we are not the first chunk because then we get // an implicit/free newline. if (contentRep.length) atePreLines--; contentRep.push((atePreLines&0xff) << 8 | CT_AUTHORED_CONTENT); contentRep.push(''); } } else { if (upToPoint === undefined) upToPoint = idxLineStart; var chunk = fullBodyText.substring(idxRegionStart, idxLastNonWhitespaceLineEnd); var atePostLines = forcePostLine ? 1 : 0; if (idxLastNonWhitespaceLineEnd + 1 !== upToPoint) { // We want to count the number of newlines after the newline that // belongs to the last non-meaningful-whitespace line up to the // effective point. If we saw a lead-in, the effective point is // preceding the lead-in line's newline. Otherwise it is the start point // of the current line. atePostLines += countNewlinesInRegion(fullBodyText, idxLastNonWhitespaceLineEnd + 1, upToPoint); } contentRep.push(((atePreLines&0xff) << 8) | ((atePostLines&0xff) << 16) | CT_AUTHORED_CONTENT); var iChunk = contentRep.push(chunk) - 1; if (considerForBoilerplate) { var newChunk = lookBackwardsForBoilerplate(chunk); if (chunk.length !== newChunk.length) { // Propagate any atePost lines. if (atePostLines) { var iLastMeta = contentRep.length - 2; // We can blindly write post-lines since boilerplate currently // doesn't infer any post-newlines on its own. contentRep[iLastMeta] = ((atePostLines&0xff) << 16) | contentRep[iLastMeta]; contentRep[iChunk - 1] = ((atePreLines&0xff) << 8) | CT_AUTHORED_CONTENT; } // If we completely processed the chunk into boilerplate, then we can // remove it after propagating any pre-eat amount. if (!newChunk.length) { if (atePreLines) { var bpAte = (contentRep[iChunk + 1] >> 8)&0xff; bpAte += atePreLines; contentRep[iChunk + 1] = ((bpAte&0xff) << 8) | (contentRep[iChunk + 1]&0xffff00ff); } contentRep.splice(iChunk - 1, 2); } else { contentRep[iChunk] = newChunk; } } } } atePreLines = 0; idxRegionStart = null; lastNonWhitespaceLine = null; idxLastNonWhitespaceLineEnd = null; idxPrevLastNonWhitespaceLineEnd = null; } /** * Interpret the current content region starting at (from the loop) * idxRegionStart as a quoted block. * * We are called by the main parsing loop and assume that all the shared state * variables that we depend on have been properly maintained and we will, * in-turn, mutate them. */ function pushQuote(newQuoteDepth) { var atePostLines = 0; // Discard empty lines at the end. We already skipped adding blank lines, so // no need to do the front side. while (quoteRunLines.length && !quoteRunLines[quoteRunLines.length - 1]) { quoteRunLines.pop(); atePostLines++; } contentRep.push(((atePostLines&0xff) << 24) | ((ateQuoteLines&0xff) << 16) | ((inQuoteDepth - 1) << 8) | CT_QUOTED_REPLY); contentRep.push(quoteRunLines.join('\n')); inQuoteDepth = newQuoteDepth; if (inQuoteDepth) quoteRunLines = []; else quoteRunLines = null; ateQuoteLines = 0; generatedQuoteBlock = true; } // == On indices and newlines // Our line ends always point at the newline for the line; for the last line // in the body, there may be no newline, but that doesn't matter since substring // is fine with us asking for more than it has. // ==== It's the big parsing loop! // Each pass of the for-loop tries to look at one line of the message at a // time. (This is sane because text/plain messages are line-centric.) // // The main goal of the loop is to keep processing lines of a single content // type until it finds content of a different type and then "push"/flush what // it accumulated. Then it can start processing that content type until it // finds a different content type. // // This mainly means noticing when we change quoting levels, and if we're // entering a deeper level of quoting, separating off any quoting lead-in (ex: // "Alive wrote:"). var idxLineStart, idxLineEnd, bodyLength = fullBodyText.length, // null means we are looking for a non-whitespace line. idxRegionStart = null, lastNonWhitespaceLine = null, // The index of the last non-purely whitespace line. (Its \n) idxLastNonWhitespaceLineEnd = null, // value of idxLastNonWhitespaceLineEnd prior to its current value idxPrevLastNonWhitespaceLineEnd = null, // Our current quote depth. 0 if we're not in quoting right now. inQuoteDepth = 0, quoteRunLines = null, // XXX very sketchy mechanism that inhibits boilerplate detection if we // have ever generated a quoted block. This seems like it absolutely // must be broken and the intent was for it to be some type of short-term // memory effect that ended up being a permanent memory effect. generatedQuoteBlock = false, atePreLines = 0, ateQuoteLines = 0; for (idxLineStart = 0, idxLineEnd = indexOfDefault(fullBodyText, '\n', idxLineStart, fullBodyText.length); idxLineStart < bodyLength; idxLineStart = idxLineEnd + 1, idxLineEnd = indexOfDefault(fullBodyText, '\n', idxLineStart, fullBodyText.length)) { line = fullBodyText.substring(idxLineStart, idxLineEnd); // - Do not process purely whitespace lines. // Because our content runs are treated as regions, ignoring whitespace // lines simply means that we don't start or end content blocks on blank // lines. Blank lines in the middle of a content block are maintained // because our slice will include them. if (!line.length || (line.length === 1 && line.charCodeAt(0) === CHARCODE_NBSP)) { if (inQuoteDepth) pushQuote(0); if (idxRegionStart === null) atePreLines++; continue; } if (line.charCodeAt(0) === CHARCODE_GT) { var lineDepth = countQuoteDepthAndNormalize(); // We are transitioning into a quote state... if (!inQuoteDepth) { // - Check for a "Blah wrote:" content line if (lastNonWhitespaceLine && RE_WROTE_LINE.test(lastNonWhitespaceLine)) { // But surprise! This could be a non-format-flowed message where the // true lead-in line started on the previous line and the "wrote" bit // that triggered us spills over. // Let's start with just the line we've got... // (count the newlines up to the lead-in's newline) var upToPoint = idxLastNonWhitespaceLineEnd; // Now let's see if the preceding line had content. We can infer this // by whether we find a newline between the prevLast and last points. // No newline means there's content on that preceding line. (There // could also be more content on the line(s) prior to that that flow // into this, but that's not particularly likely unless something is // going wrong with our heuristic. At which point it's good for us // to mitigate the damage.) if (idxPrevLastNonWhitespaceLineEnd !== null) { var considerIndex = idxPrevLastNonWhitespaceLineEnd + 1; while (considerIndex < idxLastNonWhitespaceLineEnd) { if (fullBodyText[considerIndex++] === '\n') { break; } } if (considerIndex === idxLastNonWhitespaceLineEnd) { // We didn't encounter a newline. So now we need to rewind the // point to be at the start of the PrevLast line. upToPoint = fullBodyText.lastIndexOf( '\n', idxPrevLastNonWhitespaceLineEnd - 1); lastNonWhitespaceLine = fullBodyText.substring(upToPoint + 1, idxLastNonWhitespaceLineEnd) .replace(/\s*\n\s*/, ' '); // Note, we really should be scanning further to elide whitespace, // but this is largely a hack to mitigate worse-case snippet // generating behaviour in the face of bottom-posting where the // quoting lines are the first things observed. idxPrevLastNonWhitespaceLineEnd = upToPoint - 1; if (idxPrevLastNonWhitespaceLineEnd <= idxRegionStart) { idxRegionStart = null; } } } idxLastNonWhitespaceLineEnd = idxPrevLastNonWhitespaceLineEnd; // Nuke the content region if the lead-in was the start of the region; // this can be inferred by there being no prior content line. if (idxLastNonWhitespaceLineEnd === null) idxRegionStart = null; var leadin = lastNonWhitespaceLine; pushContent(!generatedQuoteBlock, upToPoint); var leadinNewlines = 0; if (upToPoint + 1 !== idxLineStart) leadinNewlines = countNewlinesInRegion(fullBodyText, upToPoint + 1, idxLineStart); contentRep.push((leadinNewlines << 8) | CT_LEADIN_TO_QUOTE); contentRep.push(leadin); } else { pushContent(!generatedQuoteBlock); } quoteRunLines = []; inQuoteDepth = lineDepth; } // There is a change in quote depth else if (lineDepth !== inQuoteDepth) { pushQuote(lineDepth); } // Eat whitespace lines until we get a non-whitespace (quoted) line. if (quoteRunLines.length || line.length) { quoteRunLines.push(line); } else { ateQuoteLines++; } } else { // We're leaving a quoted region! if (inQuoteDepth) { pushQuote(0); idxLastNonWhitespaceLineEnd = null; } if (idxRegionStart === null) idxRegionStart = idxLineStart; lastNonWhitespaceLine = line; idxPrevLastNonWhitespaceLineEnd = idxLastNonWhitespaceLineEnd; idxLastNonWhitespaceLineEnd = idxLineEnd; } } if (inQuoteDepth) { pushQuote(0); } else { // There is no implicit newline for the final block, so force it if we had // a newline. pushContent(true, fullBodyText.length, (fullBodyText.charCodeAt(fullBodyText.length - 1) === CHARCODE_NEWLINE)); } return contentRep; }; /** * The maximum number of characters to shrink the snippet to try and find a * whitespace boundary. If it would take more characters than this, we just * do a hard truncation and hope things work out visually. */ var MAX_WORD_SHRINK = 8; var RE_NORMALIZE_WHITESPACE = /\s+/g; /** * Derive the snippet for a message from its processed body representation. We * take the snippet from the first non-empty content block, normalizing * all whitespace to a single space character for each instance, then truncate * with a minor attempt to align on word boundaries. */ exports.generateSnippet = function generateSnippet(rep, desiredLength) { for (var i = 0; i < rep.length; i += 2) { var etype = rep[i]&0xf, block = rep[i + 1]; switch (etype) { case CT_AUTHORED_CONTENT: if (!block.length) break; // - truncate // (no need to truncate if short) if (block.length < desiredLength) return block.trim().replace(RE_NORMALIZE_WHITESPACE, ' '); // try and truncate on a whitespace boundary var idxPrevSpace = block.lastIndexOf(' ', desiredLength); if (desiredLength - idxPrevSpace < MAX_WORD_SHRINK) return block.substring(0, idxPrevSpace).trim() .replace(RE_NORMALIZE_WHITESPACE, ' '); return block.substring(0, desiredLength).trim() .replace(RE_NORMALIZE_WHITESPACE, ' '); } } return ''; }; /** * What is the deepest quoting level that we should repeat? Our goal is not to be * the arbiter of style, but to provide a way to bound message growth in the face * of reply styles where humans do not manually edit quotes. * * We accept depth levels up to 5 mainly because a quick perusal of mozilla lists * shows cases where 5 levels of nesting were used to provide useful context. */ var MAX_QUOTE_REPEAT_DEPTH = 5; // we include a few more than we need for forwarded text regeneration var replyQuotePrefixStrings = [ '> ', '>> ', '>>> ', '>>>> ', '>>>>> ', '>>>>>> ', '>>>>>>> ', '>>>>>>>> ', '>>>>>>>>> ', ]; var replyQuotePrefixStringsNoSpace = [ '>', '>>', '>>>', '>>>>', '>>>>>', '>>>>>>', '>>>>>>>', '>>>>>>>>', '>>>>>>>>>', ]; var replyQuoteNewlineReplaceStrings = [ '\n> ', '\n>> ', '\n>>> ', '\n>>>> ', '\n>>>>> ', '\n>>>>>> ', '\n>>>>>>> ', '\n>>>>>>>> ', ]; var replyQuoteNewlineReplaceStringsNoSpace = [ '\n>', '\n>>', '\n>>>', '\n>>>>', '\n>>>>>', '\n>>>>>>', '\n>>>>>>>', '\n>>>>>>>>', ]; var replyQuoteBlankLine = [ '\n', '>\n', '>>\n', '>>>\n', '>>>>\n', '>>>>>\n', '>>>>>>\n', '>>>>>>>\n', '>>>>>>>>\n', ]; var replyPrefix = '> ', replyNewlineReplace = '\n> '; function expandQuotedPrefix(s, depth) { if (s.charCodeAt(0) === CHARCODE_NEWLINE) return replyQuotePrefixStringsNoSpace[depth]; return replyQuotePrefixStrings[depth]; } /** * Expand a quoted block so that it has the right number of greater than signs * and inserted whitespace where appropriate. (Blank lines don't want * whitespace injected.) */ function expandQuoted(s, depth) { var ws = replyQuoteNewlineReplaceStrings[depth], nows = replyQuoteNewlineReplaceStringsNoSpace[depth]; return s.replace(RE_NEWLINE, function(m, idx) { if (s.charCodeAt(idx+1) === CHARCODE_NEWLINE) return nows; else return ws; }); } /** * Generate a text message reply given an already quote-processed body. We do * not simply '>'-prefix everything because * 1. We don't store the raw message text because it's faster for us to not * quote-process everything every time we display a message * 2. We want to strip some stuff out of the reply. * 3. It is not our goal to provide a verbatim quoting. (However, it is our * goal in the forwarding case. Which is why forwarding makes use of the * counts of newlines we ate to try and reconstruct whitespace and the * message verbatim.) */ exports.generateReplyText = function generateReplyText(rep) { var strBits = []; // For the insertion of delimiting whitespace newlines between blocks, we want // to continue the quoting var lastContentDepth = null; // Lead-ins do not want delimiting newlines between them and their quoted // content. var suppressWhitespaceBlankLine = false; for (var i = 0; i < rep.length; i += 2) { var etype = rep[i]&0xf, block = rep[i + 1]; switch (etype) { default: case CT_AUTHORED_CONTENT: case CT_SIGNATURE: case CT_LEADIN_TO_QUOTE: // (only first-level!) // Ignore content blocks with nothing in them. (These may exist for // the benefit of the forwarding logic which wants them for whitespace // consistency.) if (block.length) { if (lastContentDepth !== null) { // Blocks lack trailing newlines, so we need to flush the newline if // there was a preceding one. strBits.push(NEWLINE); // Add appropriate delimiting whitespace if (!suppressWhitespaceBlankLine) { strBits.push(replyQuoteBlankLine[lastContentDepth]); } } strBits.push(expandQuotedPrefix(block, 0)); strBits.push(expandQuoted(block, 0)); lastContentDepth = 1; suppressWhitespaceBlankLine = (etype === CT_LEADIN_TO_QUOTE); } break; case CT_QUOTED_TYPE: var depth = ((rep[i] >> 8)&0xff) + 1; // If this quoting level is too deep, just pretend like it does not // exist. if (depth < MAX_QUOTE_REPEAT_DEPTH) { if (lastContentDepth !== null) { // Blocks lack trailing newlines, so we need to flush the newline if // there was a preceding one. strBits.push(NEWLINE); // Add appropriate delimiting whitespace if (!suppressWhitespaceBlankLine) { strBits.push(replyQuoteBlankLine[lastContentDepth]); } } strBits.push(expandQuotedPrefix(block, depth)); strBits.push(expandQuoted(block, depth)); lastContentDepth = depth; // Don't suppress a whitespace blank line unless we seem to include // a quote lead-in at the end of our block. See the regex's content // block for more details on this. suppressWhitespaceBlankLine = RE_REPLY_LAST_LINE_IN_BLOCK_CONTAINS_WROTE.test(block); } break; // -- eat boilerplate! // No one needs to read boilerplate in a reply; the point is to // provide context, not the whole message. (Forward the message if // you want the whole thing!) case CT_BOILERPLATE_DISCLAIMER: case CT_BOILERPLATE_LIST_INFO: case CT_BOILERPLATE_PRODUCT: case CT_BOILERPLATE_ADS: break; } } return strBits.join(''); }; /** * Regenerate the text of a message for forwarding. 'Original Message' is not * prepended and information about the message's header is not prepended. That * is done in `generateForwardMessage`. * * We attempt to generate a message as close to the original message as * possible, but it doesn't have to be 100%. */ exports.generateForwardBodyText = function generateForwardBodyText(rep) { var strBits = [], nl; for (var i = 0; i < rep.length; i += 2) { if (i) { strBits.push(NEWLINE); } var etype = rep[i]&0xf, block = rep[i + 1]; switch (etype) { // - injected with restored whitespace default: case CT_AUTHORED_CONTENT: // pre-newlines for (nl = (rep[i] >> 8)&0xff; nl; nl--) { strBits.push(NEWLINE); } strBits.push(block); // post new-lines for (nl = (rep[i] >> 16)&0xff; nl; nl--) { strBits.push(NEWLINE); } break; case CT_LEADIN_TO_QUOTE: strBits.push(block); for (nl = (rep[i] >> 8)&0xff; nl; nl--) { strBits.push(NEWLINE); } break; // - injected verbatim, case CT_SIGNATURE: case CT_BOILERPLATE_DISCLAIMER: case CT_BOILERPLATE_LIST_INFO: case CT_BOILERPLATE_PRODUCT: case CT_BOILERPLATE_ADS: for (nl = (rep[i] >> 8)&0xff; nl; nl--) { strBits.push(NEWLINE); } strBits.push(block); for (nl = (rep[i] >> 16)&0xff; nl; nl--) { strBits.push(NEWLINE); } break; // - quote character reconstruction // this is not guaranteed to round-trip since we assume the non-whitespace // variant... case CT_QUOTED_TYPE: var depth = Math.min((rep[i] >> 8)&0xff, 8); for (nl = (rep[i] >> 16)&0xff; nl; nl--) { strBits.push(replyQuotePrefixStringsNoSpace[depth]); strBits.push(NEWLINE); } strBits.push(expandQuotedPrefix(block, depth)); strBits.push(expandQuoted(block, depth)); for (nl = (rep[i] >> 24)&0xff; nl; nl--) { strBits.push(NEWLINE); strBits.push(replyQuotePrefixStringsNoSpace[depth]); } break; } } return strBits.join(''); }; }); // end define