Source code

Revision control

Other Tools

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