summaryrefslogtreecommitdiffstats
path: root/mailnews/addrbook/src/nsAbAutoCompleteSearch.js
diff options
context:
space:
mode:
Diffstat (limited to 'mailnews/addrbook/src/nsAbAutoCompleteSearch.js')
-rw-r--r--mailnews/addrbook/src/nsAbAutoCompleteSearch.js466
1 files changed, 466 insertions, 0 deletions
diff --git a/mailnews/addrbook/src/nsAbAutoCompleteSearch.js b/mailnews/addrbook/src/nsAbAutoCompleteSearch.js
new file mode 100644
index 000000000..c6c9b8db8
--- /dev/null
+++ b/mailnews/addrbook/src/nsAbAutoCompleteSearch.js
@@ -0,0 +1,466 @@
+/* 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/. */
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource:///modules/mailServices.js");
+Components.utils.import("resource:///modules/ABQueryUtils.jsm");
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+var ACR = Components.interfaces.nsIAutoCompleteResult;
+var nsIAbAutoCompleteResult = Components.interfaces.nsIAbAutoCompleteResult;
+
+function nsAbAutoCompleteResult(aSearchString) {
+ // Can't create this in the prototype as we'd get the same array for
+ // all instances
+ 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
+
+ modelQuery: null,
+ searchString: null,
+ searchResult: ACR.RESULT_NOMATCH,
+ defaultIndex: -1,
+ errorDescription: null,
+
+ get matchCount() {
+ return this._searchResults.length;
+ },
+
+ getValueAt: function getValueAt(aIndex) {
+ return this._searchResults[aIndex].value;
+ },
+
+ getLabelAt: function getLabelAt(aIndex) {
+ return this.getValueAt(aIndex);
+ },
+
+ getCommentAt: function getCommentAt(aIndex) {
+ return this._searchResults[aIndex].comment;
+ },
+
+ getStyleAt: function getStyleAt(aIndex) {
+ return "local-abook";
+ },
+
+ getImageAt: function getImageAt(aIndex) {
+ return "";
+ },
+
+ getFinalCompleteValueAt: function(aIndex) {
+ return this.getValueAt(aIndex);
+ },
+
+ removeValueAt: function removeValueAt(aRowIndex, aRemoveFromDB) {
+ },
+
+ // nsIAbAutoCompleteResult
+
+ getCardAt: function getCardAt(aIndex) {
+ return this._searchResults[aIndex].card;
+ },
+
+ getEmailToUse: function getEmailToUse(aIndex) {
+ return this._searchResults[aIndex].emailToUse;
+ },
+
+ // nsISupports
+
+ QueryInterface: XPCOMUtils.generateQI([ACR, nsIAbAutoCompleteResult])
+}
+
+function nsAbAutoCompleteSearch() {}
+
+nsAbAutoCompleteSearch.prototype = {
+ // For component registration
+ classID: Components.ID("2f946df9-114c-41fe-8899-81f10daf4f0c"),
+
+ // 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"]),
+
+ // 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 aDirectory The directory that the card is in.
+ * @param aCard The card to return the popularity index for.
+ */
+ _getPopularityIndex: function _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) {
+ Components.utils.reportError(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 aCard - the card whose score is being decided
+ * @param aAddress - full lower-cased address, including display name and address
+ * @param aSearchString - search string provided by user
+ * @return a score; a higher score is better than a lower one
+ */
+ _getScore: function(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 searchQuery The boolean search query to use.
+ * @param directory An nsIAbDirectory to search.
+ * @param result The result element to append results to.
+ */
+ _searchCards: function(searchQuery, directory, result) {
+ let childCards;
+ try {
+ childCards = this._abManager.getDirectory(directory.URI + searchQuery).childCards;
+ } catch (e) {
+ Components.utils.reportError("Error running addressbook query '" + searchQuery + "': " + e);
+ return;
+ }
+
+ // Cache this values to save going through xpconnect each time
+ var commentColumn = this._commentColumn == 1 ? directory.dirName : "";
+
+ // Now iterate through all the cards.
+ while (childCards.hasMoreElements()) {
+ var card = childCards.getNext();
+
+ if (card instanceof Components.interfaces.nsIAbCard) {
+ if (card.isMailList)
+ this._addToResult(commentColumn, directory, card, "", true, result);
+ else {
+ let email = card.primaryEmail;
+ if (email)
+ this._addToResult(commentColumn, directory, card, email, true, result);
+
+ email = card.getProperty("SecondEmail", "");
+ if (email)
+ this._addToResult(commentColumn, directory, card, email, false, result);
+ }
+ }
+ }
+ },
+
+ /**
+ * 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 aCard The card to check.
+ * @param aEmailToUse The email address to check against.
+ * @param aSearchWords Array of words in the multi word search string.
+ * @return True if the card matches the search parameters, false
+ * otherwise.
+ */
+ _checkEntry: function _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 directory The directory that the card is in.
+ * @param card The card that could be a duplicate.
+ * @param lcEmailAddress The emailAddress (name/address combination) to check
+ * for duplicates against. Lowercased.
+ * @param currentResults The current results list.
+ */
+ _checkDuplicate: function (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 commentColumn The text to be displayed in the comment column
+ * (if any).
+ * @param directory The directory that the card is in.
+ * @param card The card being added to the results.
+ * @param emailToUse The email address from the card that should be used
+ * for this result.
+ * @param isPrimaryEmail Is the emailToUse the primary email? Set to true if
+ * it is the case. For mailing lists set it to true.
+ * @param result The result to add the new entry to.
+ */
+ _addToResult: function(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: card,
+ isPrimaryEmail: isPrimaryEmail,
+ emailToUse: emailToUse,
+ 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.
+ */
+ startSearch: function 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 contains a comma, or the user
+ // hasn't enabled autocomplete, then just return no matches or the
+ // result ignored.
+ // The comma check is so that we don't autocomplete against the user
+ // entering multiple addresses.
+ if (!fullString || aSearchString.includes(",")) {
+ 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
+ try {
+ this._commentColumn = Services.prefs.getIntPref("mail.autoComplete.commentColumn");
+ } catch(e) { }
+
+ 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) {
+ 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: card,
+ isPrimaryEmail: (card.primaryEmail == email),
+ emailToUse: email,
+ popularity: parseInt(card.getProperty("PopularityIndex", "0")),
+ score: this._getScore(card,
+ aPreviousResult.getValueAt(i).toLocaleLowerCase(),
+ fullString)
+ });
+ }
+ }
+ }
+ 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
+ let allABs = this._abManager.directories;
+
+ // 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.
+ while (allABs.hasMoreElements()) {
+ let dir = allABs.getNext();
+ if (dir instanceof Components.interfaces.nsIAbDirectory &&
+ dir.useForAutocomplete(("idKey" in params) ? params.idKey : null)) {
+ this._searchCards(searchQuery, dir, result);
+ }
+ }
+
+ result._searchResults = [...result._collectedValues.values()];
+ }
+
+ // Sort the results. Scoring may have changed so do it even if this is
+ // just filtered previous results.
+ result._searchResults.sort(function(a, b) {
+ // Order by 1) descending score, then 2) descending popularity,
+ // then 3) primary email before secondary for the same card, then
+ // 4) by emails sorted alphabetically.
+ return (b.score - a.score) ||
+ (b.popularity - a.popularity) ||
+ ((a.card == b.card && a.isPrimaryEmail) ? -1 : 0) ||
+ a.value.localeCompare(b.value);
+ });
+
+ if (result.matchCount) {
+ result.searchResult = ACR.RESULT_SUCCESS;
+ result.defaultIndex = 0;
+ }
+
+ aListener.onSearchResult(this, result);
+ },
+
+ stopSearch: function stopSearch() {
+ },
+
+ // nsISupports
+
+ QueryInterface: XPCOMUtils.generateQI([Components.interfaces
+ .nsIAutoCompleteSearch])
+};
+
+// Module
+
+var components = [nsAbAutoCompleteSearch];
+var NSGetFactory = XPCOMUtils.generateNSGetFactory(components);