summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/addrbook/src/AbAutoCompleteSearch.jsm
blob: 8beff4f670daf7bfadc7634981da8e7bc1fe435e (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
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
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"]),
};