summaryrefslogtreecommitdiffstats
path: root/mailnews/addrbook/src/nsAbAutoCompleteSearch.js
blob: c6c9b8db8222ab5ca53de1b5238e950123deed5e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
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);