mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			442 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			442 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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/. */
 | 
						|
 | 
						|
import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
 | 
						|
 | 
						|
const lazy = {};
 | 
						|
 | 
						|
ChromeUtils.defineESModuleGetters(lazy, {
 | 
						|
  EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
 | 
						|
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
 | 
						|
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
 | 
						|
  UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
 | 
						|
});
 | 
						|
 | 
						|
const RS_COLLECTION = "quicksuggest";
 | 
						|
 | 
						|
// Entries are added to `SuggestionsMap` map in chunks, and each chunk will add
 | 
						|
// at most this many entries.
 | 
						|
const SUGGESTIONS_MAP_CHUNK_SIZE = 1000;
 | 
						|
 | 
						|
const TELEMETRY_LATENCY = "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS";
 | 
						|
 | 
						|
// See `SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD`. When a full
 | 
						|
// keyword starts with one of the prefixes in this list, the user must type the
 | 
						|
// entire prefix to start triggering matches based on that full keyword, instead
 | 
						|
// of only the first word.
 | 
						|
const KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS = ["how to"];
 | 
						|
 | 
						|
/**
 | 
						|
 * The Suggest JS backend. Not used when the Rust backend is enabled.
 | 
						|
 */
 | 
						|
export class SuggestBackendJs extends BaseFeature {
 | 
						|
  constructor(...args) {
 | 
						|
    super(...args);
 | 
						|
    this.#emitter = new lazy.EventEmitter();
 | 
						|
  }
 | 
						|
 | 
						|
  get shouldEnable() {
 | 
						|
    return !lazy.UrlbarPrefs.get("quickSuggestRustEnabled");
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {RemoteSettings}
 | 
						|
   *   The underlying `RemoteSettings` client object.
 | 
						|
   */
 | 
						|
  get rs() {
 | 
						|
    return this.#rs;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {EventEmitter}
 | 
						|
   *   The client will emit events on this object.
 | 
						|
   */
 | 
						|
  get emitter() {
 | 
						|
    return this.#emitter;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {object}
 | 
						|
   *   Global quick suggest configuration stored in remote settings. When the
 | 
						|
   *   config changes the `emitter` property will emit a "config-set" event. The
 | 
						|
   *   config is an object that looks like this:
 | 
						|
   *
 | 
						|
   *   {
 | 
						|
   *     impression_caps: {
 | 
						|
   *       nonsponsored: {
 | 
						|
   *         lifetime,
 | 
						|
   *         custom: [
 | 
						|
   *           { interval_s, max_count },
 | 
						|
   *         ],
 | 
						|
   *       },
 | 
						|
   *       sponsored: {
 | 
						|
   *         lifetime,
 | 
						|
   *         custom: [
 | 
						|
   *           { interval_s, max_count },
 | 
						|
   *         ],
 | 
						|
   *       },
 | 
						|
   *     },
 | 
						|
   *     show_less_frequently_cap,
 | 
						|
   *   }
 | 
						|
   */
 | 
						|
  get config() {
 | 
						|
    return this.#config;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {Array}
 | 
						|
   *   Array of `BasicFeature` instances.
 | 
						|
   */
 | 
						|
  get features() {
 | 
						|
    return [...this.#features];
 | 
						|
  }
 | 
						|
 | 
						|
  enable(enabled) {
 | 
						|
    if (!enabled) {
 | 
						|
      this.#enableSettings(false);
 | 
						|
    } else if (this.#features.size) {
 | 
						|
      this.#enableSettings(true);
 | 
						|
      this.#syncAll();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Registers a quick suggest feature that uses remote settings.
 | 
						|
   *
 | 
						|
   * @param {BaseFeature} feature
 | 
						|
   *   An instance of a `BaseFeature` subclass. See `BaseFeature` for methods
 | 
						|
   *   that the subclass must implement.
 | 
						|
   */
 | 
						|
  register(feature) {
 | 
						|
    this.logger.debug("Registering feature: " + feature.name);
 | 
						|
    this.#features.add(feature);
 | 
						|
    if (this.isEnabled) {
 | 
						|
      if (this.#features.size == 1) {
 | 
						|
        this.#enableSettings(true);
 | 
						|
      }
 | 
						|
      this.#syncFeature(feature);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Unregisters a quick suggest feature that uses remote settings.
 | 
						|
   *
 | 
						|
   * @param {BaseFeature} feature
 | 
						|
   *   An instance of a `BaseFeature` subclass.
 | 
						|
   */
 | 
						|
  unregister(feature) {
 | 
						|
    this.logger.debug("Unregistering feature: " + feature.name);
 | 
						|
    this.#features.delete(feature);
 | 
						|
    if (!this.#features.size) {
 | 
						|
      this.#enableSettings(false);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Queries remote settings suggestions from all registered features.
 | 
						|
   *
 | 
						|
   * @param {string} searchString
 | 
						|
   *   The search string.
 | 
						|
   * @returns {Array}
 | 
						|
   *   The remote settings suggestions. If there are no matches, an empty array
 | 
						|
   *   is returned.
 | 
						|
   */
 | 
						|
  async query(searchString) {
 | 
						|
    let suggestions;
 | 
						|
    let stopwatchInstance = {};
 | 
						|
    TelemetryStopwatch.start(TELEMETRY_LATENCY, stopwatchInstance);
 | 
						|
    try {
 | 
						|
      suggestions = await this.#queryHelper(searchString);
 | 
						|
      TelemetryStopwatch.finish(TELEMETRY_LATENCY, stopwatchInstance);
 | 
						|
    } catch (error) {
 | 
						|
      TelemetryStopwatch.cancel(TELEMETRY_LATENCY, stopwatchInstance);
 | 
						|
      this.logger.error("Query error: " + error);
 | 
						|
    }
 | 
						|
 | 
						|
    return suggestions || [];
 | 
						|
  }
 | 
						|
 | 
						|
  async #queryHelper(searchString) {
 | 
						|
    this.logger.info("Handling query: " + JSON.stringify(searchString));
 | 
						|
 | 
						|
    let results = await Promise.all(
 | 
						|
      [...this.#features].map(async feature => {
 | 
						|
        let suggestions = await feature.queryRemoteSettings(searchString);
 | 
						|
        return [feature, suggestions ?? []];
 | 
						|
      })
 | 
						|
    );
 | 
						|
 | 
						|
    let allSuggestions = [];
 | 
						|
    for (let [feature, suggestions] of results) {
 | 
						|
      for (let suggestion of suggestions) {
 | 
						|
        // Features typically return suggestion objects straight from their
 | 
						|
        // suggestion maps. We don't want consumers to modify those objects
 | 
						|
        // since they are the source of truth (tests especially tend to do
 | 
						|
        // this), so return copies to consumers.
 | 
						|
        allSuggestions.push({
 | 
						|
          ...suggestion,
 | 
						|
          source: "remote-settings",
 | 
						|
          provider: feature.name,
 | 
						|
        });
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return allSuggestions;
 | 
						|
  }
 | 
						|
 | 
						|
  async #enableSettings(enabled) {
 | 
						|
    if (enabled && !this.#rs) {
 | 
						|
      this.logger.debug("Creating RemoteSettings client");
 | 
						|
      this.#onSettingsSync = event => this.#syncAll({ event });
 | 
						|
      this.#rs = lazy.RemoteSettings(RS_COLLECTION);
 | 
						|
      this.#rs.on("sync", this.#onSettingsSync);
 | 
						|
      await this.#syncConfig();
 | 
						|
    } else if (!enabled && this.#rs) {
 | 
						|
      this.logger.debug("Destroying RemoteSettings client");
 | 
						|
      this.#rs.off("sync", this.#onSettingsSync);
 | 
						|
      this.#rs = null;
 | 
						|
      this.#onSettingsSync = null;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  async #syncConfig() {
 | 
						|
    this.logger.debug("Syncing config");
 | 
						|
    let rs = this.#rs;
 | 
						|
 | 
						|
    let configArray = await rs.get({ filters: { type: "configuration" } });
 | 
						|
    if (rs != this.#rs) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    this.logger.debug("Got config array: " + JSON.stringify(configArray));
 | 
						|
    this.#setConfig(configArray?.[0]?.configuration || {});
 | 
						|
  }
 | 
						|
 | 
						|
  async #syncFeature(feature) {
 | 
						|
    this.logger.debug("Syncing feature: " + feature.name);
 | 
						|
    await feature.onRemoteSettingsSync(this.#rs);
 | 
						|
  }
 | 
						|
 | 
						|
  async #syncAll({ event = null } = {}) {
 | 
						|
    this.logger.debug("Syncing all");
 | 
						|
    let rs = this.#rs;
 | 
						|
 | 
						|
    // Remove local files of deleted records
 | 
						|
    if (event?.data?.deleted) {
 | 
						|
      await Promise.all(
 | 
						|
        event.data.deleted
 | 
						|
          .filter(d => d.attachment)
 | 
						|
          .map(entry =>
 | 
						|
            Promise.all([
 | 
						|
              this.#rs.attachments.deleteDownloaded(entry), // type: data
 | 
						|
              this.#rs.attachments.deleteFromDisk(entry), // type: icon
 | 
						|
            ])
 | 
						|
          )
 | 
						|
      );
 | 
						|
      if (rs != this.#rs) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    let promises = [this.#syncConfig()];
 | 
						|
    for (let feature of this.#features) {
 | 
						|
      promises.push(this.#syncFeature(feature));
 | 
						|
    }
 | 
						|
    await Promise.all(promises);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Sets the quick suggest config and emits a "config-set" event.
 | 
						|
   *
 | 
						|
   * @param {object} config
 | 
						|
   *   The config object.
 | 
						|
   */
 | 
						|
  #setConfig(config) {
 | 
						|
    config = lazy.UrlbarUtils.copySnakeKeysToCamel(config ?? {});
 | 
						|
    this.logger.debug("Setting config: " + JSON.stringify(config));
 | 
						|
    this.#config = config;
 | 
						|
    this.#emitter.emit("config-set");
 | 
						|
  }
 | 
						|
 | 
						|
  async _test_syncAll() {
 | 
						|
    if (this.#rs) {
 | 
						|
      // `RemoteSettingsClient` won't start another import if it's already
 | 
						|
      // importing. Wait for it to finish before starting the new one.
 | 
						|
      await this.#rs._importingPromise;
 | 
						|
      await this.#syncAll();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // The `RemoteSettings` client.
 | 
						|
  #rs = null;
 | 
						|
 | 
						|
  // Registered `BaseFeature` instances.
 | 
						|
  #features = new Set();
 | 
						|
 | 
						|
  // Configuration data synced from remote settings. See the `config` getter.
 | 
						|
  #config = {};
 | 
						|
 | 
						|
  #emitter = null;
 | 
						|
  #onSettingsSync = null;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * A wrapper around `Map` that handles quick suggest suggestions from remote
 | 
						|
 * settings. It maps keywords to suggestions. It has two benefits over `Map`:
 | 
						|
 *
 | 
						|
 * - The main benefit is that map entries are added in batches on idle to avoid
 | 
						|
 *   blocking the main thread for too long, since there can be many suggestions
 | 
						|
 *   and keywords.
 | 
						|
 * - A secondary benefit is that the interface is tailored to quick suggest
 | 
						|
 *   suggestions, which have a `keywords` property.
 | 
						|
 */
 | 
						|
export class SuggestionsMap {
 | 
						|
  /**
 | 
						|
   * Returns the list of suggestions for a keyword.
 | 
						|
   *
 | 
						|
   * @param {string} keyword
 | 
						|
   *   The keyword.
 | 
						|
   * @returns {Array}
 | 
						|
   *   The array of suggestions for the keyword. If the keyword isn't in the
 | 
						|
   *   map, the array will be empty.
 | 
						|
   */
 | 
						|
  get(keyword) {
 | 
						|
    let object = this.#suggestionsByKeyword.get(keyword.toLocaleLowerCase());
 | 
						|
    if (!object) {
 | 
						|
      return [];
 | 
						|
    }
 | 
						|
    return Array.isArray(object) ? object : [object];
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Adds a list of suggestion objects to the results map. Each suggestion must
 | 
						|
   * have a property whose value is an array of keyword strings. The
 | 
						|
   * suggestion's keywords will be taken from this array either exactly as they
 | 
						|
   * are specified or by generating new keywords from them; see `mapKeyword`.
 | 
						|
   *
 | 
						|
   * @param {Array} suggestions
 | 
						|
   *   Array of suggestion objects.
 | 
						|
   * @param {object} options
 | 
						|
   *   Options object.
 | 
						|
   * @param {string} options.keywordsProperty
 | 
						|
   *   The name of the keywords property in each suggestion.
 | 
						|
   * @param {Function} options.mapKeyword
 | 
						|
   *   If null, the keywords for each suggestion will be taken from the keywords
 | 
						|
   *   array exactly as they are specified. Otherwise, this function will be
 | 
						|
   *   called for each string in the array, and it should return an array of
 | 
						|
   *   strings. The suggestion's final list of keywords will be the union of all
 | 
						|
   *   strings returned by this function. See also the `MAP_KEYWORD_*` consts.
 | 
						|
   */
 | 
						|
  async add(
 | 
						|
    suggestions,
 | 
						|
    { keywordsProperty = "keywords", mapKeyword = null } = {}
 | 
						|
  ) {
 | 
						|
    // There can be many suggestions, and each suggestion can have many
 | 
						|
    // keywords. To avoid blocking the main thread for too long, update the map
 | 
						|
    // in chunks, and to avoid blocking the UI and other higher priority work,
 | 
						|
    // do each chunk only when the main thread is idle. During each chunk, we'll
 | 
						|
    // add at most `chunkSize` entries to the map.
 | 
						|
    let suggestionIndex = 0;
 | 
						|
    let keywordIndex = 0;
 | 
						|
 | 
						|
    // Keep adding chunks until all suggestions have been fully added.
 | 
						|
    while (suggestionIndex < suggestions.length) {
 | 
						|
      await new Promise(resolve => {
 | 
						|
        Services.tm.idleDispatchToMainThread(() => {
 | 
						|
          // Keep updating the map until the current chunk is done.
 | 
						|
          let indexInChunk = 0;
 | 
						|
          while (
 | 
						|
            indexInChunk < SuggestionsMap.chunkSize &&
 | 
						|
            suggestionIndex < suggestions.length
 | 
						|
          ) {
 | 
						|
            let suggestion = suggestions[suggestionIndex];
 | 
						|
            let keywords = suggestion[keywordsProperty];
 | 
						|
            if (keywordIndex == keywords.length) {
 | 
						|
              // We've added entries for all keywords of the current suggestion.
 | 
						|
              // Move on to the next suggestion.
 | 
						|
              suggestionIndex++;
 | 
						|
              keywordIndex = 0;
 | 
						|
              continue;
 | 
						|
            }
 | 
						|
 | 
						|
            // As a convenience, allow `mapKeyword` to return a string even
 | 
						|
            // though the JSDoc says an array must be returned.
 | 
						|
            let originalKeyword = keywords[keywordIndex];
 | 
						|
            let mappedKeywords =
 | 
						|
              mapKeyword?.(originalKeyword) ?? originalKeyword;
 | 
						|
            if (typeof mappedKeywords == "string") {
 | 
						|
              mappedKeywords = [mappedKeywords];
 | 
						|
            }
 | 
						|
 | 
						|
            for (let keyword of mappedKeywords) {
 | 
						|
              // If the keyword's only suggestion is `suggestion`, store it
 | 
						|
              // directly as the value. Otherwise store an array of unique
 | 
						|
              // suggestions. See the `#suggestionsByKeyword` comment.
 | 
						|
              let object = this.#suggestionsByKeyword.get(keyword);
 | 
						|
              if (!object) {
 | 
						|
                this.#suggestionsByKeyword.set(keyword, suggestion);
 | 
						|
              } else {
 | 
						|
                let isArray = Array.isArray(object);
 | 
						|
                if (!isArray && object != suggestion) {
 | 
						|
                  this.#suggestionsByKeyword.set(keyword, [object, suggestion]);
 | 
						|
                } else if (isArray && !object.includes(suggestion)) {
 | 
						|
                  object.push(suggestion);
 | 
						|
                }
 | 
						|
              }
 | 
						|
            }
 | 
						|
 | 
						|
            keywordIndex++;
 | 
						|
            indexInChunk++;
 | 
						|
          }
 | 
						|
 | 
						|
          // The current chunk is done.
 | 
						|
          resolve();
 | 
						|
        });
 | 
						|
      });
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  clear() {
 | 
						|
    this.#suggestionsByKeyword.clear();
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {Function}
 | 
						|
   *   A `mapKeyword` function that maps a keyword to an array containing the
 | 
						|
   *   keyword's first word plus every subsequent prefix of the keyword. The
 | 
						|
   *   strings in `KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS` will modify this
 | 
						|
   *   behavior: When a full keyword starts with one of the prefixes in that
 | 
						|
   *   list, the generated prefixes will start at that prefix instead of the
 | 
						|
   *   first word.
 | 
						|
   */
 | 
						|
  static get MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD() {
 | 
						|
    return fullKeyword => {
 | 
						|
      let prefix = KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS.find(p =>
 | 
						|
        fullKeyword.startsWith(p + " ")
 | 
						|
      );
 | 
						|
      let spaceIndex = prefix ? prefix.length : fullKeyword.indexOf(" ");
 | 
						|
 | 
						|
      let keywords = [fullKeyword];
 | 
						|
      if (spaceIndex >= 0) {
 | 
						|
        for (let i = spaceIndex; i < fullKeyword.length; i++) {
 | 
						|
          keywords.push(fullKeyword.substring(0, i));
 | 
						|
        }
 | 
						|
      }
 | 
						|
      return keywords;
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  // Maps each keyword in the dataset to one or more suggestions for the
 | 
						|
  // keyword. If only one suggestion uses a keyword, the keyword's value in the
 | 
						|
  // map will be the suggestion object. If more than one suggestion uses the
 | 
						|
  // keyword, the value will be an array of the suggestions. The reason for not
 | 
						|
  // always using an array is that we expect the vast majority of keywords to be
 | 
						|
  // used by only one suggestion, and since there are potentially very many
 | 
						|
  // keywords and suggestions and we keep them in memory all the time, we want
 | 
						|
  // to save as much memory as possible.
 | 
						|
  #suggestionsByKeyword = new Map();
 | 
						|
 | 
						|
  // This is only defined as a property so that tests can override it.
 | 
						|
  static chunkSize = SUGGESTIONS_MAP_CHUNK_SIZE;
 | 
						|
}
 |