summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm')
-rw-r--r--comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm608
1 files changed, 608 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm b/comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm
new file mode 100644
index 0000000000..8beff4f670
--- /dev/null
+++ b/comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm
@@ -0,0 +1,608 @@
+/* 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/. */
+
+var EXPORTED_SYMBOLS = ["AbAutoCompleteSearch"];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var {
+ getSearchTokens,
+ getModelQuery,
+ modelQueryHasUserValue,
+ generateQueryURI,
+} = ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+
+var ACR = Ci.nsIAutoCompleteResult;
+var nsIAbAutoCompleteResult = Ci.nsIAbAutoCompleteResult;
+
+var MAX_ASYNC_RESULTS = 100;
+
+function nsAbAutoCompleteResult(aSearchString) {
+ // Can't create this in the prototype as we'd get the same array for
+ // all instances
+ this.asyncDirectories = [];
+ this._searchResults = []; // final results
+ this.searchString = aSearchString;
+ this._collectedValues = new Map(); // temporary unsorted results
+ // Get model query from pref; this will return mail.addr_book.autocompletequery.format.phonetic
+ // if mail.addr_book.show_phonetic_fields == true
+ this.modelQuery = getModelQuery("mail.addr_book.autocompletequery.format");
+ // check if the currently active model query has been modified by user
+ this._modelQueryHasUserValue = modelQueryHasUserValue(
+ "mail.addr_book.autocompletequery.format"
+ );
+}
+
+nsAbAutoCompleteResult.prototype = {
+ _searchResults: null,
+
+ // nsIAutoCompleteResult
+
+ searchString: null,
+ searchResult: ACR.RESULT_NOMATCH,
+ defaultIndex: -1,
+ errorDescription: null,
+
+ get matchCount() {
+ return this._searchResults.length;
+ },
+
+ getValueAt(aIndex) {
+ return this._searchResults[aIndex].value;
+ },
+
+ getLabelAt(aIndex) {
+ return this.getValueAt(aIndex);
+ },
+
+ getCommentAt(aIndex) {
+ return this._searchResults[aIndex].comment;
+ },
+
+ getStyleAt(aIndex) {
+ return "local-abook";
+ },
+
+ getImageAt(aIndex) {
+ return "";
+ },
+
+ getFinalCompleteValueAt(aIndex) {
+ return this.getValueAt(aIndex);
+ },
+
+ removeValueAt(aRowIndex, aRemoveFromDB) {},
+
+ // nsIAbAutoCompleteResult
+
+ getCardAt(aIndex) {
+ return this._searchResults[aIndex].card;
+ },
+
+ getEmailToUse(aIndex) {
+ return this._searchResults[aIndex].emailToUse;
+ },
+
+ isCompleteResult(aIndex) {
+ return this._searchResults[aIndex].isCompleteResult;
+ },
+
+ modelQuery: null,
+ asyncDirectories: null,
+
+ // nsISupports
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIAutoCompleteResult",
+ "nsIAbAutoCompleteResult",
+ ]),
+};
+
+function AbAutoCompleteSearch() {}
+
+AbAutoCompleteSearch.prototype = {
+ // This is set from a preference,
+ // 0 = no comment column, 1 = name of address book this card came from
+ // Other numbers currently unused (hence default to zero)
+ _commentColumn: 0,
+ _parser: MailServices.headerParser,
+ _abManager: MailServices.ab,
+ applicableHeaders: new Set(["addr_to", "addr_cc", "addr_bcc", "addr_reply"]),
+ _result: null,
+
+ // Private methods
+
+ /**
+ * Returns the popularity index for a given card. This takes account of a
+ * translation bug whereby Thunderbird 2 stores its values in mork as
+ * hexadecimal, and Thunderbird 3 stores as decimal.
+ *
+ * @param {nsIAbDirectory} aDirectory - The directory that the card is in.
+ * @param {nsIAbCard} aCard - The card to return the popularity index for.
+ */
+ _getPopularityIndex(aDirectory, aCard) {
+ let popularityValue = aCard.getProperty("PopularityIndex", "0");
+ let popularityIndex = parseInt(popularityValue);
+
+ // If we haven't parsed it the first time round, parse it as hexadecimal
+ // and repair so that we don't have to keep repairing.
+ if (isNaN(popularityIndex)) {
+ popularityIndex = parseInt(popularityValue, 16);
+
+ // If its still NaN, just give up, we shouldn't ever get here.
+ if (isNaN(popularityIndex)) {
+ popularityIndex = 0;
+ }
+
+ // Now store this change so that we're not changing it each time around.
+ if (!aDirectory.readOnly) {
+ aCard.setProperty("PopularityIndex", popularityIndex);
+ try {
+ aDirectory.modifyCard(aCard);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+ return popularityIndex;
+ },
+
+ /**
+ * Gets the score of the (full) address, given the search input. We want
+ * results that match the beginning of a "word" in the result to score better
+ * than a result that matches only in the middle of the word.
+ *
+ * @param {nsIAbCard} aCard - The card whose score is being decided.
+ * @param {string} aAddress - Full lower-cased address, including display
+ * name and address.
+ * @param {string} aSearchString - Search string provided by user.
+ * @returns {integer} a score; a higher score is better than a lower one.
+ */
+ _getScore(aCard, aAddress, aSearchString) {
+ const BEST = 100;
+
+ // We will firstly check if the search term provided by the user
+ // is the nick name for the card or at least in the beginning of it.
+ let nick = aCard.getProperty("NickName", "").toLocaleLowerCase();
+ aSearchString = aSearchString.toLocaleLowerCase();
+ if (nick == aSearchString) {
+ return BEST + 1;
+ }
+ if (nick.indexOf(aSearchString) == 0) {
+ return BEST;
+ }
+
+ // We'll do this case-insensitively and ignore the domain.
+ let atIdx = aAddress.lastIndexOf("@");
+ if (atIdx != -1) {
+ // mail lists don't have an @
+ aAddress = aAddress.substr(0, atIdx);
+ }
+ let idx = aAddress.indexOf(aSearchString);
+ if (idx == 0) {
+ return BEST;
+ }
+ if (idx == -1) {
+ return 0;
+ }
+
+ // We want to treat firstname, lastname and word boundary(ish) parts of
+ // the email address the same. E.g. for "John Doe (:xx) <jd.who@example.com>"
+ // all of these should score the same: "John", "Doe", "xx",
+ // ":xx", "jd", "who".
+ let prevCh = aAddress.charAt(idx - 1);
+ if (/[ :."'(\-_<&]/.test(prevCh)) {
+ return BEST;
+ }
+
+ // The match was inside a word -> we don't care about the position.
+ return 0;
+ },
+
+ /**
+ * Searches cards in the given directory. If a card is matched (and isn't
+ * a mailing list) then the function will add a result for each email address
+ * that exists.
+ *
+ * @param {string} searchQuery - The boolean search query to use.
+ * @param {string} searchString - The original search string.
+ * @param {nsIAbDirectory} directory - An nsIAbDirectory to search.
+ * @param {nsIAbAutoCompleteResult} result - The result element to append
+ * results to.
+ */
+ _searchCards(searchQuery, searchString, directory, result) {
+ // Cache this values to save going through xpconnect each time
+ let commentColumn = this._commentColumn == 1 ? directory.dirName : "";
+
+ if (searchQuery[0] == "?") {
+ searchQuery = searchQuery.substring(1);
+ }
+ return new Promise(resolve => {
+ directory.search(searchQuery, searchString, {
+ onSearchFoundCard: card => {
+ if (card.isMailList) {
+ this._addToResult(commentColumn, directory, card, "", true, result);
+ } else {
+ let first = true;
+ for (let emailAddress of card.emailAddresses) {
+ this._addToResult(
+ commentColumn,
+ directory,
+ card,
+ emailAddress,
+ first,
+ result
+ );
+ first = false;
+ }
+ }
+ },
+ onSearchFinished(status, complete, secInfo, location) {
+ resolve();
+ },
+ });
+ });
+ },
+
+ /**
+ * Checks the parent card and email address of an autocomplete results entry
+ * from a previous result against the search parameters to see if that entry
+ * should still be included in the narrowed-down result.
+ *
+ * @param {nsIAbCard} aCard - The card to check.
+ * @param {string} aEmailToUse - The email address to check against.
+ * @param {string[]} aSearchWords - Words in the multi word search string.
+ * @returns {boolean} True if the card matches the search parameters,
+ * false otherwise.
+ */
+ _checkEntry(aCard, aEmailToUse, aSearchWords) {
+ // Joining values of many fields in a single string so that a single
+ // search query can be fired on all of them at once. Separating them
+ // using spaces so that field1=> "abc" and field2=> "def" on joining
+ // shouldn't return true on search for "bcd".
+ // Note: This should be constructed from model query pref using
+ // getModelQuery("mail.addr_book.autocompletequery.format")
+ // but for now we hard-code the default value equivalent of the pref here
+ // or else bail out before and reconstruct the full c++ query if the pref
+ // has been customized (modelQueryHasUserValue), so that we won't get here.
+ let cumulativeFieldText =
+ aCard.displayName +
+ " " +
+ aCard.firstName +
+ " " +
+ aCard.lastName +
+ " " +
+ aEmailToUse +
+ " " +
+ aCard.getProperty("NickName", "");
+ if (aCard.isMailList) {
+ cumulativeFieldText += " " + aCard.getProperty("Notes", "");
+ }
+ cumulativeFieldText = cumulativeFieldText.toLocaleLowerCase();
+
+ return aSearchWords.every(String.prototype.includes, cumulativeFieldText);
+ },
+
+ /**
+ * Checks to see if an emailAddress (name/address) is a duplicate of an
+ * existing entry already in the results. If the emailAddress is found, it
+ * will remove the existing element if the popularity of the new card is
+ * higher than the previous card.
+ *
+ * @param {nsIAbDirectory} directory - The directory that the card is in.
+ * @param {nsIAbCard} card - The card that could be a duplicate.
+ * @param {string} lcEmailAddress - The emailAddress (name/address
+ * combination) to check for duplicates against. Lowercased.
+ * @param {nsIAbAutoCompleteResult} currentResults - The current results list.
+ */
+ _checkDuplicate(directory, card, lcEmailAddress, currentResults) {
+ let existingResult = currentResults._collectedValues.get(lcEmailAddress);
+ if (!existingResult) {
+ return false;
+ }
+
+ let popIndex = this._getPopularityIndex(directory, card);
+ // It's a duplicate, is the new one more popular?
+ if (popIndex > existingResult.popularity) {
+ // Yes it is, so delete this element, return false and allow
+ // _addToResult to sort the new element into the correct place.
+ currentResults._collectedValues.delete(lcEmailAddress);
+ return false;
+ }
+ // Not more popular, but still a duplicate. Return true and _addToResult
+ // will just forget about it.
+ return true;
+ },
+
+ /**
+ * Adds a card to the results list if it isn't a duplicate. The function will
+ * order the results by popularity.
+ *
+ * @param {string} commentColumn - The text to be displayed in the comment
+ * column (if any).
+ * @param {nsIAbDirectory} directory - The directory that the card is in.
+ * @param {nsIAbCard} card - The card being added to the results.
+ * @param {string} emailToUse - The email address from the card that should
+ * be used for this result.
+ * @param {boolean} isPrimaryEmail - Is the emailToUse the primary email?
+ * Set to true if it is the case. For mailing lists set it to true.
+ * @param {nsIAbAutoCompleteResult} result - The result to add the new entry to.
+ */
+ _addToResult(
+ commentColumn,
+ directory,
+ card,
+ emailToUse,
+ isPrimaryEmail,
+ result
+ ) {
+ let mbox = this._parser.makeMailboxObject(
+ card.displayName,
+ card.isMailList
+ ? card.getProperty("Notes", "") || card.displayName
+ : emailToUse
+ );
+ if (!mbox.email) {
+ return;
+ }
+
+ let emailAddress = mbox.toString();
+ let lcEmailAddress = emailAddress.toLocaleLowerCase();
+
+ // If it is a duplicate, then just return and don't add it. The
+ // _checkDuplicate function deals with it all for us.
+ if (this._checkDuplicate(directory, card, lcEmailAddress, result)) {
+ return;
+ }
+
+ result._collectedValues.set(lcEmailAddress, {
+ value: emailAddress,
+ comment: commentColumn,
+ card,
+ isPrimaryEmail,
+ emailToUse,
+ isCompleteResult: true,
+ popularity: this._getPopularityIndex(directory, card),
+ score: this._getScore(card, lcEmailAddress, result.searchString),
+ });
+ },
+
+ // nsIAutoCompleteSearch
+
+ /**
+ * Starts a search based on the given parameters.
+ *
+ * @see nsIAutoCompleteSearch for parameter details.
+ *
+ * It is expected that aSearchParam contains the identity (if any) to use
+ * for determining if an address book should be autocompleted against.
+ */
+ async startSearch(aSearchString, aSearchParam, aPreviousResult, aListener) {
+ let params = aSearchParam ? JSON.parse(aSearchParam) : {};
+ var result = new nsAbAutoCompleteResult(aSearchString);
+ if ("type" in params && !this.applicableHeaders.has(params.type)) {
+ result.searchResult = ACR.RESULT_IGNORED;
+ aListener.onSearchResult(this, result);
+ return;
+ }
+
+ let fullString = aSearchString && aSearchString.trim().toLocaleLowerCase();
+
+ // If the search string is empty, or the user hasn't enabled autocomplete,
+ // then just return no matches or the result ignored.
+ if (!fullString) {
+ result.searchResult = ACR.RESULT_IGNORED;
+ aListener.onSearchResult(this, result);
+ return;
+ }
+
+ // Array of all the terms from the fullString search query
+ // (separated on the basis of spaces or exact terms on the
+ // basis of quotes).
+ let searchWords = getSearchTokens(fullString);
+
+ // Find out about the comment column
+ this._commentColumn = Services.prefs.getIntPref(
+ "mail.autoComplete.commentColumn",
+ 0
+ );
+
+ let asyncDirectories = [];
+
+ if (
+ aPreviousResult instanceof nsIAbAutoCompleteResult &&
+ aSearchString.startsWith(aPreviousResult.searchString) &&
+ aPreviousResult.searchResult == ACR.RESULT_SUCCESS &&
+ !result._modelQueryHasUserValue &&
+ result.modelQuery == aPreviousResult.modelQuery
+ ) {
+ // We have successful previous matches, and model query has not changed since
+ // previous search, therefore just iterate through the list of previous result
+ // entries and reduce as appropriate (via _checkEntry function).
+ // Test for model query change is required: when reverting back from custom to
+ // default query, result._modelQueryHasUserValue==false, but we must bail out.
+ // Todo: However, if autocomplete model query has been customized, we fall
+ // back to using the full query again instead of reducing result list in js;
+ // The full query might be less performant as it's fired against entire AB,
+ // so we should try morphing the query for js. We can't use the _checkEntry
+ // js query yet because it is hardcoded (mimic default model query).
+ // At least we now allow users to customize their autocomplete model query...
+ for (let i = 0; i < aPreviousResult.matchCount; ++i) {
+ if (aPreviousResult.isCompleteResult(i)) {
+ let card = aPreviousResult.getCardAt(i);
+ let email = aPreviousResult.getEmailToUse(i);
+ if (this._checkEntry(card, email, searchWords)) {
+ // Add matches into the results array. We re-sort as needed later.
+ result._searchResults.push({
+ value: aPreviousResult.getValueAt(i),
+ comment: aPreviousResult.getCommentAt(i),
+ card,
+ isPrimaryEmail: card.primaryEmail == email,
+ emailToUse: email,
+ isCompleteResult: true,
+ popularity: parseInt(card.getProperty("PopularityIndex", "0")),
+ score: this._getScore(
+ card,
+ aPreviousResult.getValueAt(i).toLocaleLowerCase(),
+ fullString
+ ),
+ });
+ }
+ }
+ }
+
+ asyncDirectories = aPreviousResult.asyncDirectories;
+ } else {
+ // Construct the search query from pref; using a query means we can
+ // optimise on running the search through c++ which is better for string
+ // comparisons (_checkEntry is relatively slow).
+ // When user's fullstring search expression is a multiword query, search
+ // for each word separately so that each result contains all the words
+ // from the fullstring in the fields of the addressbook card
+ // (see bug 558931 for explanations).
+ // Use helper method to split up search query to multi-word search
+ // query against multiple fields.
+ let searchWords = getSearchTokens(fullString);
+ let searchQuery = generateQueryURI(result.modelQuery, searchWords);
+
+ // Now do the searching
+ // We're not going to bother searching sub-directories, currently the
+ // architecture forces all cards that are in mailing lists to be in ABs as
+ // well, therefore by searching sub-directories (aka mailing lists) we're
+ // just going to find duplicates.
+ for (let dir of this._abManager.directories) {
+ // A failure in one address book should no break the whole search.
+ try {
+ if (dir.useForAutocomplete("idKey" in params ? params.idKey : null)) {
+ await this._searchCards(searchQuery, aSearchString, dir, result);
+ } else if (dir.dirType == Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE) {
+ asyncDirectories.push(dir);
+ }
+ } catch (ex) {
+ console.error(
+ new Components.Exception(
+ `Exception thrown by ${dir.URI}: ${ex.message}`,
+ ex
+ )
+ );
+ }
+ }
+
+ result._searchResults = [...result._collectedValues.values()];
+ // Make sure a result with direct email match will be the one used.
+ for (let sr of result._searchResults) {
+ if (sr.emailToUse == fullString.replace(/.*<(.+@.+)>$/, "$1")) {
+ sr.score = 100;
+ }
+ }
+ }
+
+ // Sort the results. Scoring may have changed so do it even if this is
+ // just filtered previous results. Only local results are sorted,
+ // because the autocomplete widget doesn't let us alter the order of
+ // results that have already been notified.
+ result._searchResults.sort(function (a, b) {
+ // Order by 1) descending score, then 2) descending popularity,
+ // then 3) any emails that actually match the search string,
+ // 4) primary email before secondary for the same card, then
+ // 5) by emails sorted alphabetically.
+ return (
+ b.score - a.score ||
+ b.popularity - a.popularity ||
+ (b.emailToUse.includes(aSearchString) &&
+ !a.emailToUse.includes(aSearchString)
+ ? 1
+ : 0) ||
+ (a.card == b.card && a.isPrimaryEmail ? -1 : 0) ||
+ a.value.localeCompare(b.value)
+ );
+ });
+
+ if (result.matchCount) {
+ result.searchResult = ACR.RESULT_SUCCESS;
+ result.defaultIndex = 0;
+ }
+
+ if (!asyncDirectories.length) {
+ // We're done. Just return our result immediately.
+ aListener.onSearchResult(this, result);
+ return;
+ }
+
+ // Let the widget know the sync results we have so far.
+ result.searchResult = result.matchCount
+ ? ACR.RESULT_SUCCESS_ONGOING
+ : ACR.RESULT_NOMATCH_ONGOING;
+ aListener.onSearchResult(this, result);
+
+ // Start searching our asynchronous autocomplete directories.
+ this._result = result;
+ let searches = new Set();
+ for (let dir of asyncDirectories) {
+ let comment = this._commentColumn == 1 ? dir.dirName : "";
+ let cards = [];
+ let searchListener = {
+ onSearchFoundCard: card => {
+ cards.push(card);
+ },
+ onSearchFinished: (status, isCompleteResult, secInfo, location) => {
+ if (this._result != result) {
+ // The search was aborted, so give up.
+ return;
+ }
+ searches.delete(searchListener);
+ if (cards.length) {
+ // Avoid overwhelming the UI with excessive results.
+ if (cards.length > MAX_ASYNC_RESULTS) {
+ cards.length = MAX_ASYNC_RESULTS;
+ isCompleteResult = false;
+ }
+ // We can't guarantee to score the extension's results accurately so
+ // we assume that the extension has sorted the results appropriately
+ for (let card of cards) {
+ let emailToUse = card.primaryEmail;
+ let value = MailServices.headerParser
+ .makeMailboxObject(card.displayName, emailToUse)
+ .toString();
+ result._searchResults.push({
+ value,
+ comment,
+ card,
+ emailToUse,
+ isCompleteResult,
+ });
+ }
+ if (!isCompleteResult) {
+ // Next time perform a full search again to get better results.
+ result.asyncDirectories.push(dir);
+ }
+ }
+ if (result._searchResults.length) {
+ result.searchResult = searches.size
+ ? ACR.RESULT_SUCCESS_ONGOING
+ : ACR.RESULT_SUCCESS;
+ result.defaultIndex = 0;
+ } else {
+ result.searchResult = searches.size
+ ? ACR.RESULT_NOMATCH_ONGOING
+ : ACR.RESULT_NOMATCH;
+ }
+ aListener.onSearchResult(this, result);
+ },
+ };
+ // Keep track of the pending searches so that we know when we've finished.
+ searches.add(searchListener);
+ dir.search(null, aSearchString, searchListener);
+ }
+ },
+
+ stopSearch() {
+ this._result = null;
+ },
+
+ // nsISupports
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]),
+};