/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ this.EXPORTED_SYMBOLS = ['GlodaFundAttr']; var Cc = Components.classes; var Ci = Components.interfaces; var Cr = Components.results; var Cu = Components.utils; Cu.import("resource:///modules/gloda/log4moz.js"); Cu.import("resource:///modules/StringBundle.js"); Cu.import("resource:///modules/gloda/utils.js"); Cu.import("resource:///modules/gloda/gloda.js"); Cu.import("resource:///modules/gloda/datastore.js"); Cu.import("resource:///modules/gloda/datamodel.js"); // for GlodaAttachment Cu.import("resource:///modules/gloda/noun_mimetype.js"); Cu.import("resource:///modules/gloda/connotent.js"); /** * @namespace The Gloda Fundamental Attribute provider is a special attribute * provider; it provides attributes that the rest of the providers should be * able to assume exist. Also, it may end up accessing things at a lower level * than most extension providers should do. In summary, don't mimic this code * unless you won't complain when your code breaks. */ var GlodaFundAttr = { providerName: "gloda.fundattr", strings: new StringBundle("chrome://messenger/locale/gloda.properties"), _log: null, init: function gloda_explattr_init() { this._log = Log4Moz.repository.getLogger("gloda.fundattr"); try { this.defineAttributes(); } catch (ex) { this._log.error("Error in init: " + ex); throw ex; } }, POPULARITY_FROM_ME_TO: 10, POPULARITY_FROM_ME_CC: 4, POPULARITY_FROM_ME_BCC: 3, POPULARITY_TO_ME: 5, POPULARITY_CC_ME: 1, POPULARITY_BCC_ME: 1, /** Boost for messages 'I' sent */ NOTABILITY_FROM_ME: 10, /** Boost for messages involving 'me'. */ NOTABILITY_INVOLVING_ME: 1, /** Boost for message from someone in 'my' address book. */ NOTABILITY_FROM_IN_ADDR_BOOK: 10, /** Boost for the first person involved in my address book. */ NOTABILITY_INVOLVING_ADDR_BOOK_FIRST: 8, /** Boost for each additional person involved in my address book. */ NOTABILITY_INVOLVING_ADDR_BOOK_ADDL: 2, defineAttributes: function gloda_fundattr_defineAttributes() { /* ***** Conversations ***** */ // conversation: subjectMatches this._attrConvSubject = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "subjectMatches", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "subject", subjectNouns: [Gloda.NOUN_CONVERSATION], objectNoun: Gloda.NOUN_FULLTEXT, }); /* ***** Messages ***** */ // folder this._attrFolder = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "folder", singular: true, facet: true, special: Gloda.kSpecialColumn, specialColumnName: "folderID", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FOLDER, }); // tested-by: test_attributes_fundamental this._attrAccount = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "account", canQuery: "memory", singular: true, facet: true, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_ACCOUNT }); this._attrMessageKey = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "messageKey", singular: true, special: Gloda.kSpecialColumn, specialColumnName: "messageKey", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_NUMBER, canQuery: true, }); // tested-by: test_attributes_fundamental // We need to surface the deleted attribute for querying, but there is no // reason for user code, so let's call it "_deleted" rather than deleted. // (In fact, our validity constraints require a special query formulation // that user code should have no clue exists. That's right user code, // that's a dare.) Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "_deleted", singular: true, special: Gloda.kSpecialColumn, specialColumnName: "deleted", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_NUMBER, }); // -- fulltext search helpers // fulltextMatches. Match over message subject, body, and attachments // @testpoint gloda.noun.message.attr.fulltextMatches this._attrFulltext = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "fulltextMatches", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "messagesText", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FULLTEXT, }); // subjectMatches. Fulltext match on subject // @testpoint gloda.noun.message.attr.subjectMatches this._attrSubjectText = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "subjectMatches", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "subject", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FULLTEXT, }); // bodyMatches. super-synthetic full-text matching... // @testpoint gloda.noun.message.attr.bodyMatches this._attrBody = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "bodyMatches", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "body", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FULLTEXT, }); // attachmentNamesMatch // @testpoint gloda.noun.message.attr.attachmentNamesMatch this._attrAttachmentNames = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "attachmentNamesMatch", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "attachmentNames", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FULLTEXT, }); // @testpoint gloda.noun.message.attr.authorMatches this._attrAuthorFulltext = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "authorMatches", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "author", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FULLTEXT, }); // @testpoint gloda.noun.message.attr.recipientsMatch this._attrRecipientsFulltext = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrDerived, attributeName: "recipientsMatch", singular: true, special: Gloda.kSpecialFulltext, specialColumnName: "recipients", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_FULLTEXT, }); // --- synthetic stuff for some reason // conversation // @testpoint gloda.noun.message.attr.conversation this._attrConversation = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "conversation", singular: true, special: Gloda.kSpecialColumnParent, specialColumnName: "conversationID", idStorageAttributeName: "_conversationID", valueStorageAttributeName: "_conversation", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_CONVERSATION, canQuery: true, }); // --- Fundamental // From this._attrFrom = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "from", singular: true, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // tested-by: test_attributes_fundamental // To this._attrTo = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "to", singular: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // tested-by: test_attributes_fundamental // Cc this._attrCc = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "cc", singular: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // not-tested /** * Bcc'ed recipients; only makes sense for sent messages. */ this._attrBcc = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "bcc", singular: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // not-tested // Date. now lives on the row. this._attrDate = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "date", singular: true, facet: { type: "date", }, special: Gloda.kSpecialColumn, specialColumnName: "date", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_DATE, }); // tested-by: test_attributes_fundamental // Header message ID. this._attrHeaderMessageID = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "headerMessageID", singular: true, special: Gloda.kSpecialString, specialColumnName: "headerMessageID", subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_STRING, canQuery: true, }); // tested-by: test_attributes_fundamental // Attachment MIME Types this._attrAttachmentTypes = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "attachmentTypes", singular: false, emptySetIsSignificant: true, facet: { type: "default", // This will group the MIME types by their category. groupIdAttr: "category", queryHelper: "Category", }, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_MIME_TYPE, }); // Attachment infos this._attrIsEncrypted = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "isEncrypted", singular: true, emptySetIsSignificant: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_NUMBER, }); // Attachment infos this._attrAttachmentInfos = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "attachmentInfos", singular: false, emptySetIsSignificant: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_ATTACHMENT, }); // --- Optimization /** * Involves means any of from/to/cc/bcc. The queries get ugly enough * without this that it seems to justify the cost, especially given the * frequent use case. (In fact, post-filtering for the specific from/to/cc * is probably justifiable rather than losing this attribute...) */ this._attrInvolves = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrOptimization, attributeName: "involves", singular: false, facet: { type: "default", /** * Filter out 'me', as we have other facets that deal with that, and the * 'me' identities are so likely that they distort things. * * @return true if the identity is not one of my identities, false if it * is. */ filter: function gloda_explattr_involves_filter(aItem) { return (!(aItem.id in Gloda.myIdentities)); } }, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // not-tested /** * Any of to/cc/bcc. */ this._attrRecipients = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrOptimization, attributeName: "recipients", singular: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // not-tested // From Me (To/Cc/Bcc) this._attrFromMe = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrOptimization, attributeName: "fromMe", singular: false, // The interesting thing to a facet is whether the message is from me. facet: { type: "nonempty?" }, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_PARAM_IDENTITY, }); // not-tested // To/Cc/Bcc Me this._attrToMe = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "toMe", // The interesting thing to a facet is whether the message is to me. facet: { type: "nonempty?" }, singular: false, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_PARAM_IDENTITY, }); // not-tested // -- Mailing List // Non-singular, but a hard call. Namely, it is obvious that a message can // be addressed to multiple mailing lists. However, I don't see how you // could receive a message with more than one set of List-* headers, // since each list-serve would each send you a copy. Based on our current // decision to treat each physical message as separate, it almost seems // right to limit the list attribute to the copy that originated at the // list. That may sound entirely wrong, but keep in mind that until we // have seen a message from the list with the List headers, we can't // definitely know it's a mailing list (although heuristics could take us // pretty far). As such, the quasi-singular thing is appealing. // Of course, the reality is that we really want to know if a message was // sent to multiple mailing lists and be able to query on that. // Additionally, our implicit-to logic needs to work on messages that // weren't relayed by the list-serve, especially messages sent to the list // by the user. this._attrList = Gloda.defineAttribute({ provider: this, extensionName: Gloda.BUILT_IN, attributeType: Gloda.kAttrFundamental, attributeName: "mailing-list", bindName: "mailingLists", singular: false, emptySetIsSignificant: true, facet: true, subjectNouns: [Gloda.NOUN_MESSAGE], objectNoun: Gloda.NOUN_IDENTITY, }); // not-tested, not-implemented }, RE_LIST_POST: /]+)>/, /** * * Specializations: * - Mailing Lists. Replies to a message on a mailing list frequently only * have the list-serve as the 'to', so we try to generate a synthetic 'to' * based on the author of the parent message when possible. (The 'possible' * part is that we may not have a copy of the parent message at the time of * processing.) * - Newsgroups. Same deal as mailing lists. */ process: function* gloda_fundattr_process(aGlodaMessage, aRawReps, aIsNew, aCallbackHandle) { let aMsgHdr = aRawReps.header; let aMimeMsg = aRawReps.mime; // -- From // Let's use replyTo if available. // er, since we are just dealing with mailing lists for now, forget the // reply-to... // TODO: deal with default charset issues let author = null; /* try { author = aMsgHdr.getStringProperty("replyTo"); } catch (ex) { } */ if (author == null || author == "") author = aMsgHdr.author; let normalizedListPost = ""; if (aMimeMsg && aMimeMsg.has("list-post")) { let match = this.RE_LIST_POST.exec(aMimeMsg.get("list-post")); if (match) normalizedListPost = "<" + match[1] + ">"; } // Do not use the MIME decoded variants of any of the email addresses // because if name is encoded and has a comma in it, it will break the // address parser (which already knows how to do the decoding anyways). let [authorIdentities, toIdentities, ccIdentities, bccIdentities, listIdentities] = yield aCallbackHandle.pushAndGo( Gloda.getOrCreateMailIdentities(aCallbackHandle, author, aMsgHdr.recipients, aMsgHdr.ccList, aMsgHdr.bccList, normalizedListPost)); if (authorIdentities.length != 1) { throw new Gloda.BadItemContentsError( "Message with subject '" + aMsgHdr.mime2DecodedSubject + "' somehow lacks a valid author. Bailing."); } let authorIdentity = authorIdentities[0]; aGlodaMessage.from = authorIdentity; // -- To, Cc, Bcc aGlodaMessage.to = toIdentities; aGlodaMessage.cc = ccIdentities; aGlodaMessage.bcc = bccIdentities; // -- Mailing List if (listIdentities.length) aGlodaMessage.mailingLists = listIdentities; let findIsEncrypted = x => x.isEncrypted || (x.parts ? x.parts.some(findIsEncrypted) : false); // -- Encryption aGlodaMessage.isEncrypted = false; if (aMimeMsg) { aGlodaMessage.isEncrypted = findIsEncrypted(aMimeMsg); } // -- Attachments if (aMimeMsg) { // nsParseMailbox.cpp puts the attachment flag on msgHdrs as soon as it // finds a multipart/mixed part. This is a good heuristic, but if it turns // out the part has no filename, then we don't treat it as an attachment. // We just streamed the message, and we have all the information to figure // that out, so now is a good place to clear the flag if needed. let foundRealAttachment = false; let attachmentTypes = []; for (let attachment of aMimeMsg.allAttachments) { // We don't care about would-be attachments that are not user-intended // attachments but rather artifacts of the message content. // We also want to avoid dealing with obviously bogus mime types. // (If you don't have a "/", you are probably bogus.) if (attachment.isRealAttachment && attachment.contentType.includes("/")) { attachmentTypes.push(MimeTypeNoun.getMimeType(attachment.contentType)); } if (attachment.isRealAttachment) foundRealAttachment = true; } if (attachmentTypes.length) { aGlodaMessage.attachmentTypes = attachmentTypes; } let aMsgHdr = aRawReps.header; let wasStreamed = aMsgHdr && !aGlodaMessage.isEncrypted && ((aMsgHdr.flags & Ci.nsMsgMessageFlags.Offline) || (aMsgHdr.folder instanceof Ci.nsIMsgLocalMailFolder)); // Clear the flag if it turns out there's no attachment after all and we // streamed completely the message (if we didn't, then we have no // knowledge of attachments, unless bug 673370 is fixed). if (!foundRealAttachment && wasStreamed) aMsgHdr.markHasAttachments(false); // This is not the same kind of attachments as above. Now, we want to // provide convenience attributes to Gloda consumers, so that they can run // through the list of attachments of a given message, to possibly build a // visualization on top of it. We still reject bogus mime types, which // means yencode won't be supported. Oh, I feel really bad. let attachmentInfos = []; for (let att of aMimeMsg.allUserAttachments) { attachmentInfos.push(this.glodaAttFromMimeAtt(aRawReps.trueGlodaRep, att)); } aGlodaMessage.attachmentInfos = attachmentInfos; } // TODO: deal with mailing lists, including implicit-to. this will require // convincing the indexer to pass us in the previous message if it is // available. (which we'll simply pass to everyone... it can help body // logic for quoting purposes, etc. too.) yield Gloda.kWorkDone; }, glodaAttFromMimeAtt: function gloda_fundattr_glodaAttFromMimeAtt(aGlodaMessage, aAtt) { // So we don't want to store the URL because it can change over time if // the message is moved. What we do is store the full URL if it's a // detached attachment, otherwise just keep the part information, and // rebuild the URL according to where the message is sitting. let part, externalUrl; if (aAtt.isExternal) { externalUrl = aAtt.url; } else { let matches = aAtt.url.match(GlodaUtils.PART_RE); if (matches && matches.length) part = matches[1]; else this._log.error("Error processing attachment: " + aAtt.url); } return new GlodaAttachment(aGlodaMessage, aAtt.name, aAtt.contentType, aAtt.size, part, externalUrl, aAtt.isExternal); }, optimize: function* gloda_fundattr_optimize(aGlodaMessage, aRawReps, aIsNew, aCallbackHandle) { let aMsgHdr = aRawReps.header; // for simplicity this is used for both involves and recipients let involvesIdentities = {}; let involves = aGlodaMessage.involves || []; let recipients = aGlodaMessage.recipients || []; // 'me' specialization optimizations let toMe = aGlodaMessage.toMe || []; let fromMe = aGlodaMessage.fromMe || []; let myIdentities = Gloda.myIdentities; // needless optimization? let authorIdentity = aGlodaMessage.from; let isFromMe = authorIdentity.id in myIdentities; // The fulltext search column for the author. We want to have in here: // - The e-mail address and display name as enclosed on the message. // - The name per the address book card for this e-mail address, if we have // one. aGlodaMessage._indexAuthor = aMsgHdr.mime2DecodedAuthor; // The fulltext search column for the recipients. (same deal) aGlodaMessage._indexRecipients = aMsgHdr.mime2DecodedRecipients; if (isFromMe) aGlodaMessage.notability += this.NOTABILITY_FROM_ME; else { let authorCard = authorIdentity.abCard; if (authorCard) { aGlodaMessage.notability += this.NOTABILITY_FROM_IN_ADDR_BOOK; // @testpoint gloda.noun.message.attr.authorMatches aGlodaMessage._indexAuthor += ' ' + authorCard.displayName; } } involves.push(authorIdentity); involvesIdentities[authorIdentity.id] = true; let involvedAddrBookCount = 0; for (let toIdentity of aGlodaMessage.to) { if (!(toIdentity.id in involvesIdentities)) { involves.push(toIdentity); recipients.push(toIdentity); involvesIdentities[toIdentity.id] = true; let toCard = toIdentity.abCard; if (toCard) { involvedAddrBookCount++; // @testpoint gloda.noun.message.attr.recipientsMatch aGlodaMessage._indexRecipients += ' ' + toCard.displayName; } } // optimization attribute to-me ('I' am the parameter) if (toIdentity.id in myIdentities) { toMe.push([toIdentity, authorIdentity]); if (aIsNew) authorIdentity.contact.popularity += this.POPULARITY_TO_ME; } // optimization attribute from-me-to ('I' am the parameter) if (isFromMe) { fromMe.push([authorIdentity, toIdentity]); // also, popularity if (aIsNew) toIdentity.contact.popularity += this.POPULARITY_FROM_ME_TO; } } for (let ccIdentity of aGlodaMessage.cc) { if (!(ccIdentity.id in involvesIdentities)) { involves.push(ccIdentity); recipients.push(ccIdentity); involvesIdentities[ccIdentity.id] = true; let ccCard = ccIdentity.abCard; if (ccCard) { involvedAddrBookCount++; // @testpoint gloda.noun.message.attr.recipientsMatch aGlodaMessage._indexRecipients += ' ' + ccCard.displayName; } } // optimization attribute cc-me ('I' am the parameter) if (ccIdentity.id in myIdentities) { toMe.push([ccIdentity, authorIdentity]); if (aIsNew) authorIdentity.contact.popularity += this.POPULARITY_CC_ME; } // optimization attribute from-me-to ('I' am the parameter) if (isFromMe) { fromMe.push([authorIdentity, ccIdentity]); // also, popularity if (aIsNew) ccIdentity.contact.popularity += this.POPULARITY_FROM_ME_CC; } } // just treat bcc like cc; the intent is the same although the exact // semantics differ. for (let bccIdentity of aGlodaMessage.bcc) { if (!(bccIdentity.id in involvesIdentities)) { involves.push(bccIdentity); recipients.push(bccIdentity); involvesIdentities[bccIdentity.id] = true; let bccCard = bccIdentity.abCard; if (bccCard) { involvedAddrBookCount++; // @testpoint gloda.noun.message.attr.recipientsMatch aGlodaMessage._indexRecipients += ' ' + bccCard.displayName; } } // optimization attribute cc-me ('I' am the parameter) if (bccIdentity.id in myIdentities) { toMe.push([bccIdentity, authorIdentity]); if (aIsNew) authorIdentity.contact.popularity += this.POPULARITY_BCC_ME; } // optimization attribute from-me-to ('I' am the parameter) if (isFromMe) { fromMe.push([authorIdentity, bccIdentity]); // also, popularity if (aIsNew) bccIdentity.contact.popularity += this.POPULARITY_FROM_ME_BCC; } } if (involvedAddrBookCount) aGlodaMessage.notability += this.NOTABILITY_INVOLVING_ADDR_BOOK_FIRST + (involvedAddrBookCount - 1) * this.NOTABILITY_INVOLVING_ADDR_BOOK_ADDL; aGlodaMessage.involves = involves; aGlodaMessage.recipients = recipients; if (toMe.length) { aGlodaMessage.toMe = toMe; aGlodaMessage.notability += this.NOTABILITY_INVOLVING_ME; } if (fromMe.length) aGlodaMessage.fromMe = fromMe; // Content if (aRawReps.bodyLines) { aGlodaMessage._content = aRawReps.content = new GlodaContent(); if (this.contentWhittle({}, aRawReps.bodyLines, aGlodaMessage._content)) { // we were going to do something here? } } else { aRawReps.content = null; } yield Gloda.kWorkDone; }, /** * Duplicates the notability logic from optimize(). Arguably optimize should * be factored to call us, grokNounItem should be factored to call us, or we * should get sufficiently fancy that our code wildly diverges. */ score: function gloda_fundattr_score(aMessage, aContext) { let score = 0; let authorIdentity = aMessage.from; if (authorIdentity.id in Gloda.myIdentities) score += this.NOTABILITY_FROM_ME; else if (authorIdentity.inAddressBook) score += this.NOTABILITY_FROM_IN_ADDR_BOOK; if (aMessage.toMe) score += this.NOTABILITY_INVOLVING_ME; let involvedAddrBookCount = 0; for (let [, identity] in Iterator(aMessage.to)) if (identity.inAddressBook) involvedAddrBookCount++; for (let [, identity] in Iterator(aMessage.cc)) if (identity.inAddressBook) involvedAddrBookCount++; if (involvedAddrBookCount) score += this.NOTABILITY_INVOLVING_ADDR_BOOK_FIRST + (involvedAddrBookCount - 1) * this.NOTABILITY_INVOLVING_ADDR_BOOK_ADDL; return score; }, _countQuoteDepthAndNormalize: function gloda_fundattr__countQuoteDepthAndNormalize(aLine) { let count = 0; let lastStartOffset = 0; for (let i = 0; i < aLine.length; i++) { let c = aLine[i]; if (c == ">") { count++; lastStartOffset = i+1; } else if (c == " ") { } else { return [count, lastStartOffset ? aLine.substring(lastStartOffset) : aLine]; } } return [count, lastStartOffset ? aLine.substring(lastStartOffset) : aLine]; }, /** * Attempt to understand simple quoting constructs that use ">" with * obvious phrases to enter the quoting block. No support for other types * of quoting at this time. Also no support for piercing the wrapper of * forwarded messages to actually be the content of the forwarded message. */ contentWhittle: function gloda_fundattr_contentWhittle(aMeta, aBodyLines, aContent) { if (!aContent.volunteerContent(aContent.kPriorityBase)) return false; // duplicate the list; we mutate somewhat... let bodyLines = aBodyLines.concat(); // lastNonBlankLine originally was just for detecting quoting idioms where // the "wrote" line was separated from the quoted block by a blank line. // Now we also use it for whitespace suppression at the boundaries of // quoted and un-quoted text. (We keep blank lines within the same // 'block' of quoted or non-quoted text.) // Because we now have two goals for it, and we still want to suppress blank // lines when there is a 'wrote' line involved, we introduce... // prevLastNonBlankLine! This arguably suggests refactoring should be the // next step, but things work for now. let rangeStart = 0, lastNonBlankLine = null, prevLastNonBlankLine = null; let inQuoteDepth = 0; for (let [iLine, line] of bodyLines.entries()) { if (!line || (line == "\xa0")) /* unicode non breaking space */ continue; if (line.startsWith(">")) { if (!inQuoteDepth) { let rangeEnd = iLine - 1; let quoteRangeStart = iLine; // see if the last non-blank-line was a lead-in... if (lastNonBlankLine != null) { // TODO: localize quote range start detection if (aBodyLines[lastNonBlankLine].includes("wrote")) { quoteRangeStart = lastNonBlankLine; rangeEnd = lastNonBlankLine - 1; // we 'used up' lastNonBlankLine, let's promote the prev guy to // be the new lastNonBlankLine for the next logic block lastNonBlankLine = prevLastNonBlankLine; } // eat the trailing whitespace... if (lastNonBlankLine != null) rangeEnd = Math.min(rangeEnd, lastNonBlankLine); } if (rangeEnd >= rangeStart) aContent.content(aBodyLines.slice(rangeStart, rangeEnd+1)); [inQuoteDepth, line] = this._countQuoteDepthAndNormalize(line); bodyLines[iLine] = line; rangeStart = quoteRangeStart; } else { let curQuoteDepth; [curQuoteDepth, line] = this._countQuoteDepthAndNormalize(line); bodyLines[iLine] = line; if (curQuoteDepth != inQuoteDepth) { // we could do some "wrote" compensation here, but it's not really // as important. let's wait for a more clever algorithm. aContent.quoted(aBodyLines.slice(rangeStart, iLine), inQuoteDepth); inQuoteDepth = curQuoteDepth; rangeStart = iLine; } } } else { if (inQuoteDepth) { aContent.quoted(aBodyLines.slice(rangeStart, iLine), inQuoteDepth); inQuoteDepth = 0; rangeStart = iLine; } } prevLastNonBlankLine = lastNonBlankLine; lastNonBlankLine = iLine; } if (inQuoteDepth) { aContent.quoted(aBodyLines.slice(rangeStart), inQuoteDepth); } else { aContent.content(aBodyLines.slice(rangeStart, lastNonBlankLine+1)); } return true; }, };