toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
author Drew Willcoxon <adw@mozilla.com>
Fri, 19 Oct 2018 16:08:24 +0000
changeset 500675 eab3a0cf64298bbe275a6598bea9ae73811652db
parent 500407 665fecc387403fd9336daaaa6bfa619dc5b7baed
child 500676 418fa1fa4f746f82b0a1a070ef9dfb12849b5281
permissions -rw-r--r--
Bug 1496815 - Suggest search engine aliases in the address bar when '@' is typed as the first character r=mak This bug touches just about every part of the urlbar: UnifiedComplete, the autocomplete binding, the formatter, CSS. This builds on the patches in bug 1496814 and bug 1496811. Differential Revision: https://phabricator.services.mozilla.com/D8948

/* 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";

var EXPORTED_SYMBOLS = [ "PlacesSearchAutocompleteProvider" ];

ChromeUtils.import("resource://gre/modules/Services.jsm");

ChromeUtils.defineModuleGetter(this, "SearchSuggestionController",
  "resource://gre/modules/SearchSuggestionController.jsm");

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

const SearchAutocompleteProviderInternal = {
  /**
   * {Map<string: nsISearchEngine>} Maps from each domain to the engine with
   * that domain.  If more than one engine has the same domain, the last one
   * passed to _addEngine will be the one in this map.
   */
  enginesByDomain: new Map(),

  /**
   * {Map<string: nsISearchEngine>} Maps from each lowercased alias to the
   * engine with that alias.  If more than one engine has the same alias, the
   * last one passed to _addEngine will be the one in this map.
   */
  enginesByAlias: new Map(),

  /**
   * {array<{ {nsISearchEngine} engine, {array<string>} tokenAliases }>} Array
   * of engines that have "@" aliases.
   */
  tokenAliasEngines: [],

  initialize() {
    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(subject, topic, data) {
    switch (data) {
      case "engine-added":
      case "engine-changed":
      case "engine-removed":
      case "engine-current":
        this._refresh();
    }
  },

  _refresh() {
    this.enginesByDomain.clear();
    this.enginesByAlias.clear();
    this.tokenAliasEngines = [];

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

  _addEngine(engine) {
    let domain = engine.getResultDomain();
    if (domain && !engine.hidden) {
      this.enginesByDomain.set(domain, engine);
    }

    let aliases = [];
    if (engine.alias) {
      aliases.push(engine.alias);
    }
    let tokenAliases = engine.wrappedJSObject._internalAliases;
    aliases.push(...tokenAliases);
    for (let alias of aliases) {
      this.enginesByAlias.set(alias.toLocaleLowerCase(), engine);
    }

    if (tokenAliases.length) {
      this.tokenAliasEngines.push({ engine, tokenAliases });
    }
  },

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

class SuggestionsFetch {
  /**
   * Create a new instance of this class for each new suggestions fetch.
   *
   * @param {nsISearchEngine} engine
   *        The engine from which suggestions will be fetched.
   * @param {string} searchString
   *        The search query string.
   * @param {bool} inPrivateContext
   *        Pass true if the fetch is being done in a private window.
   * @param {int} maxLocalResults
   *        The maximum number of results to fetch from the user's local
   *        history.
   * @param {int} maxRemoteResults
   *        The maximum number of results to fetch from the search engine.
   * @param {int} userContextId
   *        The user context ID in which the fetch is being performed.
   */
  constructor(engine,
              searchString,
              inPrivateContext,
              maxLocalResults,
              maxRemoteResults,
              userContextId) {
    this._controller = new SearchSuggestionController();
    this._controller.maxLocalResults = maxLocalResults;
    this._controller.maxRemoteResults = maxRemoteResults;
    this._engine = engine;
    this._suggestions = [];
    this._success = false;
    this._promise = this._controller.fetch(searchString, inPrivateContext, engine, userContextId).then(results => {
      this._success = true;
      if (results) {
        this._suggestions.push(
          ...results.local.map(r => ({ suggestion: r, historical: true })),
          ...results.remote.map(r => ({ suggestion: r, historical: false }))
        );
      }
    }).catch(err => {
      // fetch() rejects its promise if there's a pending request.
    });
  }

  /**
   * {nsISearchEngine} The engine from which suggestions are being fetched.
   */
  get engine() {
    return this._engine;
  }

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

  /**
   * Returns one suggestion, if any are available, otherwise returns null.
   * Note that may be multiple reasons why suggestions are not available:
   *  - all suggestions have already been consumed
   *  - the fetch failed
   *  - the fetch didn't complete yet (should have awaited the promise)
   *
   * @returns {object} An object { suggestion, historical } or null if no
   *          suggestions are available.
   *          - suggestion {string} The suggestion.
   *          - historical {bool} True if the suggestion comes from the user's
   *            local history (instead of the search engine).
   */
  consume() {
    return this._suggestions.shift() || null;
  }

  /**
   * 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;

var 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() {
    if (!gInitializationPromise) {
      gInitializationPromise = SearchAutocompleteProviderInternal.initialize();
    }
    return gInitializationPromise;
  },

  /**
   * Gets the engine whose domain matches a given prefix.
   *
   * @param   {string} prefix
   *          String containing the first part of the matching domain name.
   * @returns {nsISearchEngine} The matching engine or null if there isn't one.
   */
  async engineForDomainPrefix(prefix) {
    await this.ensureInitialized();

    // Match at the beginning for now.  In the future, an "options" argument may
    // allow the matching behavior to be tuned.
    let tuples = SearchAutocompleteProviderInternal.enginesByDomain.entries();
    for (let [domain, engine] of tuples) {
      if (domain.startsWith(prefix) || domain.startsWith("www." + prefix)) {
        return engine;
      }
    }
    return null;
  },

  /**
   * Gets the engine with a given alias.
   *
   * @param   {string} alias
   *          A search engine alias.
   * @returns {nsISearchEngine} The matching engine or null if there isn't one.
   */
  async engineForAlias(alias) {
    await this.ensureInitialized();

    return SearchAutocompleteProviderInternal
           .enginesByAlias.get(alias.toLocaleLowerCase()) || null;
  },

  /**
   * Gets the list of engines with token ("@") aliases.
   *
   * @returns {array<{ {nsISearchEngine} engine, {array<string>} tokenAliases }>}
   *          Array of objects { engine, tokenAliases } for token alias engines.
   */
  async tokenAliasEngines() {
    await this.ensureInitialized();

    return SearchAutocompleteProviderInternal.tokenAliasEngines.slice();
  },

  /**
   * Use this to get the current engine rather than Services.search.currentEngine
   * directly.  This method makes sure that the service is first initialized.
   *
   * @returns {nsISearchEngine} The current search engine.
   */
  async currentEngine() {
    await this.ensureInitialized();

    return Services.search.currentEngine;
  },

  /**
   * 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(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,
    };
  },

  /**
   * Starts a new suggestions fetch.
   *
   * @param   {nsISearchEngine} engine
   *          The engine from which suggestions will be fetched.
   * @param   {string} searchString
   *          The search query string.
   * @param   {bool} inPrivateContext
   *          Pass true if the fetch is being done in a private window.
   * @param   {int} maxLocalResults
   *          The maximum number of results to fetch from the user's local
   *          history.
   * @param   {int} maxRemoteResults
   *          The maximum number of results to fetch from the search engine.
   * @param   {int} userContextId
   *          The user context ID in which the fetch is being performed.
   * @returns {SuggestionsFetch} A new suggestions fetch object you should use
   *          to track the fetch.
   */
  newSuggestionsFetch(engine,
                      searchString,
                      inPrivateContext,
                      maxLocalResults,
                      maxRemoteResults,
                      userContextId) {
    if (!SearchAutocompleteProviderInternal.initialized) {
      throw new Error("The component has not been initialized.");
    }
    if (!engine) {
      throw new Error("`engine` is null");
    }
    return new SuggestionsFetch(engine, searchString, inPrivateContext,
                                maxLocalResults, maxRemoteResults,
                                userContextId);
  },
});