summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/FormAutoComplete.jsm
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/satchel/FormAutoComplete.jsm
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel/FormAutoComplete.jsm')
-rw-r--r--toolkit/components/satchel/FormAutoComplete.jsm713
1 files changed, 713 insertions, 0 deletions
diff --git a/toolkit/components/satchel/FormAutoComplete.jsm b/toolkit/components/satchel/FormAutoComplete.jsm
new file mode 100644
index 0000000000..ae0815d9b2
--- /dev/null
+++ b/toolkit/components/satchel/FormAutoComplete.jsm
@@ -0,0 +1,713 @@
+/* vim: set ts=4 sts=4 sw=4 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+function isAutocompleteDisabled(aField) {
+ if (aField.autocomplete !== "") {
+ return aField.autocomplete === "off";
+ }
+
+ return aField.form && aField.form.autocomplete === "off";
+}
+
+/**
+ * An abstraction to talk with the FormHistory database over
+ * the message layer. FormHistoryClient will take care of
+ * figuring out the most appropriate message manager to use,
+ * and what things to send.
+ *
+ * It is assumed that nsFormAutoComplete will only ever use
+ * one instance at a time, and will not attempt to perform more
+ * than one search request with the same instance at a time.
+ * However, nsFormAutoComplete might call remove() any number of
+ * times with the same instance of the client.
+ *
+ * @param {Object} clientInfo
+ * Info required to build the FormHistoryClient
+ * @param {Node} clientInfo.formField
+ * A DOM node that we're requesting form history for.
+ * @param {string} clientInfo.inputName
+ * The name of the input to do the FormHistory look-up with.
+ * If this is searchbar-history, then formField needs to be null,
+ * otherwise constructing will throw.
+ */
+function FormHistoryClient({ formField, inputName }) {
+ if (formField && inputName != this.SEARCHBAR_ID) {
+ let window = formField.ownerGlobal;
+ this.windowGlobal = window.windowGlobalChild;
+ } else if (inputName == this.SEARCHBAR_ID && formField) {
+ throw new Error(
+ "FormHistoryClient constructed with both a " +
+ "formField and an inputName. This is not " +
+ "supported, and only empty results will be " +
+ "returned."
+ );
+ }
+
+ this.inputName = inputName;
+ this.id = FormHistoryClient.nextRequestID++;
+}
+
+FormHistoryClient.prototype = {
+ SEARCHBAR_ID: "searchbar-history",
+
+ cancelled: false,
+ inputName: "",
+
+ getActor() {
+ if (this.windowGlobal) {
+ return this.windowGlobal.getActor("FormHistory");
+ }
+
+ return null;
+ },
+
+ /**
+ * Query FormHistory for some results.
+ *
+ * @param {string} searchString
+ * The string to search FormHistory for. See
+ * FormHistory.getAutoCompleteResults.
+ * @param {Object} params
+ * An Object with search properties. See
+ * FormHistory.getAutoCompleteResults.
+ * @param {function} callback
+ * A callback function that will take a single
+ * argument (the found entries).
+ */
+ requestAutoCompleteResults(searchString, params, callback) {
+ this.cancelled = false;
+
+ // Use the actor if possible, otherwise for the searchbar,
+ // use the more roundabout per-process message manager which has
+ // no sendQuery method.
+ let actor = this.getActor();
+ if (actor) {
+ actor
+ .sendQuery("FormHistory:AutoCompleteSearchAsync", {
+ searchString,
+ params,
+ })
+ .then(
+ results => {
+ this.handleAutoCompleteResults(results, callback);
+ },
+ () => this.cancel()
+ );
+ } else {
+ this.callback = callback;
+ Services.cpmm.addMessageListener(
+ "FormHistory:AutoCompleteSearchResults",
+ this
+ );
+ Services.cpmm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", {
+ id: this.id,
+ searchString,
+ params,
+ });
+ }
+ },
+
+ handleAutoCompleteResults(results, callback) {
+ if (this.cancelled) {
+ return;
+ }
+
+ if (!callback) {
+ Cu.reportError("FormHistoryClient received response with no callback");
+ return;
+ }
+
+ callback(results);
+ this.cancel();
+ },
+
+ /**
+ * Cancel an in-flight results request. This ensures that the
+ * callback that requestAutoCompleteResults was passed is never
+ * called from this FormHistoryClient.
+ */
+ cancel() {
+ if (this.callback) {
+ Services.cpmm.removeMessageListener(
+ "FormHistory:AutoCompleteSearchResults",
+ this
+ );
+ this.callback = null;
+ }
+ this.cancelled = true;
+ },
+
+ /**
+ * Remove an item from FormHistory.
+ *
+ * @param {string} value
+ *
+ * The value to remove for this particular
+ * field.
+ *
+ * @param {string} guid
+ *
+ * The guid for the item being removed.
+ */
+ remove(value, guid) {
+ let actor = this.getActor() || Services.cpmm;
+ actor.sendAsyncMessage("FormHistory:RemoveEntry", {
+ inputName: this.inputName,
+ value,
+ guid,
+ });
+ },
+
+ receiveMessage(msg) {
+ let { id, results } = msg.data;
+ if (id == this.id) {
+ this.handleAutoCompleteResults(results, this.callback);
+ }
+ },
+};
+
+FormHistoryClient.nextRequestID = 1;
+
+function FormAutoComplete() {
+ this.init();
+}
+
+/**
+ * Implements the nsIFormAutoComplete interface in the main process.
+ */
+FormAutoComplete.prototype = {
+ classID: Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"),
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIFormAutoComplete",
+ "nsISupportsWeakReference",
+ ]),
+
+ _prefBranch: null,
+ _debug: true, // mirrors browser.formfill.debug
+ _enabled: true, // mirrors browser.formfill.enable preference
+ _agedWeight: 2,
+ _bucketSize: 1,
+ _maxTimeGroupings: 25,
+ _timeGroupingSize: 7 * 24 * 60 * 60 * 1000 * 1000,
+ _expireDays: null,
+ _boundaryWeight: 25,
+ _prefixWeight: 5,
+
+ // Only one query via FormHistoryClient is performed at a time, and the
+ // most recent FormHistoryClient which will be stored in _pendingClient
+ // while the query is being performed. It will be cleared when the query
+ // finishes, is cancelled, or an error occurs. If a new query occurs while
+ // one is already pending, the existing one is cancelled.
+ _pendingClient: null,
+
+ init() {
+ // Preferences. Add observer so we get notified of changes.
+ this._prefBranch = Services.prefs.getBranch("browser.formfill.");
+ this._prefBranch.addObserver("", this.observer, true);
+ this.observer._self = this;
+
+ this._debug = this._prefBranch.getBoolPref("debug");
+ this._enabled = this._prefBranch.getBoolPref("enable");
+ this._agedWeight = this._prefBranch.getIntPref("agedWeight");
+ this._bucketSize = this._prefBranch.getIntPref("bucketSize");
+ this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings");
+ this._timeGroupingSize =
+ this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000;
+ this._expireDays = this._prefBranch.getIntPref("expire_days");
+ },
+
+ observer: {
+ _self: null,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ observe(subject, topic, data) {
+ let self = this._self;
+
+ if (topic == "nsPref:changed") {
+ let prefName = data;
+ self.log("got change to " + prefName + " preference");
+
+ switch (prefName) {
+ case "agedWeight":
+ self._agedWeight = self._prefBranch.getIntPref(prefName);
+ break;
+ case "debug":
+ self._debug = self._prefBranch.getBoolPref(prefName);
+ break;
+ case "enable":
+ self._enabled = self._prefBranch.getBoolPref(prefName);
+ break;
+ case "maxTimeGroupings":
+ self._maxTimeGroupings = self._prefBranch.getIntPref(prefName);
+ break;
+ case "timeGroupingSize":
+ self._timeGroupingSize =
+ self._prefBranch.getIntPref(prefName) * 1000 * 1000;
+ break;
+ case "bucketSize":
+ self._bucketSize = self._prefBranch.getIntPref(prefName);
+ break;
+ case "boundaryWeight":
+ self._boundaryWeight = self._prefBranch.getIntPref(prefName);
+ break;
+ case "prefixWeight":
+ self._prefixWeight = self._prefBranch.getIntPref(prefName);
+ break;
+ default:
+ self.log("Oops! Pref not handled, change ignored.");
+ }
+ }
+ },
+ },
+
+ // AutoCompleteE10S needs to be able to call autoCompleteSearchAsync without
+ // going through IDL in order to pass a mock DOM object field.
+ get wrappedJSObject() {
+ return this;
+ },
+
+ /*
+ * log
+ *
+ * Internal function for logging debug messages to the Error Console
+ * window
+ */
+ log(message) {
+ if (!this._debug) {
+ return;
+ }
+ dump("FormAutoComplete: " + message + "\n");
+ Services.console.logStringMessage("FormAutoComplete: " + message);
+ },
+
+ /*
+ * autoCompleteSearchAsync
+ *
+ * aInputName -- |name| or |id| attribute value from the form input being
+ * autocompleted
+ * aUntrimmedSearchString -- current value of the input
+ * aField -- HTMLInputElement being autocompleted (may be null if from chrome)
+ * aPreviousResult -- previous search result, if any.
+ * aDatalistResult -- results from list=datalist for aField.
+ * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult
+ * that may be returned asynchronously.
+ * options -- an optional nsIPropertyBag2 containing additional search
+ * parameters.
+ */
+ autoCompleteSearchAsync(
+ aInputName,
+ aUntrimmedSearchString,
+ aField,
+ aPreviousResult,
+ aDatalistResult,
+ aListener,
+ aOptions
+ ) {
+ // Guard against void DOM strings filtering into this code.
+ if (typeof aInputName === "object") {
+ aInputName = "";
+ }
+ if (typeof aUntrimmedSearchString === "object") {
+ aUntrimmedSearchString = "";
+ }
+ let params = {};
+ if (aOptions) {
+ try {
+ aOptions.QueryInterface(Ci.nsIPropertyBag2);
+ for (let { name, value } of aOptions.enumerator) {
+ params[name] = value;
+ }
+ } catch (ex) {
+ Cu.reportError("Invalid options object: " + ex);
+ }
+ }
+
+ let client = new FormHistoryClient({
+ formField: aField,
+ inputName: aInputName,
+ });
+
+ function maybeNotifyListener(result) {
+ if (aListener) {
+ aListener.onSearchCompletion(result);
+ }
+ }
+
+ // If we have datalist results, they become our "empty" result.
+ let emptyResult =
+ aDatalistResult ||
+ new FormAutoCompleteResult(
+ client,
+ [],
+ aInputName,
+ aUntrimmedSearchString
+ );
+ if (!this._enabled) {
+ maybeNotifyListener(emptyResult);
+ return;
+ }
+
+ // Don't allow form inputs (aField != null) to get results from
+ // search bar history.
+ if (aInputName == "searchbar-history" && aField) {
+ this.log(
+ 'autoCompleteSearch for input name "' + aInputName + '" is denied'
+ );
+ maybeNotifyListener(emptyResult);
+ return;
+ }
+
+ if (aField && isAutocompleteDisabled(aField)) {
+ this.log("autoCompleteSearch not allowed due to autcomplete=off");
+ maybeNotifyListener(emptyResult);
+ return;
+ }
+
+ this.log(
+ "AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString
+ );
+ let searchString = aUntrimmedSearchString.trim().toLowerCase();
+
+ // reuse previous results if:
+ // a) length greater than one character (others searches are special cases) AND
+ // b) the the new results will be a subset of the previous results
+ if (
+ aPreviousResult &&
+ aPreviousResult.searchString.trim().length > 1 &&
+ searchString.includes(aPreviousResult.searchString.trim().toLowerCase())
+ ) {
+ this.log("Using previous autocomplete result");
+ let result = aPreviousResult;
+ let wrappedResult = result.wrappedJSObject;
+ wrappedResult.searchString = aUntrimmedSearchString;
+
+ // Leaky abstraction alert: it would be great to be able to split
+ // this code between nsInputListAutoComplete and here but because of
+ // the way we abuse the formfill autocomplete API in e10s, we have
+ // to deal with the <datalist> results here as well (and down below
+ // in mergeResults).
+ // If there were datalist results result is a FormAutoCompleteResult
+ // as defined in nsFormAutoCompleteResult.jsm with the entire list
+ // of results in wrappedResult._values and only the results from
+ // form history in wrappedResult.entries.
+ // First, grab the entire list of old results.
+ let allResults = wrappedResult._labels;
+ let datalistResults, datalistLabels;
+ if (allResults) {
+ // We have datalist results, extract them from the values array.
+ // Both allResults and values arrays are in the form of:
+ // |--wR.entries--|
+ // <history entries><datalist entries>
+ let oldLabels = allResults.slice(wrappedResult.entries.length);
+ let oldValues = wrappedResult._values.slice(
+ wrappedResult.entries.length
+ );
+
+ datalistLabels = [];
+ datalistResults = [];
+ for (let i = 0; i < oldLabels.length; ++i) {
+ if (oldLabels[i].toLowerCase().includes(searchString)) {
+ datalistLabels.push(oldLabels[i]);
+ datalistResults.push(oldValues[i]);
+ }
+ }
+ }
+
+ let searchTokens = searchString.split(/\s+/);
+ // We have a list of results for a shorter search string, so just
+ // filter them further based on the new search string and add to a new array.
+ let entries = wrappedResult.entries;
+ let filteredEntries = [];
+ for (let i = 0; i < entries.length; i++) {
+ let entry = entries[i];
+ // Remove results that do not contain the token
+ // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars
+ if (searchTokens.some(tok => !entry.textLowerCase.includes(tok))) {
+ continue;
+ }
+ this._calculateScore(entry, searchString, searchTokens);
+ this.log(
+ "Reusing autocomplete entry '" +
+ entry.text +
+ "' (" +
+ entry.frecency +
+ " / " +
+ entry.totalScore +
+ ")"
+ );
+ filteredEntries.push(entry);
+ }
+ filteredEntries.sort((a, b) => b.totalScore - a.totalScore);
+ wrappedResult.entries = filteredEntries;
+
+ // If we had datalistResults, re-merge them back into the filtered
+ // entries.
+ if (datalistResults) {
+ filteredEntries = filteredEntries.map(elt => elt.text);
+
+ let comments = new Array(
+ filteredEntries.length + datalistResults.length
+ ).fill("");
+ comments[filteredEntries.length] = "separator";
+
+ // History entries don't have labels (their labels would be read
+ // from their values). Pad out the labels array so the datalist
+ // results (which do have separate values and labels) line up.
+ datalistLabels = new Array(filteredEntries.length)
+ .fill("")
+ .concat(datalistLabels);
+ wrappedResult._values = filteredEntries.concat(datalistResults);
+ wrappedResult._labels = datalistLabels;
+ wrappedResult._comments = comments;
+ }
+
+ maybeNotifyListener(result);
+ } else {
+ this.log("Creating new autocomplete search result.");
+
+ // Start with an empty list.
+ let result = aDatalistResult
+ ? new FormAutoCompleteResult(
+ client,
+ [],
+ aInputName,
+ aUntrimmedSearchString
+ )
+ : emptyResult;
+
+ let processEntry = aEntries => {
+ if (aField && aField.maxLength > -1) {
+ result.entries = aEntries.filter(
+ el => el.text.length <= aField.maxLength
+ );
+ } else {
+ result.entries = aEntries;
+ }
+
+ if (aDatalistResult && aDatalistResult.matchCount > 0) {
+ result = this.mergeResults(result, aDatalistResult);
+ }
+
+ maybeNotifyListener(result);
+ };
+
+ this.getAutoCompleteValues(
+ client,
+ aInputName,
+ searchString,
+ params,
+ processEntry
+ );
+ }
+ },
+
+ mergeResults(historyResult, datalistResult) {
+ let values = datalistResult.wrappedJSObject._values;
+ let labels = datalistResult.wrappedJSObject._labels;
+ let comments = new Array(values.length).fill("");
+
+ // historyResult will be null if form autocomplete is disabled. We
+ // still want the list values to display.
+ let entries = historyResult.wrappedJSObject.entries;
+ let historyResults = entries.map(entry => entry.text);
+ let historyComments = new Array(entries.length).fill("");
+
+ // now put the history results above the datalist suggestions
+ let finalValues = historyResults.concat(values);
+ let finalLabels = historyResults.concat(labels);
+ let finalComments = historyComments.concat(comments);
+
+ // This is ugly: there are two FormAutoCompleteResult classes in the
+ // tree, one in a module and one in this file. Datalist results need to
+ // use the one defined in the module but the rest of this file assumes
+ // that we use the one defined here. To get around that, we explicitly
+ // import the module here, out of the way of the other uses of
+ // FormAutoCompleteResult.
+ let { FormAutoCompleteResult } = ChromeUtils.import(
+ "resource://gre/modules/nsFormAutoCompleteResult.jsm"
+ );
+ return new FormAutoCompleteResult(
+ datalistResult.searchString,
+ Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ 0,
+ "",
+ finalValues,
+ finalLabels,
+ finalComments,
+ historyResult
+ );
+ },
+
+ stopAutoCompleteSearch() {
+ if (this._pendingClient) {
+ this._pendingClient.cancel();
+ this._pendingClient = null;
+ }
+ },
+
+ /*
+ * Get the values for an autocomplete list given a search string.
+ *
+ * client - a FormHistoryClient instance to perform the search with
+ * fieldName - fieldname field within form history (the form input name)
+ * searchString - string to search for
+ * params - object containing additional properties to query autocomplete.
+ * callback - called when the values are available. Passed an array of objects,
+ * containing properties for each result. The callback is only called
+ * when successful.
+ */
+ getAutoCompleteValues(client, fieldName, searchString, params, callback) {
+ params = Object.assign(
+ {
+ agedWeight: this._agedWeight,
+ bucketSize: this._bucketSize,
+ expiryDate:
+ 1000 * (Date.now() - this._expireDays * 24 * 60 * 60 * 1000),
+ fieldname: fieldName,
+ maxTimeGroupings: this._maxTimeGroupings,
+ timeGroupingSize: this._timeGroupingSize,
+ prefixWeight: this._prefixWeight,
+ boundaryWeight: this._boundaryWeight,
+ },
+ params
+ );
+
+ this.stopAutoCompleteSearch();
+ client.requestAutoCompleteResults(searchString, params, entries => {
+ this._pendingClient = null;
+ callback(entries);
+ });
+ this._pendingClient = client;
+ },
+
+ /*
+ * _calculateScore
+ *
+ * entry -- an nsIAutoCompleteResult entry
+ * aSearchString -- current value of the input (lowercase)
+ * searchTokens -- array of tokens of the search string
+ *
+ * Returns: an int
+ */
+ _calculateScore(entry, aSearchString, searchTokens) {
+ let boundaryCalc = 0;
+ // for each word, calculate word boundary weights
+ for (let token of searchTokens) {
+ if (entry.textLowerCase.startsWith(token)) {
+ boundaryCalc++;
+ }
+ if (entry.textLowerCase.includes(" " + token)) {
+ boundaryCalc++;
+ }
+ }
+ boundaryCalc = boundaryCalc * this._boundaryWeight;
+ // now add more weight if we have a traditional prefix match and
+ // multiply boundary bonuses by boundary weight
+ if (entry.textLowerCase.startsWith(aSearchString)) {
+ boundaryCalc += this._prefixWeight;
+ }
+ entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
+ },
+}; // end of FormAutoComplete implementation
+
+// nsIAutoCompleteResult implementation
+function FormAutoCompleteResult(client, entries, fieldName, searchString) {
+ this.client = client;
+ this.entries = entries;
+ this.fieldName = fieldName;
+ this.searchString = searchString;
+}
+
+FormAutoCompleteResult.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIAutoCompleteResult",
+ "nsISupportsWeakReference",
+ ]),
+
+ // private
+ client: null,
+ entries: null,
+ fieldName: null,
+
+ _checkIndexBounds(index) {
+ if (index < 0 || index >= this.entries.length) {
+ throw Components.Exception(
+ "Index out of range.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ },
+
+ // Allow autoCompleteSearch to get at the JS object so it can
+ // modify some readonly properties for internal use.
+ get wrappedJSObject() {
+ return this;
+ },
+
+ // Interfaces from idl...
+ searchString: "",
+ errorDescription: "",
+ get defaultIndex() {
+ if (!this.entries.length) {
+ return -1;
+ }
+ return 0;
+ },
+ get searchResult() {
+ if (!this.entries.length) {
+ return Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ }
+ return Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ },
+ get matchCount() {
+ return this.entries.length;
+ },
+
+ getValueAt(index) {
+ this._checkIndexBounds(index);
+ return this.entries[index].text;
+ },
+
+ getLabelAt(index) {
+ return this.getValueAt(index);
+ },
+
+ getCommentAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ getStyleAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ getImageAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ },
+
+ removeValueAt(index) {
+ this._checkIndexBounds(index);
+
+ let [removedEntry] = this.entries.splice(index, 1);
+
+ this.client.remove(removedEntry.text, removedEntry.guid);
+ },
+};
+
+var EXPORTED_SYMBOLS = ["FormAutoComplete"];