summaryrefslogtreecommitdiff
path: root/toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
blob: f4d8f39731f9c28226cdae7476019f831431b34c (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
/* 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/. */

/*
 * Provides functions to handle search engine URLs in the browser history.
 */

"use strict";

this.EXPORTED_SYMBOLS = [ "PlacesSearchAutocompleteProvider" ];

const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
  "resource://gre/modules/SearchSuggestionController.jsm");

const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified";

const SearchAutocompleteProviderInternal = {
  /**
   * Array of objects in the format returned by findMatchByToken.
   */
  priorityMatches: null,

  /**
   * Array of objects in the format returned by findMatchByAlias.
   */
  aliasMatches: null,

  /**
   * Object for the default search match.
   **/
  defaultMatch: null,

  initialize: function () {
    return new Promise((resolve, reject) => {
      Services.search.init(status => {
        if (!Components.isSuccessCode(status)) {
          reject(new Error("Unable to initialize search service."));
        }

        try {
          // The initial loading of the search engines must succeed.
          this._refresh();

          Services.obs.addObserver(this, SEARCH_ENGINE_TOPIC, true);

          this.initialized = true;
          resolve();
        } catch (ex) {
          reject(ex);
        }
      });
    });
  },

  initialized: false,

  observe: function (subject, topic, data) {
    switch (data) {
      case "engine-added":
      case "engine-changed":
      case "engine-removed":
      case "engine-current":
        this._refresh();
    }
  },

  _refresh: function () {
    this.priorityMatches = [];
    this.aliasMatches = [];
    this.defaultMatch = null;

    let currentEngine = Services.search.currentEngine;
    // This can be null in XCPShell.
    if (currentEngine) {
      this.defaultMatch = {
        engineName: currentEngine.name,
        iconUrl: currentEngine.iconURI ? currentEngine.iconURI.spec : null,
      }
    }

    // The search engines will always be processed in the order returned by the
    // search service, which can be defined by the user.
    Services.search.getVisibleEngines().forEach(e => this._addEngine(e));
  },

  _addEngine: function (engine) {
    if (engine.alias) {
      this.aliasMatches.push({
        alias: engine.alias,
        engineName: engine.name,
        iconUrl: engine.iconURI ? engine.iconURI.spec : null,
      });
    }

    let domain = engine.getResultDomain();
    if (domain) {
      this.priorityMatches.push({
        token: domain,
        // The searchForm property returns a simple URL for the search engine, but
        // we may need an URL which includes an affiliate code (bug 990799).
        url: engine.searchForm,
        engineName: engine.name,
        iconUrl: engine.iconURI ? engine.iconURI.spec : null,
      });
    }
  },

  getSuggestionController(searchToken, inPrivateContext, maxResults, userContextId) {
    let engine = Services.search.currentEngine;
    if (!engine) {
      return null;
    }
    return new SearchSuggestionControllerWrapper(engine, searchToken,
                                                 inPrivateContext, maxResults,
                                                 userContextId);
  },

  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                         Ci.nsISupportsWeakReference]),
}

function SearchSuggestionControllerWrapper(engine, searchToken,
                                           inPrivateContext, maxResults,
                                           userContextId) {
  this._controller = new SearchSuggestionController();
  this._controller.maxLocalResults = 0;
  this._controller.maxRemoteResults = maxResults;
  let promise = this._controller.fetch(searchToken, inPrivateContext, engine, userContextId);
  this._suggestions = [];
  this._success = false;
  this._promise = promise.then(results => {
    this._success = true;
    this._suggestions = (results ? results.remote : null) || [];
  }).catch(err => {
    // fetch() rejects its promise if there's a pending request.
  });
}

SearchSuggestionControllerWrapper.prototype = {

  /**
   * Resolved when all suggestions have been fetched.
   */
  get fetchCompletePromise() {
    return this._promise;
  },

  /**
   * Returns one suggestion, if any are available.  The returned value is an
   * array [match, suggestion].  If none are available, returns [null, null].
   * Note that there are two reasons that suggestions might not be available:
   * all suggestions may have been fetched and consumed, or the fetch may not
   * have completed yet.
   *
   * @return An array [match, suggestion].
   */
  consume() {
    return !this._suggestions.length ? [null, null] :
           [SearchAutocompleteProviderInternal.defaultMatch,
            this._suggestions.shift()];
  },

  /**
   * Returns the number of fetched suggestions, or -1 if the fetching was
   * incomplete or failed.
   */
  get resultsCount() {
    return this._success ? this._suggestions.length : -1;
  },

  /**
   * Stops the fetch.
   */
  stop() {
    this._controller.stop();
  },
};

var gInitializationPromise = null;

this.PlacesSearchAutocompleteProvider = Object.freeze({
  /**
   * Starts initializing the component and returns a promise that is resolved or
   * rejected when initialization finished.  The same promise is returned if
   * this function is called multiple times.
   */
  ensureInitialized: function () {
    if (!gInitializationPromise) {
      gInitializationPromise = SearchAutocompleteProviderInternal.initialize();
    }
    return gInitializationPromise;
  },

  /**
   * Matches a given string to an item that should be included by URL search
   * components, like autocomplete in the address bar.
   *
   * @param searchToken
   *        String containing the first part of the matching domain name.
   *
   * @return An object with the following properties, or undefined if the token
   *         does not match any relevant URL:
   *         {
   *           token: The full string used to match the search term to the URL.
   *           url: The URL to navigate to if the match is selected.
   *           engineName: The display name of the search engine.
   *           iconUrl: Icon associated to the match, or null if not available.
   *         }
   */
  findMatchByToken: Task.async(function* (searchToken) {
    yield this.ensureInitialized();

    // Match at the beginning for now.  In the future, an "options" argument may
    // allow the matching behavior to be tuned.
    return SearchAutocompleteProviderInternal.priorityMatches
                                             .find(m => m.token.startsWith(searchToken));
  }),

  /**
   * Matches a given search string to an item that should be included by
   * components wishing to search using search engine aliases, like
   * autocomple.
   *
   * @param searchToken
   *        Search string to match exactly a search engine alias.
   *
   * @return An object with the following properties, or undefined if the token
   *         does not match any relevant URL:
   *         {
   *           alias: The matched search engine's alias.
   *           engineName: The display name of the search engine.
   *           iconUrl: Icon associated to the match, or null if not available.
   *         }
   */
  findMatchByAlias: Task.async(function* (searchToken) {
    yield this.ensureInitialized();

    return SearchAutocompleteProviderInternal.aliasMatches
             .find(m => m.alias.toLocaleLowerCase() == searchToken.toLocaleLowerCase());
  }),

  getDefaultMatch: Task.async(function* () {
    yield this.ensureInitialized();

    return SearchAutocompleteProviderInternal.defaultMatch;
  }),

  /**
   * Synchronously determines if the provided URL represents results from a
   * search engine, and provides details about the match.
   *
   * @param url
   *        String containing the URL to parse.
   *
   * @return An object with the following properties, or null if the URL does
   *         not represent a search result:
   *         {
   *           engineName: The display name of the search engine.
   *           terms: The originally sought terms extracted from the URI.
   *         }
   *
   * @remarks The asynchronous ensureInitialized function must be called before
   *          this synchronous method can be used.
   *
   * @note This API function needs to be synchronous because it is called inside
   *       a row processing callback of Sqlite.jsm, in UnifiedComplete.js.
   */
  parseSubmissionURL: function (url) {
    if (!SearchAutocompleteProviderInternal.initialized) {
      throw new Error("The component has not been initialized.");
    }

    let parseUrlResult = Services.search.parseSubmissionURL(url);
    return parseUrlResult.engine && {
      engineName: parseUrlResult.engine.name,
      terms: parseUrlResult.terms,
    };
  },

  getSuggestionController(searchToken, inPrivateContext, maxResults, userContextId) {
    if (!SearchAutocompleteProviderInternal.initialized) {
      throw new Error("The component has not been initialized.");
    }
    return SearchAutocompleteProviderInternal.getSuggestionController(
             searchToken, inPrivateContext, maxResults, userContextId);
  },
});